商品带着“鞋、品牌 A、价格三百元”进入营销服务。运营配置的五条规则都适用,接口却只返回三条。请求没有报错,耗时也不高。工程师把类目和品牌放进倒排索引时,漏掉了“品牌不是 B”和“无条件适用”。
下面把这个错误做成可执行实验:用同一份规则分别运行逐条解释器和倒排召回器,再比较结果。规则总量三百万是容量估算的输入,实验只运行七条规则与一千五百四十组事实组合,没有三百万规则的吞吐或延迟成绩。
先规定缺失、多值和金额的含义
五条业务规则保留原来的结构,另外加两条边界规则。金额按分存储,价格条件只接受安全整数;字符串“10000”不会自动转换。这样的限制让编译器和解释器能共用确定的语义,避免入口接受字符串、精确求值却按数值比较。
| ID | 条件 |
|---|---|
| R1 | 类目包含鞋,且价格处于零分到五万分的左闭右开区间,即低于五百元 |
| R2 | 品牌包含 A |
| R3 | 品牌有值,且所有值都不是 B |
| R4 | 类目包含鞋,或品牌包含 C |
| R5 | 无条件适用 |
| R6 | 价格大于等于一百元,且小于二百元 |
| R7 | 标签包含促销,且标签有值、所有值都不是禁售 |
表中 R1 的上界写成数值就是 50000 分。接口传入缺失字段、空值或空数组时,正向匹配和排除匹配都返回假。多值正向条件按“存在一个相等值”解释;排除条件按“至少有一个值,且没有禁止值”解释。于是品牌数组同时包含 A 和 B 时,R2 成立,R3 不成立。标签同时包含促销和禁售时,R7 不成立。
这个选择需要业务负责人确认。某些系统会把缺失品牌理解成“不是 B”,另一些系统会拒绝接受多值品牌。两种设计都能实现,索引和解释器用两套解释才会出问题。实验允许多值,是为了把集合语义暴露出来;上线入口仍可以把品牌限制为单值。
参考解释器只做一件事:遍历规则的或分支,在每个分支中逐个计算且条件。空分支返回真,所以 R5 不需要特殊的业务开关。解释器没有读取数据库、请求库存服务或修改计数器。条件没有副作用,后续才有讨论短路与顺序调整的余地。
规则语言也有明确边界。实验直接输入有界的“分支之或、条件之且”,支持等值、排除和左闭右开整数区间。它没有实现通用脚本、任意表达式解析或跨商品关联。接入端需要验证字段和操作符;遇到未知操作符,解释器抛错,发布流程应拒绝该规则,不能把错误当成不匹配。
复现漏召回,再给编译器一个证明义务
错误版本从规则里寻找能命中的正向等值条件,拿对应规则当候选。对开头的商品,逐条解释器返回 R1、R2、R3、R4、R5,错误版本只找到 R1、R2、R4。R3 没有正向等值条件,R5 没有条件,两条规则都没有索引入口。
修正时先给每个或分支选一个必要条件。R1 的分支要成立,类目必然包含鞋,因此可以挂到类目为鞋的入口。R4 有两个独立分支,编译器分别挂到类目为鞋和品牌为 C。只挂第一个入口会漏掉品牌为 C、类目为包的商品。
一个分支找不到安全入口时,编译器把整条规则放进兜底集合。R3 和 R5 都走这条路。即使同一条规则的其他分支已经进入倒排表,只要有一个分支需要兜底,整条规则仍然参与每次精确求值。这样会多算,却不会因分支选择漏掉结果。
这个构造可以按分支证明:假设某条规则对输入成立,至少有一个分支成立。如果该分支有等值入口,请求事实就包含这个入口;如果它有区间入口,请求落入覆盖它的桶;如果两种入口都没有,兜底集合已经包含规则。三种情况都能把规则交给解释器。证明依赖事实规范化和区间覆盖正确,不能靠最终结果“看起来合理”替代。
候选阶段对多个入口取并集,并按规则 ID 去重。把请求包含的类目和品牌倒排表取交集会漏掉 R2,因为 R2 没有类目要求。若要通过计数或交集进一步缩小集合,工程师需要证明每条规则要求哪些条件组合,不能把请求事实的组合误当成规则约束。
配套实验用一份清楚的逐条求值实现作参照,对每个输入先断言“参考结果都在候选中”,再断言“候选精确求值后的结果与参考结果一致”。第一条断言定位召回缺陷,第二条定位最终集合偏差。只比较最后命中数量不够,漏掉一条又多出一条时数量仍然相同。
价格桶怎样覆盖端点
R6 没有等值条件,实验按一万分,也就是一百元划分价格桶。区间为左闭右开时,整数金额的最后一个合法值是上界减一。编译器从下界所在桶开始,挂到最后合法值所在桶为止;查询端用同一除法和向下取整规则计算桶号。
这让一百元到二百元的规则只进入一个桶。九十九元九角九分不召回,一百元召回,一百九十九元九角九分召回,二百元不召回。实验对四个数值分别输出候选标记和精确结果,两者在这个整桶例子中一致。
如果区间改成一百五十元到二百五十元,两个桶都会有 posting。一百二十元的请求也会进入候选,解释器再将它排除。多出的候选属于预期成本。若工程师只给区间中点建入口,二百四十元就会漏召回;若把右端点当成合法值,则会增加一个不必要的边界桶。
实际规则可能覆盖几乎全部价格。给这样一个区间枚举数百万个桶会拖垮构建过程。实验为单个区间设置最多十六个桶的预算,超过预算就进兜底;分支数超过十六也走兜底。预算限制只改变候选量,不改变解释器接受的有界规则含义。
编译预算还不能替代解析预算。把任意布尔表达式先展开成析取范式,再检查展开后分支数,可能在检查之前耗尽内存。生产编译器可以在展开过程中计数并终止,或者保留语法树,用节点级必要条件做保守推导。本文没有实现这种解析器,配套测试只能证明已给定分支列表的行为。
价格桶方案适合边界稳定、区间跨度受控的字段。区间范围宽而端点分散时,可以评估专门的区间索引。两者的最终判断仍由同一个解释器完成;换索引时保留对照测试,比为新索引重写业务语义更容易定位回归。
测试覆盖了什么
实验组合类目缺失、鞋、包、多值和空数组,品牌缺失、空值、三个品牌、多值和空数组,价格缺失、空值、负值、零、各端点与字符串,以及四种标签输入。五乘七乘十一乘四,共一千五百四十组。脚本还独立检查超出编译预算的规则是否进入兜底。
本轮在 Node v25.5.0 上运行零依赖脚本。实际输出中的错误版本漏掉 R3、R5;修正后的版本返回五条;组合测试全部通过。规则撤销检查将 R2 从旧结果中移除,但那是单进程顺序模拟,没有验证多实例消息传播。
把文末完整代码放入 rule-lab.mjs,用 Node 运行,终端会输出区间端点和容量计算结果:
node rule-lab.mjs
文末代码包含全部规则定义,运行时直接打印实验结果。测试输入中的缺失与空值统一为无值,不把 JavaScript 的未定义值当成一个品牌。这个细节也提醒接入层:运行时对象的表示方式需要与线上 JSON 协议一致。
穷举有限集合不能证明任意规则都正确。这里的论证分成两部分:必要入口构造给出逻辑依据,有限测试检查实现是否违背该依据。下一步接入真实规则时,要加入每种新操作符的反例、来自业务配置的回放样本,以及不参与入口编译的独立解释器测试。它们解决的问题各不相同。
三百万规则的内存账单
容量计算采用一组可以替换的假设:三百万条规则,每条编译后正文二百五十六字节;百分之二的规则兜底;其余规则平均产生一点六个去重后的倒排条目;规则 ID 用四字节整数。另有二十万个入口,每个入口的键及字典开销按四十八字节、列表头按二十四字节估算,外部 ID 映射每条十六字节。
这些数值描述紧凑存储布局。实验里的 JavaScript 对象、Map 和 Set 不符合这个布局,因此下表不是 Node 堆内存测量。正文含长字符串、表达式树或重复对象时,每条二百五十六字节可能低估很多;这个参数必须用目标实现的实际序列化与驻留对象替换。
posting 条目数 P = N × (1 - f) × b
单快照 M = N×正文均长 + 4P + N×ID映射均长
+ 入口数×(字典均长 + 列表头均长) + 4Nf
峰值假设 = 2M×1.25 + 64 MiB
| 项目 | 计算结果,字节 |
|---|---|
| 规则正文 | 768000000 |
| 倒排 ID 数据区 | 18816000 |
| 外部 ID 映射 | 48000000 |
| 入口字典 | 9600000 |
| 列表头 | 4800000 |
| 兜底 ID 数据区 | 240000 |
| 单快照合计 | 849456000 |
| 双快照、百分之二十五余量与固定工作区 | 2190748864 |
公式里的双快照假设是旧快照还在服务请求,新快照已经构建完成。百分之二十五表示分配、构建临时数据等未细分空间的预留,固定六十四 MiB 表示查询工作区预算。两项都只是预算输入,不能当作测量所得的安全系数。若同时排队构建第三份快照,峰值公式就不再覆盖实际状态。
三百万位的普通位图数据区是三十七万五千字节。给二十万个入口各配一张这样的位图,光位数据就要七百五十亿字节。四字节稀疏列表与普通位图的纯数据交点约在九万三千七百五十个 ID;短列表通常不值得分配整张位图。真实选择还受容器头、压缩编码、集合运算方式和缓存访问影响。
因此可以按入口密度选择稀疏列表、压缩位图或稠密位图。这里没有运行压缩库对比,不能给某种布局填一个通用压缩倍数。更有用的输入是入口长度直方图,以及热门入口在请求流量中出现的频率;同样的总条目数,长尾入口和一个极热入口带来的查询成本不同。
兜底比例怎样进入延迟预算
百分之二的兜底听起来不多,放在三百万规则上就是每次请求至少精确检查六万条。若业务把上限改成百分之二十,基础检查量会到六十万条。倒排表节省的内存仍然存在,请求延迟却可能无法接受,所以兜底比例需要进入规则发布反馈。
查询成本可以拆成读取入口、遍历 posting、候选去重和精确求值。一个请求访问的列表长度之和记作扫描量,去重后的候选数记作候选量;同一规则挂了多个入口时,两者相差很大。只记录候选量会漏掉合并阶段的代价,只记录总规则数则无法解释热点。
精确求值也没有固定单价。R2 做一次等值判断,复杂分支可能读取多个字段并比较数组。团队可以先用候选数乘平均求值成本做预算,再用分组回放观测尾部。本文没有采集单条规则纳秒数,所以不能从六万条推算出可信的毫秒延迟。
选入口时,最短倒排表是一个起点。若每次规则更新都根据全局频率换入口,构建器可能反复搬迁大量 ID。第一版可以在发布时固定入口,按周期统计热点,再对收益明显的分支重编译。这样牺牲部分选择性,换来可解释的更新成本和容易定位的版本变化。
还有一个产品上的取舍:对没有安全入口的规则,系统可以接受并提示预计成本,也可以拒绝发布,要求运营补充租户、类目等限制。拒绝会缩小表达能力,接受会把成本转移给线上请求。无论采用哪种方案,都应把决定放在发布反馈里,避免由查询服务在高峰时悄悄跳过昂贵规则。
从小样本走到真实分布
上线前的一次回放可以以租户为单位固定规则版本、输入版本与预期结果。先让参考解释器离线生成完整命中集合,再让新索引读取相同输入。比较器保存漏召回的规则 ID、触发分支和入口,不只输出一个失败比例。业务人员才能根据条件判断是规则写错,还是编译器选错了入口。
回放输入要保留原始的缺失状态。数据导出工具如果把空数组改成空字符串,品牌排除条件的结果就会变化;如果把金额转成浮点元再转回整数分,边界也可能偏移。可以给规范化后的事实计算摘要,并让两种实现共同记录摘要。不同摘要的结果先归为输入不一致,不进入算法正确率统计。
统计性能时,单独保留热门类目和大租户。全局平均每次只召回少量规则,可能来自大量没有活动的小租户;承担交易高峰的租户却在每次请求中触发长列表。工程师应把请求权重带入候选分布,同时记录最坏的已观测输入。均匀随机抽品牌会稀释这种偏斜。
内存回放也需要包含更新。可以从完整快照开始,加载一次接近发布高峰的增量,记录旧版本仍有请求引用时的新版本完成点。若只在启动后空闲状态读取常驻内存,构建临时对象与旧版本保留都不会出现。此次容量表提供的是可复算预算,运行中的分配器和垃圾回收策略仍然可能改变峰值。
对不断更新的规则,批处理大小会影响两个方向。小批次缩短发布等待,但重复复制字典与列表头;大批次摊薄构建开销,却延长新规则到达用户的时间。可以把运营要求的生效期限作为上限,再测不同批量下的构建时间和峰值,而非先选一个整齐的定时周期。
请求工作区也要有上限。为每个并发请求分配三百万位的候选位图,单个看起来只有数百 KiB,叠加大量并发就不再便宜。复用池能减少分配,但池的等待会进入延迟;共享可变位图则会引入请求之间的污染。固定六十四 MiB 的预算只有在并发上限和回收方式明确时才有含义。
如果业务只需要优先级最高的一条规则,另开一个查询协议会比偷偷截断全量匹配安全。全量匹配接口承诺列出所有命中项,按候选数量提前停止会破坏这个承诺;最高优先级接口则可以在有严格上界证明时剪枝。返回类型与规则排序约定应先写清楚,性能优化才能使用这些条件。
更新和撤销是两条时间线
请求取得快照甲后,索引入口和规则正文都从甲读取。构建器在旁路准备快照乙,完成检查后切换新请求入口;甲上的请求继续执行,直到引用释放。若查询从乙取候选、从甲取正文,前面关于必要条件的证明就失去了共同规则版本。
设请求在时刻一持有甲,运营在时刻二撤销 R2,时刻三构建器发布乙。等待乙发布才禁用 R2,会在二到三之间继续返回活动。可以把撤销集合放在独立的授权判定中,返回业务结果前读取最新撤销代号,过滤旧快照里的 R2。回退快照时也保留撤销状态,防止一次性能回退恢复已经撤销的活动。
这仍然需要说明“生效”的边界。最后一次撤销检查后、网络发送前,另一线程可能提交撤销。普通的二次检查无法保证撤销提交瞬间所有在途响应都停止。若业务要求这样的强边界,就要让撤销提交与最终发放共享同步机制,或者把规则命中当建议,在真正发券事务中再检查可用状态。
撤销集合不断增长也会消耗空间。清理者只有确认旧快照已经退出、所有可回退版本都包含撤销信息,才可以删除对应条目。否则旧版本恢复后会失去屏障。本轮演示只检查旧结果经过撤销集合后的输出;跨进程确认、回退保留和最终发券事务留给真实服务验证。
Elasticsearch 8.19 的 percolate 查询提供保存查询、以文档反向匹配的现成路径,并利用可提取查询项筛选候选。它可以作为选型对照,但本文没有启动该版本,也没有把自写解释器的输出算作它的测试成绩。
如果真实规则大多没有选择性入口,或者每次请求都带来全新上下文、更新峰值容不下双快照,继续优化集合运算未必划算。此时可以按租户拆分规则空间、收窄规则语言,或改用能够复用中间匹配状态的实现。工程决定应来自实际分布:先拿到最长入口、兜底数量和更新峰值,再决定三百万条规则是否适合放进同一台匹配器。
完整参考解释器与边界测试
下面的独立脚本只使用 Node 内置断言。它先复现错误候选,再运行修正后的编译器和组合测试,最后计算前文的容量假设。脚本中的 Map 与 Set 只用于演示,不对应容量公式里的紧凑存储布局。
import assert from 'node:assert/strict';
const eq=(f,v)=>({op:'eq',f,v}), ne=(f,v)=>({op:'ne',f,v}), range=(f,lo,hi)=>({op:'range',f,lo,hi});
const rules=[{id:'R1',branches:[[eq('category','鞋'),range('price',0,50000)]]},{id:'R2',branches:[[eq('brand','A')]]},{id:'R3',branches:[[ne('brand','B')]]},{id:'R4',branches:[[eq('category','鞋')],[eq('brand','C')]]},{id:'R5',branches:[[]]},{id:'R6',branches:[[range('price',10000,20000)]]},{id:'R7',branches:[[eq('tags','促销'),ne('tags','禁售')]]}];
function values(x,f){return !Object.hasOwn(x,f)||x[f]==null?[]:Array.isArray(x[f])?x[f]:[x[f]];}
function atom(a,x){const vs=values(x,a.f); if(a.op==='eq')return vs.some(v=>v===a.v);if(a.op==='ne')return vs.length>0&&vs.every(v=>v!==a.v);if(a.op==='range')return vs.some(v=>Number.isSafeInteger(v)&&v>=a.lo&&v<a.hi);throw Error('unsupported op');}
const evaluate=(r,x)=>r.branches.some(b=>b.every(a=>atom(a,x)));
const key=(f,v)=>JSON.stringify([f,v]);
function compile(rs,budget=16){const postings=new Map(),fallback=new Set();const add=(k,id)=>{if(!postings.has(k))postings.set(k,new Set());postings.get(k).add(id);};for(const r of rs){if(r.branches.length>budget){fallback.add(r.id);continue;}for(const branch of r.branches){const anchor=branch.find(a=>a.op==='eq');if(anchor){add(key(anchor.f,anchor.v),r.id);continue;}const a=branch.find(a=>a.op==='range'&&Number.isSafeInteger(a.lo)&&Number.isSafeInteger(a.hi)&&a.hi>a.lo);if(a&&Math.floor((a.hi-1)/10000)-Math.floor(a.lo/10000)<budget){for(let n=Math.floor(a.lo/10000);n<=Math.floor((a.hi-1)/10000);n++)add(key(a.f+':bucket',n),r.id);}else fallback.add(r.id);}}return {postings,fallback};}
function candidates(s,x){const ids=new Set(s.fallback);for(const f of Object.keys(x))for(const v of values(x,f)){for(const id of s.postings.get(key(f,v))??[])ids.add(id);if(Number.isSafeInteger(v))for(const id of s.postings.get(key(f+':bucket',Math.floor(v/10000)))??[])ids.add(id);}return ids;}
const snapshot=compile(rules), sorted=xs=>[...xs].sort();
const demo={category:'鞋',brand:'A',price:30000};
const bad=new Set(rules.filter(r=>r.branches.some(b=>b.some(a=>a.op==='eq'&&atom(a,demo)))).map(r=>r.id));
const expected=rules.filter(r=>evaluate(r,demo)).map(r=>r.id);
assert.deepEqual(expected.filter(id=>!bad.has(id)),['R3','R5']);
console.log(JSON.stringify({node:process.version,naive:{expected,actual:sorted(bad),missed:['R3','R5']},fixed:rules.filter(r=>candidates(snapshot,demo).has(r.id)&&evaluate(r,demo)).map(r=>r.id),fallback:sorted(snapshot.fallback)}));
let cases=0;
for(const category of [undefined,'鞋','包',['鞋','包'],[]])for(const brand of [undefined,null,'A','B','C',['A','B'],[]])for(const price of [undefined,null,-1,0,9999,10000,19999,20000,49999,50000,'10000'])for(const tags of [undefined,[],['促销'],['促销','禁售']]){const x={category,brand,price,tags};const want=rules.filter(r=>evaluate(r,x)).map(r=>r.id),cs=candidates(snapshot,x);assert(want.every(id=>cs.has(id)),'recall');assert.deepEqual(rules.filter(r=>cs.has(r.id)&&evaluate(r,x)).map(r=>r.id),want);cases++;}
for(const price of [9999,10000,19999,20000])console.log(JSON.stringify({price,R6:evaluate(rules[5],{price}),candidate:candidates(snapshot,{price}).has('R6')}));
const oversized={id:'budget',branches:Array.from({length:17},(_,i)=>[eq('brand',String(i))])};assert(compile([oversized]).fallback.has('budget'));
const revoked=new Set(['R2']);const oldResult=rules.filter(r=>evaluate(r,demo)).map(r=>r.id);assert(!oldResult.filter(id=>!revoked.has(id)).includes('R2'));
const N=3000000,f=.02,b=1.6;const parts={rules:N*256,postings:N*(1-f)*b*4,idMap:N*16,dictionary:200000*48,postingHeaders:200000*24,fallback:N*f*4};const single=Object.values(parts).reduce((a,b)=>a+b,0);console.log(JSON.stringify({cases,assertions:'all passed',budgetFallback:true,revocationGate:'R2 suppressed; sequential simulation only',capacityAssumptions:{N,f,b,bytesPerRule:256,bytesPerId:4},bytes:parts,single,doubleWith25PercentAnd64MiB:2*single*1.25+64*1024*1024,oneBitmap:N/8}));











