怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

项目背景设定 面向多个国家的搜索服务,Java + Spring Boot + Elasticsearch。同一套服务支撑商品搜索(电商)与内容搜索(社区笔记、短视频)。索引量级千万条,用户语言覆盖中英日韩泰及阿拉伯语。
为什么选这三个模块 搜索最容易写成「我用了 ES,调了个 match 查询」。真正有含金量的是三件事:多语言分词(国际业务的真难点,中文英文的经验在泰语阿拉伯语上完全不适用)、索引怎么更新和重建而不影响线上(面试必问)、相关性怎么调以及 ES 挂了怎么办(这两个决定搜索是不是真能用)。

模块一:多语言分词与索引设计

  1. 多语言分词与索引设计(按语言分字段 + 多字段召回 + 权重分层)★★★
    简历这样写 多语言搜索的分词与索引设计(Elasticsearch + 按语言分析器 + 多字段 mapping + 同义词与纠错词典):把标题描述按语言拆成多个子字段各挂对应分析器(中文分词、日文形态素、泰语无空格切分、阿拉伯语词干还原),查询时按用户语言命中主字段、其余字段兜底召回;配套同义词、停用词与拼写纠错词典,并对品牌型号等强精确词保留不分词字段。搜索接口 P95 约 120ms(千万级索引,压测 300 QPS),改造前泰语与阿拉伯语查询大量搜不到结果,改造后同一批查询词可正常召回。
    展开完整拆解
    为什么要这么设计

    第一版是最常见的做法:一个 title 字段,挂标准分析器,查询用 match。中文和英文场景勉强能用,扩到其他语言之后彻底崩了。

    一是泰语压根搜不到。泰语词与词之间不用空格,标准分析器按空白切分,整句话被当成一个词条。用户搜其中一个词,永远匹配不上。

    二是日语的问题类似但更微妙。日语混用汉字、平假名、片假名,同一个词有多种写法(片假名和罗马字),按字符切分会把词切碎,按空白切分又切不开,需要专门的形态素分析。

    三是阿拉伯语有词形变化。同一个词根加不同前后缀表示不同语法形态,不做词干还原的话「书」和「书们」是两个完全不同的词条。而且阿拉伯语是从右往左书写,但这只影响展示不影响索引,容易被误当成分词问题。

    四是精确词被分词毁掉。用户搜商品型号「iPhone15ProMax」或者游戏点卡的面额编码,分词器把它切成几段,结果召回一堆不相关的东西,而真正精确匹配的那个反而排在后面。

    核心认识是:分词是语言特定的,不存在一个通用分析器能处理所有语言。所以设计成「一个逻辑字段拆成多个语言子字段,各挂各自的分析器」,再加一个不分词字段保住精确匹配。

    整体链路
    索引 mapping(一个逻辑字段 → 多个子字段) │ ├─ title 主字段,标准分析器(兜底) ├─ title.zh 中文分词器(IK 之类,带自定义词典) ├─ title.ja 日文形态素分析 ├─ title.th 泰语切分(无空格语言,靠词典 + 统计) ├─ title.ar 阿拉伯语词干还原 + 变音符号归一 ├─ title.en 英文词干还原 + 小写归一 ├─ title.keyword 完全不分词,保精确匹配(型号 / 编码 / 品牌) └─ title.pinyin 中文拼音(首字母与全拼,给联想用) 说明:不是每条文档都要填所有子字段 按文档自身语言只写对应的几个,减少索引膨胀 查询链路 │ ├─ 识别查询语言(用户 locale 优先,其次按字符集判断) │ ├─ 构造多字段查询(各字段权重不同) │ title.keyword 权重最高 完全精确匹配 │ title.<用户语言> 权重次高 主召回路径 │ title 权重较低 跨语言兜底 │ 正文 / 标签 权重最低 扩大召回面 │ ├─ 同义词扩展(查询时扩展,不在索引时扩展) │ ├─ 拼写纠错:无结果或结果极少 → 用编辑距离找候选 → 提示「您是否想搜」 │ ├─ 过滤条件走 filter 而非 query(不参与打分,可缓存) │ 在售状态 / 地区可售 / 价格区间 / 品类 │ └─ 返回文档 ID 列表 → 批量补详情(详情走缓存,不放进 ES) 原则:ES 只存「用于检索和排序的字段」 商品价格库存这类高频变动的不进 ES,查出 ID 后回源
    分步拆解
    1. 先确定哪些字段要进 ES,这一步比分词更重要。原则是只放「用于检索、过滤、排序」的字段。商品的价格、库存、销量这类高频变动的字段如果进 ES,每次变动都要更新索引,写入压力会压垮集群。做法是ES 只返回文档 ID,详情从缓存或数据库批量补。例外是「必须用于排序或过滤的」——比如按价格区间过滤,那价格就得进,但要接受它的更新成本。
    2. 按语言拆子字段,而不是按语言拆索引。拆索引(每种语言一个索引)看起来更干净,但跨语言搜索就要查多个索引再归并,而且商品往往有多语言标题(同一个商品的中文名和英文名),拆索引会让同一商品变成多条文档。拆子字段可以在一次查询里同时命中多个语言字段。
    3. 无空格语言(泰语、日语、中文)必须用专门的分析器。这类语言的切分依赖词典和统计模型,标准分析器完全无效。要准备自定义词典——商品名里的品牌、型号、行业术语通用词典里没有,不加进去就会被切错。
    4. 保留一个完全不分词的字段。型号、编码、品牌这类精确词绝不能被切开。查询时给这个字段最高权重,用户搜完整型号时它一定排第一。这一步是提升「搜得准」体感最直接的手段。
    5. 同义词在查询时扩展,不要在索引时扩展。索引时扩展的问题是改同义词词典要重建整个索引,代价极大;查询时扩展只要重载词典。代价是查询稍慢一点,完全可接受。这个取舍是搜索里的经典考点。
    6. 过滤条件必须走 filter 不走 queryfilter 不参与相关性打分,结果可以被缓存,性能差异很明显。「在售状态」「地区可售」这些是布尔判断,没有「更相关」的概念,放进 query 纯属浪费。
    7. 拼写纠错只在结果太少时触发。每次查询都算候选词是浪费。做法是先正常查,结果数低于阈值才走纠错,并且以「您是否想搜 X」的形式提示而不是直接替换用户的词——直接替换会让用户困惑,尤其是他搜的是个生僻但正确的词时。
    8. 中文要额外做拼音字段。用户经常用拼音或首字母搜索(「hw」搜华为)。这需要单独的拼音分析器字段,主要给搜索联想用。但拼音召回的准确率低,权重要压得很低,否则会污染正常结果。
    关键决策与取舍

    子字段多了索引会膨胀,这是明确的代价。一个标题拆八个子字段,索引体积会显著增长,写入也更慢。控制手段是按文档自身语言只写需要的子字段——一个只有中文和英文标题的商品,不需要填泰语和阿拉伯语字段。另外正文这种长文本不做多语言拆分,只挂主分析器,因为正文的召回权重低,不值得为它付索引成本。

    为什么不直接用云搜索服务或者数据库全文索引。数据库的全文索引(MySQL 的 fulltext)对中日韩泰的支持很差,而且没法做相关性调优和聚合,压根撑不住。云搜索服务省运维但定制受限——自定义词典、打分函数、多语言分析器的组合往往受平台限制。我们的量级和定制需求下自建 ES 更合适。要能说出「什么量级下我不会自建」:如果只有几万条数据、查询量很低,数据库加缓存就够了,上 ES 是过度设计。

    踩过的坑:改了自定义词典之后老数据搜不到。往中文词典里加了一批品牌词,新写入的文档能按新词切分,但已经索引的文档还是按旧词典切的,导致同一个品牌有的商品搜得到有的搜不到,表现得像是数据丢了。原因是分析器是在写入时作用的,改词典不会重写已有倒排索引。修法是改词典必须伴随索引重建(下一个模块讲怎么不停机重建)。这个坑几乎是必踩的,能讲出来说明真运维过 ES。

    踩过的坑二:语言识别错导致召回为空。按字符集判断查询语言,用户用英文搜中文商品(搜品牌的英文名),被判定为英语,只查了英文字段,而商品只有中文标题,结果为空。修法是永远保留跨语言兜底字段并且不做硬路由——用户语言字段给高权重,但其他字段也参与召回,只是权重低。硬路由到单一字段是这里最容易犯的错。

    没做的部分:没做向量检索(语义搜索)。它能解决「用户描述需求但不知道商品名」的场景,但需要模型、向量索引和额外算力,当时的投入产出不划算。这是明确的能力缺口,被问到要承认而不是硬说不需要。

    数字是怎么测的

    搜索接口 P95:用千万级的真实数据集(或者按真实分布造的数据),压测 100 / 200 / 300 QPS 梯度,查询词要用线上真实的搜索词分布——热门词大量重复(会命中缓存)、长尾词各不相同,两者的耗时差很多。只用随机词压测会偏悲观,只用热门词会偏乐观。300 QPS 下 P95 约 120ms。要说清索引规模、分片数、集群规格,这三个不说的话延迟数字没有参考价值。

    「泰语阿拉伯语搜不到」怎么验证:整理一批真实的泰语和阿拉伯语搜索词(从线上无结果日志里捞),改造前后各跑一遍,对比有结果的条数。用「大量搜不到 → 可正常召回」这种定性描述,因为「召回率」需要人工标注每个查询的正确答案作为分母,没做标注就报不出准确率。

    可以补充的硬指标:无结果查询的绝对数量——从搜索日志里统计每天返回 0 条结果的查询次数,改造前后对比。这个数字分母清晰(就是搜索请求数)、口径明确、可复现,比「准确率」可信得多。

    不要报「搜索转化率提升 X%」或者「搜索准确率 95%」。前者是业务指标受太多因素影响;后者需要人工标注的评测集,没建评测集就说不出这个数,一问「你的标注集多大、谁标的」就穿。

    面试追问
    Q:中文分词具体怎么做的?为什么标准分析器不行? A:标准分析器对中文是按单字切分(每个汉字一个词条)。这样召回率很高但准确率很差——搜「北京大学」会召回所有含「北」「京」「大」「学」任一字的文档,「南京大学」也会被搜出来。中文需要基于词典加统计的分词器(IK、jieba 之类),把「北京大学」切成一个或两个有意义的词。实践中通常索引时用细粒度切分(提高召回),查询时用粗粒度切分(提高准确),这个组合是中文搜索的常规做法。另外自定义词典是必需的,商品名里的品牌型号通用词典没有,不加会被切错。
    Q:用户搜索词里既有中文又有英文,怎么处理? A:不做硬路由,多字段同时召回。构造 multi_match 或者 bool should,让中文子字段、英文子字段、主字段、精确字段都参与,各自不同权重。混合语言的查询词经过各语言分析器时,能被识别的部分会产生词条,不能识别的部分产生噪音词条但权重低,不影响结果。关键是不要试图先判断语言再决定查哪个字段——判断一定会错,而多字段召回本身就有容错性。这也是为什么必须保留一个跨语言兜底字段。
    Q:ES 里要不要存商品价格和库存? A:能不存就不存。这两个字段变动极频繁(价格调整、每笔订单都改库存),如果进 ES,每次变动都要更新文档,千万级商品的写入压力会压垮集群,而且 ES 的更新实际是「标记删除 + 重新写入」,代价比想象的大。做法是ES 只返回商品 ID,价格库存从缓存批量补但有例外:如果业务需要「按价格区间过滤」或者「按价格排序」,价格就必须进 ES,否则做不了。这时的折中是价格进 ES 但降低更新频率(比如批量定时同步,接受分钟级延迟),并且在返回给用户前用缓存里的实时价格覆盖显示。库存则只存一个「是否有货」的布尔标记而不是具体数量,变动频率低得多。
    Q:同义词为什么放在查询时扩展而不是索引时? A:核心差异是改词典的代价。索引时扩展意味着同义词在写入时就展开进倒排索引,查询很快,但改一次同义词词典就要重建整个索引——千万级文档重建是小时级的工程。查询时扩展只需要重载词典,改完立刻生效。运营调同义词的频率很高(每次发现搜不到就要加一条),所以查询时扩展的灵活性远比那点性能重要。代价是查询变复杂:一个词扩展成五个,查询条件变大,耗时略增;而且同义词链过长会导致召回失控(A 等于 B、B 等于 C,结果搜 A 召回了 C 的东西)。所以同义词表要控制规模并且只做单向扩展,这个约束要说出来。

模块二:索引重建与增量同步

  1. 索引重建与增量同步(别名切换 + Binlog 订阅 + 一致性校验)★★★
    简历这样写 索引更新与不停机重建链路(Canal/Binlog 订阅 + 消息队列 + ES 别名切换 + 定时校验):增量更新由订阅数据库变更驱动,按文档 ID 聚合去重后批量写入,避免业务代码里散落双写;全量重建走「新建索引 → 灌数据 → 追平增量 → 别名原子切换」,线上无感知;配套定时抽样比对数据库与索引,偏差告警。千万级索引全量重建约 25 分钟且期间线上查询不受影响,增量同步延迟 P95 约 3 秒
    展开完整拆解
    为什么要这么设计

    第一版的索引更新是业务代码里双写:商品保存成功后调一次 ES 的更新接口。这个做法的问题不是不能用,而是会慢慢烂掉。

    一是漏更新。商品能被修改的入口有十几个——运营后台、批量导入、定时下架任务、供应商接口同步。每个入口都要记得写 ES,只要有一个人漏了,那条数据就永久不一致,而且很难发现。

    二是双写不一致。数据库写成功、ES 写失败(网络抖动、ES 短暂不可用),这条数据就搜不到了。加重试的话又要考虑重试期间的顺序问题——先改成 A 后改成 B,如果 A 的重试比 B 晚成功,索引里就是 A。

    三是业务代码被搜索逻辑污染。商品保存的方法里混着构造 ES 文档的代码,改索引结构要改业务代码。

    第二个更痛的问题是重建索引。改 mapping、换分词器、加字段都需要重建,而重建期间如果直接往线上索引操作,搜索就废了。第一次改分词器时我们选了凌晨停机重建,用户投诉「凌晨搜不了东西」,而且随着数据量涨,停机窗口越来越不够。

    所以两个方向:更新改成订阅数据库变更(不侵入业务代码,天然不漏)重建改成双索引加别名切换(线上无感知)

    整体链路
    增量同步(不在业务代码里双写) │ ├─ MySQL Binlog ─▶ Canal / Debezium ─▶ 消息队列 │ 好处:所有修改入口都会产生 binlog,天然不漏 │ ├─ 消费端:按文档 ID 在时间窗口内聚合去重 │ 同一商品 1 秒内改了 5 次 → 只查一次最新状态、只写一次 ES │ ├─ 反查数据库拿完整文档(不用 binlog 里的行数据拼) │ 原因:ES 文档往往需要多表关联(商品 + 品类 + 品牌 + 标签) │ ├─ 批量写 ES(bulk),失败的单条重试,连续失败进死信 │ └─ 顺序保证:同一文档 ID 路由到同一分区,保证不乱序 全量重建(不停机) │ ├─ 1 新建索引 product_v2(新 mapping / 新分词器) │ ├─ 2 记下当前 binlog 位点 │ ├─ 3 从数据库全量灌入 v2(分片并行,按主键区间切分) │ 期间 v1 仍在服务线上查询 │ ├─ 4 从步骤 2 的位点开始追增量,追平到当前 │ 追平判定:消费延迟持续低于阈值 │ ├─ 5 校验:抽样比对 v1 / v2 / 数据库,文档总数与关键字段 │ ├─ 6 别名原子切换:alias search_product 从 v1 指向 v2 │ 应用一直查别名,切换对应用完全透明 │ ├─ 7 观察一段时间,v1 保留不删(随时可切回) │ └─ 8 确认无问题后删除 v1 一致性校验(每日) ├─ 全量对比文档总数(数据库在售商品数 vs 索引文档数) ├─ 抽样对比关键字段(标题 / 状态 / 品类) └─ 偏差超阈值 → 告警 + 输出差异清单(不自动修,先看根因)
    分步拆解
    1. 订阅 binlog 而不是业务双写,这是最重要的一步。所有修改数据库的路径都会产生 binlog,不存在「某个入口忘了同步」的可能。而且业务代码完全不感知搜索的存在,索引结构变化不需要动业务。
    2. 消费端要反查数据库拿完整文档,不要直接用 binlog 里的行数据。因为 ES 文档通常是多表关联的结果(商品加品类名加品牌名加标签),binlog 只给你单表的变更行,拼不出完整文档。反查会带来数据库压力,所以必须先聚合去重。
    3. 按文档 ID 在时间窗口内聚合去重,收益极大。批量导入或者批量改价时,同一个商品可能在一秒内被改十几次,每次都反查加写 ES 是巨大浪费。窗口内只保留一个 ID,反查一次最新状态写一次。这也顺便解决了乱序——反查的总是最新状态,不管中间经过多少次修改。
    4. 顺序保证靠分区路由。同一文档 ID 的变更消息必须进同一个分区,由同一个消费者串行处理。否则同一商品的两次变更被两个消费者并发处理,谁先写完不确定,索引里可能留下旧值。
    5. 全量重建的关键是「先记位点,再灌全量,再追增量」这个顺序。先记位点保证不漏——灌全量期间发生的变更都在位点之后,追增量时会补上。顺序反了(先灌全量再记位点)就会丢掉灌数据期间的变更。
    6. 全量灌入要按主键区间并行。单线程扫千万条太慢。按主键切成若干区间并行读写,并且用游标翻页而不是 offset(深分页会越来越慢)。写 ES 用 bulk 批量,批大小要实测——太大容易超时和触发内存压力,太小吞吐低。
    7. 重建期间要临时调整索引参数。把副本数设为 0、刷新间隔调大(甚至关闭),灌完再恢复。这能让灌入速度提升明显,因为省掉了副本同步和频繁刷新的开销。这是 ES 重建的标准做法,不做的话重建时间会长很多。
    8. 别名是不停机切换的前提,必须一开始就用。应用永远查别名不查具体索引名。切换别名是原子操作,应用侧零改动零重启。如果一开始就写死索引名,后面想不停机重建就得先改代码上线,很被动。
    9. 旧索引不要立刻删。切换后保留一段时间,出问题能立刻切回——这是最快的回滚手段。确认无误再删,代价只是多占一份存储。
    10. 每日一致性校验是必需的,不是可选项。异步链路必然有偏差(消息丢失、消费异常、重建时机边界)。校验的重点是告警而不是自动修数——自动修会掩盖 bug,而且修错方向会造成二次问题。
    关键决策与取舍

    binlog 订阅引入了新的依赖和延迟。相比双写的「同步立即可见」,订阅链路有秒级延迟——商品改完立刻搜可能搜到旧的。大部分场景可接受,但有例外:商品下架必须立刻从搜索结果消失(否则用户点进去发现下架,体验很差,甚至有合规风险)。对这类强实时要求的状态变更,我们额外保留了一条同步的「立即失效」通道——下架时直接调 ES 更新状态字段,不等 binlog。「主链路异步,个别强实时场景开同步小口子」,这个折中要说出来,声称全部异步没问题是不诚实的。

    反查数据库带来的压力必须控制。大批量变更时(比如批量下架一万个商品)会产生一万次反查。控制手段:聚合去重(前面讲的)、批量反查(一次 IN 查询取多个 ID 而不是逐个查)、反查走只读库消费端限速。最后一条很关键——宁可同步慢一点,也不能让搜索的同步把主库压垮。

    踩过的坑:重建时追增量永远追不平。灌全量花了 20 分钟,这 20 分钟积压了大量增量消息,开始追增量时又有新的变更不断产生,消费速度和产生速度接近,延迟一直降不下来。修法是提高追增量阶段的消费并发,并且判定标准改成「延迟持续低于阈值一段时间」而不是「延迟为 0」——永远等不到 0,因为线上一直有变更。这个坑说明「追平」需要一个可达成的定义。

    踩过的坑二:切换别名后立刻删旧索引,出问题无法回滚。有一次新索引的 mapping 有个字段类型配错,切换后部分查询报错,而旧索引已经删了,只能紧急重建,故障持续了一个多小时。修法就是那条纪律:切换后旧索引至少保留一天。这是花代价学到的。

    没做的部分:没做重建的自动化编排。整个重建流程(建索引、灌数据、追增量、校验、切换)是靠脚本加人工确认串起来的,每次重建要人盯着。理想状态是做成一个可观测的工作流,一键触发、自动校验、异常自动中止。

    数字是怎么测的

    全量重建耗时:从「开始灌数据」到「别名切换完成」的墙钟时间,取几次重建的实际记录。约 25 分钟。必须同时说明索引规模、并行度、集群规格、重建期间的参数调整(副本数 0、刷新间隔调大)——不说这些的话 25 分钟这个数字没有可比性。

    「线上查询不受影响」怎么验证:重建期间持续压测查询接口,对比重建前后的 P95 是否有明显抬升。要诚实:重建会占用集群资源,查询延迟通常会有小幅上升,说「完全无影响」不可信。正确的表述是「查询延迟无明显抬升」或者给出具体的抬升幅度。

    增量同步延迟 P95:在文档里埋一个「数据库更新时间」字段,消费端写入时对比当前时间,上报差值。取 P95 约 3 秒。要区分平峰和批量变更时的延迟——批量导入期间延迟会明显变大,只报平峰数字是选择性呈现。

    一致性偏差:每日校验发现的差异记录数。用绝对数(每日个位数到十几条)而不是「一致率 99.99%」——千万级分母下任何比率都好看得没有意义。

    面试追问
    Q:为什么不在业务代码里直接双写 ES? A:能用,但会随时间烂掉。核心问题是「漏」——修改商品的入口有十几个(后台、批量导入、定时任务、供应商同步),任何一个新入口的开发者忘了写 ES,那部分数据就永久不一致,而且不会立刻暴露,可能几个月后才有人报「这个商品搜不到」。第二个问题是不一致:库写成功 ES 写失败怎么办,重试的话怎么保证顺序。第三个是耦合,业务代码里混着索引构造逻辑。订阅 binlog 把这三个问题一次解决——binlog 是数据库变更的唯一真相,不存在漏的可能。代价是引入了新组件和秒级延迟,所以强实时的状态变更(比如下架)我们额外开了同步通道。
    Q:重建索引期间,线上的数据变更怎么不丢? A:靠「先记 binlog 位点,再灌全量,再从位点追增量」这个顺序。开始灌数据前记下当前位点,灌数据期间产生的所有变更都在这个位点之后,全量灌完后从位点开始重放增量消息,就能补上这段时间的变更。顺序绝对不能反——如果先灌全量再记位点,灌数据那 20 分钟里的变更就丢了。另外「追平」的判定要可达成:不能等延迟为 0(线上一直有新变更,永远等不到),而是「延迟持续低于阈值一段时间」。追平之后再做抽样校验,最后才切别名。
    Q:同一个商品连续改了两次,会不会索引里留下旧值? A:如果不处理会。两个措施。一是同一文档 ID 路由到同一分区串行消费,避免两次变更被并发处理导致写入顺序不确定。二是消费端反查数据库拿最新状态而不是用 binlog 里的行数据——这样即使消息乱序,反查到的总是当前最新值,写进去就是对的。第二条其实更根本,它让顺序问题变得不重要。再加一层保险可以用版本号:ES 支持外部版本号,用数据库的更新时间戳或版本字段作为 version,ES 会拒绝写入版本更旧的文档。三层里至少要有前两层。
    Q:怎么知道索引和数据库是一致的? A:定时校验,分两层。粗校验是总数比对:数据库里符合条件的商品数(在售、未删除)和索引文档总数应该一致,差多了立刻告警。这个成本极低可以高频跑。细校验是抽样字段比对:随机抽一批 ID,逐个对比标题、状态、品类这些关键字段,找出内容不一致的。抽样比例按成本定,不用全量。发现偏差后不自动修——自动修会掩盖根因,而且如果是同步链路有 bug,修完还会再错。正确做法是输出差异清单、告警、人工定位根因,修完根因再批量修数据。另外要监控同步链路本身:消息积压量、消费延迟、死信数量,这些指标异常往往比数据偏差更早暴露问题。

模块三:相关性排序与降级

  1. 相关性排序与降级(打分分层 + 业务权重 + 故障兜底)★★★
    简历这样写 搜索相关性与可用性保障(Elasticsearch function_score + 多路兜底 + 熔断降级):把排序拆成「文本相关性 × 业务质量分 × 时效衰减」三层并可配置权重,精确匹配与完整词匹配单独提权;针对无结果与 ES 故障设计四级降级(放宽条件 → 纠错重搜 → 缓存热门结果 → 数据库兜底),搜索入口不返回错误页。无结果的查询由每天数千次降至数百次,ES 演练故障期间搜索入口仍可返回结果(质量下降但可用)。
    展开完整拆解
    为什么要这么设计

    第一版排序就是 ES 默认的相关性打分。问题分两类。

    一是排序不符合业务预期。默认打分只看文本相关性,结果是一个标题里堆满关键词的低质商品排在正品前面——因为它的词频高。而销量好、评价高、真正该被推荐的商品排在后面。运营天天来反馈「搜这个词第一个出来的是什么垃圾」。

    二是没有兜底,搜不到就是白屏。用户搜了个错别字、或者搜了个平台没有的品类,返回 0 条,页面一片空白。更糟的是 ES 集群抖动时,搜索接口直接报错,用户看到的是错误页——而搜索是电商最核心的入口之一,它挂了等于店关门

    所以两个方向:把排序拆成可解释可配置的几层(文本相关性只是其中一层,业务质量必须参与),给「无结果」和「服务故障」都设计兜底路径(搜索入口永远要有内容返回)。

    第二点尤其重要,它体现的是一个通用判断:用户高频入口的可用性优先于结果质量。返回质量差一些的结果远好于返回错误。

    整体链路
    排序打分(三层相乘,权重可配置) │ ├─ 第一层 文本相关性 │ ES 原生打分(词频 / 逆文档频率 / 字段长度归一) │ 叠加提权:精确匹配 > 完整词匹配 > 部分匹配 │ 标题命中权重高于正文命中 │ ├─ 第二层 业务质量分(离线算好写进索引,不实时算) │ 销量 / 评分 / 转化率 / 退款率 / 商家等级 │ 取对数压缩,避免头部商品分数碾压一切 │ ├─ 第三层 时效衰减(内容搜索用,商品搜索弱化) │ 发布时间越久权重越低,用衰减函数而非硬截断 │ └─ 最终分 = 文本分 × 质量分权重 × 时效权重 权重配置化,运营可按品类调整并有灰度与回滚 强制规则(不参与打分,直接干预) ├─ 置顶:运营指定某词搜某商品排第一(活动需要) ├─ 打压:低质或违规商品强制降权 ├─ 过滤:下架 / 地区不可售 / 违规 直接排除 └─ 打散:同一商家连续出现超过 N 个则往后挪 无结果的四级降级 │ ├─ 1 放宽匹配条件(必须全词匹配 → 允许部分匹配) ├─ 2 拼写纠错后重搜 + 提示「您是否想搜 X」 ├─ 3 退回品类推荐(按查询词猜品类,展示该品类热门) └─ 4 展示平台热门 + 明确文案「没找到相关商品,看看这些」 ES 故障的降级 │ ├─ 熔断器:ES 连续超时 → 打开熔断,不再打过去 │ ├─ 一级 读搜索结果缓存(热门词的结果本来就缓存着) ├─ 二级 数据库标题前缀匹配(只支持简单查询,够用) ├─ 三级 返回预置的热门商品列表(本地缓存,永远可用) │ └─ 全程:接口不返回 5xx,返回结果 + 降级标记 客户端按标记决定是否提示「搜索服务繁忙」
    分步拆解
    1. 业务质量分要离线算好写进索引,不要查询时实时算。销量、评分、转化率这些需要聚合计算,实时算会让每次查询都变慢。做法是离线任务定时算出一个综合质量分,作为普通字段写进 ES,查询时直接参与打分函数。更新频率按业务定,通常每天一次足够——销量排名不会一小时内天翻地覆。
    2. 质量分要做对数压缩。销量从 10 到 10000 是三个数量级,直接乘进去会让爆款碾压一切,搜任何词出来的都是那几个爆款,长尾商品永无出头之日。取对数把差距压到可控范围,让文本相关性还能起作用。这一步是「相关性」和「热度」平衡的关键。
    3. 精确匹配必须单独提权。用户搜完整的商品型号或品牌名,那个精确匹配的结果必须排第一。做法是查询时对不分词字段做一次精确匹配并给很高的权重加成。这是提升「搜得准」体感最直接的一步。
    4. 时效衰减用衰减函数不用硬截断。硬截断(只搜最近 30 天)会导致好内容突然消失;衰减函数让老内容权重逐渐降低但仍可被搜到。商品搜索的时效权重要压得很低甚至关掉——用户搜商品不在乎它什么时候上架;内容搜索(笔记、视频)时效才重要。同一套框架不同业务用不同权重,这是配置化的价值。
    5. 强制规则(置顶、打压、过滤)不要混进打分函数。它们是确定性的业务干预,用打分权重去实现会不可控(权重给多大才能保证排第一?)。做法是过滤走 filter,置顶走结果后处理(查出来后把指定商品插到第一位),打压直接给极低权重或过滤。分开之后行为可预测、可解释。
    6. 结果打散避免同一商家垄断。某个商家上了一百个相似商品,搜索结果前两页全是他家的。在结果后处理阶段做打散:同一商家连续出现超过阈值就往后挪。这个规则简单但对结果观感的改善很明显。
    7. 无结果的降级要逐级放宽,并且要告知用户。从「放宽匹配」到「纠错重搜」到「品类推荐」到「平台热门」,每一级都比上一级更宽。关键是文案要诚实——直接展示热门商品而不说明,用户会以为这些就是搜索结果,反而困惑。要明确写「没找到相关商品,为你推荐」。
    8. ES 故障降级的核心是「接口绝不返回错误」。用熔断器检测 ES 状态,熔断后走缓存、数据库前缀匹配、预置热门列表三级兜底。返回体里带降级标记,让客户端知道当前是降级状态,可以选择提示用户。预置热门列表要放在应用本地缓存,这样即使 Redis 也挂了还能返回。
    9. 权重调整必须能灰度和回滚。改排序权重是高风险操作,一个系数改错整个搜索结果就变样了。做成配置 + 灰度比例 + 一键回滚,先给小流量看效果。这是工程纪律不是技术难点,但没有它就不敢调权重。
    关键决策与取舍

    相关性和业务目标是有冲突的,必须承认。纯相关性排序对用户最「诚实」,但平台希望优质高毛利的商品靠前,运营希望活动商品置顶。我的处理是把这些诉求显式化而不是偷偷塞进打分——质量分是一层可配置权重、置顶是一条可审计的强制规则。这样至少能解释「为什么这个排第一」,出问题能定位。最糟的做法是把各种业务诉求都糅进一个巨大的打分公式,最后没人知道为什么。

    为什么不上机器学习排序(LTR)。它确实是相关性的天花板,但需要标注数据、特征工程、模型训练与在线推理一整套基建,而且效果依赖数据量。我们的阶段用「规则加可配置权重」已经能解决大部分明显问题,投入产出更划算。要能说出「什么条件下我会上 LTR」:有稳定的点击行为数据、有评测集、有算法同学配合的时候。答不出条件就说明只是听说过这个词。

    降级到数据库前缀匹配的能力是很弱的。它做不了分词、做不了相关性排序,只能支持最简单的标题包含查询,而且要小心不能用左模糊(like '%词%' 无法走索引,会全表扫描把数据库拖垮)。所以这一级只作为短时间兜底,而且要限流。要明确说这是「保证有结果」而不是「保证质量」,把降级说得很美好反而不可信。

    踩过的坑:质量分权重调大之后长尾商品完全搜不到了。为了压制低质商品把质量分权重调高,结果新上架、没销量的商品分数极低,即使标题完全匹配也排在几百名之后,新商家反馈「我的商品搜不到」。修法是给新商品一个保护期的初始质量分(按品类均值而不是 0),并且限制质量分的影响上限这个坑说明排序调优必须看长尾而不只看头部,只检查热门词的结果会漏掉这类问题。

    踩过的坑二:熔断打开后一直不恢复。熔断器的半开探测配置有问题,ES 恢复了但熔断没关,搜索一直在降级状态跑了几个小时才被发现。修法是熔断状态必须有独立的监控和告警,而不是只监控接口成功率——降级状态下接口是「成功」的,成功率看不出问题。降级是一种需要被主动观测的状态,它不会自己报警。

    没做的部分:没建搜索评测集。所有排序调优都是靠运营反馈和人工抽查,没有量化的评测基线,导致「这次调整到底是变好还是变坏」说不清。这是最应该补但一直没补的基础工作。

    数字是怎么测的

    无结果查询次数:从搜索日志统计每天返回 0 条结果的查询次数。这个指标的口径必须说清——是降级前的原始查询算,还是降级后仍无结果才算?我们统计的是「四级降级都走完仍然没有任何内容返回」的次数,从每天数千次降到数百次。用绝对数而不是「无结果率」,因为率的分母(总搜索量)每天波动,看不出改善。

    「ES 故障期间搜索仍可用」怎么验证:必须做故障演练——把 ES 地址指到不可用端点,或者直接在测试环境停掉集群,验证:接口不返回 5xx、能返回兜底内容、熔断正常打开、告警正常触发、ES 恢复后熔断正常关闭。最后一条最容易漏,也正是我们踩过坑的地方。没演练过的降级不能写进简历,一问「你怎么知道它有效」就答不上来。

    排序效果怎么量化:诚实的答案是我们没有量化基线,只有运营反馈和人工抽查。可以报的是过程性指标:「搜索结果首屏无相关商品」的投诉工单数量变化。不要报「相关性提升 X%」——没有评测集算不出这个数,编一个一问就穿。

    接口 P95:加了打分函数和后处理之后延迟会略增,要报出来。主动给出优化的代价,比只报好处可信。

    面试追问
    Q:ES 挂了,搜索接口怎么办? A:绝不返回错误,搜索是核心入口,报错等于店关门。三级兜底:一级读搜索结果缓存,热门词的结果本来就缓存着,覆盖了相当一部分查询;二级退回数据库标题前缀匹配,能力很弱(不能分词、不能相关性排序)但能返回东西,注意只能用前缀匹配不能用左模糊,否则全表扫描会把数据库也拖垮,而且必须限流;三级返回预置的热门商品列表,放在应用本地缓存里,即使 Redis 也挂了还能返回。前面挂一个熔断器,检测到 ES 连续超时就直接走降级不再打过去,避免大量请求堆在超时等待上。返回体带降级标记,客户端可以提示用户「搜索服务繁忙,结果可能不完整」。还有一点很重要:降级状态要单独监控告警,因为降级时接口是「成功」的,看成功率发现不了。
    Q:搜「苹果」,怎么让 iPhone 排在苹果水果前面(或者反过来)? A:这是查询意图识别问题,纯靠文本相关性解决不了,因为两者的文本匹配度差不多。几个手段,按成本从低到高:一是用点击行为——统计搜这个词的用户实际点了哪些商品,把高点击的品类提权,这是最有效且不需要人工的办法;二是运营配置词到品类的映射,明确指定「苹果」优先手机品类,简单直接但只能覆盖头部词;三是看用户上下文,他刚浏览过手机品类就偏手机。要说明的是:这个问题没有唯一正确答案,取决于平台的商品结构(一个卖 3C 的平台和一个卖生鲜的平台答案相反),所以最好的方案是让数据(点击行为)来决定而不是靠人拍
    Q:运营要求某个词搜出来第一个必须是指定商品,你怎么实现? A:不要用调权重实现——权重给多大才能保证一定排第一?没法确定,而且会影响其他结果的相对顺序。正确做法是结果后处理阶段强制插入:正常查询拿到结果列表,然后把指定商品插到第一位(如果它本来在列表里就移到第一,不在就补进来)。这样行为完全确定。配套要有几件事:置顶规则要有生效时间范围(活动结束自动失效)、要有审计记录(谁配的、什么时候)、要校验被置顶的商品是否可售(置顶一个已下架商品是事故)、要限制置顶数量(前三个都置顶就没有搜索了)。把业务干预和相关性打分分开,是这道题的核心。
    Q:用户搜了个平台完全没有的东西,你返回什么? A:逐级降级,但每一级都要让用户知道发生了什么。先放宽匹配条件(从全词匹配放到部分匹配);还是没有就拼写纠错重搜,并提示「您是否想搜 X」;还没有就猜品类给推荐(从查询词里提取可能的品类词,展示该品类热门);最后展示平台热门关键是文案必须诚实:直接展示热门商品而不说明,用户会以为「这些就是我搜的东西」,体验更差。要明确写「没找到相关商品,为你推荐这些」。另外无结果的查询词要专门记日志给运营看——这些词代表用户有需求而平台没有供给,是选品的重要输入。把「无结果」当成一个业务信号而不只是技术问题,这点会加分。

没有匹配的内容,换个关键词试试。

项目拆解 · 搜索服务(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据