1. 方案整体设计与选型思路
做搜索框的人大概都有过这样的体验:用户点进搜索框,刚敲两个字,下面就开始下拉一长串候选词;按下回车之后,结果列表里的关键词又被一层高亮标出来。这套“输入即提示 + 结果即标红”的组合,几乎是内容型产品的标配。我这次要聊的,就是用 SpringBoot 整合 ElasticSearch,把自动补全和搜索关键字高亮这两个功能一次做扎实。它解决的核心问题其实有两个:一是降低用户的输入成本,把长尾词、热门词直接推到用户面前;二是提升结果的可扫描性,让用户一眼看到自己搜的词落在哪。适合已经能跑通 ElasticSearch 基础读写、但卡在补全准确度或高亮效果上的后端同学,也适合刚接手搜索模块、需要快速摸清整体链路的初中级开发参考。
选型上我没有太多纠结。自动补全这类前缀匹配场景,ElasticSearch 自带的completionsuggester 是基于 FST(有限状态转换器)实现的,查询走的是内存里的前缀自动机,响应时间通常在个位数毫秒,比用match_phrase_prefix或者wildcard硬怼要稳得多。高亮则直接用原生highlight能力,不需要自己写一套文本匹配逻辑,也不用把原文拉回应用层再二次处理。
真正需要花心思的地方在于:索引怎么设计才能同时喂饱补全和搜索;补全词从哪儿来、怎么更新;高亮在中文分词下为什么经常“该亮的不亮、不该亮的乱亮”。这些细节才是把 Demo 变成生产可用的分水岭。
1.1 为什么不用 match_phrase_prefix 做补全
很多人第一反应是拿match_phrase_prefix顶上,反正也是前缀匹配。我早期也这么干过,后来被两件事劝退。第一是性能,match_phrase_prefix本质上还是走倒排索引的 term 扫描,前缀越长、候选 term 越多,扫描代价越大,QPS 一上来就开始抖。第二是控制力太弱,你没法给它加权重、没法去重、没法按分类做上下文过滤,返回结果往往是一堆长尾垃圾词排在最前面。
completionsuggester 的代价是索引体积。FST 会被完整加载进堆内存,字段越长、词量越大,占的内存越多。这里有个经验值:单字段 FST 占用大致和去重后的输入词条数、平均词长成正比,几十万级别的候选词,通常也就是几十 MB 的量级,对现代机器不算负担。但如果你的候选词有千万级,就得考虑分索引或者拆 field 了。
1.2 索引结构:一个索引还是两个索引
线上我倾向于一个索引、多个字段,而不是补全一个索引、搜索一个索引。理由很实在:两个索引意味着两份数据、两套同步逻辑、两次一致性风险。补全字段本身不参与全文检索,占的空间有限,放在同一个索引里,文档写入一次就能同时生效,维护成本低一大截。
字段规划大概是这样:title做全文检索用ik_max_word建索引、ik_smart查询;title_suggest专门做补全,type设completion;再留一个category做过滤、hot_score做排序权重。正文内容如果很长,单独放content字段,高亮时只对它取片段,避免整段返回把响应体撑爆。
1.3 客户端选型:别被 Spring Data 的自动配置绊住
SpringBoot 生态里操作 ElasticSearch 有三条路:spring-boot-starter-data-elasticsearch的 Repository、官方的RestHighLevelClient、以及 8.x 之后推出的ElasticsearchClient(Java API Client)。Repository 写简单 CRUD 很爽,但补全的SuggestBuilder、高亮的HighlightBuilder这类偏底层的 DSL 构造,用 Repository 反而绕。
我的建议是:SpringBoot 2.x 配 ES 7.x,直接用RestHighLevelClient手搓 Bean,把自动配置关掉或者绕开,避免版本兼容带来的启动报错;SpringBoot 3.x 配 ES 8.x,用新的ElasticsearchClient,DSL 是强类型的,写起来更安全但代码量大一些。下面实操部分我以 7.17 为例,因为存量项目最多,8.x 的差异我会单独点出来。
注意:
spring-boot-starter-data-elasticsearch会尝试自动创建客户端 Bean,如果你自己也声明了一个,容易出现“多个同类型 Bean”或者端口、协议对不上的问题。稳妥做法是在配置类上排除自动配置,或者干脆不引这个 starter,只引elasticsearch-rest-high-level-client。
2. 自动补全:从索引结构到 suggest 查询
自动补全的体验好坏,八成取决于索引里的数据长什么样,剩下两成才看查询怎么写。很多人上来就调查询参数,结果怎么调都不对味,本质是候选词本身不够干净。
2.1 completion 字段的几个反直觉特性
第一个特性是只能前缀匹配,不能中间匹配。用户搜“手机壳”,如果你的候选词是“苹果手机手机壳”,前面没有“手机壳”开头,那这条就出不来。解决办法是写入时塞多个input:把标题、标题的后缀切片、别名、拼音全拼和首字母都作为 input 塞进去,用一个weight统一加权。比如“苹果手机手机壳”,input 里放["苹果手机手机壳", "手机壳", "sj", "shoujike"],这样无论用户从哪个角度敲,都有机会命中。
第二个特性是默认不会去重。同一个建议词可能从不同文档冒出来十几次,下拉框里就重复十几次。skip_duplicates(true)必须加,这不是可选项。
第三个特性是**max_input_length默认 50 个字符**。中文场景下超过 50 字的标题会被截断,倒也不算大事,但如果你的候选词是拼接出来的长串,记得显式调大,或者写入前先做裁剪。
第四个特性是fuzzy 不是万能的。加fuzziness能容忍拼写错误,但对中文的收益有限,反而容易引入噪音。我的做法是只在拼音字段上开 fuzzy,中文前缀查询不开,或者把prefix_length设成 2,至少保证前两个字是对的。
2.2 索引 mapping 怎么写
下面这段 mapping 是我线上跑的简化版,可以直接抄:
PUT /content_index { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "max_result_window": 10000 }, "mappings": { "properties": { "id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "title_suggest": { "type": "completion", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "max_input_length": 50, "preserve_separators": true, "preserve_position_increments": true }, "category": { "type": "keyword" }, "hot_score": { "type": "integer" }, "update_time": { "type": "date" } } } }preserve_separators建议保持 true,否则“Spring Boot”里的空格会被吃掉,用户敲“spring b”反而补不出来。preserve_position_increments影响的是短语补全的精度,除非你有明确的性能诉求,否则别动它。
至于title_suggest的 analyzer,中文场景下ik_max_word建索引能切出更多细粒度词,查询时用ik_smart收得更紧,这个组合我实测召回和噪音的平衡点最好。拼音支持需要额外装analysis-pinyin插件,它不属于官方默认发行版,装之前先确认运维能接受插件引入,这是很多人踩过的坑——本地装好了,上线发现集群没这个插件,索引直接建不起来。
2.3 候选词怎么来、怎么更新
补全词库的更新有两条路:全量重建和增量更新。全量适合首次初始化,或者规则大改后的重建;线上跑的是增量。
增量的典型链路是业务表变更 → 发 MQ 消息(带主键和操作类型)→ 消费端回查最新数据 → 组装 suggest input → 写 ES。用 MQ 而不是直接在业务事务里双写,是为了解耦:ES 挂了不能把主流程带崩,写失败还能重试。
另一种方式是监听数据库变更日志,比如用 CDC 组件抓取增量,这种方式对业务代码零侵入,但要处理好 DDL 变更和字段映射,运维复杂度也上一个台阶。中小团队我更推荐 MQ 这条路,可控、好排查。
组装 input 的时候有个细节值得说:别把整段正文塞进去。补全词应该是用户会主动敲的短词,从正文里截几个短语出来没意义,还白白撑大 FST。我的规则是取标题本身、标题按标点切分后的后半段、以及人工维护的热搜词表,三路合并去重。
List<String> buildInputs(String title, List<String> aliases) { List<String> inputs = new ArrayList<>(); if (StringUtils.isNotBlank(title)) { inputs.add(title); // 标题按分隔符切段,后半段往往是核心词 String[] parts = title.split("[\\s\\-_|,,::]+"); if (parts.length > 1) { inputs.add(parts[parts.length - 1]); } } inputs.addAll(aliases); return inputs.stream().filter(StringUtils::isNotBlank).distinct().limit(10).collect(Collectors.toList()); }limit(10)是防呆的,一个文档塞几十个 input,FST 会膨胀得很难看,而且收益递减。
2.4 suggest 查询的构造
RestHighLevelClient 下的构造写法:
SuggestBuilder suggestBuilder = new SuggestBuilder(); suggestBuilder.addSuggestion("title_sg", SuggestBuilders.completionSuggestion("title_suggest") .prefix(keyword) .size(10) .skipDuplicates(true) .fuzzyOptions(FuzzyOptions.builder() .fuzziness(Fuzziness.ONE) .prefixLength(2) .transpositions(false) .build()) ); SearchRequest request = new SearchRequest("content_index") .source(new SearchSourceBuilder() .suggest(suggestBuilder) .fetchSource(false)); // 补全不需要返回原文,省带宽 SearchResponse response = client.search(request, RequestOptions.DEFAULT);fetchSource(false)是我后来才加上的。补全接口 QPS 很高,返回体里如果带着_source,网络开销和序列化开销都会被放大,而前端下拉框根本用不上这些字段,纯属浪费。
如果要做分类隔离(比如只补全当前频道下的词),用contexts。mapping 里给title_suggest加"contexts": [{"name": "category", "type": "category"}],查询时用contexts传值。注意 context 必须在建索引时就定义好,后期加要重建索引,这个决策要提前想清楚。
实操心得:补全接口一定要加本地缓存,比如按前缀缓存 5 到 10 分钟。热门前缀的重复率极高,缓存命中能挡掉七八成的请求,对 ES 的保护非常明显。缓存 key 记得带上 context 值,不然会出现“某频道搜出别的频道词”的串味问题。
3. 关键字高亮:标签、分片与中文分词的坑
高亮这件事,看起来只是“把匹配词包一层标签”,实际动手会发现坑不少:明明命中了却不亮、亮了但亮错位置、返回的片段把一句话拦腰截断、前端渲染时标签被转义成字符串。
3.1 highlight 的工作机制
ElasticSearch 返回的高亮不是引擎“猜”出来的,而是必须拿到三样东西之一才能定位原文:term vector(term_vector: with_positions_offsets)、已存储字段(store: true),或者_source里重新分析。默认的unifiedhighlighter 在大多数字段上走的是第三条路,也就是拿_source的原文再做一次分析,然后按位置回填标签。这也是为什么它不需要你额外做存储配置,代价是一定的 CPU 开销。
理解了这一点,就明白“高亮不上”的常见原因了:要么字段在 query 里根本没参与检索,要么分析器对不上导致位置偏移,要么字段类型压根不是text(比如keyword字段做高亮,行为就很奇怪,通常需要配合term查询和require_field_match: false)。
3.2 三种 highlighter 的取舍
| Highlighter | 依赖 | 中文表现 | 适用场景 |
|---|---|---|---|
| unified | _source或 postings | 好,默认推荐 | 绝大多数场景 |
| plain | term vector | 一般,需额外存储 | 老版本兼容 |
| fvh(fast vector) | term vector + offsets | 好但吃存储 | 超大文档、超高频高亮 |
我基本只用unified,它是 7.x 之后的默认值,中文分词配合ik表现稳定,也不需要给每个字段加term_vector。fvh在文档极长、高亮调用极频繁时有优势,但要在 mapping 里显式打开term_vector: with_positions_offsets,索引体积能涨三成以上,不是特别有必要别开。
3.3 高亮构造与片段控制
HighlightBuilder hb = new HighlightBuilder() .field("title") .field("content") .preTags("<em class=\"hl\">") .postTags("</em>") .fragmentSize(80) .numOfFragments(1) .noMatchSize(60) .requireFieldMatch(false); SearchRequest request = new SearchRequest("content_index") .source(new SearchSourceBuilder() .query(query) .highlighter(hb) .from(0).size(20));几个参数值得展开讲。fragmentSize(80)控制片段长度,80 个字符在中文场景下大概是一到两句话,够用户判断上下文了;numOfFragments(1)只取第一段,多段拼接反而让列表卡变得臃肿,如果确实需要展示多处命中,我一般放到详情页再拿完整内容处理。noMatchSize(60)是个容易忽略的点:当这个字段没有命中、但同一文档的其他字段命中时,返回前 60 个字符作为摘要,避免出现“列表项里空了一大块”的视觉断层。
requireFieldMatch(false)要看场景。它的作用是让高亮字段不必出现在 query 里,比如用户只在content里搜,但我想顺便把title里同样的词也亮出来。开启它会有轻微性能损耗,但如果你的标题和正文经常出现同一个关键词,视觉收益明显。
3.4 中文分词下的典型错位
最经典的坑:用户搜“手机”,ik_smart把查询词切成“手机”,但文档里的“智能手机”被ik_max_word切成了“智能 / 手机”。这种情况下高亮通常是对的,因为词元能对上。真正出问题的是搜长词,比如“苹果手机壳”,查询被切成“苹果 / 手机 / 手机壳”多个 term,unified高亮按位置合并时会表现得很割裂,有时候只亮“苹果”,有时候把中间的字也包进去。
解决办法有两个方向。一是让索引和查询用同一个 analyzer,别一边max_word一边smart,错位会少很多;二是对高亮字段单独指定highlight_query,把查询语句换成你期望的词元组合,比如用match_phrase替代multi_match,高亮会跟着短语的边界走,视觉上更整齐。
HighlightBuilder.Field titleField = new HighlightBuilder.Field("title") .highlightQuery(QueryBuilders.matchPhraseQuery("title", keyword));这段配置的意思是:检索逻辑照旧,但高亮时按短语匹配来定位,避免多 term 拆分导致的碎片化高亮。这一招在标题字段上效果尤其明显。
3.5 前端渲染与转义
后端返回的是带<em>的字符串,前端如果用{{ }}插值,标签会被当成纯文本转义,页面上直接出现<em class="hl">这串字符。这时候很多人第一反应是上v-html或者dangerouslySetInnerHTML,然后 XSS 风险就来了——文档内容里如果混进了<script>,直接执行。
正确顺序是:后端先把原文做一次 HTML 转义,再交给高亮器包标签。如果用的是_source重分析路径,转义得放在写入阶段或者返回前的后处理阶段,需要小心别把已经加好的<em>也转义掉。我的做法是返回前用正则把<em这类被误转义的标签还原,或者干脆在高亮前后做两次处理,逻辑上区分“原文区”和“标签区”。
注意:不要把
preTags设成<b>或者<span style="color:red">这类带样式的标签,样式应该由 CSS 类控制。标签里写死颜色,主题切换时你会后悔的。
4. 完整实操:一套能跑起来的整合代码
前面讲原理,这一节直接给可复现的骨架。环境是 JDK 8 或 11、SpringBoot 2.7、ElasticSearch 7.17、分词插件使用 IK。
4.1 依赖与版本对齐
<dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.17.10</version> </dependency> <dependency> <groupId>org.elasticsearch</groupId> <artifactId>elasticsearch</artifactId> <version>7.17.10</version> </dependency>两个版本必须完全一致,否则启动就报NoSuchMethodError。SpringBoot 的依赖管理会偷偷把elasticsearch的版本拉到你没预期的值,最稳的做法是在dependencyManagement里显式锁版本,或者干脆把 SpringBoot 自带的 ES 依赖排除掉。这个坑我踩过不止一次,报错信息还特别不直观,往往表现为“启动时类加载失败”。
4.2 客户端 Bean 的配置
@Configuration public class EsConfig { @Value("${es.host:127.0.0.1}") private String host; @Value("${es.port:9200}") private int port; @Bean(destroyMethod = "close") public RestHighLevelClient restHighLevelClient() { return new RestHighLevelClient( RestClient.builder(new HttpHost(host, port, "http")) .setRequestConfigCallback(rc -> rc .setConnectTimeout(3000) .setSocketTimeout(30000)) .setHttpClientConfigCallback(hc -> hc .setMaxConnTotal(100) .setMaxConnPerRoute(50)) ); } }超时和连接池这两项必须显式配。默认的连接数偏小,补全接口并发一高就会在客户端侧排队,表现是接口 RT 莫名其妙地涨,但 ES 服务端监控看起来风平浪静。socketTimeout给 30 秒是留了余量的,正常查询几百毫秒就该回来,超时说明有问题需要排查,而不是等它慢慢跑。
4.3 索引初始化与数据灌入
索引创建建议放在启动时做一次幂等检查,存在就跳过,不存在才建。别用@PostConstruct直接跑,多实例部署时会并发创建,虽然 ES 会返回资源已存在的异常,但日志会很难看。加个分布式锁或者干脆改成运维手工执行都行。
数据灌入的核心是把业务对象转成带 suggest input 的文档:
public void bulkIndex(List<ContentDoc> docs) throws IOException { BulkRequest bulk = new BulkRequest(); for (ContentDoc doc : docs) { Map<String, Object> suggest = new HashMap<>(); suggest.put("input", buildInputs(doc.getTitle(), doc.getAliases())); suggest.put("weight", doc.getHotScore()); Map<String, Object> source = new HashMap<>(); source.put("id", doc.getId()); source.put("title", doc.getTitle()); source.put("content", doc.getContent()); source.put("category", doc.getCategory()); source.put("hot_score", doc.getHotScore()); source.put("title_suggest", suggest); bulk.add(new IndexRequest("content_index") .id(doc.getId()) .source(source)); } BulkResponse resp = client.bulk(bulk, RequestOptions.DEFAULT); if (resp.hasFailures()) { log.error("部分文档写入失败: {}", resp.buildFailureMessage()); } }weight的作用是给建议词排序,热点内容自然应该排前面。取值建议控制在 0 到 10000 之间,别用销量这种无上界的数值,否则权重分布会极不均匀,冷门词永远出不来。
4.4 补全接口
public List<String> suggest(String prefix, String category, int size) { if (StringUtils.isBlank(prefix) || prefix.length() > 30) { return Collections.emptyList(); } CompletionSuggestionBuilder csb = SuggestBuilders .completionSuggestion("title_suggest") .prefix(prefix) .size(size) .skipDuplicates(true); if (StringUtils.isNotBlank(category)) { csb.contexts(SuggestionContexts.of( Map.of("category", List.of(category)))); } SuggestBuilder sb = new SuggestBuilder().addSuggestion("sg", csb); SearchSourceBuilder ssb = new SearchSourceBuilder() .suggest(sb).fetchSource(false); try { SearchResponse resp = client.search( new SearchRequest("content_index").source(ssb), RequestOptions.DEFAULT); return resp.getSuggest().getSuggestion("sg").getEntries().stream() .flatMap(e -> e.getOptions().stream()) .map(CompletionSuggestion.Entry.Option::getText) .map(Text::string) .distinct() .collect(Collectors.toList()); } catch (IOException e) { log.warn("补全查询失败, prefix={}", prefix, e); return Collections.emptyList(); } }补全接口的异常处理原则是绝不向上抛。下拉框是辅助功能,挂了不能影响用户正常搜索,返回空列表然后静默降级就好。这个判断很重要,我见过补全服务抖动把整个搜索页拖垮的案例。
4.5 搜索接口与高亮结果解析
public PageResult search(String keyword, String category, int page, int size) { BoolQueryBuilder bool = QueryBuilders.boolQuery() .should(QueryBuilders.multiMatchQuery(keyword, "title^3", "content") .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)) .minimumShouldMatch(1); if (StringUtils.isNotBlank(category)) { bool.filter(QueryBuilders.termQuery("category", category)); } HighlightBuilder hb = new HighlightBuilder() .field("title").field("content") .preTags("<em class=\"hl\">").postTags("</em>") .fragmentSize(80).numOfFragments(1).noMatchSize(60) .requireFieldMatch(false); SearchSourceBuilder ssb = new SearchSourceBuilder() .query(bool) .highlighter(hb) .from((page - 1) * size).size(size) .timeout(TimeValue.timeValueSeconds(3)) .trackTotalHitsUpTo(1000); // 省略:执行请求、遍历 hits、取 highlight 字段覆盖原文 }title^3的权重设置是有讲究的。标题命中通常比正文命中更相关,给三倍是个经验起点,具体数值要看你的数据分布。判断依据很简单:随机抽 30 个查询,看前 10 条结果里标题命中的占比,如果低于一半,说明权重给低了;如果全是标题命中、正文一条没有,说明给高了。
trackTotalHitsUpTo(1000)也是必要配置。ES 7.x 之后默认只精确统计到 10000 条,超过就返回 10000。如果你只需要展示“约 300 条结果”,限制在 1000 能显著降低统计开销,翻页翻不了那么深的时候这钱花得不值。
5. 常见问题速查与排查实录
下面这些是我和团队在几个项目里反复遇到的,整理成表格方便对照。
| 现象 | 最可能的原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 补全完全没结果 | 字段类型不是 completion,或 mapping 未生效 | 查_mapping确认类型 | 重建索引 |
| 补全有结果但顺序乱 | 未设置 weight 或 weight 过于集中 | 抽样看_score | 调整 weight 分布 |
| 补全词重复 | 未开skip_duplicates | 请求体检查 | 加skipDuplicates(true) |
| 中文前缀补不出 | 输入词未做分词切分 | 看 FST 里的 input 列表 | 写入时补多 input |
| 高亮不生效 | 字段未参与 query,或类型是 keyword | 看请求 DSL | 加requireFieldMatch(false)或改 text |
| 高亮位置错乱 | 索引和查询 analyzer 不一致 | 对比分析结果 | 统一 analyzer |
| 高亮标签被转义 | 前端直接插值渲染 | 看 DOM | 转义顺序调整 + 安全渲染 |
| 补全响应慢 | 返回了_source或连接池不足 | 看响应体大小 | fetchSource(false)、调连接数 |
| 数据不更新 | 增量链路断了 | 查 MQ 消费位点 | 补消费 + 对账 |
5.1 补全不生效的三层排查法
遇到“怎么敲都不出词”,按这个顺序查,能省很多时间。
第一层查 mapping。跑GET content_index/_mapping,确认title_suggest的类型真的是completion,而不是当年建索引时手滑写成text。类型写错只能重建索引,没有别的路。
第二层查数据。跑GET content_index/_search {"size":1},看_source里的title_suggest到底长什么样,input是不是空数组,是不是被max_input_length截断成半截。我遇到过写入时把input塞成字符串而不是数组的情况,ES 不报错,但补全永远查不出来。
第三层查查询。用GET content_index/_search手写一段 suggest DSL 直接打到 ES,绕过应用层。如果 ES 能出结果而应用不行,问题就在代码的参数拼接上,通常是prefix被trim掉了或者编码有问题;如果 ES 也出不来,回到第二层。
5.2 高亮片段被截断得莫名其妙
fragmentSize设 80,返回的片段前后不着调,一句话从中间断开。这是unified高亮按字符数硬切的正常行为,它不会智能断句。想要更自然的片段,有两个办法:调大fragmentSize到 120 到 150,让一句话尽量完整;或者用boundary_scanner配合sentence模式,按句子边界切分,代价是片段长度不可控,偶尔会返回很长的一段。
new HighlightBuilder().field("content") .boundaryScannerType(HighlightBuilder.BoundaryScannerType.SENTENCE) .boundaryMaxScan(50) .fragmentSize(120);中文的句子边界扫描依赖标点,如果你的内容标点使用不规范(大量空格断句、无标点长段落),这个模式会退化成字符切分,效果不一定比默认好。上线前拿真实数据跑一批看看,别拍脑袋。
5.3 内存与 FST 的隐形压力
补全字段吃的是堆内内存,而且是常驻的,不会因为查询结束就释放。集群规模小的时候要注意:如果节点堆内存 8G,堆里塞了 2G 的 FST,那留给查询聚合的空间就很紧张了,表现是 GC 频繁、查询偶发超时。
判断方法看监控里segments.memory这部分,再结合 GC 日志。如果 FST 占比过高,能做的有几步:精简 input 列表(去掉过长的、低频的)、拆分成多个 suggest 字段分散加载、或者把补全服务单独拆一个小集群专门承载,把压力从主搜索集群隔离出去。最后这一招成本最高但最彻底,访问量大的内容平台值得考虑。
5.4 数据一致性的兜底
增量同步一定会漏,这不是悲观,是必然。网络抖动、消费重试耗尽、ES 短暂不可用,任何一个环节都能让某条数据对不上。所以必须要有一层兜底:每天凌晨跑一次全量对账,拿业务库的update_time和 ES 里的做比对,差异部分重新灌一遍。
对账脚本不用写得太复杂,按时间窗口分批拉取,每批 500 条,比对 id 和update_time,不一致的塞进补偿队列。这个脚本我一般写成独立的小任务,跑在业务低峰期,挂了也不影响主流程。省下这个事,早晚要在某个深夜被叫起来手动补数据。
实操心得:给补全接口加一个“兜底词表”。当补全结果为空,且前缀长度小于 2 时,返回一批人工维护的热搜词。用户敲一个字就看到有内容,体验上不会觉得搜索框是坏的。这个词表放在配置中心或者 Redis 里,随时能改,不用发版。
6. 几个我认为值得坚持的做法
一路做下来,有些东西是踩坑踩出来的执念,写在这里供参考。
补全接口一定要设超时和降级。这是下拉框,不是核心交易链路,它慢 200 毫秒用户根本没感知,但它要是卡住 3 秒,整个搜索页的体感就崩了。我在客户端配置里给补全单独设了 800 毫秒超时,超了直接返回空,日志记一笔就行。
索引别名一定要用。所有读写都走content_index_alias,真正的索引叫content_index_v1、v2。重建索引的时候新建 v2、灌数据、切别名,全程零停机。反过来,如果代码里写死了索引名,每次改 mapping 都得停服,这个代价太大了。
高亮标签的类名统一成hl,前后端约定死。别今天<em>明天<mark>,样式文件改起来没完。标签本身用<em>语义上更合适,它表示强调,不是纯样式标记。
别在补全请求里做权限过滤。补全的 QPS 是搜索的几倍,每次请求都带上用户身份去过滤,成本撑不住。正确做法是按频道或者分类做粗粒度隔离,细粒度的权限在用户点进搜索结果之后再校验。
最后分享一个调试小技巧:把线上真实的补全请求日志采一份下来,脱敏后灌到一个测试索引里,本地改参数对着这批数据反复试。比拍脑袋猜参数快得多,也比在线上灰度安全得多。我一般会采 2000 条左右,覆盖长前缀、短前缀、带空格、带英文数字几种情况,基本能把参数调到八分准。