
ruflo 这个项目源自我去年在做订单风控时的一次改不动了。那时候系统里最可怕的代码不是分布式事务也不是消息队列而是一段将近四百行的 if-else 规则判断——运营提一个新规则我要顺着那串逻辑找到第几层嵌套再下手改完还得担心影响别的分支。后来我在团队里牵头搞了个轻量级规则流引擎给它取名 ruflo拆开就是 RULE FLOw。一句话解释它把业务里那些复杂的条件判断拆成一串可以单独维护、编排、追踪的节点数据从图的一端进去决策结果从另一端出来。这篇文章会把 ruflo 从设计动机、核心模型、实操跑通一直讲到线上踩坑。如果你是后端开发正在被频繁变化的业务规则折腾或者想评估自研规则引擎和工作流引擎的边界这文章应该对你有用。1. 从订单风控的一堆 if-else到 ruflo 的诞生先说清楚一个很现实的问题为什么不在代码里继续写 if-else非要折腾一个规则流引擎出来。这不是技术洁癖而是被业务节奏逼的。1.1 业务侧的痛点规则一变代码就重构大部分业务系统的规则一开始都是长在代码里的。拿订单域来说一个典型的支付前风控判断大概是这个样子用户是否新注册订单金额是否超过阈值支付渠道是否在黑名单里收货地址是否和历史常用地址匹配该用户当天是否已经下过 N 单这些条件堆在一个方法里看起来还能接受。问题是运营同学不会只提一次需求。我曾经接到一个需求说支付前风控规则从金额大于 1000 且新用户改成金额大于 2000 或历史订单数大于 5。表面上看只改了三个字段但实际改起来要动整个判断链路因为后面还跟着不同的处置动作拒绝、转人工审核、放行、追加验证。这类改动每次都要走完整回归因为条件之间相互耦合谁也不敢保证改一个分支不影响另一个分支。等规则累积到几十条之后代码就成了大家都不敢碰的雷区。另一个隐性痛点是规则变化的频率远高于系统版本的发布频率。运营希望今天提需求、明天上线、后天看到效果但按代码发版的节奏光排期就要等一两周。等需求上线大促都结束了。所以最核心的诉求不是把 if-else 换成 switch而是把决定走哪个分支这件事本身从业务代码里抽离出去变成一份可配置、可编排、可观测的资产。1.2 为什么是规则流而不是工作流或规则引擎决定自研之前我们把市面上的方案都过了一遍。你问为什么不用现成的规则引擎这个问题我们也问过自己。工作流引擎先被排除了。工作流强调的是状态流转节点之间存在等待、挂起、人工审批这类操作需要持久化流程实例。而我们这个场景是一锤子买卖请求进来走完判断链路马上返回结果没有中间态也不需要人去审批。拿工作流引擎去跑这种计算型任务等于让一个带着数据库事务的庞然大物去算 11重且不划算。传统规则引擎也有问题。比如成熟的 BRMS 产品规则表达能力强但对应的是陡峭的学习曲线和相对笨重的部署方式。业务团队要维护一堆 DRL 文件还得专门培训规则语法很多团队根本养不起这个成本。对中小团队来说这属于杀鸡用牛刀而且牛刀你还未必能驾驭。ruflo 的定位就落在这两者中间它不管状态不挂起不持久化只做一件事——给一组输入数据算出一个决策结果。形式上它不依赖复杂的规则语法而是用节点 连线构成一张有向图每个节点负责一个小判断连线决定判断完之后往哪儿走。打个比方工作流是工厂产线工单在一个个工位之间流转每个工位可能有人工参与ruflo 是一条判断管道数据从一头进去经过若干关卡从另一头出来时已经带上了决策结论。这里有个对比表可以更直观看清三者的差异维度工作流引擎传统规则引擎ruflo核心抽象状态、任务、流转规则集、事实、冲突消解节点、连线、上下文是否持久化状态是否否是否支持人工参与是否否规则表达方式流程设计器专业 DSL 语法JSON 表达式适用场景审批、工单、订单状态机复杂计费、征信评估风控、营销、路由决策选型结论如果只是想在应用内嵌一个决策计算器优先考虑规则流而不是全量规则引擎。这个方向也是 ruflo 核心设计思想的起点。2. ruflo 的核心抽象节点、连线、上下文各管一摊ruflo 的设计很简单简单到整个运行时只有三个核心概念节点、连线、执行上下文。但把这三件事的边界划清楚花了我不少时间。2.1 三类节点的职责划分与调度策略ruflo 里的节点不是一刀切的。我把它抽象成三类条件节点ConditionNode负责做判断。它接收上下文里的数据返回一个布尔结果然后根据结果走向不同的下游连线。动作节点ActionNode负责执行具体动作。比如给订单打标、记录风控日志、调用外部服务。动作节点没有分支逻辑执行完就进入下一个节点。边界节点StartNode / EndNode标记一张规则图的开始和结束。StartNode 只有一个下游EndNode 是终点所有链路最终都必须汇合到 EndNode。条件节点和动作节点是可以组合嵌套的比如先判断用户是否新客是的话走进一个动作节点再往下判断金额是否超限。这种结构天然就把业务上的规则拆成了独立单元每个节点可以单独看、单独改、单独测试。调度策略上我默认用的是 BFS广度优先。为什么不用 DFS因为规则流场景里经常会出现一个条件节点后面挂三个动作并行执行的结构BFS 能保证同一层的兄弟节点都被遍历到逻辑上更贴近业务直觉。DFS 更适合深层链路但需要防栈溢出而且对并行执行不友好。每个节点还带两个额外的元数据字段权重和超时时间。权重用来决定同一优先级下节点的执行顺序超时时间兜底防止某个动作节点调用外部服务时卡死整条链路。2.2 条件表达式和动作绑定的设计取舍条件节点核心是条件表达式。设计表达式语法时我踩过一个很大的坑——差点引入 Groovy。Groovy 功能确实强但作为规则表达式它等于在配置里开了一个任意代码执行的入口。安全团队看到这玩意直接红灯。所以 ruflo 走了另一条路自研一个轻量级布尔表达式解析器。它支持这些语法比较表达式amount 1000、userLevel vip逻辑组合amount 1000 channel in [app, h5]字符串操作address contains 杭州自定义函数isNewUser(userId, startTime)、blacklistHit(ip, type)这套解析器不做通用编程语言该做的事不支持循环、不支持变量赋值、不支持方法随意调用。所有变量都必须在执行前通过变量表显式声明解析器只认白名单里的函数。语法简单了安全性也好控制了。动作绑定则走注册中心机制。每个动作节点配置一个 ref 字段指向一个预先注册的动作 Bean。比如{ id: action_mark_risk, type: action, ref: markRiskOrder, params: { riskType: HIGH } }引擎启动时扫描所有动作 Bean构建 ref 到执行器的映射。节点执行时直接查表调用避免了反射带来的性能损耗也让动作实现保持 POJO 风格单元测试好写。2.3 执行上下文数据在两节点之间怎么传上下文是规则流里最容易被忽略、也最容易出问题的部分。很多规则引擎把上下文设计成一个巨大的 Map所有节点都能随便读写。后患就是你根本不知道某个字段是哪个节点什么时候写进去的排查问题全靠猜。ruflo 的上下文把变量按用途分成三个区域输入区input请求进来时填充只读对应业务请求参数。计算区compute节点之间传递的中间结果可读写。比如 A 节点计算出风险分B 节点读取风险分继续判断。输出区output引擎执行结束后对外暴露的结果通常由 EndNode 从 compute 区挑选字段写入。这样做的最大好处是每个节点读什么、写什么都清清楚楚。节点声明式地定义 inputs 和 outputs 字段引擎在调度前做一次校验如果上游没有产出下游需要的字段直接报配置错误而不是等运行时拿到 null 再一脸茫然。并发安全方面上下文实例是每次执行 new 出来的不进线程池复用。同一张规则图可以被多个请求并发执行但每个请求持有自己的上下文互不干扰。3. 十分钟跑通 ruflo从规则定义到引擎执行聊完设计进入实操环节。我以订单风控里最经典的一个规则为例订单金额超过 1000 且不是新用户直接转人工审核其余情况自动放行。3.1 规则文件长什么样ruflo 的规则文件是 JSON 格式一张图就是一个 JSON 对象。上面的风控规则定义出来是这样{ name: order_risk_check, version: 1.0.0, nodes: [ { id: start, type: start, next: judge_amount }, { id: judge_amount, type: condition, expression: amount 1000, trueNext: judge_new_user, falseNext: action_pass }, { id: judge_new_user, type: condition, expression: isNewUser(userId) false, trueNext: action_manual_review, falseNext: action_pass }, { id: action_manual_review, type: action, ref: manualReview, next: end }, { id: action_pass, type: action, ref: passOrder, next: end }, { id: end, type: end } ] }注意看trueNext和falseNext这两个字段它们是条件节点的专属连线定义。条件成立走 trueNext不成立走 falseNext语义非常明确运营同学读 JSON 也能大概理解流程走向。isNewUser(userId)是引擎内置的注册函数之一不用在规则文件里额外定义。3.2 引擎初始化与一次完整调用链路Java 侧的使用代码非常短核心就三步加载规则文件、构建引擎、执行。// 1. 加载规则定义支持从文件、DB、配置中心读取 RuleDefinition definition RuleDefinitionLoader.loadFromJson(jsonString); // 2. 构建引擎实例引擎是不可变对象构建后可安全共享 RuleFlowEngine engine RuleFlowEngineBuilder.create() .registerFunction(CommonFunctions.class) .registerAction(manualReview, new ManualReviewAction()) .registerAction(passOrder, new PassOrderAction()) .build(definition); // 3. 构造上下文并执行 ExecutionContext context ExecutionContext.create(); context.setInput(amount, 1200); context.setInput(userId, U12345); ExecutionResult result engine.execute(context); System.out.println(result.getOutput(decision)); // 输出类似 MANUAL_REVIEW引擎的execute方法内部做四件事从 StartNode 开始根据next落到第一个节点。循环从调度队列取出节点执行。条件节点走表达式求值根据 trueNext/falseNext 把下一个节点塞回队列动作节点执行注册的 Action再把自身的next塞回队列。执行过程中记录每个节点的开始时间、结束时间、执行状态到 trace 列表。到达 EndNode 时结束循环把 output 区的内容封装成 ExecutionResult 返回。整个执行过程是单线程同步的没有异步、没有回调理解起来很直接。这也是刻意为之——规则流本来就是毫秒级计算引入异步反而增加心智负担。3.3 常用策略参数说明引擎构建时除了注册函数和动作还支持一批策略参数。我挑几个最常用的列出来参数名默认值作用maxNodes200单次执行最大节点数防止死循环拖垮线程enableCycleChecktrue构建图时是否执行环检测continueOnErrorfalse节点执行异常时是中断还是跳过继续expressionCacheSize10000表达式解析结果缓存上限actionTimeoutMs0动作节点超时时间0 表示不限制enableTracetrue是否记录每个节点的执行轨迹maxNodes这个参数我建议任何生产环境都显式设置。虽然构建时会做环检测但万一出现逻辑上的死循环比如两个节点互相依赖这个参数就是最后的保命闸。4. ruflo 实战踩坑循环、异常回滚与性能抖动光跑通 demo 不算完真正让 ruflo 变可靠的是后面这些坑。每个坑都对应了一个线上事故或一次差点上线的回滚。4.1 循环依赖图判定比想象中更容易漏第一次做环检测时我以为简单统计节点数超过一定数量就报错。后来线上规则配置越来越多有人配置了一条 A→B→C→A 的环节点数刚好 3 个没超阈值结果请求进来后执行到 C 又跳回 A一直循环到 maxNodes 上限才被强制掐断。那次线上每分钟报错上千次差点把监控系统刷爆。事后我把环检测换成标准的 DFS 三色标记法白色表示未访问灰色表示正在访问路径上黑色表示已完成访问。如果在 DFS 过程中遇到一个灰色节点说明存在环直接构建失败并报出环上所有节点 ID。这个逻辑不复杂但要注意性能。规则图构建只发生一次引擎构建阶段所以判环的复杂度只要控制在 O(NM) 就行。我们实测 200 个节点、400 条边的图判环耗时在 5ms 以内完全可接受。判环代码的核心思路大致是这样private void dfs(String nodeId, SetString visiting, SetString visited) { visiting.add(nodeId); for (String nextId : graph.get(nodeId)) { if (visiting.contains(nextId)) { throw new CycleDetectedException(nodeId - nextId); } if (!visited.contains(nextId)) { dfs(nextId, visiting, visited); } } visiting.remove(nodeId); visited.add(nodeId); }建议把环检测做的结论直接输出到构建日志里哪怕没有环也打印一条cycle check passed, nodesN, edgesM方便出问题时快速排除这一个环节。4.2 节点执行异常时上下文里到底剩什么第二个让我印象深刻的坑是异常语义不清晰。最初版本里任何一个节点抛异常都会导致整个引擎立即中断。当时觉得这很合理fail-fast 嘛。但业务方提了个需求风控规则里某个外部黑名单接口超时不应该阻断整个下单流程而是应该跳过这个节点按未命中黑名单继续执行。这个需求逼着我重新设计了异常处理分支。现在的行为是这样默认 fail-fast节点抛异常引擎立即终止ExecutionResult里标记successfalse并携带异常节点 ID 和异常堆栈。配置continueOnErrortrue节点异常时跳过该节点把节点状态标记为 ERROR继续走默认的next连线。上下文里可能残留该节点写入一半的数据所以动作实现要保证要么不写、要么一次性写完整。这引出一个很容易忽略的问题节点执行一半抛异常上下文里到底剩什么ruflo 的做法是不做事务性回滚因为回滚复杂且大多数场景不需要。但我会在文档里明确写一句如果业务需要原子性请在动作节点内部自己保证引擎只保证执行前不污染上下文执行后如果失败则节点级状态为 ERROR。实际操作中最好的防护是每次请求 new 一个干净的上下文不要为了省一次 new 的开销去搞 ThreadLocal 复用。因为节点一旦异常context 里的脏数据会影响下一次执行而且这种问题非常难排查。4.3 表达式解析带来的性能抖动与缓存方案刚上线时线上出现过一次偶发的高延迟查了半天发现是表达式解析惹的祸。ruflo 的条件表达式支持动态变量也就是说同一个规则节点在不同请求里表达式字符串虽然相同但变量值不同。我最初实现在每次节点执行时都重新解析一次表达式字符串结果就是把简单的大小比较硬生生做成了字符串解析加 AST 构建耗时翻了十几倍。后来针对这个做了两层优化第一层是表达式缓存。以表达式字符串为 key解析后的 AST 缓存起来。因为规则节点的表达式是配置静态的实际运行中同一节点的 expression 内容不变缓存命中率接近 100%。缓存用 ConcurrentHashMap 容量上限超过上限走 LRU 淘汰。第二层是预编译预热。引擎构建完成后遍历所有条件节点把表达式全部解析一遍并放入缓存。这样线上第一个请求进来时不需要再承担首次解析的耗时。预热是同步做的构建引擎慢几毫秒完全值得。这是预热的核心代码片段for (Node node : definition.getNodes()) { if (node instanceof ConditionNode) { ConditionNode cn (ConditionNode) node; parser.parseAndCache(cn.getExpression()); } }我还特意在监控里加了一个指标ruleflow_expression_cache_miss_count。这个指标如果持续增长说明表达式没有走缓存多半是有人把带变量值的表达式动态拼进去了。出现这种情况要立刻排查因为运行时动态拼表达式不仅性能差还容易产生缓存穿透甚至缓存雪崩。5. 把 ruflo 接到真实业务前需要想清楚的几件事ruflo 跑通 demo 很容易但真正接进业务线有几个额外的问题值得提前思考。这些不全是技术问题但处理不好会直接影响运维体验。5.1 规则可观测性执行轨迹与日志设计规则流引擎最大的优势之一是决策过程可追踪。但如果你不提前设计 trace这个优势就等于零。我见过不少系统里规则引擎执行完只记录一个最终结论中间走过了哪些节点完全看不见出了问题只能靠猜。ruflo 的每个 ExecutionResult 里默认携带 nodeTrace 列表包含每个节点的 ID、名称、开始时间、耗时、状态、输出摘要。生产环境建议把这个 trace 序列化成 JSON 打到业务日志里和请求的 traceId 关联起来。日志格式长这样{ traceId: 0a3f8c2e1b, rule: order_risk_check, version: 1.0.0, trace: [ {nodeId: start, costMs: 0.1, status: SUCCESS}, {nodeId: judge_amount, costMs: 0.4, status: SUCCESS, result: true}, {nodeId: judge_new_user, costMs: 0.3, status: SUCCESS, result: false}, {nodeId: action_pass, costMs: 1.2, status: SUCCESS} ], decision: PASS }有了这个日志线上排查业务问题就变成查 trace → 看节点走向 → 定位是哪个判断和预期不一致非常高效。5.2 从 ruflo 内置函数到自定义扩展函数内置函数不可能覆盖所有业务场景。ruflo 定义了一个函数注册接口任何 Java 类只要实现它就能把自定义函数注入规则表达式。接口很薄核心就一个方法public interface FunctionProvider { String getName(); Object apply(ListObject args, ExecutionContext context); }比如业务方经常要根据用户所在城市做不同策略那就实现一个cityMatch函数注册进去规则表达式就能写成cityMatch(userCity, 杭州, 上海)。函数里可以做查表、调远程服务引擎完全不关心只负责调用和接受返回值。这里有两个注意点。第一函数命名要规范建议统一小写驼峰避免和引擎内部函数冲突第二函数注册要在引擎构建前完成构建后不可变保证同一引擎实例对同一规则文件的行为完全一致也方便单元测试时构造不同函数组合。5.3 线上灰度与规则变更发布流程规则引擎带来的一个直接好处是规则改动不用发版但这也带来一个坏处不会发版意味着更容易手一抖就改错。我经历过一次线上事故运营把金额大于 1000误改成金额大于 100一瞬间大量订单被转人工审核还好发现得早。所以 ruflo 的规则文件我强烈建议走版本化发布流程规则文件全部纳入 Git 仓库管理线上发布基于 Git commit SHA保证可回溯。发布走灰度 - 白名单 - 百分比 - 全量四步。灰度环境先跑通联调白名单放几个测试账号百分比从 1% 逐步提升到 100%。任何一次变更都生成新版本号旧版本文件保留在存储里支持一键回切。实现层面其实不复杂规则引擎不直接读配置文件而是读一个当前生效版本的配置项。发布动作就是先把新版规则写入存储再把配置项从旧版本号切到新版本号。回滚就是再把配置项切回去秒级生效。这套机制上线后运营提规则变更我们基本能保证 10 分钟内完成全量发布而且敢在发布后大胆观察指标——反正出了问题一键回滚。最后再说一个我个人的习惯不管规则配置得多清晰线上一定要给 ruleflow 增加决策结果分布的监控。比如转人工审核占比、自动放行占比这些指标在规则变更后要能立刻看到波动。规则引擎不是放出去就不管了它本质上和代码一样需要持续维护、持续观测只是迭代周期从周缩短到了分钟。