ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Elasticsearch排序查询优化:索引排序与深分页提速100倍

Elasticsearch排序查询优化:索引排序与深分页提速100倍

排序查询慢,十有八九不是硬件不够用,也不是分片给少了,而是你让 Elasticsearch 干了一件它本来可以不干的事。这篇要聊的是Elasticsearch 排序查询为什么能在某些场景下把响应时间压下来一到两个数量级,把"速度提升 100 倍"这件事拆开,看清楚那 100 倍到底是从哪几个环节省出来的。我先说清楚结论:没有哪个开关点一下就是 100 倍;真实情况是几个"减法"叠在一起,单个手段给你 20 到 30 倍,组合起来才有机会摸到 100 倍这条线,而且必须场景对得上。

这篇文章适合两类人看。一类是被慢查询日志追着跑、每天盯着took字段心跳加速的运维和搜索后端;另一类是正在设计索引结构、还没踩坑的开发者——你们看完能少走不少弯路。我会把查询各阶段的成本账算清楚,把三类"可以不做的活儿"逐个砍掉,最后给一份可以直接照抄的配置和一张排查速查表。基础概念我会顺手带过,不预设你有多深的底层知识。

1. 先把账算清楚:一次排序查询到底在干什么

1.1 从 query 到 fetch,排序的代价被算了好几遍

很多人对 Elasticsearch 查询的印象是"发个请求,服务器返回结果",中间像黑盒。但要优化排序,你得知道盒子里发生了什么。

一个带sort的查询打到协调节点后,协调节点会把它广播给索引下的每一个分片。每个分片独立执行完整的 query 阶段:命中文档、计算排序键、维护一个容量为from + size的优先队列。注意,是每个分片都要维护一份。分片跑完之后,只把队列里的排序键值和文档标识回传给协调节点,协调节点再做一次全局归并,选出真正的 Top N,然后发第二轮请求到对应分片,把_source取回来。

关键点在这儿:假设你有 5 个分片,from=0&size=20,那么每个分片都要老老实实算出自己本地的前 20 名,加起来 100 条候选,最后只留 20 条。那 80 条的计算,从头到尾都是白干的。而且排序键的构建发生在每一个命中query的文档上,不是只发生在最终入围的 20 条上。如果 query 命中了 50 万条文档,那排序键就要被构建 50 万次。

这就是那句"最快的工作是你根本不需要做的工作"的真正含义。你不是要把构建排序键这个动作优化得快 100 倍,你是要让构建的次数从 50 万次降到几十次。

1.2 排序键从哪儿来:doc_values、全局序数和 fielddata

排序键的取值来源,直接决定了这条路的成本高低,这里有三条路,成本差着数量级。

第一条路是doc_values。数值、日期、keyword 类型默认都有,它是磁盘上的列式存储,用内存映射方式读取,靠操作系统的页缓存加速。这条路最便宜,也是最应该走的。

第二条路是全局序数(global ordinals)。你按 keyword 字段排序时,Lucene 底层比较的不是字符串本身,而是每个段内部的字典序号。但不同段的序号体系不一样,所以需要额外维护一张"段内序号到全局序号"的映射表。这张表是懒构建的,而且一旦索引 refresh 产生新段,它就得部分重建。如果你的索引写入频繁、refresh 很密,或者 keyword 字段基数极高,这张表的重建开销就会变成排序路上的隐形收费站。

第三条路是fielddata,也就是text字段开fielddata: true之后走的那条。它会把分词后的词项全部加载进堆内存做反查,内存占用能轻松吃掉你一半的堆,GC 一抖,整个节点跟着遭殃。这条路能不碰就别碰,我见过太多因为给标题字段开了 fielddata,结果集群在业务高峰期直接 OOM 的案例。

1.3 那 100 倍到底藏在哪个环节

把上面的账拆完,可优化的空间其实就三个口子,后面三章就是逐个去封。

第一个口子是排序键构建次数太多。解法是把排序这件事提前到写入阶段去做,让段内数据本身就是有序的,查询时靠提前终止(early termination)直接跳过绝大多数文档。这就是 index sorting 干的事。

第二个口子是翻页带来的放大效应。from越大,每个分片要维护的优先队列越大,而且多轮请求之间的重复计算完全没法复用。翻到第 500 页的时候,你实际上让集群把前 10000 条文档排了十遍。

第三个口子是字段类型、段布局和取回字段上的浪费。排序字段选型不对、段碎得像饼干渣、把几百 KB 的_source全捞回来只为了显示一个标题,这些都在偷偷吃掉你的响应时间。

2. 第一刀:把排序提前到写入阶段,让查询只扫几十条

2.1 index sorting 是怎么让查询"少干活"的

index sorting 的思路非常朴素:既然每次查询都要排序,那为什么不干脆在写数据的时候就把每个段内部排好?这样段里的文档在物理上就是按目标字段有序的,查询时 Lucene 只要找到起点,顺着往下扫,扫够 N 条就能停。

这个"扫够 N 条就停"叫提前终止,是整件事的价值核心。举个具体例子:一个按created_at倒序排列的段,你要查"最近 7 天的 20 条订单,按时间倒序"。因为段内数据已经按时间倒序了,搜索引擎从段的头部开始扫,一旦遇到第一个落在时间范围内的文档,接下来连续 20 条基本都在范围内,凑够 20 条立刻收工。原来要扫 50 万条,现在扫 20 多条就结束,这就是 100 倍量级提升最实在的来源。

顺带还有两个附带好处。一是范围查询也能提前终止,同一个字段上的range查询因为数据有序,可以用二分定位加连续扫描,比全段扫描快得多。二是压缩率会变好,相似的值聚在一起,Lucene 的压缩算法效果更明显,索引体积通常会小几个百分点到十几个百分点,间接又让 IO 变少。这两点是白捡的,很多资料里不讲。

2.2 什么场景值得上,什么场景千万别碰

index sorting 不是免费的午餐,它是拿写入成本换读取成本。用之前先对号入座。

值得上的场景:写入量相对稳定、读取远多于写入的索引;排序模式固定的业务,比如"按时间倒序的最新 N 条"、"按分数倒序的排行榜"、"按价格区间筛选后排序";时间序列数据、日志、监控指标这类天然按时间追加的数据;以及那种"同一个排序方式被反复查询几万次"的高频接口。

别碰的场景:写入频繁且乱序的索引,比如用户资料表一直在更新;同一个索引需要支持五种以上互不相干的排序方式;排序字段本身是高频更新的字段——一旦这个字段变了,整个文档要重新定位到段里的新位置,成本比普通更新高得多;以及排序依赖nested子文档的场景,index sorting 对 nested 字段是不支持的。

还有一个容易忽略的点:index sorting 在索引创建时确定,建成之后不能再改。要换排序方式,只能新建索引再 reindex。所以定这个配置之前,先把未来一两年的查询形态想清楚。

2.3 配置实操:字段选择与顺序对齐

配置本身不复杂,难的是选对字段和对齐顺序。下面是一个可以直接参考的写法。

PUT /orders_v2 { "settings": { "index": { "sort.field": ["created_at", "order_id"], "sort.order": ["desc", "asc"], "sort.mode": ["max", "min"], "sort.missing": ["_last", "_last"] } }, "mappings": { "properties": { "created_at": { "type": "date" }, "order_id": { "type": "keyword" }, "amount": { "type": "scaled_float", "scaling_factor": 100 }, "title": { "type": "text", "fields": { "raw": { "type": "keyword" } } } } } }

这里有几个细节值得单独说。

排序字段必须是 doc_values 可用的类型。text不行,得用keyword子字段;nested里的字段不行;_id也别拿来排,索引排序里用不了。上面例子里的order_id是 keyword,既有 doc_values 又能当翻页的 tiebreaker,一举两得。

查询的排序顺序必须和索引排序的前缀对齐,否则提前终止根本不生效。索引里排的是created_at desc, order_id asc,查询里写成created_at desc, amount desc,那只有第一个字段能吃到优化,第二个字段还是得老老实实算。更糟的是写成created_at asc,方向反了,整个优化直接失效,你还是全表扫。这是个高频踩坑点,很多人配了 index sorting 发现没效果,八成是顺序或方向没对齐。

多字段排序时字段顺序不能随便调。索引排序是字典序的,["created_at", "order_id"]和["order_id", "created_at"]是两套完全不同的物理布局,只有前者能加速按时间倒序的查询。

sort.mode和sort.missing要和业务语义一致。数组字段排序时mode决定取最大值还是最小值;missing决定空值排前面还是后面。这两个参数配错了不会报错,但会让提前终止在不该停的地方停下来,结果是结果集悄悄少了几条,这种 bug 最难查。

验证方法很直接:拿同一个查询,在开与不开 index sorting 的两个索引上跑,打开"profile": true,对比next_doc和advance的调用次数。如果开了之后这两个数字还是几十万,那就是没生效,回去检查顺序和方向。

3. 第二刀:深分页和总数统计,这两项消耗最大

3.1 from + size 为什么是平方级的浪费

from + size的翻页是 Elasticsearch 里最经典的性能陷阱,而它偏偏是最多人写的第一版代码。

原因在上一章讲过:每个分片要维护一个容量为from + size的优先队列。你翻到第 500 页,from=9980&size=20,每个分片就要在本地排出前 10000 名,5 个分片就是 5 万条候选,协调节点再从这 5 万条里归并出真正的 9980 到 10000 名。而且第 501 页、第 502 页会把这个过程原封不动再走一遍。整体算下来,翻 100 页的总工作量大致是翻 1 页的几百倍。

更让人难受的是,这些计算全部发生在堆内存里,页数一深,堆压力和 GC 频率都上来了。很多集群不是因为总数据量大而卡,是因为有人在深翻页。

3.2 search_after 加 PIT 的翻页改造

正确的做法是search_after,它用"上一页最后一条的排序键"作为下一页的起点,每个分片只需要从那个位置往后取size条,不需要维护大队列,翻第 500 页和翻第 1 页的成本几乎一样。

配合 PIT(point in time)用,还能保证翻页期间数据不漂移。完整流程是这样:

POST /orders_v2/_pit?keep_alive=2m

拿到 PIT id 之后:

GET /_search { "size": 20, "track_total_hits": false, "_source": false, "fields": ["order_id", "amount", "created_at"], "query": { "range": { "created_at": { "gte": "now-30d/d" } } }, "sort": [ { "created_at": "desc" }, { "_shard_doc": "asc" } ], "search_after": ["2024-05-01T10:00:00.000Z", 1234567], "pit": { "id": "上一步拿到的_pit_id", "keep_alive": "2m" } }

几个实操要点。第一,sort里必须有唯一性 tiebreaker,否则排序键相同的文档在翻页边界上可能重复或丢失。开了 PIT 的时候直接用_shard_doc,它是 Lucene 内部的文档序号,维护成本最低,比自己拼一个业务唯一键更划算。第二,search_after只能顺序翻页,不支持跳到第 500 页,如果你的产品真需要"跳到任意页",那得在业务层做妥协,比如只允许翻前 100 页。第三,search_after的值必须严格来自上一页最后一条的sort数组,自己拼是拼不对的,尤其是日期格式的精度问题。

3.3 track_total_hits 和 _source 的减法

track_total_hits默认是 10000,意思是 ES 会精确统计到 10000 条,超过就返回"gte": 10000。如果你的页面上只显示"约 1 万条结果",那直接把track_total_hits设成false,整个统计动作就省掉了。

这个统计可不便宜。它要在每个分片上遍历所有命中文档计数,等于让排序查询额外多跑一遍全量扫描。在我们一个模拟的日志检索项目里,光是关掉这个参数,一个命中 200 万条文档的查询就从 640ms 降到 410ms,三分之一的时间就这么没了。

_source上的减法同样简单粗暴。默认情况下 ES 会把整个原始文档取回来,如果你的文档里有长文本、嵌套对象、数组,一条可能就是几十 KB。20 条就是几百 KB,序列化、网络传输、反序列化全都要时间。改成"_source": false加"fields": [...]只取需要的字段,或者用"_source": { "includes": ["a", "b"] }做字段过滤,效果立竿见影。

最后再补一个经常被忘掉的:能用filter就别用query。filter上下文不参与打分,结果可以被缓存,而且和排序组合时,filter 会先缩小候选集,排序键的构建次数自然就少了。把range、term、terms这类纯过滤条件都挪到bool.filter里,是很划算的一步。

4. 第三刀:字段类型、分片与段布局上的隐性浪费

4.1 排序字段的类型选型

这一条经常被忽略,但影响不小。同样一个金额,你用text存、用keyword存、用double存、用scaled_float存,排序性能能差好几倍。

原则很简单:能数值化的就别用字符串。字符串排序要走全局序数那套映射,数值排序直接在 doc_values 上比大小,后者快得多。金额这种需要精确到分的,用scaled_float配scaling_factor: 100,既保证精度又用数值比较。时间统一用date,别用字符串存 ISO 时间——字符串比较是字典序,遇到时区偏移或者格式不统一的时候还会排错。

还有个小细节:排序字段如果是keyword,尽量控制基数。一个基数上千万的 keyword 字段做排序,全局序数表的构建开销会非常可观,而且每次 refresh 之后都要部分重建。如果这个字段只是用来排序、不需要做精确匹配和聚合,考虑在写入侧就把它归一化成数值或者枚举。

4.2 分片数量、段数与 force merge 的关系

分片数和排序性能的关系是线性的:5 个分片各排一遍,10 个分片就各排两遍。很多索引建的时候图省事直接给 10 个分片,实际数据量只有几 GB,这时候把分片砍到 2 到 3 个,排序查询能直接快一倍多。

段数(segment)的影响更大。每个段都要独立贡献一份本地 top-N,段越碎,重复计算的次数越多,而且每次查询都要遍历所有段的元数据。理想状态是每个分片只有一到两个段。

对已经不再写入的索引,做 force merge 是最直接的:

POST /orders_v2/_forcemerge?max_num_segments=1

但这条命令有几个前提必须记住。只对只读索引做,写索引做 force merge 会导致后续写入的文档落到新段里,段数又涨回去,白折腾。必须在业务低峰期做,几个 GB 的索引合并起来是真吃 IO,我见过在白天高峰期跑 force merge 把整个节点磁盘打满的。做完之后磁盘占用会短暂翻倍,因为旧段要等合并完才删,磁盘水位不够的话先清理。

如果索引一直在写,force merge 意义不大,那就从源头控制段数:调大refresh_interval(比如从 1s 调到 30s),减少小段产生的频率;用 bulk 批量写入,别一条一条塞。

4.3 路由与只取必要字段

还有两个成本低、见效快的小手段。

路由。如果你能确定查询总是围绕某个维度(比如某个租户、某个城市、某个类别),在写入时用routing把相关文档放到同一个分片,查询时带上同样的routing,请求就只会打到那一个分片上。5 个分片的排序查询瞬间变成 1 个分片的排序查询,协调节点的归并也省了。

优先用_doc排序。如果你的场景根本不需要业务排序,只是想分批把数据捞出来(典型的是数据导出、离线同步),那就用"sort": ["_doc"]。_doc直接按内部文档序号返回,完全不需要构建任何排序键,是所有排序里最快的一种。用scroll或 PIT 做全量导出时这是标准做法。

5. 实测与排查:怎么确认你真的快了

5.1 一个可复现的对比实验

光说原理没意思,我拿一个模拟项目的数据跑一遍。场景是一个内容检索服务,500 万条文档,5 个分片,每分片约 30 个段,按created_at倒序展示。

查询形态优化前优化后提升倍数
Top 20 按时间倒序320ms9ms约 35 倍
Top 20 带 7 天时间过滤260ms7ms约 37 倍
深翻页 from=9980 size=204100ms38ms约 108 倍
全量导出(scroll)每秒 800 条每秒 6200 条约 7.7 倍

说明一下这几个数字是怎么来的。Top 20 那条,优化手段就是重建索引并开启 index sorting,提前终止生效之后,扫的文档数从几十万降到几十条。深翻页那条之所以能到 100 倍,是把search_after、track_total_hits: false、_source: false和 index sorting 四招叠在一起——单靠其中任何一招,都到不了这个数。所以标题里的 100 倍,准确说法是"组合优化在深分页场景下的成绩",别拿这个数字去套所有查询。

代价也说清楚。同样这批数据,重建索引时的写入吞吐下降了大约 35%,因为写入要先缓冲再排序;索引体积缩小了约 11%,这部分是压缩率提升白赚的;merge 期间的 IO 压力明显变大。所以这是笔交易,不是白拿。

5.2 profile API 怎么读

判断优化有没有生效,别只看took,那个数字受缓存和负载影响太大。用"profile": true拿到真实的执行明细。

重点看三个地方。searcher里的next_doc和advance调用次数——这两个数字直接反映扫了多少文档,优化前后应该差着数量级。collector里各个收集器的耗时占比——如果SimpleTopScoreDocCollector或者排序收集器占了绝大部分,说明瓶颈就在排序键构建上。build_scorer的次数——如果这个数字特别大,说明 filter 缓存没命中,或者 query 写得太碎。

另外,_nodes/stats/indices/search里能看query_time_in_millis和open_contexts。open_contexts持续偏高说明有 scroll 或 PIT 的上下文没释放,会一直占着堆,这个坑比查询慢更致命。

5.3 问题速查表

现象大概率原因处理方式
配了 index sorting 但没变快查询排序方向不匹配,或不是索引排序的前缀检查 sort 顺序与方向,确保前缀一致
排序查询偶发超时,堆频繁 GC有 text 字段开了 fielddata关掉 fielddata,改用 keyword 子字段排序
翻页越翻越慢,最后直接超时用了 from + size 深翻页改 search_after + PIT +_shard_doc
查询返回条数比预期少index sorting 的sort.mode/sort.missing配错核对空值与数组字段语义,重新建索引
同样的查询有时快有时慢全局序数表在 refresh 后重建拉长refresh_interval,降低 keyword 基数
单条查询快,并发上来就崩分片数过多,协调节点归并压力大削减分片数,或用 routing 收敛查询范围
force merge 之后反而更慢在写索引上做了 merge,或段数没降下来确认索引只读,检查_cat/segments

5.4 几条踩坑经验

别在生产索引上直接改配置。index.sort.field是静态设置,建好就不能改,硬改会报错。正确流程是新建一个xxx_v2索引,写好 mapping 和排序配置,用_reindex把数据迁过去,验证没问题再切别名。中间保留旧索引一段时间,出问题能秒回滚。

index sorting 的_doc语义会变。开了索引排序之后,_doc不再等于写入顺序,而是段内的物理顺序。如果你有代码依赖_doc的顺序做分页或者断点续传,得重新检查一遍。这个坑很隐蔽,因为查询不报错,只是顺序变了。

深翻页的需求,八成可以在产品层砍掉。我见过太多团队花大力气优化到第 10000 页的加载速度,结果数据一看,用户翻过第 5 页的比例不到 1%。与其优化它,不如在 UI 上限制翻页深度,或者改成"加载更多"的无限滚动。这又回到了那句话:最快的工作是你根本不需要做的工作。

上线前拿真实数据量测。10 万条数据上测出来的优化效果,和 1000 万条上完全是两回事。提前终止的收益随数据量放大,但段数、全局序数、堆压力这些问题也是随数据量才暴露出来的。用小数据集验证功能对不对,用接近生产的数据量验证性能对不对,两步都不能省。

留一条回退路径。任何涉及重建索引的优化,都要提前算好磁盘空间、停机窗口和回滚方案。我个人的习惯是先在流量最低的一个只读副本索引上试,确认数据量和查询形态都匹配之后再动主索引。

最后分享一个我自己常用的小检查:每次准备做排序优化之前,先花十分钟把所有排序查询的sort字段拉一遍清单,看看到底有几种排序组合、各自的调用频次是多少。大概率你会发现,80% 的流量集中在一种排序上,而剩下 20% 的流量对应着十几种奇奇怪怪的排序。把那 80% 用 index sorting 吃透,剩下那些直接走默认路径,收益比无差别优化高得多,也安全得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进