1. 写在前面:我为什么要放下 Node-RED
做规则引擎这个事,起因很朴素:我们团队在风控与活动营销场景里,用 Node-RED 当了两年"规则系统",最后终于被逼到非换不可的地步。Node-RED 作为流编排工具确实好用,拖拽式节点、可视化调试、社区生态也很热闹,可一旦你想把几十条、上百条业务规则从流程里"拎出来"独立管理,它就开始露怯:规则散落在 function 节点里、参数没有类型约束、同一个逻辑换个环境就得重新连线。更别提评审时,业务同学面对一张流程图根本说不清"这条规则到底什么条件下触发"。
所以去年我花了大半个月调研,最后定了一个看起来有点"反潮流"的方案:不用 Node-RED,也不引入任何商业闭源规则引擎,而是基于标准决策模型语法(DMN 子集),自己写一个 Go 实现。这个方案的核心价值在于,规则不再依赖特定平台的私有 DSL,而是回归到标准语法;执行引擎则用 Go 从零实现,兼顾性能和部署自由度。这篇文章适合有同样困惑的人:把流编排工具用出了规则引擎的痛点,想换又怕伤筋动骨,或者单纯想在 Go 技术栈里塞一个轻量规则引擎。
1.1 流编排工具和规则引擎,边界先搞清楚
Node-RED 到底是什么?说白了,它是一个基于 Node.js 的流式编程环境,用"节点"代表一个处理动作,用连线表示数据流,特点是低代码、可视化、部署快。在 IoT 场景里,它就是为数据管道而生的:传感器数据进来,经过 MQTT 接入、格式转换、阈值判断、告警推送,一条连线一个节点,看板一目了然。
但规则引擎解决的是另一个问题:把业务决策从代码里抽离出来,以"规则"为单位管理。规则是"当条件成立时执行动作"的显式声明,它能被单独配置、单独测试、单独审计。这两者看似有交集,实际定位完全不同。流编排关注"数据怎么走",规则引擎关注"条件怎么判"。你用 Node-RED 的 switch 节点做几个分支判断没问题,可当分支变成组合条件、嵌套决策、多级回退,流程图就退化成意大利面。
我见过太多团队把 Node-RED 当规则引擎用:规则逻辑塞进 function 节点的 JavaScript 代码里,线上改一个阈值要发版重启,规则之间互相调用靠消息流转,调试时靠往 debug 节点塞日志。这已经不是工具选型的问题,而是架构错配。流编排工具擅长的是数据管道的组装,它并不具备规则资产化管理的能力——版本对比、优先级排序、命中策略控制、条件与动作的显式建模,这些才是规则引擎的本职。
1.2 真正触发我自研的三根刺
触发我下决心的有三个具体场景。
第一,规则复用难。我们有两个业务方,一个做信贷审批,一个做活动风控,规则高度相似:都是判断用户年龄、地域、设备指纹、历史行为。在 Node-RED 里,这些逻辑只能复制一份流程,改动要同步两份,线上还经常出现两边行为不一致的事故。规则明明可以沉淀成公共资产,却被流程的物理边界硬生生切开了。
第二,规则和代码耦合太深。规则本质上就是一段 JavaScript,存在 flow JSON 里。Git 合并冲突成了家常便饭,Code Review 时根本看不出业务逻辑改动是不是符合预期。业务同学说"我已经在后台配好了",实际上函数代码还得我们改。更糟的是,规则没有独立的测试入口,你没法针对某一条规则写单元测试,只能启动整个流程跑端到端,效率低到让人绝望。
第三,性能与部署被 Node.js 运行时锁死。我们的规则调用频率很高,一个请求要跑十几条规则,Node-RED 单线程事件循环在高负载下开始丢消息;社区一直讨论的"node-red 多线程"问题,解法无非是 cluster 加 Redis 共享状态,复杂度上去了,收益却有限。更重要的是,我们的核心服务是 Go 微服务,为跑几个规则再养一个 Node.js 应用,运维成本翻倍不说,链路延迟还多一跳。三根刺叠在一起,我决定找一个更符合"决策管理"本质的方案。
2. 方案选型拆解:标准语法 + Go,到底图什么
2.1 不自己发明 DSL,选标准语法做蓝本
当时摆在面前的路有三条:继续用 Node-RED 硬扛;接一个成熟商业规则引擎;自己发明一套 DSL。
商业引擎我们认真评估过,功能确实强,但有两个现实问题:许可证成本高,按 CPU 核数收费,我们这种几千条规则的中型规模,报价已经是百万元级别;二是产品绑定深,规则存进它家的格式,将来想迁出来几乎等于重写。自研 DSL 看着自由,但 DSL 只有自己人看得懂,社区工具链全要自己造,长期维护成本不低。
最后我选了第三条路的改良版:以 OMG 的 DMN(Decision Model and Notation)标准为蓝本,实现它的核心决策表与 FEEL 表达式子集,加上一个极简的 JSON 规则描述格式。选择 DMN 的理由很朴素:它是一个公开标准,有明确的规范文档和测试用例,以后想接可视化建模工具、想换引擎实现,都有现成的路;语法层面,决策表和 FEEL 的"每行一个规则、每列一个条件"的表达方式,业务同学学习成本极低,不需要懂编程。
这个选择还有个附加好处:用标准语法开路,规则模型和引擎实现天然解耦。规则可以被人读、被工具编辑、被不同语言实现执行,这正是"标准语法先行"的核心价值。就算哪天 Go 引擎被团队嫌弃了,规则资产也能平移到另一个 DMN 兼容实现上,不会推倒重来。
2.2 Go 作为第一个实现语言,图的是什么
选 Go 不是跟风,是技术栈与现实需求的交集。
先说需求。我们要在微服务内嵌一个规则引擎,也要能独立部署成规则服务,偶尔还要在边缘设备上跑。这意味着引擎必须能编译成单一二进制、无外部运行时依赖、支持嵌入(import 进业务代码),同时性能要扛得住每秒几千次的规则命中。
Go 在这几点上几乎是标准答案:编译产物是原生机器码,内存占用低,goroutine 天然适合并发规则评估;单二进制部署,Docker 镜像小了三分之二;标准库强,网络、JSON、并发全是内置的。更关键的是,我们核心链路本来就在 Go,规则引擎以库的形式嵌进去,零额外网络开销,延迟可以从毫秒级压到微秒级。
社区近期常聊"go 集成 wasm 虚拟机",这其实是 Go 引擎的一个很有意思的扩展点:Go 编译出的规则引擎核心可以打成 wasm 模块,云端验证过的同一套决策逻辑原样下沉到边缘设备。Go 生态对 wasm 的支持比多数语言成熟,这让我们对技术栈的长期价值更有信心。
2.3 架构与目录设计
最终落地的是一个以库/SDK 形态为主、可扩展独立服务的项目,目录结构大致如下:
go-rule-engine/ ├── api/ // 对外暴露的 Go SDK 接口 ├── parser/ // DMN 子集解析器:XML/JSON -> 内部AST ├── expr/ // FEEL 子集表达式解析与求值 ├── engine/ // 决策表执行引擎,命中策略 ├── builder/ // 规则编译与校验 ├── runtime/ // 运行时上下文与扩展函数注册 └── examples/ // 可运行示例这里最关键的切割是 parser 和 engine 分离:parser 负责把标准语法变成中间表示(AST),engine 只认 AST 不认原始语法。这样以后想支持 YAML 格式、想接入可视化建模工具,只需要加一个 parser,执行引擎完全不动。这是标准语法方案最大的红利,也是我在重构时踩过几次坑后才想明白的设计——最初我把语法解析和执行逻辑写在一个包里,每加一种格式就要动执行代码,牵一发动全身。
2.4 这个方案适合谁
如果你面对的情况符合下面任意一条,这套思路大概率值得抄作业:
- 规则数量超过 20 条,且跨团队共享,需要独立管理与版本控制;
- 规则需要被频繁修改,但不能每次都发版,业务人员要能看懂;
- 你的核心服务是 Go,不希望为规则逻辑单独养一套 Node.js 或 Java 运行时;
- 对许可证和供应商绑定有顾虑,希望规则资产永远留在自己手里。
3. 核心细节解析与实操要点
3.1 规则模型:决策表 + 命中策略,先理解这四件事
我从 DMN 里砍掉了一半东西,最终保留下来的核心概念只有四个:
- 决策表(Decision Table):一张表表达一组规则,行是规则,列是条件或结论;
- 输入表达式(Input):命中的输入值,比如 age、region;
- 输出表达式(Output):命中后产生的结论;
- 命中策略(Hit Policy):多行规则同时命中时,怎么处理。
命中策略是 DMN 里最容易踩坑的地方。最初我只实现了常见的 FIRST(第一条命中),后来发现业务上有"多条同时命中、按优先级取结果"的需求,才补上 UNIQUE、PRIORITY 和 COLLECT 三种。实现上并不复杂,关键是语义要想清楚:
- UNIQUE:同一组输入至多命中一行,命中多行视为规则错误;
- FIRST:按行号从前往后,取第一条命中的;
- PRIORITY:按输出列的优先级值,取优先级最高的;
- COLLECT:收集所有命中的输出,可做聚合(求和、最大、最小)。
工程上我做了个小改动:每条规则加了一个可选字段 rule_priority,值越小优先级越高。它配合 PRIORITY 策略使用,让业务同学不用调整行顺序,就能表达"这条规则更重要"。别小看这个字段,它解决了一个非常现实的痛点:决策表排序在多人协作时是最容易打架的,有了显式优先级,大家就不用靠调整行位置来博弈了。
3.2 FEEL 子集:表达式的解析与求值
DMN 完整的 FEEL 表达式很庞大,函数式编程、for each、Y combinator 都有,我按需求砍掉了大概七成,最终只保留这些能力:
- 字面量:数字、字符串、布尔、时间日期;
- 比较运算:==、!=、<、<=、>、>=;
- 逻辑运算:and、or、not;
- 算术运算:+、-、*、/、%、^(幂);
- 成员访问与区间:a.b、list[i]、a between 1 and 10;
- 少量内置函数:min、max、sum、if、count、string、number。
解析器按经典的递归下降写法实现,词法分析生成 token 流,语法分析生成 AST,求值器遍历 AST。这里有一个工程化技巧:AST 节点除了存操作符和操作数,还存了源代码位置(行列号),报错时能直接告诉业务同学"第 3 行第 5 列出问题了",定位问题的速度能快一大截。
求值器的性能关键在三点:预处理、短路求值、对象图访问。规则里的条件表达式会在引擎初始化时解析一次,运行时不再做字符串解析;逻辑运算天然支持短路,第一段不满足就不评估第二段;对象字段访问尽量走索引而不是反射,我们在本地缓存了字段路径到字段值的映射,比原生的 reflect 快一个数量级。
3.3 规则编译与执行的关键环节
整个执行链路分三个阶段:编译期、构造期、运行期。
编译期拿到标准语法的规则描述后,做三件事:语法解析生成 AST、规则语义校验、AST 转成可执行对象。语义校验很重要但常被忽略,我们会检查:表格里是否有重复的规则 ID、引用的输入字段是否在上下文中存在、表达式是否有类型错误。这一层挡住的低级错误,比运行时排查省心十倍。
构造期内核是规则索引。为了不写 O(n) 的线性扫描,我们把条件里用到的"枚举字段"(比如 region in ["NJ","SH"])抽出做成倒排索引,直接定位候选规则集;连续区间字段(age between 18 and 60)则建立区间树。实际场景下,一次性评估 1000 条规则,用索引后只实际执行十几条条件判断,性能提升非常明显。
运行期则最朴素:上下文对象绑定到求值器,按候选规则逐条执行条件,命中后按命中策略选结果。这里有个细节:上下文绑定要复用,我们用一个带 reset 的对象池,避免每次规则评估都 new 一个 map,内存压力小很多。
4. 实操过程与核心环节实现
4.1 最小可运行版本:从解析器到执行器
我直接说怎么落地一个最小可运行版本。第一步定义规则 JSON 格式,这是给业务和运维看的:
{ "decisionId": "user_credit_check", "hitPolicy": "FIRST", "inputs": [ {"id": "age", "label": "年龄", "type": "number"}, {"id": "region", "label": "地区", "type": "string"}, {"id": "isVip", "label": "会员", "type": "boolean"} ], "outputs": [ {"id": "limit", "label": "额度", "type": "number"}, {"id": "level", "label": "等级", "type": "string"} ], "rules": [ {"id": "r1", "when": "age >= 18 and age <= 60 and region in [\"SH\",\"BJ\"] and isVip == true", "then": {"limit": 50000, "level": "gold"}}, {"id": "r2", "when": "age >= 18 and age <= 60", "then": {"limit": 20000, "level": "silver"}} ] }第二步,解析器将 when 字符串交给 FEEL 子集解析器生成 AST,每个输入字段编译成求值闭包;第三步,引擎按"输入 -> 候选集 -> 逐条判定 -> 命中策略"的顺序执行。整个过程是标准的编译执行链,和你在编译原理课上学到的套路完全一致——词法、语法、AST、求值,只是规模小了很多。
engine := engine.New(engine.WithHitPolicy(engine.FirstHit)) err := engine.Load([]byte(ruleJSON)) // 编译期 if err != nil { return } ctx := map[string]interface{}{"age": 30, "region": "SH", "isVip": true} result, err := engine.Evaluate(ctx) // 运行期 fmt.Println(result) // {"limit": 50000, "level": "gold"}这段代码调用了不到十条,但背后把解析、编译、校验、求值全串起来了。第一次跑通的时候,我拿一张 50 行的真实业务决策表做测试,输出结果毫秒级返回,那感觉比在 Node-RED 里连着线等部署要踏实太多。建议你也按这个顺序迭代:先跑通单表、再补命中策略、最后加索引优化,别一上来就追求完整 DMN 实现,那会把项目拖死在早期。
4.2 接入业务系统的两种姿势
引擎交付形态我定了两个:内嵌 SDK 和独立服务。
内嵌 SDK 适合核心链路上的高频率调用,比如请求进来先做风控决策。直接 import 库,初始化一次引擎,请求级别调用 Evaluate,延迟在几十微秒级别,瓶颈基本不在这。独立服务适合规则量巨大、团队要共享规则配置中心、或者规则引擎升级不想影响业务进程的场景。我们后来做了个 HTTP + gRPC 双协议包装,规则文件走配置中心下发,引擎实例支持热更新:同一个 engine 对象用原子指针切换整个规则集,业务进程无感。
热更新机制有一个点必须提醒:规则集切换时,正在执行的旧规则请求不能被中断。我的实现是等到当前 Evaluate 结束,再替换全局规则集指针,保证一致性。上线切了一次之后,再也不想回到 Node-RED 那种"改规则=停服务=排队重启"的节奏了。
4.3 性能压测:和 Node-RED 的真实对比
我拿同一套 50 条规则的决策表,做了三组压测数据。第一组是 Node-RED flow 在 Node.js 环境下的处理能力;第二组是 Go 引擎动态解析模式;第三组是 Go 引擎编译索引模式。测试机 4C8G,请求压满 30 秒,数据如下(单位:TPS):
| 方案 | 执行方式 | 峰值TPS | P99延迟 |
|---|---|---|---|
| Node-RED流式 | 线性执行 | 680 | 213ms |
| Go引擎(无索引) | 线性执行 | 9200 | 1.42ms |
| Go引擎(索引优化) | 索引+候选集 | 28700 | 0.38ms |
数据绝对值会受到测试场景影响,量级感受才是重点:Node-RED 受 Node.js 单线程的限制,压测一到高并发就出现事件循环阻塞,P99 直接冲到几百毫秒;Go 引擎即便不做索引优化,也已经高出十倍以上;加上索引后,快了四十倍不止,P99 稳稳压在亚毫秒。这对我们这种规则调用频率高的业务是质的差异。
4.4 顺手聊聊"Go 集成 wasm 虚拟机"这个扩展点
经常看到有人讨论 Go 和 wasm 的结合,我也在这套引擎上做了一次 PoC:把核心求值引擎用 GOOS=js GOARCH=wasm 编译成 wasm 模块,放在边缘网关里跑。
好处很直接:规则集在云端用完整版 Go 引擎测试通过后,原样编译下沉到边缘设备,决策逻辑完全一致。实测下来,wasm 模块体积 4.2MB,单次求值大概 6 到 9 微秒,完全满足边缘场景。唯一要留意,wasm 运行时是单线程的,并发能力要靠宿主环境调度,别指望着它替代云端的完整 Go 引擎。这个实验给我最大的启发是:标准语法加可编译的引擎实现,天然就为多端部署留好了通路,这是用 Node-RED 那类运行时绑定的工具永远给不了的自由度。
5. 常见问题与排查技巧实录
5.1 命中策略:一次线上事故的教训
上线初期最疼的一次事故,是 FIRST 命中策略带来的。业务配置了一张阶梯利率决策表,行顺序没排好,结果利率高的规则排在前面,导致所有客户都命中高利率档。业务同学完全没意识到,他们以为"命中策略=取最好的那条",实际引擎实现是"取第一条"。
后来我做了三件事:一是把命中策略设计成决策表显式字段,不配默认策略就直接拒绝加载;二是写单元测试时,刻意覆盖"多行同时命中"的用例,并打印命中行和优先级;三是在引擎里加了一个 debug hook,命中后会把所有候选行和选择过程打印出来。现在再出问题,基本都是五秒钟定位到具体行的水平。
5.2 表达式求值里藏得很深的一个隐蔽 Bug
FEEL 解析器有一个坑值得重点提醒:运算符与关键字之间空格处理的问题。标准语法里 age>=18 和 age >= 18 都合法,但如果在解析器里把 >= 识别为单 token,那 age> =18 这种写法会被错误地拆成两个 token,语法直接报错。我们的解决办法是词法分析阶段做最长匹配,并专门写了一个"错误提示友好化"层,遇到这类问题直接给出"是不是加个空格试试"的建议。
另一个隐蔽 Bug 出在校验类型上。有规则写 age in [18, 60],期初把区间端点理解成闭区间,没问题;后来加了一批字符串规则,发现 string in ["SH","BJ"] 和 number in [18,60] 的语义不一样,前者是集合归属、后者是数值区间。幸好我们有显式的类型信息,在类型检查阶段分开处理了。这种边界在纯 Node-RED 的 JavaScript 里根本没人管,反正一套逻辑走天下,但标准语法的类型意识一定要提早建立。
5.3 从 Node-RED 迁移规则时的三连坑
迁移阶段集中踩了三个坑,列出来给你避雷:
第一,别直接翻译 function 节点里的 JavaScript 逻辑。function 节点里是过程式代码,有循环、有中间变量、有副作用,翻译成声明式规则是把命令式逻辑改成条件表达,这中间需要重新梳理业务意图,不能机械转换。我的做法是先让业务同学重新用自然语言描述每条规则的条件和动作,再落到决策表里。
第二,上下文对象的字段对不上。Node-RED 里 flow 上下文什么都能塞:对象、数组、嵌套结构、甚至是 Buffer。标准语法下输入字段必须提前声明类型,迁移时常见问题一是字段名不一致,二是类型不匹配。我们写了个临时适配层,测试阶段做字段级差异报告,比手工对数据省事多了。
第三,别一次迁移完,先双跑。我们在切流量前,把 Node-RED 旧流程和 Go 新引擎同时跑了两周,每次输出结果做 diff。第一周 diff 出几十条不一致,大部分是命中策略优先级顺序问题;第二周收敛到零。双跑期过了之后,才敢下掉旧的 Node-RED 流程。这个保守策略在规则系统迁移里强烈建议保留,它能兜住所有你以为想清楚、实际还没想清楚的语义细节。
5.4 排查技巧速查表
最后给一个实际用着比较顺的排查技巧速查表,虽然不复杂,但每次出问题都能对应上:
| 症状 | 排查路径 | 定位办法 |
|---|---|---|
| 结果和预期不符 | 先查命中策略,再查优先级 | 开启 debug hook,打印候选行和选择过程 |
| 表达式报错 | 检查 token 是否被错误切割 | 查看错误消息里的行列号提示 |
| 性能突然下降 | 判断候选集是否退化 | 打索引命中日志,看实际评估了多少行 |
| 热更新后行为变化 | 立刻回滚并对比两套规则集 diff | 规则集做版本号管理,测试环境预跑一次 |
| 类型错误 | 检查输入类型声明与数据实际类型 | 适配层输出字段级差异报告 |
最后说几句
折腾完这套东西,我最大的体会是:Node-RED 没问题,问题是拿流编排工具硬扛规则引擎场景。工具选型错配的代价,不在第一天显现,而在规则堆积到一定体量、业务开始频繁提变更需求的那天集中爆发。标准语法加 Go 先行实现的组合,找到一个平衡点:规则模型足够通用,不会被语言和平台绑死;执行性能又足够硬,能真正扛起生产流量。
如果你也卡在这个阶段,我的建议是先别急着买商业引擎,也别急着撸一套私有 DSL,先拿一个标准语法子集搭最小原型跑起来,用真实规则集压测两周,你自然会有判断。规则引擎的工程化门槛比想象中小,难点全在语义边界和一致性上——把命中策略和类型系统这两件事钉死,后面会顺利很多。
最后再分享一个小技巧:规则引擎上线后,别把规则文件扔在代码仓库里没人管,尽早接一个简单的配置管理后台,给规则加上版本、发布时间和变更人。规则资产化这件事,做得越早,后面省钱省得越明显。我自己就是因为前期没有版本记录,在排查一次线上差异时翻了半天 git 历史,那滋味真的不好受。