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 存到数据库或配置中心,业务方改规则时只需要改一条记录,完全不用动代码。这才是规则引擎的初衷。

💡 我的建议:规则 ID 最好用有业务含义的编码,比如 R + 模块号 + 序号。别用自增 ID,否则后期排查问题时你根本不知道 R1024 是啥意思。

4.2 规则的加载:别在每次请求时读数据库

规则定义好了,怎么加载到内存里?

我曾经见过一个系统,每次拆分请求都去查一次规则表。结果数据库连接池被打满,订单处理超时。为什么会这样?因为规则是相对静态的数据,没必要每次都查。

正确的做法是:启动时全量加载 + 运行时增量刷新

我常用的方案:

  1. 应用启动时:从数据库或配置中心加载所有活跃规则,按优先级排序后放入本地缓存
  2. 运行时:通过监听配置变更事件,增量更新缓存中的规则
  3. 兜底策略:如果缓存失效,从数据库重新加载

来看一个简单的加载流程:

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");
    }
}
⚠️ 注意:多线程环境下,用 volatile 关键字保证规则列表的可见性。否则一个线程刷新了规则,另一个线程还在用旧规则,会出现诡异的问题。

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;
}
💡 避坑指南:我曾经遇到过一个问题——串行执行时,前一条规则把订单拆成了 5 个子单,后一条规则又对每个子单继续拆分。结果拆出了 20 多个包裹,仓库直接炸了。后来我加了一个「最大拆分深度」的限制,超过 3 层就停止。这个参数一定要可配置。

4.5 规则引擎的整体架构

说了这么多,我们来画一张图,把整个流程串起来。

规则引擎核心架构 规则定义层 规则数据结构 | 条件定义 | 动作定义 | 优先级管理 存储位置:数据库 / 配置中心 / 本地文件 规则加载层 启动全量加载 | 运行时增量刷新 | 本地缓存 加载策略:懒加载 / 预加载 / 定时刷新 规则匹配层 条件评估 | 优先级排序 | 索引查询 | 剪枝优化 匹配策略:遍历匹配 / 索引匹配 / 规则链 规则执行层 串行执行 | 优先级覆盖 | 合并执行 | 深度控制

这张图把规则引擎分成了四层:定义、加载、匹配、执行。每一层各司其职,层与层之间通过接口解耦。这样设计的好处是——你可以单独替换某一层的实现,而不影响其他层。

比如,规则定义层从 JSON 换成 YAML,只需要改定义层的解析逻辑,匹配和执行层完全不用动。

总结一下:规则引擎设计的核心就三件事——
1. 把规则从代码里抽出来,变成可配置的数据
2. 用缓存和索引让匹配变快
3. 用清晰的执行策略避免规则冲突

嗯,做到这三点,你的拆分规则引擎基本就稳了。剩下的就是根据业务场景不断打磨细节。


专注资料整理