商品进入营销服务时,请求携带类目、品牌、价格等事实,服务端要从三百万条规则中找出所有适用项。逐条解释表达式很容易吃掉请求的延迟预算,工程师通常会先用倒排索引召回候选,再做精确求值。
三百万只是容量假设。吞吐还取决于规则分布、机器配置和压测环境。设计索引时,关键问题是一次请求必须检查哪些规则,以及跳过一条规则需要什么依据。
漏召回藏在正常结果里
假设系统保存了这些规则,缺失价格不满足价格条件,缺失品牌不满足品牌排除条件:
| 规则 | 条件 |
|---|---|
| R1 | 类目是鞋,并且价格小于 500 元 |
| R2 | 品牌是 A |
| R3 | 品牌字段存在,并且品牌不是 B |
| R4 | 类目是鞋,或者品牌是 C |
| R5 | 无条件适用 |
输入商品为“鞋、品牌 A、价格 300 元”,五条规则都应匹配。若倒排索引只收录正向等值条件,按 category=鞋、brand=A 查找候选,系统能找到 R1、R2、R4,却会漏掉 R3 和 R5。
程序不会为漏召回报错。用户看到活动没有生效,监控里的计算耗时甚至可能很好看。索引设计需要守住一个不变量:
对任意输入 x:
如果 evaluate(rule, x) 为真,
那么 rule 必须出现在 candidates(x) 中。
候选集可以包含最终不匹配的规则。精确求值器能够排除它们。候选阶段漏掉的规则,后续步骤无法补救。
用必要条件选择索引入口
先把一种受控规则语言限定为若干分支的“或”,每个分支内部是条件的“且”。对一个分支,如果某个正向等值条件是它成立的必要条件,就可以把该条件选为索引入口。
R1 可以挂在 category=鞋 下。R4 有两个分支,需要分别挂在 category=鞋 与 brand=C 下。R3 没有这种正向等值入口,R5 也没有,因此进入兜底集合。遇到一种暂时无法证明安全的表达式,编译器也把它放入兜底集合,或者拒绝发布并说明不支持的语法,不能默默忽略。
下面是算法示意,不对应某个索引库的可运行 API:
compile(rule):
branches = boundedDisjunction(rule)
if branches cannot be produced within the compile budget:
fallback.add(rule.id)
return
for branch in branches:
anchor = chooseNecessaryEquality(branch)
if anchor is absent:
fallback.add(rule.id)
else:
postings[anchor].add(rule.id)
match(input, snapshot):
candidateIds = copy(snapshot.fallback)
for equality in factsOf(input):
candidateIds.union(snapshot.postings[equality])
for id in candidateIds:
if evaluate(snapshot.rules[id], input):
emit(id)
这个构造的依据是:只要一个分支成立,它的入口条件就成立,请求便能沿着该入口找到规则。没有入口的分支通过兜底集合参与计算。多个分支命中同一规则时,候选 ID 集合负责去重。
把请求涉及的所有倒排表直接取交集会漏掉 R2,因为 R2 没有类目约束。交集优化必须对应规则要求的条件组合;集合操作的速度无法证明组合语义正确。
任意布尔表达式展开成析取范式可能产生大量分支。因此编译预算也属于规则语言的约束。需要更复杂的表达式时,可以保留表达式树,为节点推导安全的必要条件;推导失败就保守召回。
候选集的大小只是成本的一部分
给分支挑入口时,优先考虑选择性较高且维护成本可接受的条件。例如某个品牌对应的规则很少,把它作为入口可能比“在售”更有效。不过,倒排表当前的长度只是一个估计:热门品牌进入活动季后,候选量可能变化。
区间规则需要单独处理。可以用区间索引,也可以将价格映射到离散桶。采用价格桶时,规则必须挂到与其合法范围相交的所有桶,再由求值器检查精确端点。少挂一个边界桶,就会把性能优化变成业务错误。金额在示例中以分保存为整数;开闭区间、币种和换算规则应在编译前统一。
精确求值器还要固定以下语义:缺失字段是否满足“不等于”,多值字段代表“任意一个满足”还是“全部满足”,日期按哪个时区解释。解释器与索引编译器必须使用同一套定义。便宜条件优先、遇假短路只适用于没有副作用的条件,不能随意重排依赖外部调用的判断。
对三百万个连续规则 ID,一个未压缩位图约占 375,000 字节,即约 366 KiB。这只是一个集合的理论数据区,不包含对象、映射和索引开销。若每个属性值都分配完整位图,长尾属性可能耗费大量空间。稀疏列表、压缩位图和普通位图应按实际密度选择,不能仅看单次集合运算耗时。
一次请求只使用一份规则快照
请求应在开始时取得一个索引快照,并用该快照的规则正文完成求值。索引来自新版本、正文来自旧版本时,连“不漏召回”的前提都会失效。
可行的实现方式是在旁路构建新快照,完成静态检查后切换引用,等旧请求结束再回收旧快照。代价是更新窗口的双份内存。需要增量更新时,也应给倒排条目、删除标记和规则正文建立版本关系,并记录请求实际使用的版本。
回退通常能恢复上一份可用快照,但已被业务撤销的规则不能因回退重新生效。禁用名单或生效版本限制要独立于性能回退策略。规则撤销属于正确性要求,应在结果返回前得到满足。
用参考实现和真实分布做决定
工程团队应保留一个慢而清晰的参考解释器,用相同规则与输入比较结果集合。随机规则生成可以覆盖表达式组合,人工案例则专门覆盖否定、缺失值、边界价格和无条件规则。比较时固定输入事实和规则版本,避免把版本差异误判为算法缺陷。
生产规则系统不一定采用同一种匹配算法。Forgy 对 Rete 算法的经典描述展示了另一条路径:保存跨规则共享的中间匹配结果,以内存和更新成本换取重复求值的减少。倒排召回是否合适,应由规则结构、事实更新频率和延迟预算决定,不能仅凭规则总数选择。
容量评估至少记录候选数量分布、兜底规则比例、精确求值次数、更新积压和快照内存峰值。平均候选数会掩盖热点请求。如果业务允许任意脚本,而且大部分规则缺乏可筛选条件,倒排方案会退化成大范围求值。团队需要据此限制规则语言、按租户拆分,或调整延迟目标。
现成系统也值得纳入对照。Elasticsearch 8.19 的 percolate 查询提供了文档匹配已存查询的能力,并在索引时提取查询项帮助筛选候选。它能否承受目标规则分布,需要独立评估。自研引擎应拿出更清楚的语义边界、可接受的更新成本或适配需求,而不是只给出一组没有实验条件的延迟数字。
