当时我们做抽奖系统的时候,确实遇到了人群分类的问题。最开始的做法特别粗暴,直接写了一堆if-else:
if(user.getAge() > 18 && user.getGender().equals("男")){
// 给男性成年人分配A策略
} else if(user.getVipLevel() > 3){
// 给高等级VIP分配B策略
}
但是后期增加不同的规则,就会导致代码越来越臃肿,代码很快就变成了一锅粥。修改的时候也是,可能某个策略出问题了,但是不好去查找,只能挨着debug。
后来我们调研了规则引擎的方案,主要想解决两个痛点:
把业务规则从代码里抽离出来,让运营可以自己配置
让规则之间相互独立,修改一个不会影响其他
具体实现我们用了决策树的方式,当然还有其他的规则引擎。比如说用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秒(避免重复调用)
评论