这篇主要解决什么问题

我后来回头看这类活动系统,最容易失控的不是规则多,而是两层逻辑混在一起:

1、人群准入写在业务代码里

2、规则命中继续写在 if/else 里

3、阶段切换时又把条件复制一遍

4、最后没人说得清“这次为什么过了,为什么没过”

所以这类能力要先拆成两层:

1、人群组件负责准入、过滤、短路

2、Drools 负责组合条件匹配和决策输出

这不是概念拆分,而是为了让执行链路能解释、能扩展、能排查。

我理解的人群组件,应该只做过滤,不做决策

人群组件适合处理下面这类问题:

1、是否命中某个标签

2、是否在灰度、白名单、黑名单内

3、渠道、身份、地域是否允许进入

4、当前阶段是否允许继续往下执行

这层的特点是“过”或者“不过”,本质上是过滤器。

如果把下面这些也塞进人群组件,边界就会很快变糊:

1、新客且近 7 天未领券,命中 A 奖励

2、老客消费金额超过阈值,命中 B 奖励

3、命中标签后再结合库存、预算、时间窗决定动作

这些已经不是准入判断,而是规则决策。

人群组件的运行模型,核心是原子人群和逻辑人群

真正落地时,我更倾向把人群组件拆成两层:

1、原子人群:一个最小可执行判断,比如标签命中、渠道命中、身份命中

2、逻辑人群:把多个原子人群按 AND/OR/NOT 组合起来

运行时其实只需要区分两种执行策略:

1、ATOM_CROWD

2、LOGIC_CROWD

这两个枚举更像执行模型,不是业务阶段。

业务阶段应该放在规则组或阶段关系上,比如准入、玩法、发奖这些阶段,而不是直接写到人群定义里。

为什么阶段不要直接塞进人群定义

如果把阶段语义直接塞到人群本身,后面很容易遇到两个问题:

1、同一个人群定义无法复用到不同阶段

2、阶段一变,人群配置也要跟着改

更稳的方式通常是:

1、人群定义只描述“怎么判断”

2、阶段关系表描述“在哪个阶段执行”

3、规则组描述“执行完后进入哪组规则”

这样一来,人群定义、阶段装配、规则决策三层职责就分开了。

一条更像工程实现的执行链路

我更认可的链路是下面这样:

1、按阶段装配当前要执行的人群组件

2、先执行原子人群,再执行逻辑人群

3、只要当前阶段准入失败,直接短路返回

4、准入通过后,构建当前阶段的规则 facts

5、把 facts 放进 Drools session,执行对应规则组

6、把命中结果收口成统一的 RuleResult

这样做有两个直接收益:

1、准入失败和规则未命中可以明确区分

2、排查时能知道卡在人群层还是规则层

Drools 什么时候值得引入

Drools 不是有规则就一定要上。

我一般只在下面这些场景考虑它:

1、组合条件已经明显多维化

2、多条规则之间会持续新增、修改、下线

3、同一批事实可能命中多条规则

4、规则需要和主流程代码解耦

如果只是 2 到 3 个固定判断,普通代码通常更直接;但一旦条件组合开始上升,Drools 会比长串 if/else 更容易维护。

用 Drools 之前,先把 facts 设计好

Drools 能不能用得稳,关键不在 rule 文件,而在 facts 是否稳定。

我通常不会直接把数据库实体丢给 Drools,而是单独建一个规则事实对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RuleFacts {

private String userId;

private String channel;

private String stageType;

private boolean crowdPassed;

private boolean newUser;

private BigDecimal payAmount;

private Integer stock;

private Integer remainBudget;

private LocalDateTime now;
}

这样做的目的很明确:

1、规则只依赖稳定字段,不直接依赖数据库结构

2、业务实体怎么演进,不会直接把 rule 搞崩

3、Drools 看到的是决策数据,不是持久化对象

一个最小可用的结果模型也要先定下来

只把 facts 扔进 session 还不够,还得先想清楚规则命中后要返回什么。

1
2
3
4
5
6
7
8
9
10
public class RuleResult {

private String ruleCode;

private String actionCode;

private Integer priority;

private String reason;
}

没有统一结果结构时,最后往往会退化成:

1、规则里直接改各种上下文变量

2、主流程到处判断中间状态

3、线上看到“没命中”,但不知道是哪条规则在控制

一个更像实际使用的 Drools 规则示例

下面这个示例会比“判断新客送券”更接近实际一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rule "play_stage_new_user_reward"
salience 100
agenda-group "PLAY"
when
$facts : RuleFacts(
stageType == "PLAY",
crowdPassed == true,
newUser == true,
payAmount >= 30,
stock > 0,
remainBudget > 0
)
then
insert(new RuleResult("R_PLAY_001", "NEW_USER_REWARD", 100, "新客且金额达标"));
end

这里有几个点是我觉得比较实用的:

1、agenda-group 把规则限制在当前阶段

2、salience 控制优先级

3、规则里只产出结果,不直接写库

4、命中理由也作为结果的一部分带出来

规则引擎负责决策,真正的写库、发奖、发消息这些动作,仍然应该回到主流程里处理。

Java 侧怎么执行 Drools

如果只是想先验证规则建模,我一般会先用最小方式把 session 跑起来:

1
2
3
4
5
6
7
8
9
KieSession kieSession = kieContainer.newKieSession("ruleSession");
try {
RuleFacts facts = buildFacts(context);
kieSession.getAgenda().getAgendaGroup("PLAY").setFocus();
kieSession.insert(facts);
kieSession.fireAllRules();
} finally {
kieSession.dispose();
}

这个过程中最重要的不是 fireAllRules(),而是前后两个动作:

1、进 session 前把 facts 收干净

2、出 session 后把结果统一收口

如果 session 里既塞 facts 又塞数据库实体,还混着一堆可变上下文,后面几乎一定会越来越难维护。

我更偏好的收口方式

Drools 执行完后,我更愿意只拿统一结果,而不是让规则里直接改主流程对象。

1
2
3
4
5
6
7
8
9
List<RuleResult> resultList = new ArrayList<>();
kieSession.setGlobal("resultList", resultList);
kieSession.insert(facts);
kieSession.fireAllRules();

RuleResult finalResult = resultList.stream()
.sorted(Comparator.comparing(RuleResult::getPriority).reversed())
.findFirst()
.orElse(null);

这样后面的主流程只关心两件事:

1、命中了什么

2、接下来执行什么动作

代码层和规则层的边界会清楚很多。

落地时最容易踩的几个坑

1、人群层和规则层同时做业务判断

人群组件里写一半决策,Drools 再补另一半,最后没有人能解释完整链路。

2、facts 直接绑定实体

实体字段一改,rule 文件就跟着脆。

3、规则里直接做副作用操作

如果在 rule 里直接写库、调下游、发消息,调试和回滚都会很麻烦。

4、规则拆得过碎

Drools 不是把每个小条件都拆一条 rule,拆太碎后,优先级和命中顺序会越来越难控。

5、没有解释结果

没有命中链路、规则编码、命中理由时,线上排查基本只能翻日志猜。

如果让我从零开始收这套能力

我的顺序通常会是:

1、先把人群组件收成原子 + 逻辑两层

2、再把阶段类型和规则组边界理清

3、再给 Drools 抽稳定的 facts 和 result 模型

4、最后才让复杂决策逐步迁进 rule 文件

这个顺序的好处是,不会一上来就把 Drools 推成一个大而全的平台,也不会把业务复杂度直接压进规则引擎。

小结

人群组件和 Drools 不是替代关系,而是前后两层。

人群组件负责把“不该进来的人”挡在前面,Drools 负责把“已经进来的人”按事实做组合决策。真正稳定的落地方式,不是写几条 rule,而是把阶段、facts、结果、执行边界这几件事先收清楚。