用 Elasticsearch 圈商品:字段模型先于查询优化

从同一 SKU 的条件关联出发,区分显式字段、flattened 与 nested 的用途,并说明圈品结果在排序、导出和写入延迟上的一致性边界。

·6 min
商品卡片经过筛选,同一件蓝色商品的 SKU 条件被框选确认。

运营想圈出“有红色款、价格不超过 300 元”的鞋。一件商品有两个 SKU:红色款 399 元,蓝色款 199 元。它是否应该入选?

如果条件要求同一个 SKU 同时满足颜色和价格,这件商品就不应该出现。把颜色与价格分别收集到商品级数组后,两项条件却都能命中。后续再调分片数、缓存或查询超时,也不会修复这个结果。

动态圈品首先要确定筛选对象和条件之间的关系。下面按 Elasticsearch 8.19 的字段与查询语义讨论一种设计,示例价格采用人民币分,不代表现有系统的实际模型。

红色和低价必须属于同一个 SKU

品牌、类目、商品状态通常挂在商品上,颜色、规格和售价则跟着 SKU 变化。业务人员需要逐个确认字段归属。某些平台允许不同供货方使用各自品牌,同一个字段名在那里就可能属于 SKU。

查询要求同一个 SKU 满足多个条件时,把 SKU 数组建成 nested 字段。普通 object 数组会丢失对象之间的配对关系;nested 把联合判断限制在一个子对象内,同时增加文档数量和查询成本。参见 nested 官方说明

statuscategory_idskus.colorkeywordskus.price_centlongskusnested。以下是搜索请求体示例:

{
  "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 } } }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "sort": [
    { "product_id": "asc" }
  ]
}

product_id 在此假设为可排序、唯一的 keyword 字段。这两个 SKU 条件必须出现在同一个 nested 查询内。拆成两个独立的 nested 查询后,它们仍可能分别命中两个 SKU。

调用方若要导出 SKU 清单,可以按 SKU 建文档。这样会重复商品字段,也会增加商品级计数、去重和更新传播的工作。产品经理应先定义结果代表商品还是 SKU,工程团队再计算存储成本。

长尾属性收进 flattened

商家每提交一个新属性,动态 mapping 都可能增加一个字段。属性积累后,团队还要处理类型冲突和字段上限。

价格、时间、库存需要明确的比较语义,应使用显式类型。查询少、只做精确匹配的长尾属性可以放进 flattened。这里的“查询少”取决于业务使用和变更频率,没有通用阈值。

flattened 会按类似 keyword 的方式索引叶子值。数字外观的字符串也不能当作普通数值字段来比较,范围与排序遵循字符串语义。需要“容量大于 256”这样的业务条件时,应建立数值字段,而不是依赖属性 JSON 的表面类型。flattened 文档明确说明了这些限制。

团队也可以把属性存成 [{key, value}]nested 数组,但每项属性都会生成子文档,数字、日期和枚举还需要各自的值字段与验证逻辑。键值关联查询确有需求时再采用这种模型。

服务端把条件树编译成 DSL

圈品界面保存一份受控条件树,其中包含字段 ID、操作符、通过类型校验的值和分组关系。服务端据此生成 DSL,并加入租户隔离条件。前端无需提交任意 Elasticsearch 查询。

“品牌 A 或品牌 B”属于集合准入条件。生成 bool.should 时显式设定 minimum_should_match: 1,不要依赖默认值:同一个 bool 带有 mustfilter 时,默认值为 0,两个品牌条件可能只参与评分而不限制入选范围。bool 查询文档说明了这个差异。

圈品一般不需要相关性评分,基础条件可以放在过滤上下文中。需要商品名称搜索时,再单独讨论分词和相关性;不要用“分数更高”解释商品是否符合一个明确的价格限制。

每次保存规则还应记录条件树版本、字段字典版本和单位。否则一个属性从字符串改成数值后,旧规则可能得到不同解释。可解释性应包含“哪个条件让商品入选”,也要能回答“它为什么被排除”。后者通常需要针对单个商品运行诊断求值,不能只看搜索命中的说明。

预览和执行使用各自的时间点

运营在页面上看到的是索引在某一时刻的匹配数量。数据库写入完成后,搜索索引仍可能保留旧值。业务若承诺保存后立刻可见,工程团队需要为这条路径安排刷新机制并评估吞吐代价。

如果圈品用于稍后发送优惠券,需要明确执行时重新求值,还是使用审批时的固定名单。前者会随着库存、价格变动改变结果;后者需要保存规则版本、生成时间和名单。导出期间也要固定读取视图,否则分批读取时商品变更可能造成重复或遗漏。本文不规定具体分页 API,实施时应按实际版本核对一致性和资源占用。

涉及资金或库存的执行环节,还要回到权威业务状态复核。索引适合寻找候选,不能单凭几分钟前的搜索结果承诺商品现在可售。

先指出哪条路径慢

“圈品慢”可能指首屏等待、精确计数,或者名单导出追不上更新。三条路径消耗不同资源,排查时应明确其中一条。

评估时应分别记录条件组合、命中规模、SKU 数量分布和更新频率。高选择性品牌筛选不能代表全站范围条件;没有 SKU 的商品、价格缺失、同一商品跨 SKU 命中等案例也要列入结果检查。应先比较结果集合是否正确,再讨论索引大小和响应时间。

接口契约应写明筛选对象、比较类型和名单时效。团队随后才能在同一份正确结果上比较索引大小与响应时间。