从混乱if-else到清晰规则流:轻量级规则流编排引擎的设计与落地

发布时间:2026/9/10 6:21:17
从混乱if-else到清晰规则流:轻量级规则流编排引擎的设计与落地 ruflo 这个名字是我在提交代码时随手敲出来的本来想写 rule-flow结果手一抖少了个 e后来懒得改就一直沿用了。它是 Rule Flow 的简写定位是一个轻量级的规则流编排引擎。说直白点就是帮你把一段又长又乱的 if-else 改写成一张可以动态调整的流程图让每个判断节点都能独立维护、独立测试、独立复用。做这个东西的起因很现实我们系统里有一段订单风控逻辑十几个 if 嵌套每次产品提需求改一处要担心另外三处测试用例越补越多线上还是偶尔出问题。既然规则本身是散的为什么不把它们拆成节点再用连线决定走向带着这个想法我在团队内部写了 ruflo经过几个版本的迭代现在已经成为多个中台服务处理业务规则时的公共底座。如果你是后端开发、架构师或者正在处理审批流、数据清洗、策略编排这类逻辑这篇文章应该能给你一些可以直接落地的参考。1. ruflo 到底解决的是哪一类问题1.1 从十几层 if 嵌套到可视化规则流先还原一下最原始的场景。我们有一个订单提交接口风控逻辑大概是这样的先判断用户是否在风控名单里在的话直接拒绝再判断订单金额是否超过阈值超过走人工审核然后判断下单频率是否异常异常则要求短信验证最后判断收货地址和常用地址是否一致不一致要增加风险分。这段逻辑如果用伪代码写就是一个深度十级的缩进金字塔。这种代码的问题不在于单个分支难写而在于分支之间的关系是隐式的。后来的人看代码只能看到一个一个孤立的 if看不出完整决策路径。产品说把金额阈值从 5000 改成 3000你改一行常量产品又说风控名单校验放在金额判断之后你得把整个 if 块搬走还要确认没有变量依赖错误。更难受的是可观测性这套规则跑完没有任何日志告诉你因为命中了哪个条件所以走了哪条路。ruflo 把这个问题拆成三个部分节点、边、上下文。业务逻辑被切成分散的执行单元单元之间通过有向边描述顺序和条件执行过程中的共享数据放在上下文对象里。这样一来改规则就是改节点或调边而不是动一个几百行的函数。1.2 为什么不用现成的规则引擎和工作流引擎有人会问市面上不是有 Drools、Flowable 这些成熟方案吗为什么还要自研我在调研的时候整理过一张对比表方案擅长场景引入成本灵活性Drools复杂规则推理、规则包管理重需要学习 DSL 和 Fact 模型强但小项目过度设计FlowableBPMN 流程、人工审批流重需要数据库和流程设计器适合人参与的长流程自研 ruflo服务内轻量规则编排极低一个 jar/包引入完全可控代码即配置我们当时的核心诉求不是做一套 BPMN 规范也不是要做推理机只是想把服务内碎片化的规则逻辑理清楚。Drools 的 DSL 有学习成本成员对这种 when-then 的写法接受度不一Flowable 要配数据库表还要处理流程实例持久化对普通接口型场景来说太重了。ruflo 坚持一个原则规则流只存在内存里跟着应用启动加载跟着应用停止销毁不碰数据库和外部存储。这样既保证了性能也让心智负担降到最低。2. 核心运转原理节点、边、上下文三者如何协作2.1 节点的最小单元和四类内置节点在 ruflo 里一个节点就是一个最小执行单元。它不关心你内部写了多少代码只要求实现一个统一接口接收上下文处理业务逻辑返回一个结果对象。结果对象里可以携带状态码、输出数据、错误信息引擎根据这些信息决定下一步怎么走。内置了四种基础节点类型普通执行节点无分叉执行完走到下一个节点。条件节点根据上下文里的某个字段决定走哪一条出边。这是最关键的类型承担了 if-else 的职责。并行节点把一条流转拆成多个分支同时执行等所有分支完成后再汇合。适合互不依赖的检查项。结束节点没有出边代表整个流程终止同时把最终结果写回上下文。这个设计看起来简单但想清楚很关键。我没有让节点直接持有下一个节点的指针而是把节点和边分离。节点只知道自己有哪些出边 ID边上有条件表达式。这样做的好处是调整流程走向时不需要编译节点代码只改配置文件里的边即可。2.2 上下文对象数据如何安全地在节点之间流动上下文在 ruflo 里是一个强类型的 Map 结构每个线程执行时持有自己的一份副本。节点之间不能直接互相调用只能通过上下文读写数据。很多新手会在这时候犯一个错误把上下文设计成全局单例。我当时也走过弯路后来被并发问题教训了才改成无状态的运行时上下文。具体实现上我抽象了一个Context接口public interface Context { void put(String key, Object value); Object get(String key); T T get(String key, ClassT type); MapString, Object snapshot(); }节点执行前从上下文取需要的字段执行后把结果放回去。流程结束时整个上下文的快照就是这一轮请求的完整数据指纹可以直接打日志也可以用于审计。这个设计让到底发生了什么变得完全可追溯比之前散落的 System.out 强太多了。2.3 边的求值顺序与短路逻辑边不是一个简单的from - to它还包含一个可选的condition表达式。ruflo 支持两种表达式一种是 SpEL 这种通用表达式语言另一种是自定义 Java Predicate。条件节点会对所有出边按顺序求值第一个命中的边生效后面即使满足条件也不会被选中。这个逻辑借鉴了规则引擎中的冲突解决思路避免出现多边同时命中导致的分叉不确定性。举个例子一个金额判断节点有三条出边金额小于 100、金额在 100 到 1000 之间、金额大于 1000。三条边的条件分别是amount 100、amount 100 amount 1000、amount 1000。按顺序求值时数据只会走到实际匹配的那条边不会同时执行多个分支。如果配置出边时没有严格写好条件互斥那就是配置问题引擎只保证按序短路不保证逻辑正确。3. 从零接入 ruflo一个订单风控规则流的完整落地3.1 定义上下文模型这里我以订单风控为例演示一个最简落地版本。假设我们服务的技术栈是 Java Spring Boot引入 ruflo 依赖后第一步是定义上下文。public class OrderRiskContext extends BaseContext { private String userId; private BigDecimal amount; private String addressId; private int orderCountLastHour; private boolean inBlacklist; private int riskScore; private String finalDecision; // getter/setter 省略 }注意字段类型尽量用包装类而不是基本类型避免条件表达式里出现空指针。比如orderCountLastHour可能为空如果用 int 类型上下文在反序列化时会默认补 0但这个 0 可能不是业务上的真实值最终会误导规则判断。3.2 用 YAML 描述一条风控规则链ruflo 的规则流用 YAML 描述。下面是一个包含六个节点的流开始节点、风控名单检查、金额判断、频率检查、风险分累加、结束节点。id: orderRiskFlow name: 订单风控流程 nodes: - id: start type: start next: blacklistCheck - id: blacklistCheck type: condition conditions: - expression: #context.inBlacklist true next: reject - expression: #context.inBlacklist false next: amountCheck - id: reject type: end outputs: decision: REJECT - id: amountCheck type: condition conditions: - expression: #context.amount 3000 next: frequencyCheck - expression: #context.amount 3000 next: manualReview - id: manualReview type: end outputs: decision: MANUAL - id: frequencyCheck type: condition conditions: - expression: #context.orderCountLastHour 20 next: addRiskScore - expression: #context.orderCountLastHour 20 next: pass - id: addRiskScore type: action handler: addRiskScoreHandler next: pass - id: pass type: end outputs: decision: PASS这个 YAML 文件最大的价值在于产品同学可以直接看懂规则走向甚至能指着某一行的条件说这个阈值不对。以前的 if-else 代码产品看不懂测试不好写现在配置文件就是需求文档本身。3.3 引擎启动与执行结果解析加载并执行流程的代码如下RuleFlowEngine engine RuleFlowEngineBuilder.create() .registerHandler(addRiskScoreHandler, (context) - { OrderRiskContext riskContext (OrderRiskContext) context; int currentScore riskContext.getRiskScore(); riskContext.setRiskScore(currentScore 10); return NodeResult.success(); }) .build(); RuleFlow flow engine.loadFlow(orderRiskFlow, yamlContent); OrderRiskContext ctx new OrderRiskContext(); ctx.setUserId(user123); ctx.setAmount(new BigDecimal(2800)); ctx.setOrderCountLastHour(25); ctx.setInBlacklist(false); ExecutionReport report engine.execute(flow, ctx); System.out.println(decision report.getContext().get(decision));执行完成后ExecutionReport里除了最终结果还会记录每个节点的开始时间、结束时间、状态、输出数据。这样每次风控拦截我们都能追溯到是哪个节点、基于什么数据做出的决定。这在处理客诉时特别有用。4. 生产环境必须处理的四个边界问题4.1 循环依赖检测为什么不能只靠深度限制规则流是图结构有环的话会导致死循环。ruflo 在构建图时会做一次可达性分析检测是否存在环。一开始我天真地以为只要设置最大执行深度比如 100 次就能兜底。但在实际生产中发现深度限制只会掩盖问题一旦流程真的出现环前面几十个节点可能已经产生了副作用比如发了消息、改了状态这时候再中断已经晚了。所以 ruflo 的做法是在流程构建阶段就做完整的 DFS 检测记录每个节点的访问状态如果发现有边回到某个还在当前路径栈里的节点直接报配置错误拒绝加载这个流。这能保证有问题的配置根本不会部署到线上。4.2 并发执行与线程安全设计一个规则流可能在同一个时间点被多个请求执行所以节点处理器必须是线程安全的不能持有可变的成员变量。我们踩过的一个典型坑是在addRiskScoreHandler里定义了一个成员 Map 用来缓存中间计算结果结果并发时会串数据。ruflo 强制要求节点处理器无状态所有中间数据都放上下文。对于并行节点实现上是创建一个线程池来跑子分支然后等待所有分支完成。这里要注意线程池的隔离不能直接用全局共享的线程池来做风控规则流因为如果某个流程阻塞了会影响其他无关业务。建议给 ruflo 单独配一个线程池核心线程数和最大线程数根据接口 QPS 评估队列不要设太长。4.3 超时控制与失败回滚有些节点会调用外部 RPC比如查用户积分、查风控黑名单。外部接口超时是不确定事件所以 ruflo 在节点执行时支持配置超时时间。实现上是给每个节点包一层 Future超过阈值就取消执行并走超时处理边。回滚是个更现实的问题。假设已经执行了扣减库存节点下一步执行生成订单失败这时候库存已经扣了怎么办ruflo 不强行做分布式事务它只提供两个机制一是节点级事务钩子如果节点实现了Compensable接口失败时会回调compensate()二是流级别的最终状态写入如果流程没有走到成功结束节点就输出一个失败报告由上层业务决定是否发起补偿。这种取舍很重要。规则流引擎不是事务管理器把所有东西都塞进去只会让系统更复杂。我见过一些团队为了保证最终一致性把补偿逻辑也写进流程里结果流程变成了一个巨型状态机比原来的 if-else 更难维护。4.4 性能观测链路耗时统计与日志埋点规则流跑得慢到底慢在哪个节点这是上线后最常面对的问题。ruflo 从设计初期就把每个节点的耗时埋点作为一等公民执行报告里按耗时倒序输出所有节点。我还会把每次执行的完整决策路径记录成一条日志格式是flowId - nodeId - conditionKey result。例如orderRiskFlow - blacklistCheck - inBlacklistfalse orderRiskFlow - amountCheck - amount2800,conditionlt3000 orderRiskFlow - frequencyCheck - orderCountLastHour25,conditiongt20这种日志配合链路追踪能在出问题时秒级定位。之前没有规则流时想看一条请求走了哪些分支得人肉从业务日志里拼凑效率完全不同。5. 我踩过的三个坑和最终的改法5.1 坑一规则节点里共享了可变对象第一个坑是在做用户标签聚合节点时踩的。我在处理器里写了一个成员变量ListString tagCache想着复用这个列表来减少对象创建。结果线上出现用户 A 的标签串到用户 B 身上的事故。排查链路是从日志里发现同一个处理器的输出在不同请求间错乱然后追到对象引用上。解决方式很粗暴节点处理器全部定义为 Spring 的单例 Bean但内部不允许有任何非 final 的成员变量所有临时数据都来自上下文。如果确实需要缓存用ThreadLocal且必须在 finally 中清理或者直接上 Caffeine 这种本地缓存并严格按 key 区分数据归属。现在我更推荐后者因为它生命周期清晰不受线程复用影响。5.2 坑二YAML 配置正确但执行结果不符合直觉第二个坑有点隐蔽。我在一个条件节点里写了amount 3000和amount 3000两条出边但测试时发现金额 3000 的订单走了amount 3000分支。查了很久才发现是上下文里 amount 是 String 类型表达式引擎做的是字符串比较3000 3000在字典序比较下居然可能为真。这个问题的根因是类型元数据缺失。ruflo 在读取 SpEL 表达式时如果上下文中的字段没有声明类型就会退化成字符串比较。后来我在定义节点参数时强制要求给每个字段设置类型- id: amountCheck type: condition inputFields: - name: amount type: java.math.BigDecimal conditions: - expression: amount 3000并且在流程构建时做一次静态检查表达式中的字段类型和实际上下文类型不一致就直接拒绝加载。从那以后类似的问题再也没有在测试环境之外出现过。5.3 坑三大流量下上下文对象频繁创建引发 GC 压力第三个坑有点性能洁癖。订单接口峰值 QPS 超过 2000 后GC 明显变频繁。我们用 JFR 抓了一下发现超过 30% 的对象分配都在创建订单风控上下文上。每个请求都要 new 一个OrderRiskContext里面还挂着一堆字段快照这个开销被放大了。最后的优化方案是对象池。ruflo 提供了一个轻量级的上下文池请求结束后归还对象核心思路是复用Context内部的 HashMap 结构。但这里有个前提上下文里的数据必须完全清理干净不能把上次请求的数据带出来。我实现了一个clearForReuse()方法在归还时清空节点执行记录只保留一些可复用的数组和 Map 结构。优化后风控流程的整体耗时和 GC 压力都有明显下降。这些坑踩下来我最大的体会是写规则流引擎核心不是把流程跑通而是把数据边界、类型边界和生命周期边界都划清楚。ruflo 现在看起来只是个简单的编排工具但它背后的这些设计都是被生产环境教育的。如果你也想在自己的项目里落地类似的东西建议先从最薄的规则流开始把节点处理器做好隔离把上下文类型管好再慢慢加并行和补偿能力。规则流的本质不是炫技是让业务逻辑变得可读、可测、可追溯。