从零构建高效搜索:Elasticsearch分页与高亮实战指南
你有没有遇到过这样的场景?用户在搜索框输入“Java并发编程”,点击回车后,页面卡顿两秒才返回结果,翻到第50页时直接报错“请求超时”;更糟的是,匹配的关键词根本没有高亮,用户得一个字一个字地扫视内容。
这背后的问题,往往不是数据量太大,而是我们对Elasticsearch基本用法的理解还停留在表面。尤其是分页查询和高亮显示这两个看似简单的功能,一旦用错,轻则体验拉胯,重则压垮集群。
今天我们就来拆解这两个核心能力——不讲理论堆砌,只聚焦真实项目中怎么用、怎么避坑、怎么优化。
分页不止是from + size,深分页陷阱你踩过几个?
别再无脑写from=1000, size=10了
新手上手 Elasticsearch 最常写的代码是什么?
{ "from": 1000, "size": 10, "query": { ... } }看起来没问题:跳过前1000条,取第1001~1010条。但你知道吗?这个请求在分布式环境下其实是这样执行的:
假设你的索引有5个分片。协调节点会向每个分片发送请求:“请返回你本地排序后的第1001~1010条”。可每个分片并不知道全局排名,所以它只能先把前1010条都查出来,排序后截取,再发给协调节点。最后协调节点再做一次合并排序,取出真正的第1001~1010条。
也就是说,为了拿10条数据,系统实际处理了5 × 1010 = 5050条记录。随着from越来越大,内存和CPU消耗呈线性增长。
当from + size > 10000时,默认就会触发熔断:
Result window is too large, from + size must be less than or equal to: [10000]你可以调大index.max_result_window,但这是饮鸩止渴——堆内存爆炸只是时间问题。
真正适合深分页的方案:search_after
如果你需要遍历成千上万条数据(比如后台导出、日志审计),应该用search_after。
它的思路很像数据库里的游标:记住上一页最后一个文档的排序值,下次直接从那个位置往后读。
它是怎么工作的?
假设你要按发布时间倒序展示文章,每页10条:
第一次请求不需要游标:
{ "size": 10, "query": { "match": { "title": "elasticsearch基本用法" } }, "sort": [ { "publish_date": "desc" }, { "_id": "asc" } // 必须加唯一字段防歧义 ] }拿到第一页最后一条数据:
{ "publish_date": "2023-06-01T08:00:00", "_id": "article_9876" }第二页就用这两个值作为“起点”:
{ "size": 10, "query": { ... }, "sort": [ ... ], "search_after": ["2023-06-01T08:00:00", "article_9876"] }注意:sort字段必须包含唯一组合(如时间+ID),否则可能出现漏数据或重复。
为什么它性能稳定?
因为每次请求只扫描一页的数据量,不会随翻页深度增加而变慢。各分片只需定位到指定排序值之后的下N条即可,无需生成大量中间结果。
Java 实现示例(基于官方 High Level Client)
public SearchResponse fetchNextPage(RestHighLevelClient client, String lastDate, String lastId) throws IOException { SearchRequest request = new SearchRequest("articles"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchQuery("content", "elasticsearch基本用法")); sourceBuilder.size(10); sourceBuilder.sort("publish_date", SortOrder.DESC); sourceBuilder.sort("_id", SortOrder.ASC); if (lastDate != null && lastId != null) { sourceBuilder.searchAfter(Arrays.asList(lastDate, lastId)); } request.source(sourceBuilder); return client.search(request, RequestOptions.DEFAULT); }关键点:
- 维护前后请求之间的sort值传递
- 前端需缓存游标状态(可用 Base64 编码传输)
- 不支持“跳转任意页”,仅适用于连续翻页
✅ 推荐使用场景:后台管理列表、大数据导出、离线分析任务。
高亮不只是<em>包一下,准确性和性能一样都不能妥协
用户为什么看不到关键词高亮?
很多开发者以为加上highlight就万事大吉:
"highlight": { "fields": { "content": {} } }结果发现:
- 中文断词不准,“搜索引擎”被拆成“搜”“索”“引”“擎”
- 返回的片段太长,整段文字都被标黄
- 查询速度明显下降,QPS 从 1000 掉到 300
这些问题根源在于:你没告诉 Elasticsearch 如何高效又精准地找匹配文本。
高亮的本质:一次独立于主查询的文本分析过程
很多人误以为高亮是从_source里直接替换字符串。其实不然。
Elasticsearch 会在匹配字段中重新运行一次轻量级分析流程,找出最相关的文本片段,并插入标签。这个过程发生在协调节点,非常吃 CPU。
而且,默认使用的plain highlighter是基于标准分词器的,对中文支持极差。
如何让高亮又快又准?
第一步:选对高亮器类型
| 类型 | 适用场景 | 性能 | 准确性 |
|---|---|---|---|
plain | 默认,通用短文本 | 一般 | 低 |
fvh(Fast Vector Highlighter) | 长文本、强调速度 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
postings | 精确短字段匹配 | ⭐⭐⭐ | ⭐⭐⭐ |
推荐优先使用fvh,但它要求字段开启 term vector 存储:
PUT /articles { "mappings": { "properties": { "content": { "type": "text", "term_vector": "with_positions_offsets" // 关键配置! } } } }开启后,高亮性能可提升数倍,且支持精确到字符级别的定位。
第二步:合理控制摘要长度
避免返回整个字段内容,设置合理的片段参数:
"highlight": { "pre_tags": ["<mark>"], "post_tags": ["</mark>"], "fields": { "title": { "fragment_size": 150, "number_of_fragments": 1 }, "content": { "type": "fvh", "fragment_size": 180, "number_of_fragments": 3, "no_match_size": 100 // 没匹配时也返回前100字符 } } }解释一下这几个参数的实际意义:
-fragment_size: 每个片段最多多少字符(不是字数!中文要注意)
-number_of_fragments: 返回几个高亮块,0 表示返回完整字段
-no_match_size: 即使没找到关键词,也返回一段摘要,提升用户体验
第三步:Spring Data Elasticsearch 实战代码
@Service public class ArticleSearchService { @Autowired private ElasticsearchTemplate elasticsearchTemplate; public List<SearchResult> searchWithHighlight(String keyword) { NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 主查询 queryBuilder.withQuery(QueryBuilders.matchQuery("content", keyword)); // 高亮配置 HighlightBuilder highlightBuilder = new HighlightBuilder() .field("title", 150, 1) .field("content", 180, 3) .preTags("<mark>") .postTags("</mark>") .highlighterType("fvh"); // 使用 fvh queryBuilder.withHighlightBuilder(highlightBuilder); SearchHits<Article> hits = elasticsearchTemplate.search( queryBuilder.build(), Article.class); return hits.stream().map(hit -> { SearchResult result = new SearchResult(); result.setId(hit.getId()); result.setTitle(hit.getContent().getTitle()); result.setContent(hit.getContent().getContent()); // 提取高亮片段 List<String> titleHls = hit.getHighlightField("title"); List<String> contentHls = hit.getHighlightField("content"); result.setHighlightedTitle(titleHls.isEmpty() ? null : titleHls.get(0)); result.setHighlightedContent(contentHls.isEmpty() ? null : contentHls.get(0)); return result; }).collect(Collectors.toList()); } }前端可以直接渲染highlightedTitle字段,记得做好 XSS 防护!
真实业务场景怎么落地?
场景一:电商平台商品搜索
需求:
用户搜“无线蓝牙耳机”,希望看到标题和描述中关键词高亮,支持翻几十页。
挑战:
- 商品总量百万级,传统from/size翻到第50页直接卡死
- 描述字段含HTML标签,高亮可能破坏结构
解决方案:
1. 分页改用search_after,排序规则:销量降序 + 上架时间降序 + ID 升序
2. 对description字段启用fvh高亮,设置fragment_size=200
3. 前端维护双向游标(前进/后退),通过 JWT 或 sessionStorage 缓存上下文
4. 对富文本字段先 strip HTML 再高亮,防止标签错乱
场景二:企业知识库检索
需求:
员工查“elasticsearch基本用法”,文档正文要高亮。
痛点:
- 文档包含代码块,for(int i=0;...被错误标亮
- 搜索结果太多,用户找不到重点
对策:
1. 使用matched_fields只对非代码区域高亮
2. 设置"require_field_match": true,避免无关字段也被渲染
3. 结合前端虚拟滚动,在可视区域内动态注入高亮标签
4. 对技术术语建立同义词库,提高召回率
设计时必须考虑的四个维度
1. 性能权衡:别为美观牺牲响应速度
高亮和分页都会显著增加查询耗时。建议策略:
- 首页/高频访问页:开启全量高亮
- 深层页面:仅标题高亮或关闭高亮
- API 接口:提供highlight=false参数供客户端控制
2. 安全防护:别让<mark>成为 XSS 入口
返回的高亮字段是带 HTML 标签的字符串,前端必须:
- Vue 使用v-html时配合DOMPurify清洗
- React 用dangerouslySetInnerHTML前进行 escape 处理
- 后端也可预处理,替换为 CSS class,由前端统一渲染样式
3. 中文适配:IK 分词器是标配
默认分词器对中文极其不友好。务必安装 IK 插件:
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.10.0/elasticsearch-analysis-ik-7.10.0.zip并在 mapping 中指定:
"analyzer": "ik_max_word"这样才能保证“全文检索”不会被切成“全”“文”“检”“索”。
4. 监控可观测性:让问题提前暴露
在生产环境埋点监控以下指标:
- 平均查询延迟(含/不含高亮)
-search_after请求占比
- 高亮字段数量分布
- termvector 存储占用增长率
一旦发现某类查询延迟突增,立即检查是否开启了过多字段高亮。
掌握这些elasticsearch基本用法,你不只是会写 DSL,而是真正理解了如何在性能、一致性与用户体验之间找到平衡点。
分页不是简单跳页,高亮也不只是视觉装饰。它们是搜索系统能否扛住流量、留住用户的关键细节。
下一步,不妨试试把这些技巧应用到你的项目中——比如把老系统的from/size改造成search_after,看看接口延迟能降多少?或者给知识库加上精准中文高亮,看用户停留时长有没有提升?
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考