一件鞋有红色款和蓝色款,红色卖 399 元,蓝色卖 199 元。运营筛选“红色、不超过 300 元”,这件商品应不应该入选?如果活动要求用户能买到一双满足条件的鞋,答案是否定的。把 SKU 数组压成商品级颜色和价格集合,却会让两个条件分别成立。
这个错误通常不会触发异常。Elasticsearch 返回合法 JSON,结果数量也像那么回事。直到活动上线,运营才发现名单里有些商品根本买不到符合条件的规格。调缓存、加分片或提高超时都不能修正这种结果。
本文用六件测试商品完成一次圈品:建字段模型,重现错误查询,处理“不能有缺货款”的附加条件,再导出一个固定时间点的名单。示例针对 Elasticsearch 8.19,价格单位为人民币分;单节点、关闭鉴权的实验配置只适用于隔离本机,不能用于正式服务。
名单里的一行到底代表什么
先约定本例返回商品 ID。一件商品只要存在一个红色、价格不超过 300 元且有库存的 SKU,就算符合活动门槛。这个定义包含一个存在量词:三个条件必须由同一个 SKU 满足。
如果执行系统需要 SKU ID 清单,数据模型可以换成每个 SKU 一条文档。但商品名称、类目和品牌会重复保存;商品改名时需要更新多条记录,统计商品数又要去重。两种模型各有成本,不应该先选一个索引结构,再让运营接受结果单位变化。
本例的测试数据如下。P6 虽然有符合规格的 SKU,但商品已经下架,必须由商品级状态过滤排除。P4 没有 SKU,可以用于检查空集合。
| 商品 | SKU | 状态 |
|---|---|---|
| P1 | 红色 399 元,库存 3;蓝色 199 元,库存 5 | 在售 |
| P2 | 红色 299 元,库存 2 | 在售 |
| P3 | 红色 249 元,库存 0 | 在售 |
| P4 | 没有 SKU | 在售 |
| P5 | 红色 200 元,库存 1;蓝色 500 元,库存 0 | 在售 |
| P6 | 红色 100 元,库存 1 | 下架 |
按前面的业务定义,应该返回 P2、P5。这个结果可以先由人手算,再写成断言。任何查询优化都必须保留这个集合。数据量只有六件时就发现语义错误,远比上线后对几百万条结果做解释便宜。
实际圈品规则往往还包含租户、销售渠道和权限。它们来自服务端可信上下文,不允许用户在规则 JSON 中自行指定。以下省略多租户字段,保持样例集中在 SKU 关系上;扩展到多租户系统时,查询预览、计数和导出都要加同一层隔离条件。
用 nested 保存条件之间的配对关系
创建一个独立实验索引,显式定义需要比较和排序的字段:
PUT editorial_products
{
"mappings": {
"dynamic": "strict",
"properties": {
"product_id": { "type": "keyword" },
"status": { "type": "keyword" },
"category_id": { "type": "keyword" },
"skus": {
"type": "nested",
"properties": {
"color": { "type": "keyword" },
"price_cent": { "type": "long" },
"stock": { "type": "integer" }
}
},
"attrs": { "type": "flattened" }
}
}
}
这段是 Dev Tools 请求写法,首行不是 JSON 的一部分。通过 HTTP 客户端调用时,将方法、路径和 JSON 请求体分开。向 editorial_products/_doc/p1 写入 P1:
{
"product_id": "p1",
"status": "ON_SALE",
"category_id": "shoes",
"skus": [
{ "color": "red", "price_cent": 39900, "stock": 3 },
{ "color": "blue", "price_cent": 19900, "stock": 5 }
],
"attrs": { "material": "mesh" }
}
其余商品使用同样结构,P4 的 skus 是空数组,P6 的状态为 OFF_SALE。实验写入完成后显式刷新,让随后的查询能看到这些记录;正式写入链路不要为了复制实验步骤而给每次更新强制刷新,应按可见性要求选择策略。
普通 object 数组会把对象内部的字段展开,不能依赖它保留“红色对应 39900”的配对。nested 为子对象保留独立匹配语义,但增加文档与查询开销。选择它的理由是业务确实需要同 SKU 联合判断,不能把所有结构化 JSON 都默认建成 nested。Elastic nested 文档说明了这一区别。
长尾属性放进 flattened,可以避免商家每加一个字段就增加一套 mapping。它适合不需要复杂类型语义的属性查找;价格、库存和日期仍然使用显式数值或日期字段。数字看起来像数字,不代表在字符串式比较中能得到数值顺序。flattened 的限制应成为字段字典的一部分,而不是等运营配置规则后再暴露。
两个看起来合理的查询都会选错
第一种错误是把 SKU 颜色、价格和库存映射为普通对象字段,然后在商品上分别过滤。P1 同时存在红色、低于 300 元的价格和正库存,因此会通过。这只能说明商品的某些 SKU 分别满足了条件。
第二种错误更隐蔽:模型已经使用 nested,却把每个条件放进各自的 nested 查询。每个子查询都可以找到不同的子对象。P1 的红色款负责颜色,蓝色款负责价格,库存再由任意有货款满足,最终仍被选中。
正确做法是把三个条件放进同一个 nested 查询:
POST editorial_products/_search
{
"size": 50,
"query": {
"bool": {
"filter": [
{ "term": { "status": "ON_SALE" } },
{ "term": { "category_id": "shoes" } },
{
"nested": {
"path": "skus",
"score_mode": "none",
"query": {
"bool": {
"filter": [
{ "term": { "skus.color": "red" } },
{ "range": { "skus.price_cent": { "lte": 30000 } } },
{ "range": { "skus.stock": { "gt": 0 } } }
]
}
}
}
}
]
}
},
"sort": [{ "product_id": "asc" }]
}
预期对照十分明确:普通对象的分离过滤返回 P1、P2、P5;三个独立 nested 查询也返回这三件;同一个 nested 查询只返回 P2、P5。测试不能只断言总数等于二,还要比较具体 ID,防止一次漏检和一次误检互相抵消。
score_mode: none 表明本例不需要子对象相关性分数。圈品条件决定准入,不能用“分数更高”解释一件超过价格上限的商品为何入选。商品名称的全文搜索可以另行参与排序,但不能替代明确的价格、库存与权限条件。
如果列表需要说明哪个 SKU 符合条件,可以使用受控数量的 inner_hits,或在详情诊断中单独展示。不要默认把所有命中 SKU 的全部字段附到每件商品上,否则一个清单查询可能变成体积很大的商品详情响应。解释结果和批量导出可以采用不同的数据投影。
“有货款存在”与“任何款都不能缺货”是两道题
运营又增加要求:“这次活动的商品不能有缺货 SKU。”P5 虽然有一个符合条件的红色款,但蓝色款库存为零,因此应该被排除,最终只剩 P2。
这个条件需要在商品层排除“存在缺货 SKU”的商品。把下面的 must_not 加进最外层 bool,与原来的 filter 并列:
"must_not": [
{
"nested": {
"path": "skus",
"query": { "term": { "skus.stock": 0 } }
}
}
]
如果将 must_not stock=0 放到一个 nested 查询内部,表达的是“存在一个不缺货的 SKU”。P5 的红色款满足它,仍会被保留。否定的位置改变了量词范围,不能只看字段和操作符相同就认为两种 DSL 等价。
空数组也有自己的语义。如果只有“没有缺货 SKU”这个条件,P4 没有任何子对象,当然也找不到缺货子对象;这与“至少有一个可售 SKU”不同。本例同时保留原来的正向存在条件,因此 P4 不会进名单。业务若要求商品必须有规格,规则模型应该明确包含这个条件,不能寄希望于空数组碰巧不出现。
缺失 stock 字段又是另一个问题。它不等于数值零,gt:0 也不会把缺失值当有货。若使用“排除 stock=0”代表库存充足,缺失库存可能漏进来。入口校验可以要求库存存在且非负;存量数据不满足时,则需要明确的未知状态和治理路径。
这些反例适合直接进入规则编译器测试。界面写“全部”“任意”“不存在”时,后端应有不同的节点类型,而不是让一个模糊的“非”按钮同时代表几种语义。
编译时先确定量词的作用域,再组合叶子条件。“存在一个红色且便宜的款”将两个叶子放在同一子对象范围;“不存在零库存款”则在商品范围否定整个存在表达式。把非运算推进子对象内部,会从“没有坏款”变成“能找到一个不匹配坏条件的款”。下面是后一种错误写法的SKU查询部分;它没有检查商品的其他款是否缺货。
{
"nested": {
"path": "skus",
"query": {
"bool": { "must_not": [{ "term": { "skus.stock": 0 } }] }
}
}
}
若目标是“每个款库存已知且为正”,可排除存在缺失库存或库存不大于零的子对象,并另加至少有一个SKU的条件。仅排除零库存无法覆盖缺失值。为这条规则增加一件“合格红款加未知库存蓝款”的商品,才能测出边界;现有六件样本尚未验证这部分。
规则编辑器保存条件树,不保存任意 DSL
圈品后台可以保存受控条件树:字段 ID、比较操作、值、分组和 SKU 作用域。服务端将树编译成 DSL,补齐状态和权限过滤,并给查询设置资源预算。前端不需要获得执行任意 Elasticsearch 查询的能力。
字段字典必须包含类型、单位和作用域。价格输入 300 元,服务端转成 30000 分;如果另一个来源使用美元,不应在同一数值字段里混着比较。一个字段从商品级移到 SKU 级时,旧规则不能沿用名称悄悄改变含义。应保存规则版本与字典版本,迁移时给运营看到前后名单差异。
同一作用域的 OR 条件也要明确编译。例如“品牌 A 或品牌 B”,在已有 filter 的 bool 中加入 should,若不设置 minimum_should_match: 1,可能没有形成预期的准入约束。这类错误与跨 SKU 问题相似:JSON 合法,结果却偏离了业务规则。对编译器做集合断言比只测试请求返回 200 更有效。
条件树在本例中是无状态表达式:输入规则与字段字典,得到固定查询,不读取上一页的命中结果来决定下一页条件。相对日期先在任务创建时解析成具体时间,分页期间复用同一份查询。若每页按当前时间重新编译,即使都写着“最近七天”,比较边界也已经改变,固定PIT无法替应用修正这个规则漂移。
解释器还应区分预览与执行。预览名单表示索引当前看到的状态;执行优惠券或库存动作时,需要回到权威业务数据确认资格。名单导出成功不能替业务系统承诺几分钟后的价格和库存。规则版本、索引读取时间与最终执行时间都应保留,方便追查差异。
导出不能一边翻页一边换名单
普通列表可以接受商品更新后结果变化,固定名单导出则不同。如果每页都重新读取当前索引,第一页读完后有商品上架、下架或修改排序字段,后面的页面可能出现遗漏或重复。稳定的排序字段只解决并列次序,不固定读取视图。
PIT 配合 search_after 可以在一个读取视图里遍历。先打开 PIT:
POST /editorial_products/_pit?keep_alive=1m
拿到 ID 后,向 /_search 请求第一页,不再在搜索路径里指定索引。请求包含相同 query、PIT、排序和页面大小:
{
"pit": { "id": "实际返回的PIT_ID", "keep_alive": "1m" },
"size": 1,
"query": { "match_all": {} },
"sort": [
{ "product_id": "asc" },
{ "_shard_doc": "asc" }
]
}
上面为突出分页结构而使用 match_all;运行圈品时每页复用选定的完整查询。以下分页实验明确回到基础规则 good,因此P5仍合格;附加缺货排除的 withoutOOS 只作上一节对照,不是本轮导出规则。页面大小一便于观察,实际批量大小按响应体积选择。
取最后一个 hit 的完整 sort 数组,原样放进下一页的 search_after。不能只取 product_id 而丢掉后面的排序值。后续响应如果返回新的 PIT ID,下一次请求使用最新值。完成、取消或异常退出时关闭 PIT:
DELETE /_pit
{"id":"最后使用的PIT_ID"}
可以用一轮明确的更新测试验证视图:第一页面得到 P2 后,把 P5 改成下架并刷新,再插入排序更靠前的新商品 P0。继续原 PIT 应仍读到 P5;新的普通查询则应该看到 P0、P2。旧视图中的 P5 不代表它现在仍可售,只说明导出保持了打开视图时的集合。
PIT 会占用资源,不是永久名单仓库。保留时间应覆盖页间处理,不应无限延长;后台也要限制并行导出数。任务中断后,如果 PIT 已失效,旧游标不能与新 PIT 随意拼接。可以从头生成一份新名单,或者在有明确一致性设计的情况下恢复,但应标注生成批次与读取时间。
导出写文件也有提交问题。先把数据写到临时对象或临时文件,遍历完成并核对成功后再标记任务完成,避免用户下载到一半内容却看到成功状态。上传结果未知时先检查已生成文件,不能自动再启动一份没有关联 ID 的导出任务。PIT 管理的是读取视图,任务状态和文件交付仍由应用负责。分页官方指南列出了 PIT、排序和 search_after 的配合方式。
恢复任务时先读取批次记录中的规则版本、固定查询、最新PIT和完整游标,再判断旧视图是否仍有效。过期就创建新批次并从头遍历,旧临时文件不混入新结果。完整导出还要走到空页、检查重复ID及每页超时和分片失败;HTTP成功不能代替这些检查。下方实测只覆盖两页视图一致性,尚未验证完整文件交付。
慢的是预览、计数,还是导出
圈品系统通常把三种工作放在一个页面:展示前几十个结果,计算精确总数,生成完整名单。首屏只需要很少的商品,但精确计数可能遍历大量匹配;导出还要承担字段读取、网络传输和文件写入。把它们混在一个“查询耗时”里,很难判断该优化哪一段。
产品可以先显示首屏,再异步提供有明确状态的精确数量。若使用有界计数,界面应展示“至少多少”,不能把下界标成精确总数。取消一个导出任务时,也要停止继续翻页和写文件,不能只是关闭抽屉。
要求精确数量时,可在同一规则和PIT的搜索中设置 track_total_hits: true,同时确认返回的总数关系是 eq 且没有部分结果。数量只与该视图对应,不能拿另一次实时计数验收旧PIT导出。首屏可以不等待精确数,计数任务则承担相应扫描成本;这项产品取舍与查询结果是否正确分开判断。
nested 的成本需要根据 SKU 数量分布评估。一万个商品各有两个 SKU,与少数商品携带数千个 SKU,可能有不同的尾延迟和更新压力。只用平均 SKU 数建容量模型,会忽略大文档。频繁库存更新还会改变索引写入负担,索引方案应和更新频率一起评估。
迁移模型时,旁路新索引与旧索引可以同时接收变更,用同一组受控规则比较结果差异。对差异逐个归因:修正了跨 SKU 误匹配,还是新模型漏了某个字段?只对总数,会让误检和漏检相互掩盖。切换前保留索引版本与规则版本的对应关系,回退不能把新的规则交给不理解其字段语义的旧模型。
完整运行入口与这次结果
下面是已执行的完整 experiment.mjs。它复用 good 查询做分页,另建 flat_skus 普通对象字段供错误模型对照。运行前准备隔离的Elasticsearch 8.19.0,地址限制为本机HTTP端口,并保证测试索引尚不存在;重跑使用新的隔离环境,不删除未知索引。端口按实际映射替换。
EDITORIAL_ES_URL=http://127.0.0.1:19200 node experiment.mjs
import assert from 'node:assert/strict';
const base=process.env.EDITORIAL_ES_URL;const u=new URL(base);if(u.hostname!=='127.0.0.1'||u.protocol!=='http:'||!u.port)throw Error('isolated loopback required');
const index='editorial_products';
async function call(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'content-type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(30000)});const j=await r.json();if(!r.ok)throw Error(JSON.stringify(j));return j;}
console.log(JSON.stringify({version:(await call('/')).version}));
const skuProps={color:{type:'keyword'},price_cent:{type:'long'},stock:{type:'integer'}};
await call('/'+index,'PUT',{mappings:{dynamic:'strict',properties:{product_id:{type:'keyword'},status:{type:'keyword'},category_id:{type:'keyword'},skus:{type:'nested',properties:skuProps},flat_skus:{type:'object',properties:skuProps},attrs:{type:'flattened'}}}});
const docs=[['p1',[['red',39900,3],['blue',19900,5]]],['p2',[['red',29900,2]]],['p3',[['red',24900,0]]],['p4',[]],['p5',[['red',20000,1],['blue',50000,0]]],['p6',[['red',10000,1]]]];
for(const [id,skus]of docs){const mapped=skus.map(([color,price_cent,stock])=>({color,price_cent,stock}));await call(`/${index}/_doc/${id}`,'PUT',{product_id:id,status:id==='p6'?'OFF_SALE':'ON_SALE',category_id:'shoes',skus:mapped,flat_skus:mapped,attrs:{size:'256'}});}
await call(`/${index}/_refresh`,'POST');
const root=[{term:{status:'ON_SALE'}},{term:{category_id:'shoes'}}];
const conditions=[{term:{'skus.color':'red'}},{range:{'skus.price_cent':{lte:30000}}},{range:{'skus.stock':{gt:0}}}];
const good={bool:{filter:[...root,{nested:{path:'skus',score_mode:'none',query:{bool:{filter:conditions}}}}]}};
const separate={bool:{filter:[...root,...conditions.map(query=>({nested:{path:'skus',query}}))]}};
const flat={bool:{filter:[...root,{term:{'flat_skus.color':'red'}},{range:{'flat_skus.price_cent':{lte:30000}}},{range:{'flat_skus.stock':{gt:0}}}]}};
async function ids(query){return(await call(`/${index}/_search`,'POST',{query,size:20,sort:[{product_id:'asc'}]})).hits.hits.map(x=>x._id);}
const rows={flat:await ids(flat),separateNested:await ids(separate),sameNested:await ids(good)};
assert.deepEqual(rows.flat,['p1','p2','p5']);assert.deepEqual(rows.separateNested,['p1','p2','p5']);assert.deepEqual(rows.sameNested,['p2','p5']);
const withoutOOS={bool:{filter:good.bool.filter,must_not:[{nested:{path:'skus',query:{term:{'skus.stock':0}}}}]}};
rows.noOutOfStock=await ids(withoutOOS);assert.deepEqual(rows.noOutOfStock,['p2']);console.log(JSON.stringify({queryResults:rows}));
let pit=(await call(`/${index}/_pit?keep_alive=1m`,'POST')).id;
try{
const first=await call('/_search','POST',{pit:{id:pit,keep_alive:'1m'},query:good,size:1,sort:[{product_id:'asc'},{_shard_doc:'asc'}]});pit=first.pit_id||pit;assert.equal(first.hits.hits[0]._id,'p2');
await call(`/${index}/_update/p5?refresh=true`,'POST',{doc:{status:'OFF_SALE'}});
const mapped=[{color:'red',price_cent:10000,stock:1}];await call(`/${index}/_doc/p0?refresh=true`,'PUT',{product_id:'p0',status:'ON_SALE',category_id:'shoes',skus:mapped,flat_skus:mapped,attrs:{}});
const next=await call('/_search','POST',{pit:{id:pit,keep_alive:'1m'},query:good,size:1,sort:[{product_id:'asc'},{_shard_doc:'asc'}],search_after:first.hits.hits.at(-1).sort});pit=next.pit_id||pit;assert.equal(next.hits.hits[0]._id,'p5');
const live=await ids(good);assert.deepEqual(live,['p0','p2']);console.log(JSON.stringify({pitFirst:'p2',pitSecond:'p5',current:live,sortValues:first.hits.hits[0].sort}));
}finally{const close=await call('/_pit','DELETE',{id:pit});assert.equal(close.succeeded,true);console.log(JSON.stringify({pitClosed:close.succeeded}));}
console.log('ALL_ASSERTIONS_PASSED');
本次真实引擎运行的标准输出如下,版本号为8.19.0,进程退出码为0。它确认了上文对应的预期集合、两页PIT与当前视图差异,以及关闭成功;未覆盖内层否定、缺失库存和异常恢复。
{"version":{"number":"8.19.0","build_flavor":"default","build_type":"docker","build_hash":"93788a8c2882eb5b606510680fac214cff1c7a22","build_date":"2025-07-23T22:10:18.138212839Z","build_snapshot":false,"lucene_version":"9.12.2","minimum_wire_compatibility_version":"7.17.0","minimum_index_compatibility_version":"7.0.0"}}
{"queryResults":{"flat":["p1","p2","p5"],"separateNested":["p1","p2","p5"],"sameNested":["p2","p5"],"noOutOfStock":["p2"]}}
{"pitFirst":"p2","pitSecond":"p5","current":["p0","p2"],"sortValues":["p2",4]}
{"pitClosed":true}
ALL_ASSERTIONS_PASSED
这次六件商品的例子给出了一个可以带走的判断方法:先用具体商品手算结果,再让字段模型和 DSL 实现同一语义;导出时再明确读取的时间点。P1 不该因为颜色和价格分属两个 SKU 而入选,P5 也不该因为旧 PIT 里还能读到就被承诺有货。把这两条边界分清,后面的查询优化才是在加速正确的名单。











