当时我们做抽奖系统的时候,确实遇到了人群分类的问题。最开始的做法特别粗暴,直接写了一堆if-else:

if(user.getAge() > 18 && user.getGender().equals("男")){
    // 给男性成年人分配A策略
} else if(user.getVipLevel() > 3){
    // 给高等级VIP分配B策略
}

但是后期增加不同的规则,就会导致代码越来越臃肿,代码很快就变成了一锅粥。修改的时候也是,可能某个策略出问题了,但是不好去查找,只能挨着debug。

后来我们调研了规则引擎的方案,主要想解决两个痛点:

  1. 把业务规则从代码里抽离出来,让运营可以自己配置

  2. 让规则之间相互独立,修改一个不会影响其他

具体实现我们用了决策树的方式,当然还有其他的规则引擎。比如说用Grovy脚本来写规则的,但是这种只有脚本写错或者死循环,整个系统直接GG(҂◡_◡),而且脚本的调试也是很麻烦的( ̄▽ ̄);另外一种是开源的Drools,这个的功能很强大还是开源的,回顾这个项目的时候,发现决策量化相对于Drools不是那么方便,后面会去了解一下的👀

最后一种就是我们的决策树了,当然它没有前面两种那么强大,但是针对这个项目就很方便,比如要判断一个用户该用哪种抽奖策略,就像走迷宫一样:

  • 第一个路口问:是新人吗?

    • 是 → 走左边,给新手礼包

    • 不是 → 第二个路口问:是高消费用户吗?

      • 是 → 走右边,给豪华抽奖

      • 不是 → 继续往下走...

这样就很像数据结构中的树,所以我们使用了三张表去描述这个树:

  • rule_tree 存储树的基本信息

  • rule_tree_node 存储每个节点的详细信息

  • rule_tree_node_line 存储节点间的连接关系

项目结构是这样的:

Lottery
└── src
    └── main
       └── java
          └── cn.itedus.lottery.domain.rule
              ├── model
              │   ├── aggregates
              │   │   └── TreeRich.java
              │   ├── req
              │   │   └── DecisionMatterReq.java
              │   ├── res
              │   │   └── EngineResult.java
              │   └── vo
              │       ├── TreeNodeLineVO.java
              │       ├── TreeNodeVO.java
              │       └── TreeRootVO.java	
              └── service
                  ├── engine
                  │   ├── impl	
                  │   │   └── TreeEngineHandle.java
                  │   ├── EngineBase.java 
                  │   ├── EngineConfig.java
                  │   └── IEngine.java	
                  └── logic
                      ├── impl	
                      │   ├── UserAgeFilter.java
                      │   └── UserGenderFilter.java
                      ├── BaseLogic.java
                      └── LogicFilter.java

可以这么去看这个项目结构

  • 树结构执行引擎

    • EngineConfig 是配置中心,管理所有可用的规则过滤器

    • EngineBase 是执行引擎的基类,定义了怎么遍历规则树

    • TreeEngineHandle 是实际干活的,负责加载规则树并执行判断

  • 过滤器

    • LogicFilter所有规则过滤器的通用标准

  • 树结构模型

    • TreeNodeLineVO

    • TreeNodeVO

    • TreeRootVO

  • 具体规则实现继承基础实现类BaseLogic封装了通用的规则判断逻辑,比如大于小于这些比较操作

    • UserAgeFilter 专门处理年龄判断

    • UserGenderFilter 专门处理性别判断

执行的流程差不多是这样的:

  • TreeEngineHandle 接到请求,先去数据库加载整棵规则树的结构

    • 主要是RuleRepository从数据库三张表里把整棵树拼出来:

      • rule_tree表告诉根节点在哪

      • rule_tree_node表记录所有关卡信息

      • rule_tree_node_line表记录每个关卡的过关条件

  • 按照EngineBase定义的方式遍历规则树

 while (Constants.NodeType.STEM.equals(treeNodeInfo.getNodeType())) {
            String ruleKey = treeNodeInfo.getRuleKey();
            ILogicFilter logicFilter = logicFilterMap.get(ruleKey);
            String matterValue = logicFilter.matterValue(matter);
            Long nextNode = logicFilter.filter(matterValue, treeNodeInfo.getTreeNodeLineInfoList());
            treeNodeInfo = treeNodeMap.get(nextNode);
            }
  • 现在到达的节点类型是"果实节点"直接返回节点里配置的策略

遇到的问题 (メ`ロ´)/︻┻┳═一:

  • 为什么用树结构不去使用规则链表呢?

选树结构主要是因为抽奖策略有明确的分层逻辑。比如先分性别,再分年龄段,最后分地域,这种层级关系用树来表达最直观。之前试过用扁平化的规则链表,当规则多了之后,运营很难理清优先级。不过树结构确实有缺点,比如处理'或逻辑'(年龄<18或>60)时需要创建冗余分支,这时候用决策表可能更合适。

  • 如果新加入一个判断规则,你觉得你的代码需要改哪些地方?

写个UserLoginCountFilter继承BaseLogic,从用户对象取登录次数。在EngineConfig里注册这个Filter(加个@Component就行)。运营在后台配置节点,比如设置rule_limit_type=4(大于等于),rule_limit_value=5。其他代码完全不用动。

  • 如果你的树结构有10层深度,你要怎么办?

用Redis缓存整棵规则树结构,

  • 你觉得你的这个设计是策略模式+责任链模式吗?

更像是组合模式+模板方法模式。组合模式体现在用树结构统一处理单个节点和整棵树,而BaseLogic的filter()就是模板方法——它定义了比较流程,但把获取值的逻辑留给子类实现。如果用责任链模式,所有规则要串成一条链,但我们的规则是有明确层级关系的。

  • 比如说遇到节假日,每年的都不一样,这时候你要怎么去规则

可以设计一个HolidayFilter,它读取的rule_limit_value不是固定值,而是配置的节假日key。刚开始去配rule_limit_value="spring_festival",然后运行时去查独立的节假日表。这样运营只需要每年更新节假日表,不用改规则树。不过后来发现更好的是对接国家法定节假日API。

  • 规则引擎里的BaseLogic为什么要用抽象类而不是接口?decisionLogic方法为什么不设计成抽象的?

因为要封装通用的filter()方法逻辑(遍历连线做比较),这些固定流程不需要子类重写。而matterValue()必须让子类实现,所以用抽象类。

它已经实现了所有比较逻辑(大于小于等于),如果抽象了,每个子类都得重复写一遍这些比较代码

  • 规则节点的rule_limit_value字段如果既要支持数字比较又要支持正则表达式,该怎么改造?

在rule_tree_node_line表加了个value_type字段(1数字 2正则 3枚举),修改BaseLogic的decisionLogic方法,value_type字段的判断,运营配置时可以选择比较类型

  • 用户属性值缺失时(比如没采集到年龄),规则引擎该怎么处理?

在matterValue()里返回特殊值"NULL",然后在在decisionLogic里增加判断,在日志里打warning提醒运营补数据。

  • 如果运营配置了一个永远走不到结果节点的规则树,系统如何避免死循环?

执行时计数,这个最直接。在EngineBase里加了个计数器,每次进入节点就+1。就像游乐场排队,超过限制就直接拦停。

建规则时检查可达性。运营点保存按钮时,后台会跑个DFS算法模拟走一遍。

定时任务扫环,这个最复杂。我们每天凌晨跑个job,用Tarjan算法检测强连通分量。

  • 如果把规则引擎改造成支持热更新,即修改规则不重启服务,你会怎么设计?

规则变更时发布MQ消息, 规则引擎监听消息,重新加载变化的规则树,用双重检查锁保证并发安全

  • 如果要给规则引擎添加调试模式,记录用户走过的每个判断节点,该怎么实现?

引擎核心用ThreadLocal维护了一个调用栈,每次进入节点前就把节点ID、用户属性值、时间戳这些信息压栈,遇到异常或者命中特殊调试标记时,就把整个调用链JSON序列化后存到MongoDB,包括当时各个节点的中间判断结果。

  • 运营反馈有用户应该匹配策略A但实际走到了策略B,你怎么排查?

检查用户属性值是否正确(比如年龄是不是传成了字符串)

查看规则树版本(运营可能改了规则但没全量发布)

检查连线条件(比如是不是配成了age>30而不是>=30)

查看调试日志,复现用户路径

  • 如果某个规则判断需要调用外部接口(比如风控系统),该怎么改造现有架构?

新增RemoteFilter基类,处理重试/熔断

规则配置里增加超时时间和降级策略

结果缓存5秒(避免重复调用)