4. 拆分规则引擎设计:规则的定义、加载、匹配与执行
规则引擎,说白了就是订单拆分系统的「大脑」。
我见过不少团队,一上来就写 if-else 硬编码。刚开始还行,等业务方提了第 37 个拆分需求时,代码已经成了一团乱麻。嗯,这就是我们今天要聊的核心——怎么把规则从代码里「抽」出来,变成一个可配置、可扩展的引擎。
4.1 规则的定义:先想清楚「规则长什么样」
我个人习惯,设计任何系统前先定义数据结构。规则也不例外。
一条拆分规则,至少包含三个要素:
- 条件(Condition):什么情况下触发这条规则?比如「订单金额 > 5000」
- 动作(Action):触发后做什么?比如「按商品品类拆分」
- 优先级(Priority):多条规则冲突时听谁的?
我在项目中遇到过最坑的事——规则没有优先级字段。结果两条规则同时匹配,系统随机选了一条执行,仓库那边拆出来的包裹乱七八糟。从那以后,优先级成了我定义规则时的必选项。
来看一个典型的规则定义结构:
{
"ruleId": "R001",
"ruleName": "大额订单按品类拆分",
"priority": 10,
"condition": {
"field": "orderAmount",
"operator": "GREATER_THAN",
"value": 5000
},
"action": {
"type": "SPLIT_BY_CATEGORY",
"params": {
"maxItemsPerPackage": 3
}
},
"status": "ACTIVE"
}
你想想看,如果把这个 JSON 存到数据库或配置中心,业务方改规则时只需要改一条记录,完全不用动代码。这才是规则引擎的初衷。
4.2 规则的加载:别在每次请求时读数据库
规则定义好了,怎么加载到内存里?
我曾经见过一个系统,每次拆分请求都去查一次规则表。结果数据库连接池被打满,订单处理超时。为什么会这样?因为规则是相对静态的数据,没必要每次都查。
正确的做法是:启动时全量加载 + 运行时增量刷新。
我常用的方案:
- 应用启动时:从数据库或配置中心加载所有活跃规则,按优先级排序后放入本地缓存
- 运行时:通过监听配置变更事件,增量更新缓存中的规则
- 兜底策略:如果缓存失效,从数据库重新加载
来看一个简单的加载流程:
public class RuleEngine {
private volatile List<SplitRule> rules;
@PostConstruct
public void init() {
// 启动时全量加载
this.rules = loadAllActiveRules();
}
public void refresh() {
// 运行时增量刷新
List<SplitRule> latest = loadAllActiveRules();
this.rules = latest;
}
private List<SplitRule> loadAllActiveRules() {
// 从数据库加载,按优先级排序
return ruleRepository.findByStatusOrderByPriority("ACTIVE");
}
}
4.3 规则的匹配:从「遍历所有规则」到「快速命中」
规则加载到内存后,接下来就是匹配。最朴素的做法是遍历所有规则,逐个判断条件是否满足。
但如果规则有成百上千条,每次遍历的性能开销就上来了。我记得有一次压测,单机 QPS 到 200 时,规则匹配就占了 60% 的 CPU 时间。
怎么优化?我分享两个实战中用过的方法:
| 方法 | 适用场景 | 原理 |
|---|---|---|
| 优先级剪枝 | 规则有明确的优先级层级 | 一旦命中高优先级规则,直接返回,不再匹配低优先级规则 |
| 条件索引 | 规则条件字段固定(如金额、品类) | 按字段值建立哈希索引,O(1) 时间复杂度定位候选规则 |
举个例子,如果规则都是按「订单金额区间」来定义的,可以建一个区间树:
// 按金额区间建立索引
TreeMap<Double, List<SplitRule>> amountIndex = new TreeMap<>();
// 查询时:找到金额所在的区间,只匹配该区间内的规则
Double key = amountIndex.floorKey(orderAmount);
List<SplitRule> candidates = amountIndex.get(key);
这样就把 O(n) 的遍历变成了 O(log n) 的查找。你想想看,规则越多,这个优化带来的收益越大。
4.4 规则的执行:拆分动作的编排与组合
匹配到规则后,就该执行了。但这里有个容易被忽略的问题——多条规则可能同时匹配。
比如:规则 A 说「按品类拆分」,规则 B 说「按仓库拆分」。两条都匹配了,怎么办?
我见过三种处理策略:
- 串行执行:先按规则 A 拆,再对拆分结果按规则 B 拆。适合规则之间不冲突的场景。
- 优先级覆盖:只执行优先级最高的那条规则。适合规则互斥的场景。
- 合并执行:把多条规则的动作合并成一个复合动作。比如先按品类拆,再按仓库拆,最终生成一个二维拆分结果。
我个人比较推荐「串行执行 + 优先级覆盖」的组合策略。默认串行执行,但如果规则明确标记了「互斥」,就只执行优先级最高的那条。
来看执行引擎的核心逻辑:
public List<OrderSplit> execute(Order order) {
List<SplitRule> matchedRules = matchRules(order);
// 按优先级排序
matchedRules.sort(Comparator.comparingInt(SplitRule::getPriority));
List<OrderSplit> splits = new ArrayList<>();
splits.add(new OrderSplit(order)); // 初始状态:整个订单作为一个拆分单元
for (SplitRule rule : matchedRules) {
if (rule.isExclusive()) {
// 互斥规则:只执行优先级最高的
splits = rule.getAction().execute(splits);
break;
} else {
// 非互斥规则:串行执行
splits = rule.getAction().execute(splits);
}
}
return splits;
}
4.5 规则引擎的整体架构
说了这么多,我们来画一张图,把整个流程串起来。
这张图把规则引擎分成了四层:定义、加载、匹配、执行。每一层各司其职,层与层之间通过接口解耦。这样设计的好处是——你可以单独替换某一层的实现,而不影响其他层。
比如,规则定义层从 JSON 换成 YAML,只需要改定义层的解析逻辑,匹配和执行层完全不用动。
1. 把规则从代码里抽出来,变成可配置的数据
2. 用缓存和索引让匹配变快
3. 用清晰的执行策略避免规则冲突
嗯,做到这三点,你的拆分规则引擎基本就稳了。剩下的就是根据业务场景不断打磨细节。