一段代码里出现第十个 if (type == ...) 时,"上规则引擎"的声音就会冒出来。但它不是免费午餐:编译期能查出的错误变成运行期错误,IDE 的跳转与重构换成字符串表达式。判断标准只有一个——规则的变化频率是否已高于代码的发布频率。
1. 什么时候该迁移,什么时候不该
| 信号 | 该迁移 | 不该迁移 |
|---|---|---|
| 变更频率 | 每月都在改,改一次发一次版 | 一年动不了一次 |
| 谁在改 | 业务方要求自己配 | 只有开发看得懂 |
| 组合数 | 条件 × 动作的组合爆炸 | 三条互不干扰的分支 |
| 复杂度 | 单条规则本身很简单 | 每条规则都是一段算法 |
| 排错需求 | 要能回答"为什么这单走了这条路" | 出问题看日志就够 |
| 团队状态 | 有人能维护引擎与规则表 | 没人有精力做这套基建 |
反过来说,这几种情况引入引擎基本是亏的:规则写死且稳定;只有两三条;规则里要查库、调外部接口、算复杂金额;团队没人愿意写回归用例。把简单逻辑复杂化,是规则引擎最常见的失败方式。
2. 四个实现层级与成本对照
if 到规则引擎之间不是一步跳,中间有两个更务实的选择。
| 层级 | 做法 | 改动成本 | 上线成本 | 表达能力 | 适合 |
|---|---|---|---|---|---|
硬编码 if |
直接写在 Service | 改代码 | 发版 | 最强 | 稳定、量少 |
| 策略模式 + 配置 | 每种规则一个实现类,用配置选类 | 加类 | 发版(加新规则) | 强 | 规则种类有限、分支清晰 |
| 表达式引擎 | 条件写成字符串存库,运行期求值 | 改数据 | 不发版 | 中 | 阈值/条件频繁变 |
| 完整规则引擎 | 事实 + 规则 + 会话,支持多规则推理 | 改规则集 | 不发版 | 中偏强(有 DSL) | 规则数量大、需链式推理 |
// 层级 1:硬编码
if (order.getAmount() > 5000 && user.isVip()) { order.setDiscount(0.9); }
// 层级 2:策略 + 配置(配置决定用哪个策略)
order.setDiscount(strategies.get(config.get("discount.strategy")).apply(order));
// 层级 3:条件从库里读出来,运行期求值
// 库里存:amount > 5000 && vip == true 结果列:discount = 0.9
判断方法很朴素:改动是否需要发版。若每次调整只改数据、不发版,第三层就够,不必上第四层。
3. 表达式引擎:SpEL、Aviator、QLExpress
| 维度 | SpEL | Aviator | QLExpress |
|---|---|---|---|
| 来源 | Spring 自带 | 独立开源库 | 独立开源库(阿里系) |
| 语法 | 类 Java,功能全 | 类 JS,轻量 | 类 Java,支持宏与函数 |
| 依赖 | 已在 Spring Boot 里 | 需引依赖 | 需引依赖 |
| 性能取向 | 一般,适合低频 | 面向高频、可编译 | 一般,功能丰富 |
| 安全默认值 | 能力大、风险也大 | 可关掉不必要的特性 | 可限制函数与变量 |
| 适合 | 已有 Spring 生态、低频规则 | 高频、条件简单 | 需要脚本体量与函数库 |
| 资料 | 官方文档详尽 | 中文文档为主 | 中文文档为主 |
// SpEL:解析一次、复用(不要每次 new)
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("amount > 5000 && vip");
// 生产务必用 SimpleEvaluationContext,限制可写属性与方法调用
EvaluationContext ctx = SimpleEvaluationContext
.forReadOnlyDataBinding()
.withRootObject(orderView)
.build();
boolean hit = Boolean.TRUE.equals(exp.getValue(ctx, Boolean.class));
// Aviator:同样是编译一次、复用
Expression compiled = AviatorEvaluator.compile("amount > 5000 && vip");
Object result = compiled.execute(Map.of("amount", order.getAmount(), "vip", user.isVip()));
表达式的安全边界必须显式设定——表达式注入的本质是"把用户可控的字符串当代码执行":
| 危险写法 | 后果 | 处理 |
|---|---|---|
用 StandardEvaluationContext 求值外部输入 |
可调用任意方法、读系统属性 | 换只读上下文 |
表达式里允许 T(...) 类型引用 |
可构造任意对象 | 关闭类型引用 |
| 缓存无上限 | 表达式种类多则内存涨 | 限定表达式来源集合 |
4. 完整规则引擎:fact、rule、when/then、session
Drools 这套概念值得单独记,因为换成别的引擎,名词不同但结构一样。
| 概念 | 含义 | 对应到业务里 |
|---|---|---|
| Fact | 放进规则引擎工作内存的事实对象 | 订单、用户、商品 |
| Rule | 一条"条件 → 动作" | when 金额>5000 then 打折 |
when(LHS) |
条件部分,模式匹配 | 金额、等级、时间窗 |
then(RHS) |
动作部分,可修改事实或输出 | 置折扣、加标签、写审计 |
| Session | 工作内存 + 待匹配规则 | 一次评估的上下文 |
| Agenda / 冲突 | 多条规则都匹配时的执行顺序 | 谁先谁后、谁覆盖谁 |
// 典型流程:装载规则 → 插入事实 → 触发 → 取回被修改的事实
KieServices ks = KieServices.Factory.get();
KieContainer kc = ks.getKieClasspathContainer();
KieSession session = kc.newKieSession("order-rules");
try {
OrderFact fact = new OrderFact(order);
session.insert(fact);
session.fireAllRules();
apply(fact); // 事实被 RHS 修改过,回写到业务对象
} finally {
session.dispose(); // 每次评估后销毁,避免状态串味
}
学习成本要摆明:DSL 语法、Session 生命周期、Agenda 冲突策略、规则文件的热更新与版本管理,这四样至少吃掉一个熟手工程师一两周的产出,之后还要有人长期维护。团队三五个人、规则不到几十条时,收益通常盖不住成本。
5. 规则的组织:存库 + 热加载 + 版本化
不管用哪一层实现,"规则怎么存、怎么生效、怎么回滚"这三件事都得想清楚。
| 要素 | 做法 | 注意 |
|---|---|---|
| 存储 | 规则表(条件、动作、优先级、启用状态) | 条件与动作分列存,便于统计 |
| 生效 | 定时拉取 + 版本号比对,或发布时推送 | 避免每次请求都查库 |
| 热加载 | 内存里保留"规则集 + 版本号",变更后整体替换 | 用不可变对象整体替换,不要原地改 |
| 版本化 | 每次修改生成新版本,旧版本保留 | 回滚就是切回旧版本 |
| 审计 | 谁在什么时候改了什么 | 这是规则引擎相比 if 的最大额外收益 |
CREATE TABLE rule_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(64) NOT NULL, -- 业务场景
rule_code VARCHAR(64) NOT NULL, -- 规则标识
condition_exp VARCHAR(1024) NOT NULL, -- 条件表达式
action_json JSON NOT NULL, -- 动作参数
priority INT NOT NULL DEFAULT 100, -- 越小越先
enabled TINYINT NOT NULL DEFAULT 1,
version INT NOT NULL DEFAULT 1,
UNIQUE KEY uk_biz_code (biz_type, rule_code, version)
);
// 热加载:整体替换 + 版本号,读侧只用 volatile 引用
@Component
public class RuleRegistry {
private volatile RuleSet current = RuleSet.empty();
@Scheduled(fixedDelay = 30_000)
public void refresh() {
long latest = ruleDao.maxVersion();
if (latest == current.version()) return; // 没变就不动
current = RuleSet.compile(ruleDao.loadEnabled(latest)); // 原子替换
}
public RuleSet get() { return current; }
}
热加载失败必须告警且保留旧规则,而不是把内存清空。清空等于所有规则失效,业务上往往表现为"折扣全没了"或"全部走兜底分支",比报错更难查。
6. 可测试性:一条规则一个用例
规则从代码里搬进数据库后,最大的损失是编译期检查与 IDE 跳转。补回来的唯一手段是测试用例。
| 层次 | 测什么 | 形式 |
|---|---|---|
| 表达式本身 | 语法、字段是否存在 | 加载时校验,失败即拒绝发布 |
| 单条规则 | 命中 / 不命中 / 边界值 | 每条至少三个用例 |
| 规则组合 | 多条同时命中的结果 | 决策表风格用例 |
| 兜底分支 | 全不命中时的默认行为 | 必须有用例,最易漏 |
| 回归 | 本次改动是否影响存量规则 | 全量规则跑一遍的回归套件 |
@Test
void vip_over_5000_gets_discount() {
RuleSet set = RuleSet.compile(ruleDao.loadEnabled(1L));
Assessment r = set.assess(new OrderView(new BigDecimal("5000.01"), true));
assertEquals(new BigDecimal("0.90"), r.discount());
// 边界:恰好 5000 不应命中(条件为 > 5000)
assertNull(set.assess(new OrderView(new BigDecimal("5000"), true)).discount());
}
规则发布前强制跑全量回归,把"改坏了"挡在发布环节而不是等投诉。这一步越自动,允许业务方自助改规则的空间越大。
7. 规则冲突与优先级
多条规则同时命中时的处理方式,决定了整套规则是否可预期。
| 策略 | 说明 | 适用 |
|---|---|---|
| 优先级数值 | 数值小的先执行 | 最常见、最直观 |
| 首个命中即停 | 按序求值,命中一条就结束 | 互斥型规则(分类、路由) |
| 全部执行并合并 | 都执行,动作累加或取极值 | 优惠叠加、标签累加 |
// 首个命中即停,排序在编译期完成,运行期只做短路求值
for (CompiledRule rule : set.ordered()) {
if (rule.matches(view)) return rule.assess(view);
}
return set.fallback().assess(view); // 兜底一定存在
发布时校验很有价值:把"两条规则重叠"变成拒绝发布的错误,而不是运行期随机命中一条。
8. 排错:七个常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 改了规则没生效 | 缓存没刷新,版本号比对失效 | 检查刷新任务与版本号;提供手动刷新入口 |
| 规则改了以后大量订单走默认值 | 加载失败后规则集被清空 | 加载失败保留旧版本并告警 |
| 表达式报"找不到属性" | 事实对象字段名与表达式不一致 | 加载时做一次字段存在性校验 |
| 线上出现不该有的折扣 | 表达式注入或条件可被外部构造 | 只读上下文 + 白名单字段 |
| 规则改坏没人发现 | 没有回归用例 | 全量回归纳入发布流程 |
| 冲突时行为随机 | 依赖规则加载顺序 | 显式 priority,并做重叠校验 |
9. 边界:这些情况别上规则引擎
| 场景 | 更合适 | 理由 |
|---|---|---|
| 规则稳定、一年不动 | 硬编码 if |
引擎的全部收益为零 |
| 规则是复杂算法 | 代码 | DSL 表达力有限,写出来更难懂 |
| 只有两三条规定 | if 或决策表 |
引入成本大于收益 |
| 要求强一致、不可出错 | 决策表 + 全量测试 | 引擎的灵活性反而是风险 |
一句话:改规则要不要发版,是唯一的硬判据。 答案是"要"就不要上引擎;答案是"不要",才轮到选哪一层。
10. 自测
- 判断"是否该上规则引擎"的那条硬判据是什么?
- "策略模式 + 配置"和"表达式引擎",差别在改动是否需要发版吗?
- SpEL 生产环境为什么建议用只读上下文?
- 规则热加载失败时,为什么必须保留旧规则而不是清空?
- 多条规则同时命中,有哪几种处理策略,各自适合什么场景?
- 规则从代码搬进数据库后,损失了什么,靠什么补回来?