WEBKT

自研规则引擎的 AST 节点怎么设计,才能不卡在扩展和性能的十字路口?

6 0 0 0

线上跑过一次促销规则,表达式树里有三百多个 AND/OR 节点,几十个自定义函数调用。解释执行,单次评估耗时 12ms。规则一热,CPU 直接打满。换一套字节码方案后,降到 0.4ms。但团队花了三周才把 AST 转成可执行的指令序列,调试器也废了。这就是自研规则引擎最常撞到的墙:扩展性要灵活,执行性能要快,两者在 AST 设计阶段就互相拉扯。

先看节点结构怎么落盘。别搞巨型基类塞满字段。实际项目里,节点通常拆成三层:元信息层(ID、行号、作用域)、结构层(操作符类型、子节点列表)、求值层(输入上下文、结果类型)。结构层用枚举或整型标签区分 LiteralVariableRefBinaryOpFunctionCall,求值层不直接绑死虚函数。用标签分发代替虚表调用,能少掉 30% 左右的间接寻址开销。Go 里用 switch node.Type,Java 里用 sealed interface 或模式匹配,C++ 用 std::variant,都比纯 OOP 继承干净。

扩展性靠什么撑住?访问者模式是标配,但别写死。节点提供 accept(Visitor),访问者按需注册。业务方要加个 GeoDistance 函数,不用改引擎核心,只加一个 FunctionCall 变体和一个对应的访问者实现。更激进的做法是把求值逻辑抽成闭包或函数指针,在 AST 构建阶段就绑定好。树建完,直接跑闭包链,跳过二次分发。代价是内存占用上升,适合规则加载后不常变的场景。

性能瓶颈不在树本身,在解释循环。每次执行都重新遍历节点、查变量映射、做类型转换,虚函数调用压栈出栈,GC 频繁分配中间对象。实测数据很直观:纯解释执行,QPS 卡在 2k~5k;加上节点缓存和类型特化,能到 1.5w;引入字节码或 JIT,稳态能摸到 8w+。数字会随硬件波动,但量级差是真实的。

要不要上 JIT 或字节码生成?先看三个硬指标:

  1. 规则更新频率。分钟级热更,字节码重编译成本极高,解释器兜底更稳。天级/周级更新,预编译划算。
  2. 规则复杂度与调用频次。简单布尔表达式,解释器够吃。嵌套循环、数组遍历、复杂数学运算,字节码能把循环展开、常量折叠、内联函数调用,收益立竿见影。
  3. 团队工程能力。手写字节码(ASM、LLVM、BPF)或接 GraalVM/Truffle,门槛不低。调试断点难设,错误栈容易断层。如果连 AST 的 visitor 都还没跑顺,先别碰 JIT。

折中方案是混合架构。冷规则走轻量解释器,带缓存池复用求值上下文。热规则命中阈值后,后台触发字节码编译,替换原节点执行路径。切换过程用双写或影子流量验证,失败自动降级。类型系统要提前收敛,规则 DSL 尽量静态可推断。动态类型满天飞,JIT 优化器只能保守处理,性能反而倒退。

落地前对照这份清单过一遍:

  • 节点是否支持无侵入扩展?新增操作符/函数需改核心代码,说明抽象没切干净。
  • 变量查找是否做了索引映射?哈希表每次查 O(1) 但常数大,数组偏移或寄存器映射更快。
  • 中间结果是否复用?避免每次求值都 new ArrayList 或装箱基础类型。
  • 编译/解释切换是否有熔断机制?字节码生成失败不能阻塞主流程。
  • 监控埋点是否覆盖节点命中率、编译耗时、降级次数?没有数据支撑,优化就是盲猜。

规则引擎不是越底层越厉害。能稳定支撑业务迭代、出问题能快速回滚、团队能看懂能改,才是好架构。AST 设计先保正确和可维护,再用数据说话决定要不要上字节码。别为了炫技把简单问题复杂化。

架构师林工 规则引擎AST设计JIT编译

评论点评