引言
任何业务系统里都有一批「如果这样就这样,如果那样就那样」的判断:贷款额度超过 50 万要人工复核、客户等级是金卡就打 9 折、风险评分大于 80 就拒绝。这些判断最初写在 if-else 里,随着业务变化越堆越多,最终变成一个没人敢改的 decide() 方法。
规则引擎要解决的就是这件事:把业务规则从代码里抽出来,用表格或 DSL 表达,让业务方能读、能改、能测试、能版本化。它和工作流引擎是互补的:工作流回答「流程怎么走」,规则引擎回答「这个条件下该走哪条路」。
但规则引擎也是最容易被滥用的技术之一。把 20 行 if-else 搬进规则引擎,只会增加一个需要运维的组件和一层需要穿透的抽象,收益为负。判断标准是「规则的变更频率与变更主体」:如果规则每月都要改且由业务方提出,就值得抽出来;如果一年改一次且由工程师改,留在代码里更好。
本文先讲规则的三种表达形式与选型,再深入 DMN 决策表与命中策略,然后讲 Drools 与 DRL、规则版本化、测试策略,最后讲与工作流引擎和策略即代码的边界。想先看流程侧的内容,可以从 BPMN 2.0 与 Camunda 实战 开始。
目录
- 规则引擎要解决什么问题
- 规则的三种表达形式
- DMN 决策表基础
- 命中策略的选择
- FEEL 表达式与数据类型
- 决策需求图与多级决策
- Drools 与 DRL
- 事实、会话与议程
- 规则版本化与灰度
- 规则的可测试性
- 与工作流引擎的集成
- 与策略即代码的边界
- 评分卡与风险模型
- 规则的冲突与优先级
- 规则的性能优化
- 规则的可观测
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 规则引擎要解决什么问题
规则引擎解决四个具体问题,只要命中一个就值得评估引入:
- 规则频繁变更,且变更由业务方提出,走研发排期太慢。
- 规则数量多(超过 50 条)且互相有关联,代码里的优先级难以维护。
- 规则需要可解释:能回答「为什么这个申请被拒绝了」。
- 规则需要审计:监管要求能提供「当时的规则是什么」。
反过来,如果规则稳定、数量少、不需要解释与审计,那么一个带注释的 if-else 就是最好的规则引擎。引入规则引擎的成本是「多一个组件、多一门 DSL、多一层调试穿透」,这些成本在规则简单时完全无法被收益覆盖。
还有一个中间态值得考虑:把规则抽成独立的模块或配置类,用清晰的数据结构表达,而不引入引擎。这样既获得了「规则集中、可测试」的收益,又避免了引擎的复杂度。很多项目真正需要的只是这一步。
2. 规则的三种表达形式
| 形式 | 表达力 | 业务方友好度 | 可测试性 | 典型工具 |
|---|---|---|---|---|
| 决策表 | 中(条件组合) | 高(就是表格) | 高(逐行可测) | DMN、Excel |
| DSL 规则 | 高(支持推理) | 中 | 中 | Drools DRL |
| 表达式 | 低到中 | 低 | 高 | CEL、SpEL、JS |
决策表是最实用的形式:一行一条规则,一列一个条件,最后一列是结论。业务方能直接读,工程师能逐行写测试。它的局限是「表达能力有限」——不支持跨规则的推理与事实传播。
DSL 规则(Drools 的 DRL)支持前向推理:规则 A 的结论可以成为规则 B 的条件,适合复杂场景(比如风控里的多轮推导)。代价是可读性下降,且规则的执行顺序不直观(由引擎的议程决定)。
表达式(CEL、SpEL)适合「单条判断」,比如 API 网关的路由条件、特性开关的开关条件。它们不适合表达多条规则的组合。
选择建议:先用决策表覆盖 80% 的场景,只有确实需要推理时才上 DRL。不要一开始就选表达力最强的工具,那通常意味着最长的学习曲线与最高的维护成本。
3. DMN 决策表基础
DMN(Decision Model and Notation)是 OMG 的决策建模标准,与 BPMN 同源。一个决策表由输入列、输出列和规则行组成。
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
id="riskDecision" name="风险评估">
<decision id="riskLevel" name="风险等级">
<decisionTable id="riskTable" hitPolicy="FIRST">
<input id="in1" label="客户等级">
<inputExpression typeRef="string"><text>customer.tier</text></inputExpression>
</input>
<input id="in2" label="申请金额">
<inputExpression typeRef="number"><text>amount</text></inputExpression>
</input>
<output id="out1" label="风险等级" name="level" typeRef="string"/>
<output id="out2" label="是否人工复核" name="manual" typeRef="boolean"/>
<rule id="r1">
<inputEntry><text>"PLATINUM"</text></inputEntry>
<inputEntry><text>< 100000</text></inputEntry>
<outputEntry><text>"LOW"</text></outputEntry>
<outputEntry><text>false</text></outputEntry>
</rule>
<rule id="r2">
<inputEntry><text>"PLATINUM","GOLD"</text></inputEntry>
<inputEntry><text>[100000..500000]</text></inputEntry>
<outputEntry><text>"MEDIUM"</text></outputEntry>
<outputEntry><text>true</text></outputEntry>
</rule>
<rule id="r3">
<inputEntry><text>-</text></inputEntry>
<inputEntry><text>> 500000</text></inputEntry>
<outputEntry><text>"HIGH"</text></outputEntry>
<outputEntry><text>true</text></outputEntry>
</rule>
</decisionTable>
</decision>
</definitions>
输入条目支持多种写法:单值("PLATINUM")、枚举("PLATINUM","GOLD")、区间([100000..500000])、比较(< 100000)、通配(-)。这些写法让表格保持简洁,但也意味着输入列的类型必须正确,否则区间比较会退化成字符串比较。
4. 命中策略的选择
命中策略(Hit Policy)决定「多条规则同时匹配时取哪一条」,是 DMN 里最重要的语义选择:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| UNIQUE | 只允许一条匹配,多条则报错 | 规则互斥的严格场景 |
| FIRST | 取第一条匹配(按顺序) | 有优先级的场景(推荐默认) |
| PRIORITY | 取输出值优先级最高的 | 输出可排序的场景 |
| ANY | 允许多条匹配,但结论必须相同 | 交叉校验规则一致性 |
| COLLECT | 收集所有匹配的输出 | 需要汇总(求和、计数) |
| RULE ORDER | 按规则顺序收集 | 需要有序结果 |
FIRST 是最常用的,因为它符合人的直觉:从上往下看,第一条匹配的生效。代价是「规则的顺序变得重要」,插入一条规则可能改变已有行为,所以规则表要版本化并做回归测试。
UNIQUE 看起来最安全,但它在规则有重叠时会直接报错,而规则重叠在业务上很常见(比如「金卡且金额大」同时匹配两条)。用它需要把规则设计成完全互斥,维护成本高。
COLLECT 常用于需要汇总的场景,比如「所有适用的折扣规则,累加折扣率」。
5. FEEL 表达式与数据类型
FEEL(Friendly Enough Expression Language)是 DMN 的表达式语言,语法接近自然语言:
// 条件判断
amount > 100000 and customer.tier = "PLATINUM"
// 区间与列表
amount in [100000..500000]
tier in ("GOLD", "PLATINUM")
// 日期计算
now() - order.createdAt < duration("P30D")
// 条件表达式
if amount > 500000 then "HIGH" else "LOW"
FEEL 的类型系统有三个容易踩的坑:
- 数字与字符串不自动转换。
amount > "100000"会报类型错误,而在 JSON 里传入的数字如果被解析成字符串就会失败。 - 日期与时间有时区语义。
date("2026-10-07")与date and time("2026-10-07T00:00:00")是不同的类型,比较时会出错。 - 空值(null)的传播。
null > 100的结果是null而不是false,在条件里会被当作「不匹配」,这通常符合预期但要显式测试。
在 Camunda 里调用 DMN 时,输入的变量类型要与 DMN 定义一致,否则会出现「本地测试通过、线上类型不匹配」的问题。建议在 DMN 的单元测试里覆盖类型边界(字符串数字、空值、极值)。
6. 决策需求图与多级决策
一个复杂决策通常由多个子决策组成,它们之间有依赖关系。DMN 用决策需求图(DRG)表达这种依赖:
[客户等级] [申请金额]
\ /
\ /
[基础风险评分] [额度校验]
\ /
\ /
[最终审批路由]
每个方框是一个决策,箭头表示「输入依赖」。拆成多级决策的好处是每级都可以独立测试与复用:基础风险评分 可以在多个流程里复用,额度校验 可以单独调整阈值。
在 Camunda 里,一个 DRG 里的多个决策可以在同一个 DMN 文件里定义,用 informationRequirement 声明依赖,引擎会按依赖顺序求值。
<decision id="finalRoute" name="最终审批路由">
<informationRequirement>
<requiredDecision href="#riskLevel"/>
</informationRequirement>
<decisionTable hitPolicy="FIRST">...</decisionTable>
</decision>
实践建议:把决策拆到「每个决策表不超过 15 行」的粒度。超过这个规模时,通常意味着可以按维度拆成多个决策。
7. Drools 与 DRL
Drools 是 Java 生态最成熟的规则引擎,用 DRL 描述规则。它的核心优势是支持前向推理与复杂事件处理(CEP)。
// DRL 文件
rule "高额订单需要总监审批"
when
$o : Order(amount > 100000, status == "PENDING")
not ApprovalRequest(orderId == $o.id, level == "DIRECTOR")
then
insert(new ApprovalRequest($o.getId(), "DIRECTOR"));
modify($o) { setNeedsDirector(true) };
end
rule "黑名单客户直接拒绝"
when
$o : Order(customerId != null)
$c : Customer(id == $o.customerId, blacklisted == true)
then
modify($o) { setStatus("REJECTED"); setReason("黑名单客户") };
end
KieServices ks = KieServices.Factory.get();
KieContainer kc = ks.getKieClasspathContainer();
KieSession session = kc.newKieSession("orderRules");
session.insert(order);
session.insert(customer);
session.fireAllRules();
session.dispose();
DRL 的 when 部分是模式匹配(类似 SQL 的 where),then 部分是动作。insert 会把新事实加入工作内存,可能触发其他规则,这就是前向推理。
DRL 的代价是「规则的执行顺序不可预测」(由引擎的议程与冲突解决策略决定),排查问题时需要开 agenda-group 与审计日志。这也是为什么多数业务规则场景用 DMN 就够。
8. 事实、会话与议程
Drools 的三个核心概念需要理解清楚才能用好:
- 事实(Fact):插入工作内存的数据对象,规则对它们做模式匹配。
- 会话(KieSession):工作内存的容器,有状态会话可跨请求保留事实,无状态会话每次执行后清空。
- 议程(Agenda):待执行规则的队列,按优先级与冲突解决策略排序。
// 有状态会话:适合持续推理(比如实时风控)
KieSession session = kc.newKieSession("riskSession");
// 无状态会话:适合一次性求值(比如审批路由)
StatelessKieSession stateless = kc.newStatelessKieSession("routeSession");
stateless.execute(List.of(order, customer));
选择标准是「规则之间是否需要跨请求共享事实」。审批路由这类「一次输入、一次输出」的场景应该用无状态会话,避免内存泄漏与状态污染。有状态会话必须显式 dispose(),否则会泄漏。
9. 规则版本化与灰度
规则是业务逻辑的一部分,必须像代码一样版本化。三个层次:
- 规则文件版本化:DMN/DRL 文件放进 Git,走 PR 评审。
- 部署版本化:引擎部署时生成版本号,流程实例绑定启动时的版本。
- 生效版本化:支持按时间、按流量、按客户灰度切换规则版本。
# 规则灰度配置
rules:
risk-assessment:
active: v12
canary:
version: v13
percent: 5
match: "customer.tier == 'PLATINUM'"
rollback: v11
灰度对规则引擎尤其重要,因为规则变更的影响往往是「静默的」:不会报错,只是结论变了。一个把「额度 50 万」误写成「额度 5 万」的规则,会让大量申请被错误拒绝,且没有任何异常日志。所以规则上线必须有「对比运行」(影子模式):新旧规则同时计算,记录差异,人工确认后再切换。
10. 规则的可测试性
决策表最大的优势是「天然可测试」。每条规则行都是一个测试用例:
@ParameterizedTest
@CsvSource({
"PLATINUM, 50000, LOW, false",
"PLATINUM, 200000, MEDIUM, true",
"GOLD, 200000, MEDIUM, true",
"SILVER, 600000, HIGH, true",
})
void riskLevelTest(String tier, double amount, String expectedLevel,
boolean expectedManual) {
Map<String, Object> result = dmnEngine.evaluate("riskLevel",
Map.of("customer", Map.of("tier", tier), "amount", amount));
assertThat(result.get("level")).isEqualTo(expectedLevel);
assertThat(result.get("manual")).isEqualTo(expectedManual);
}
除了逐行测试,还要测三类边界:规则的覆盖完整性(是否存在没有规则覆盖的输入组合)、规则的互斥性(用 UNIQUE 策略时检查是否有重叠)、规则的单调性(比如「金额越大风险等级不应越低」)。
第三类测试需要业务方参与定义不变量。这些不变量是规则的「业务契约」,比逐行测试更能发现设计错误。
11. 与工作流引擎的集成
规则引擎与工作流引擎的集成有三种模式:
- 流程中调用规则:BPMN 的 BusinessRuleTask 调用 DMN,把结果写入流程变量,用于后续网关判断。
- 规则驱动流程:规则的输出直接是「下一步该走哪个节点」,流程由规则表驱动。
- 规则独立于流程:规则作为服务被调用,流程与规则完全解耦。
<bpmn:businessRuleTask id="riskDecision" name="风险评估"
camunda:decisionRef="riskLevel"
camunda:resultVariable="riskResult"
camunda:mapDecisionResult="singleResult"/>
第二种模式(规则驱动流程)在审批流里很常见:一个「路由规则表」决定「这个申请该走哪几级审批」。它的好处是流程定义保持简单(一个多实例审批节点),复杂度收敛到规则表里。坏处是流程的可视化变得没有意义(图上只有一个节点,实际路径由规则决定)。
建议:网关判断用第一种模式(规则只提供结论,流程决定路径),审批人路由用第二种模式(规则提供列表,流程用多实例展开)。这样既保持了流程的可读性,又把易变的规则抽了出去。
12. 与策略即代码的边界
「策略即代码」(Policy as Code)与规则引擎有重叠但侧重不同:
| 维度 | 规则引擎 | 策略即代码 |
|---|---|---|
| 目的 | 业务决策 | 合规与治理约束 |
| 典型工具 | DMN、Drools | OPA/Rego、Kyverno、Sentinel |
| 触发点 | 业务流程内部 | 准入控制(K8s、CI、API 网关) |
| 失败语义 | 返回结论 | 拒绝操作 |
| 变更主体 | 业务方 | 平台与安全团队 |
选择原则:业务决策用规则引擎,基础设施与平台的约束用策略即代码。两者的技术可以互相借鉴(比如 OPA 也可以做业务决策),但组织归属不同,混用会导致「谁负责改规则」的争议。策略即代码的实践见 策略即代码与治理 。
一个实际的分工例子:API 网关用 OPA 做「这个租户能不能访问这个接口」的准入判断,业务流程里用 DMN 做「这个订单该不该人工审核」的业务决策。
13. 评分卡与风险模型
评分卡是规则引擎最常见的应用形态:多个因子加权求和得到分数,再按分数分档。
因子 权重 取值规则 得分
客户等级 30 PLATINUM=100, GOLD=70 按等级线性
历史逾期次数 25 0 次=100, 1 次=50, >=2=0
申请金额 20 < 5万=100, 5-20万=60, >20万=20
渠道风险 15 自有=100, 合作方=70
设备指纹 10 可信=100, 未知=50
总分 = Σ(权重 × 得分),然后按总分分档:>= 80 自动通过,60 到 80 人工复核,< 60 拒绝。
-- 评分卡规则的持久化
CREATE TABLE scorecard_factor (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
card_code VARCHAR(64) NOT NULL,
factor_code VARCHAR(64) NOT NULL,
weight DECIMAL(5,2) NOT NULL,
value_expr TEXT NOT NULL, -- FEEL 表达式
version INT NOT NULL,
UNIQUE KEY uk_card_factor_ver (card_code, factor_code, version)
);
把评分卡做成数据表的好处是「调权重不需要改代码」,业务方可以在管理后台调整并预览效果。代价是需要一套「权重调整的审批与回归」机制,否则权重会被随意调整而无人知晓。
14. 规则的冲突与优先级
规则冲突有三种表现:
- 重叠:两条规则同时匹配,结论不同。用
FIRST时结果取决于顺序。 - 覆盖:一条规则永远无法匹配(被前面的规则遮蔽)。这是静默的 bug。
- 循环:Drools 里规则 A 触发 B、B 又触发 A,导致无限循环。
// Drools 的冲突解决:显式指定优先级
rule "高优先级规则"
salience 100
when ... then ... end
salience 是 Drools 的优先级属性,数值越大越先执行。但依赖 salience 是脆弱的:新增规则时需要重新审视所有优先级。更好的做法是让规则尽量互斥(通过条件设计),把优先级作为最后手段。
检测覆盖与重叠的实用方法是「规则覆盖率分析」:对输入空间做采样或穷举,统计每条规则被命中的次数。命中次数为 0 的规则要么是冗余的,要么是被遮蔽的,都应该被审查。
15. 规则的性能优化
规则引擎的性能瓶颈通常在「模式匹配」而不是「规则数量」。优化手段:
- 用索引:Drools 的 RETE 网络会对事实建立索引,但要求事实对象的
equals/hashCode正确实现。 - 减少事实数量:只插入规则需要的事实,不要插入整个上下文。
- 用无状态会话:避免跨请求的事实累积。
- 缓存决策结果:对相同的输入(同样的客户、同样的金额)缓存结论,避免重复计算。
@Cacheable(value = "riskDecision", key = "#customerId + ':' + #amount")
public String evaluateRisk(String customerId, BigDecimal amount) {
return dmnEngine.evaluate("riskLevel", buildInput(customerId, amount));
}
缓存要注意「规则版本变化时失效」:缓存 key 里应该包含规则版本,或者规则发布时主动清空缓存。忘记这一点会导致「规则改了但结论没变」的诡异问题。
16. 规则的可观测
规则引擎的观测重点是「可解释性」:每次决策都要能回答「哪条规则命中、输入是什么、输出是什么」。
public DecisionResult evaluate(String decisionRef, Map<String, Object> input) {
DecisionResult result = engine.evaluate(decisionRef, input);
// 记录决策日志,包含命中的规则 id
decisionLog.record(DecisionLog.builder()
.decisionRef(decisionRef)
.input(input)
.output(result.getOutputs())
.matchedRules(result.getMatchedRuleIds())
.ruleVersion(result.getVersion())
.durationMs(result.getDurationMs())
.build());
return result;
}
三个关键指标:决策耗时(P99)、规则命中分布(哪些规则最常命中)、规则版本分布(有多少请求还在用老版本)。第二个指标能发现「某条规则从未命中」的异常,第三个能确认灰度发布是否生效。
决策日志的存储量可能很大(每次决策一条),要评估保留期与采样策略。合规场景通常要求全量保留一段时间(比如 1 年),之后可降采样。
17. 落地路线图
- 第 1 周:把现有代码里的 if-else 规则清单化(列成表格),与业务方确认哪些规则易变。
- 第 2 周:只把「易变且业务方关注」的规则抽成 DMN 决策表,用
FIRST命中策略。 - 第 3 周:为决策表写逐行测试与边界测试,接入 CI。
- 第 4 周:加入决策日志与灰度机制(影子模式对比新旧规则)。
第一步的清单化不要跳过。它本身就是一次「规则审计」,常常能发现互相矛盾或从未生效的规则。很多团队在清单化阶段就解决了问题,不需要引入引擎。
18. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 决策表(DMN) | 业务方可读,逐行可测 | 表达力有限,不支持推理 |
| DRL(Drools) | 支持前向推理与 CEP | 顺序不直观,调试复杂 |
| 表达式(CEL) | 轻量、性能好 | 只适合单条判断 |
| 抽成独立模块 | 无新组件,规则集中 | 业务方仍不能直接改 |
| FIRST 命中策略 | 符合直觉,易于理解 | 规则顺序变成隐式契约 |
| UNIQUE 命中策略 | 强制规则互斥 | 重叠时报错,维护成本高 |
| 规则存数据库 | 可在线调整 | 需要审批与回归机制 |
| 规则存文件 | 可版本化、走 PR | 调整需要发版 |
| 结果缓存 | 性能提升明显 | 需要处理版本失效 |
19. 常见坑清单
- 把 20 行 if-else 搬进规则引擎,收益为零但多了一个组件要运维。
- DMN 输入列类型定义错误(数字写成字符串),区间比较退化成字典序比较。
- 用
UNIQUE策略但规则实际有重叠,运行时直接报错导致流程中断。 - 用
FIRST策略但不做回归测试,插入新规则改变了已有行为。 - 规则里写死具体的人或组织(审批人姓名),人员变动后规则失效。
- 规则变更不做灰度,一次误改导致大量申请被错误拒绝且无告警。
- 规则结果缓存不带版本号,规则更新后缓存未失效,结论不一致。
- Drools 有状态会话忘记
dispose(),内存持续增长直到 OOM。 - 规则之间存在循环触发(A 触发 B,B 触发 A),引擎陷入无限循环。
- 规则从未命中(被前置规则遮蔽),无人发现直到业务投诉。
- 决策日志全量落库但没评估量级,半年后表爆掉。
- 把基础设施准入控制(K8s 准入、网关鉴权)也塞进业务规则引擎,职责混乱。
20. 小结
规则引擎的价值是「把易变的业务判断从代码里抽出来,让业务方能读、能改、能测」。它只在规则确实易变且由业务方驱动时才值得引入,否则一个结构清晰的配置类就够了。选择表达形式时,决策表覆盖 80% 场景,DRL 只在需要前向推理时使用。
落地时的三条底线:规则必须版本化(走 PR 与回归测试)、规则上线必须能灰度(影子模式对比)、每次决策必须可解释(记录命中规则与输入输出)。这三条做到了,规则引擎的长期维护成本就可控。
下一步建议读 策略即代码与治理 ,把「业务决策」与「平台约束」的边界划清;如果规则是为审批流提供路由,则应该结合 人工任务与审批流表单 一起看,理解规则输出如何驱动多实例审批节点。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。