Skip to content

MongoDB 电商实战 05|一个搜索框,为什么需要三种查询方式? ​

MongoDB 电商实战 05|一个搜索框,为什么需要三种查询方式?封面

运营提出一个需求:“后台增加一个搜索框,可以搜索商品和订单。”开发者最容易想到的方案,是把商品名称、商品编码、SKU 编码、订单号等字段都拼成一个不区分大小写的正则表达式,再对结果做分页。

这种实现短期看起来方便,数据量和使用频率上来后,却可能让一次搜索变成大范围扫描。更重要的是,用户输入订单号时想要准确定位一笔交易;输入商品编码时想要快速补全;输入“蓝牙耳机”时想要发现相关商品。这三种需求并不是同一种搜索。

**本篇把搜索拆成三类:精确查询、前缀查询和全文搜索。**筛选、排序与分页,则是在这三类查询上继续叠加的能力。

先确定搜索语义,而不是先写正则 ​

一个搜索框不代表数据库只执行一种查询。后台可以保留统一的输入框,但服务端必须知道当前搜索范围和查询语义。

用户输入或操作用户真正想做什么合适的查询方式
粘贴订单号 ORD-20261009-001找到唯一订单等值查询
输入商品编码 EAR-1001定位一个商品规范化后等值查询
输入 EAR-10查找以该编码开头的商品前缀查询
输入“蓝牙耳机”找到相关商品全文搜索
按分类、价格和销量缩小结果在候选商品中筛选和排序搜索过滤与排序

界面可以提供“商品 / 订单”的搜索范围,也可以对格式明确的订单号和编码做规则识别。但不要仅凭字符串外观猜测所有用户意图。例如 iPhone 16 既可能是商品名称,也可能是型号。无法可靠识别时,应让用户选择搜索范围,而不是对多个集合和字段执行无边界的模糊查询。

一个实用的接口边界可以是:订单号走订单查询接口,商品编码走商品或 SKU 查询接口,商品名称走商品搜索接口。它们可以共享一个页面输入框,但不应该共享一条任意拼装的数据库查询。

第一类:订单号和商品编码走精确查询 ​

订单号是交易标识,不是自然语言。客服输入完整订单号时,系统应该直接定位订单,而不是在订单号中搜索任意子串。

javascript
// 订单号在整个订单集合中唯一
db.orders.createIndex(
  { orderNo: 1 },
  { unique: true, name: "uk_orders_order_no" }
);

// 精确查询
db.orders.findOne({ orderNo: "ORD-20261009-001" });

真实项目中,建立唯一索引之前必须先检查历史数据。唯一索引不能替代字段必填校验;单字段唯一索引还会把缺失值或 null 当作索引键处理,历史数据中存在多个缺失值时,建索引可能失败。

先检查缺失或空值:

javascript
db.orders.countDocuments({
  $or: [
    { orderNo: { $exists: false } },
    { orderNo: null },
    { orderNo: "" }
  ]
});

再检查重复订单号:

javascript
db.orders.aggregate([
  { $match: { orderNo: { $type: "string" } } },
  {
    $group: {
      _id: "$orderNo",
      count: { $sum: 1 }
    }
  },
  { $match: { count: { $gt: 1 } } }
]);

检查结果并完成数据修复后,再创建唯一索引。应用层和集合校验也应保证 orderNo 必填、类型固定、格式合法。不要等到索引构建失败,才发现历史订单存在重复或脏数据。

商品编码和 SKU 编码也应分别精确查询。它们代表不同层级:商品级编码定位一个 SPU,SKU 编码定位具体规格。

javascript
// 商品级编码:定位商品
db.products.findOne({ codeNormalized: "ear-1001" });

// SKU 编码:定位具体规格,再通过 productId 找商品
db.skus.findOne(
  { codeNormalized: "ear-1001-black" },
  { _id: 1, productId: 1, spec: 1 }
);

单商家案例中,可以分别建立唯一索引:

javascript
db.products.createIndex(
  { codeNormalized: 1 },
  { unique: true, name: "uk_products_code" }
);

db.skus.createIndex(
  { codeNormalized: 1 },
  { unique: true, name: "uk_skus_code" }
);

如果未来扩展为多商家,编码只要求在商家内部唯一,那么索引就需要调整为 { tenantId: 1, codeNormalized: 1 },查询也必须带上当前用户有权访问的 tenantId。唯一性范围是业务规则,不应该等到多租户改造时才临时决定。

第二类:编码提示使用前缀查询 ​

运营输入 EAR-10 时,可能还没有输入完整编码,希望系统给出匹配项。这是前缀查询,不是任意位置包含查询。

为了让编码查找规则稳定,写入和查询两侧都应执行同一套规范化处理。本案例规定编码仅使用英文字母、数字和连字符,并统一转成小写:

javascript
function normalizeCode(value) {
  if (typeof value !== "string") {
    throw new Error("INVALID_CODE");
  }

  const normalized = value.trim().toLowerCase();

  if (!/^[a-z0-9-]{1,64}$/.test(normalized)) {
    throw new Error("INVALID_CODE");
  }

  return normalized;
}

这是本案例的编码规则,不适用于中文商品名称。真实系统必须先确定空格、全角字符、连字符和大小写的业务规则;改变规范化规则时,还要迁移历史字段,并检查规范化后是否产生重复编码。

因为这里的字符白名单不允许正则元字符,所以可以安全地把规范化后的前缀放入正则表达式:

javascript
const prefix = normalizeCode("EAR-10");
const pattern = new RegExp("^" + prefix);

const products = db.products.find({
  codeNormalized: pattern
})
.sort({ codeNormalized: 1 })
.limit(21)
.toArray();

^ 表示从字符串开头匹配。普通 B-Tree 索引对区分大小写的前缀正则表达式,有机会根据前缀构造索引范围。由于写入时已经统一为小写,查询不需要再使用正则的 i 选项。

这里有两个边界:

第一,/^ear-/ 与 /ear-/ 不是同一种查询。前者限定了开头,索引可能缩小扫描范围;后者匹配字符串任意位置,通常不能利用相同的前缀范围优化。第二,limit(21) 只限制返回数量,不代表数据库最多只检查 21 个索引键或文档。仍然要通过 explain("executionStats") 检查实际扫描量。

本案例的 codeNormalized 已经有唯一索引,它可以服务精确查询和前缀查询,不需要为了同一个字段再建立一条完全相同的普通索引。只有查询排序、字段组合或其他工作负载确实不同,才考虑新增索引。

如果接口允许输入其他字符,不能直接执行 new RegExp("^" + userInput)。必须使用可靠的正则转义函数,限制关键词长度、请求频率和最大返回数量,并拒绝客户端直接传入任意正则表达式。不要让一个商品编码自动补全接口变成用户可控的正则执行入口。

第三类:商品名称使用全文搜索 ​

商品名称和编码不同。用户输入“蓝牙耳机”,可能希望找到名称包含“无线蓝牙耳机”的商品,也可能希望搜到品牌别名、商品俗称或型号相关的商品。

这类需求通常包含几个部分:

  • **分词:**把自然语言查询转换成可匹配的词项。
  • **字段权重:**商品名称、品牌和别名的重要程度可能不同。
  • **相关性:**名称直接命中的商品通常应该排在只命中别名的商品前面。
  • **同义词与别名:**例如业务确认“无线耳机”和“蓝牙耳机”可以互相召回。
  • **错字容忍:**用户输错一个字符时,是否尝试返回近似结果。

这些能力不是一个 $regex 就能完整解决的。对于小规模、范围明确的后台筛选,可以使用精确查询、前缀查询或受限条件;如果产品希望提供中文分词、相关性排序和更完整的搜索体验,就需要评估专门的搜索能力。

普通 Text Index 不等于中文搜索引擎 ​

MongoDB 普通文本索引通过 $text 提供基础全文搜索能力,但官方列出的 $text 语言列表不包含中文。把 default_language 设置为 none,只是关闭停用词过滤和词干处理,并不等于获得中文分词器。

MongoDB Search 提供了更多语言分析器,例如 lucene.smartcn、lucene.chinese 和 lucene.cjk。其中 lucene.smartcn 是中文分析器,可以作为本案例的评估起点,但不能仅凭分析器名称就认定搜索质量符合业务要求。商品名称中经常同时存在中文、英文型号、数字、品牌简称和自定义俗称,需要用真实商品目录验证。

如果项目采用独立搜索服务,也可以实现类似能力,但需要单独评估分词器、同义词、纠错、排序、索引更新和运维成本。搜索引擎不是越复杂越好,应该由真实查询需求和搜索质量评估结果决定。

用 MongoDB Search 实现商品名称搜索 ​

下面的示例使用 MongoDB Search。它只适用于已经启用 MongoDB Search 的目标部署;有副本集并不自动代表已经具备 $search 能力。

本案例为商品集合增加搜索索引,明确列出需要检索、过滤和排序的字段,避免把整个商品文档都动态索引。

javascript
db.products.createSearchIndex("product_search_v1", {
  mappings: {
    dynamic: false,
    fields: {
      name: {
        type: "string",
        analyzer: "lucene.smartcn"
      },
      brandName: {
        type: "string",
        analyzer: "lucene.smartcn"
      },
      aliases: {
        type: "string",
        analyzer: "lucene.smartcn"
      },
      status: { type: "token" },
      categoryId: { type: "token" },
      minPriceFen: { type: "number" },
      paidUnitsSummary: { type: "number" },
      _id: { type: "token" }
    }
  }
});

这里有意区分了两类字段:

  • name、brandName 和 aliases 使用 string 类型,并通过中文分析器建立可全文检索的词项。
  • status、categoryId 和 _id 使用 token 类型,以便进行精确过滤或排序;数值字段使用 number 类型。

MongoDB Search 对字符串执行 equals、in 和字符串排序时,需要相应的 token 映射。一个字段如果既要全文检索又要精确过滤或排序,可以按目标操作符为它建立多种映射,而不是假设 string 类型自动满足全部用途。

索引创建后,应检查索引状态,确认构建完成并进入可用状态,再开放接口流量。索引定义变更也应按版本管理,先验证新索引,再切换应用,不要在高峰期临时修改生产搜索定义。

关键词、分类、价格范围和排序放在同一个查询中 ​

假设运营希望搜索“蓝牙耳机”,只看已上架的音频分类商品,并将展示最低价限制在 100~500 元之间。商品价格以人民币分保存,因此查询条件应为 10000 到 50000。

javascript
const keyword = "蓝牙耳机";

const filters = [
  { equals: { path: "status", value: "active" } },
  { equals: { path: "categoryId", value: "audio" } },
  {
    range: {
      path: "minPriceFen",
      gte: 10000,
      lte: 50000
    }
  }
];

const result = db.products.aggregate([
  {
    $search: {
      index: "product_search_v1",
      compound: {
        should: [
          {
            text: {
              query: keyword,
              path: "name",
              score: { boost: { value: 5 } }
            }
          },
          {
            text: {
              query: keyword,
              path: "brandName",
              score: { boost: { value: 2 } }
            }
          },
          {
            text: {
              query: keyword,
              path: "aliases",
              score: { boost: { value: 1 } }
            }
          }
        ],
        minimumShouldMatch: 1,
        filter: filters
      },
      sort: {
        score: { $meta: "searchScore" },
        _id: 1
      }
    }
  },
  // Search 索引可能存在短暂延迟;当前状态以 products 集合中的值为准。
  { $match: { status: "active", categoryId: "audio", minPriceFen: { $gte: 10000, $lte: 50000 } } },
  { $limit: 20 },
  {
    $project: {
      _id: 1,
      name: 1,
      codeNormalized: 1,
      minPriceFen: 1,
      paidUnitsSummary: 1,
      summaryUpdatedAt: 1,
      score: { $meta: "searchScore" }
    }
  }
]).toArray();

示例使用 should 将名称、品牌和别名作为可匹配字段,并通过 minimumShouldMatch: 1 要求至少命中其中一个字段。boost 的 5、2、1 只是演示名称、品牌和别名的相对权重,不是通用最佳值,必须根据运营提供的查询样本调优。compound.filter 用于增加必须满足的筛选条件,但不参与相关性评分。这样可以把关键词匹配与分类、状态、价格等条件分开表达。

注意,这里的 minPriceFen 是商品列表使用的最低价摘要。它可能因为 SKU 调价、上下架和摘要刷新而短暂落后于真实 SKU 价格。因此,这个范围筛选表达的是“按商品展示摘要筛选”,不能作为下单价格校验。详情页和下单流程仍应重新读取 SKU 权威价格。

搜索相关性、价格排序和销量排序不是同一个排序 ​

后台通常会提供几种固定排序方式:

排序方式Search 排序字段需要注意的地方
相关度优先searchScore,再按唯一 ID适合关键词发现;相同分数必须有稳定的次级排序
价格从低到高minPriceFen,再按唯一 ID价格是列表摘要,可能短暂延迟
销量从高到低paidUnitsSummary,再按唯一 ID销量受统计口径和刷新时间影响

例如按销量排序时,可以将 Search 查询的排序改为:

javascript
const salesSort = {
  paidUnitsSummary: -1,
  _id: 1
};

// 在 $search 阶段中使用:sort: salesSort

paidUnitsSummary 必须在 Search 索引中以可排序的数值类型建立映射,_id 也必须按实际 BSON 类型正确建立可排序映射。本案例的 _id 是字符串,所以示例使用 token 类型。

接口只允许有限的排序白名单,例如 relevance、priceAsc 和 salesDesc。不要让客户端提交任意字段名和任意排序对象。不同排序方式需要不同的查询定义,也可能产生不同的分页游标。

销量字段还必须有明确统计口径。本文的 paidUnitsSummary 表示已支付件数摘要;是否扣除退款、如何处理部分退款以及何时刷新,都应由业务定义。它不是财务报表的最终统计口径。

搜索索引与商品集合之间存在延迟 ​

搜索索引是商品数据的派生视图,不是商品和交易数据的权威来源。商品改名、上架、下架、改价或销量摘要变化后,搜索结果不一定在同一瞬间反映最新值。

如果使用 MongoDB Search,索引数据由搜索组件根据 MongoDB 的变更流维护;Atlas 管理相关组件,自建部署则需要自行部署和运维 mongot。如果使用 Elasticsearch 等独立搜索服务,通常需要通过 Change Streams、消息队列或 Outbox 等机制把商品变更可靠地同步到搜索端,并实现全量回填、失败重试、死信处理和索引重建。

这两种架构不要混为一谈:使用 MongoDB Search 时,不需要为了把同一个集合的数据复制到 Search 索引而再写一套普通业务同步任务;使用外部搜索服务时,应用团队需要负责同步链路和数据修复。无论采用哪种方案,都必须监控索引延迟和失败情况。

更重要的是,搜索结果不能直接用于交易判断:

  • 搜索结果显示商品已上架,不代表数据库中的商品状态此刻仍然有效。
  • 搜索结果显示最低价为 199 元,不代表下单时仍能以 199 元成交。
  • 搜索结果显示有货,不代表具体 SKU 仍有可售库存。

用户进入商品详情或提交订单时,服务端必须重新读取商品与 SKU 的权威数据,并执行状态、价格、优惠、限购和库存校验。搜索只负责帮助用户发现商品,不负责承诺交易结果。

如果后台要求下架商品不能继续出现在列表中,可以在搜索索引之外增加权威状态复核。但复核后会出现候选商品被过滤、当前页不足 20 条的情况;如果仍要求填满一页并正确判断 hasMore,服务端就必须继续读取后续候选,直到凑足有效结果或搜索结束。不能简单过滤一次后,仍把搜索端原始返回数量当作最终分页数量。

MongoDB Search 的部署不是免费的 ​

第一篇已经说明,本系列以副本集为基础讨论数据库部署。但“部署了副本集”不等于“已经启用 MongoDB Search”。

Atlas 会管理 MongoDB Search 相关组件;自建环境则需要部署和维护 mongot。它与 mongod 是不同进程,通过变更流同步数据并维护自己的搜索索引存储,客户端仍连接 mongod,而不是直接连接 mongot。自建部署还需要根据 MongoDB 版本、Community 或 Enterprise 版本、拓扑结构和高可用要求核对官方支持矩阵。单机 standalone mongod 不能直接作为自建 Search 的生产拓扑。

因此,选型时至少要比较三条路径:

方案优点需要承担的成本
普通 MongoDB 查询组件少,适合精确查询和受限前缀查询不适合复杂中文相关性搜索;任意包含查询容易扩大扫描
MongoDB Search搜索索引与 MongoDB 数据结合,支持分析器、过滤、排序和搜索分页需要确认目标部署支持;自建环境需运维 mongot 和搜索索引
独立搜索服务搜索能力和扩展方式选择较多需要维护数据同步、回填、重试、权限、监控和额外基础设施

如果项目只是几千个商品、只有编码查找和简单分类筛选,未必需要马上引入搜索引擎。若已经有大量商品、中文检索体验要求高,并且团队能够承担相应部署和运维,才有理由进一步评估 MongoDB Search 或独立搜索服务。

搜索结果返回 ID 后,如何回查商品? ​

如果使用 MongoDB Search,$search 聚合阶段本身会返回 products 集合中的文档,可以在同一条聚合管道中投影列表所需字段,不必再逐条回查。如果采用 Elasticsearch 等独立搜索服务,搜索端常常只返回商品 ID 和相关性顺序,应用再根据 ID 读取 MongoDB 商品文档。

后一种架构中,一个常见错误是搜索服务返回 20 个商品 ID 后,应用再循环查询 20 次数据库。这会重新引入第二篇讨论过的 N+1 查询问题。更合适的方式是批量读取,再按照搜索结果原来的 ID 顺序恢复:

javascript
const searchIds = ["p_1003", "p_1001", "p_1005"];

const products = db.products.find({
  _id: { $in: searchIds },
  status: "active"
}).toArray();

const productMap = new Map(
  products.map(product => [product._id, product])
);

// $in 不保证按照 searchIds 的顺序返回,必须恢复搜索排序
const orderedProducts = searchIds
  .map(id => productMap.get(id))
  .filter(Boolean);

如果一次读取很多商品,还要限制字段投影,避免把长描述、内部采购字段或完整 SKU 全部返回给列表接口。商品列表只返回页面需要的字段,详情接口再读取完整信息。

这里还要注意:如果搜索结果中有商品刚好下架,权威查询会把它过滤掉,最终页面可能不足指定数量。对于普通后台,可以接受短页并在下一次请求继续;如果产品要求每页必须填满,就需要在服务端继续向搜索引擎取候选项,同时正确推进搜索游标。不要通过重复执行同一个查询来填满页面。

高频问题:中文分析器能自动解决错别字和同义词吗? ​

不能。分析器主要负责文本切分和词项处理,不等于业务词典,也不等于拼写纠错。用户把“蓝牙耳机”输成“蓝牙耳急”,或者用“无线耳机”寻找商品,涉及的是纠错和同义词召回,不是单纯把文本切成词。

MongoDB Search 的 text 操作符提供 fuzzy 选项,可以按编辑距离扩展候选词,但它不是语义理解,也不保证中文错字一定能得到合理结果。模糊匹配范围过大时,还可能增加无关结果和查询成本。官方文档也说明,fuzzy 与 synonyms 不能在同一个 text 查询中同时使用。

更可控的起点通常是维护经过运营确认的商品别名,例如把“无线耳机”加入相关商品的 aliases;当同义词规模和维护需求上升,再评估 Search 的 synonym mapping。错别字纠正则应单独定义策略和测试集。不要把分词、同义词和拼写纠错统称为“中文搜索能力”。

每次上线前都应使用真实查询样本评估召回率和前几条结果的相关性,并检查模糊匹配是否引入过多误召回。搜索质量需要业务人员验收,不能只看“查询能返回结果”。

高频问题:筛选多一些,为什么索引就变慢了? ​

筛选条件增加并不意味着索引自动失效,真正影响成本的是查询选择性、排序要求和数据分布。

如果采用普通 MongoDB 查询,需要按照高频查询形态设计 B-Tree 索引,继续使用第三篇讲过的 ESR 原则评估等值、排序和范围条件。不要为每一种筛选组合机械创建索引,因为每条索引都会增加写入维护、存储和缓存成本。

如果采用 MongoDB Search,则应把全文匹配与过滤条件放进合适的 Search 查询结构中,并确保过滤和排序字段已经按对应类型建立 Search 索引。Search 的 string、token 和 number 映射有不同用途,不能把普通 B-Tree 索引的设计规则直接当成 Search 索引规则。

无论采用哪种方案,都应限制可用筛选和排序白名单。低频、大范围、组合复杂的分析需求,可以进入独立报表或异步导出流程,不应该要求普通商品搜索接口承担任意查询。

高频问题:搜索分页能否直接使用第四篇的游标? ​

不能直接照搬。第四篇的游标分页针对普通 MongoDB 查询,依靠固定排序字段和唯一 ID 组成继续位置,例如 createdAt + _id。它适合订单列表等顺序明确的业务查询。

全文搜索的排序可能基于相关性分数,也可能按价格或销量排序。MongoDB Search 提供了自己的搜索分页机制:在支持的集群版本(MongoDB 7.0.5 及以上)中,可以使用 searchAfter 和 searchBefore,并通过 searchSequenceToken 获取继续位置。

下面以相关度排序为例。第一页查询多取一条,用来判断是否还有下一页:

javascript
const pageSize = 20;
const keyword = "蓝牙耳机";

const searchStage = {
  $search: {
    index: "product_search_v1",
    compound: {
      should: [
        {
          text: {
            query: keyword,
            path: "name",
            score: { boost: { value: 5 } }
          }
        },
        {
          text: {
            query: keyword,
            path: "brandName",
            score: { boost: { value: 2 } }
          }
        },
        {
          text: {
            query: keyword,
            path: "aliases",
            score: { boost: { value: 1 } }
          }
        }
      ],
      minimumShouldMatch: 1,
      filter: [
        { equals: { path: "status", value: "active" } },
        { equals: { path: "categoryId", value: "audio" } }
      ]
    },
    sort: {
      score: { $meta: "searchScore" },
      _id: 1
    }
  }
};

const candidates = db.products.aggregate([
  searchStage,
  // 在 Search 候选结果上复核当前商品状态,再计算分页数量。
  { $match: { status: "active", categoryId: "audio" } },
  { $limit: pageSize + 1 },
  {
    $project: {
      _id: 1,
      name: 1,
      minPriceFen: 1,
      paginationToken: { $meta: "searchSequenceToken" }
    }
  }
]).toArray();

const hasMore = candidates.length > pageSize;
const items = candidates.slice(0, pageSize);
const nextSearchToken = hasMore
  ? items[items.length - 1].paginationToken
  : null;

下一页需要使用上一页最后一条实际展示记录对应的 searchSequenceToken:

javascript
// 沿用第一页完全相同的查询条件和排序,只增加继续位置。
searchStage.$search.searchAfter = nextSearchToken;

const nextCandidates = db.products.aggregate([
  searchStage,
  { $match: { status: "active", categoryId: "audio" } },
  { $limit: pageSize + 1 },
  {
    $project: {
      _id: 1,
      name: 1,
      minPriceFen: 1,
      paginationToken: { $meta: "searchSequenceToken" }
    }
  }
]).toArray();

示例展示了核心流程,实际接口还要处理空关键词、筛选参数校验、异常令牌和查询超时。searchAfter 令牌只能用于生成它的同一搜索查询;下一次请求必须保持搜索字段、关键词、过滤条件和排序语义一致。用户修改关键词、分类或排序方式时,应清空旧游标,从第一页重新查询。

生产 API 不应把搜索令牌当成权限凭证。可以由服务端把令牌、查询条件摘要、排序版本和过期时间封装成不透明游标并签名;每次请求仍要重新验证用户身份和数据访问范围。

为了减少相关性分数相同时的顺序不确定,示例在 searchScore 后增加了唯一的 _id 排序。若按价格或销量排序,也应增加唯一字段作为次级排序。需要注意,排序字段发生变化、商品被更新或删除时,连续搜索仍可能出现结果位置变化;它不是固定时点的数据快照。需要固定结果集的审计或导出任务,应使用独立任务和明确的数据快照策略。

搜索服务不可用时,应该怎样降级? ​

降级不是“搜索挂了就换成全表正则”。这种做法可能把搜索服务的故障转化为 MongoDB 交易库的压力事故。

一个较安全的降级策略是区分查询类型:

  • 订单号精确查询继续使用订单集合的唯一索引,不依赖商品全文搜索服务。
  • 商品编码精确查询和受限前缀查询继续使用商品或 SKU 集合上的普通索引。
  • 商品名称全文搜索不可用时,明确提示“商品名称搜索暂时不可用”,必要时提供分类浏览或精确编码查找。

还应为搜索接口设置超时、并发限制和请求频率限制。搜索服务恢复后,检查索引延迟和索引状态,不要未经验证就把全部流量瞬间切回。

未来扩展:多商家搜索不能只在详情接口校验权限 ​

本系列当前案例是单商家系统,因此暂不引入 tenantId。如果未来扩展为多商家,搜索索引和搜索查询都要包含商家范围;数据库批量回查时也必须再次验证商家归属。

权限边界还会影响搜索建议、匹配总数、分面统计和高亮片段。不能只在最终详情接口拦截越权访问,却在搜索结果数量或自动补全中泄露其他商家的商品信息。缓存键、搜索游标和统计结果也必须包含商家及用户权限范围。

同样,客服、运营和财务角色可以访问的字段不同。搜索索引需要什么字段、接口允许返回什么字段、日志记录什么信息,都应纳入权限与数据最小化设计。不要因为搜索引擎已经索引了某个字段,就默认所有后台角色都可以看到它。

怎样安排一个低风险的上线过程? ​

可以分阶段演进,而不是一开始就引入完整搜索架构。

**第一阶段:先把标识查询做可靠。**确认订单号、商品编码和 SKU 编码的含义、唯一性范围、规范化规则和必填校验;清理历史数据后建立唯一索引。精确查询和编码前缀提示先使用普通索引。

**第二阶段:建立真实搜索评估集。**从运营和客服日常工作中收集关键词,包含商品名称、品牌、品类、型号、别名、错别字和中英文混合输入。记录期望命中结果、排序理由和不可接受的误命中。

**第三阶段:选择搜索实现。**根据数据规模、中文搜索质量、部署方式、预算和团队运维能力,对比 MongoDB Search 与独立搜索服务。确认目标 MongoDB 版本和部署形态支持所需功能,再决定是否上线。

**第四阶段:灰度和监控。**先让部分运营人员试用,观察零结果比例、结果点击情况、搜索延迟、错误率、索引同步延迟和数据库回退量。对搜索索引定义和分析器修改建立版本与回归测试。

日志只记录排障必需的信息,避免长期保存不必要的个人信息或完整敏感查询内容。搜索质量需要运营参与验收,不能只看数据库执行时间。

上线前准备一张搜索验收表 ​

测试输入或场景预期行为重点检查
完整订单号精确定位唯一订单唯一索引、权限范围
完整商品编码精确定位 SPU规范化、唯一性
完整 SKU 编码定位具体规格SKU 与商品关联
商品编码前缀返回匹配编码前缀索引范围、排序稳定
中文品类词返回相关商品分词和相关性
品牌别名找到配置过的商品别名维护和排序
英文型号按型号召回商品中英文混合检索
常见错别字按产品规则返回近似结果或无结果纠错能力不能误召回过多
一个字符或空白输入拒绝或要求补充关键词防止无界查询
包含正则特殊字符作为普通文本处理或拒绝不允许用户提交任意正则
商品刚刚下架不允许完成交易搜索延迟、交易侧复核
搜索服务不可用精确查询仍可用,全文搜索明确报错不触发全表模糊查询
修改价格或销量摘要搜索结果逐步更新摘要口径、索引延迟
翻页期间修改商品行为符合动态结果约定游标语义和排序稳定性

还要通过代表性数据验证性能。前缀查询使用 explain("executionStats") 检查 totalKeysExamined、totalDocsExamined 和实际执行计划;全文搜索则检查索引状态、延迟、结果相关性、分页稳定性和服务资源占用。不要只用几十条测试数据证明搜索已经满足生产要求。

运营也必须参与结果验收。开发者认为“字符串匹配成功”,不代表运营认为“找到了正确商品”。每次调整分析器、别名、同义词或排序规则,都应使用同一组测试关键词做回归,避免提升某一类搜索效果,却破坏另一类高频查询。

本篇小结:一个输入框,背后是三套查询策略 ​

订单号和完整编码要准确,因此使用等值查询和唯一索引;编码提示只需要从开头匹配,因此使用规范化字段和前缀查询;商品名称需要理解关键词和相关性,因此评估全文搜索能力。

筛选、排序和分页不是额外随意拼接的参数,而是搜索接口的一部分。它们会影响索引设计、查询成本、结果顺序和游标有效性。搜索结果可以帮助用户发现商品,却不能替代交易数据库对商品状态、SKU 价格和库存的最终校验。

第四篇解决的是普通业务列表如何沿着稳定位置继续读取;本篇进一步说明,搜索结果也需要分页,但应使用搜索系统支持的继续位置,而不是直接套用普通列表的排序游标。两篇的共同目标都是让查询成本和数据行为可预测。

一个可靠的搜索功能,不是把所有字段都变成模糊匹配,而是先理解用户要找什么,再选择合适的查询方式,并为索引、权限、数据延迟和故障处理负责。


参考文档:

上次更新于: