Elasticsearch 深度分页的代价与替代方案:from/size、scroll、search_after 与 PIT 选择
Elasticsearch 深度分页的代价与替代方案from/size、scroll、search_after 与 PIT 选择1. 先看一个真实场景第 1 页很快第 1000 页为什么卡住假设你在做一个电商后台的订单查询系统。运营同学打开页面时只看到第 1 页10 条订单响应 20 毫秒一切正常。但当他把页码翻到第 1000 页或者用脚本从第 1 页一直拉到第 2000 页导出数据时接口要么返回 504 超时要么直接抛出这个异常Result window is too large, from size must be less than or equal to: [10000] but was [20000]. See the scroll api for a more efficient way to request large data sets.很多人第一反应是“把index.max_result_window调大就行了”。调大之后异常确实消失了但集群开始出现协调节点内存飙升、查询线程池排队、部分请求变慢。问题没有解决只是从“报错”变成了“慢性病”。要理解这件事必须先建立一个最小模型Elasticsearch 的查询请求并不是在一个大数组里按位置切片而是每个分片各自排序取一段再由协调节点把多路结果合并后截取。from 越大每个分片要丢弃的“前 N 条”就越多协调节点要归并的数据量也就越大。深度分页的代价不是“读得远”而是“算得多、传得多、丢得多”。本文的目标是让你先能复述这套整体框架再分别理解 from/size、scroll、search_after 和 PIT 各自的位置、代价与适用边界最后能给出一份可以直接用于工程判断的选型清单。2. 一句话模型与全局框架先记住一句话模型一次分页查询 每个分片按排序规则产出局部有序结果 → 协调节点归并成全局有序 → 按 from/size 截取窗口。把整体拆成三部分它们的连接关系如下客户端请求(fromN, sizeS, sort) | v 协调节点 Coordinating Node | 分发查询query phase ------------------------------------------------ v v v v Shard-0 Shard-1 Shard-2 Shard-3 局部排序TopN 局部排序TopN 局部排序TopN 局部排序TopN (NS 条) (NS 条) (NS 条) (NS 条) | | | | ------------------------------------------------ v 协调节点归并排序取 [N, NS) v 返回给客户端这里有两个关键角色数据分片Shard真正存储倒排索引和文档的地方每个分片只知道自己那部分数据因此只能给出“局部有序”。协调节点收到请求的节点它不存数据负责把查询分发到所有相关分片再把各分片的局部结果归并成“全局有序”。一次请求的流转顺序是协调节点解析 from/size → 向每个分片下发from size的取数要求 → 每个分片在自己内部排序并返回前from size条 → 协调节点做多路归并 → 截取从 from 开始的 size 条。注意一个容易被忽略的数字如果索引有 5 个主分片from9900, size10那么每个分片都可能要返回 9910 条候选文档给协调节点。协调节点要同时持有这 5 × 9910 条文档的排序字段再做归并。这就是深分页内存放大的来源。3. from/size 的上限从哪里来3.1 max_result_window 的实际含义index.max_result_window默认是 10000它限制的不是“总文档数”而是from size的上限。也就是说from9990, size10可以通过from10000, size10就会报错。这个限制的本质是保护 JVM 堆和协调节点。归并排序时协调节点需要为每个分片、每个候选文档维护一个排序键值并用优先队列选出全局前from size条。分片数越多、from 越大堆上瞬时对象越多GC 压力越大。3.2 调大限制会带来什么操作表面效果实际代价调大max_result_window报错消失能翻更深页协调节点排序队列变长堆占用上升GC 变频繁增加分片数单分片数据变少每分片都要返回 fromsize 条归并总量反而可能更大增加副本数读吞吐提升不改变归并代价深分页问题依旧减少分片数归并路数变少单分片数据变大局部排序成本上升这张表想说明一件事深分页的瓶颈主要在协调节点的归并阶段而不是在磁盘读取阶段。因此“加机器、加副本、加分片”都不是针对性解法。3.3 什么时候 from/size 仍然是好选择不要因为存在上限就否定 from/size。它的优点是简单、无状态、天然支持随机跳页。当满足以下条件时它是最优选择页数较浅比如后台列表前 50 页以内用户会随机跳页需要直接访问第 7 页查询条件简单不需要跨页保持一致性。真正需要替代方案的是“深”和“持续向后扫描”这两类需求而不是普通的前几页浏览。4. scroll为一次离线扫描维护一个快照上下文4.1 它解决什么问题当你要导出一批订单、做全量重建索引、跑离线分析时需要的不是“翻页”而是“从头到尾稳定地读一遍”。这时候如果每页都用 from/size除了代价高还有一个一致性问题如果两页之间发生了写入、更新或段合并同一批数据可能被跳过或重复。scroll 的思路是第一次请求时创建一个上下文scroll context把当时各分片的段状态固定下来之后每次用scroll_id继续取下一批直到取完或超时。4.2 请求示例第一步创建 scroll并指定上下文保留时间curl-XPOSThttp://localhost:9200/orders/_search?scroll5m-HContent-Type: application/json-d { size: 1000, query: { range: { created_at: { gte: 2024-01-01 } } }, sort: [_doc] }这里size: 1000表示每次返回 1000 条_doc表示按 Lucene 内部文档顺序返回跳过了全局排序速度最快。响应里会包含一个_scroll_id。第二步用 scroll_id 继续取curl-XPOSThttp://localhost:9200/_search/scroll-HContent-Type: application/json-d { scroll: 5m, scroll_id: DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAAEW... }当返回的hits.hits为空时说明已经取完。最后应主动清理上下文释放分片持有的资源curl-XDELETEhttp://localhost:9200/_search/scroll-HContent-Type: application/json-d { scroll_id: [DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAAEW...] }4.3 scroll 的代价与边界scroll 的快照语义是它的优点也是它的成本来源。上下文存活期间分片需要保留相关段文件不被合并删除会占用文件句柄和堆内存。常见的错误配置是把scroll30m设得很长却在拿到第一批数据后就忘了继续滚动导致上下文堆积。特征说明是否有状态有服务端维护 scroll 上下文是否支持跳页不支持只能顺序向后一致性快照不受期间写入影响适合场景全量导出、重建索引、离线分析主要风险上下文超时未清理段文件无法回收还有一个容易误解的点scroll 不是“实时视图”。它固定的是创建时刻的段状态之后的新写入不会出现在这次 scroll 中。这对导出是优点对实时查询是缺点。5. search_after无状态游标式的深分页5.1 核心机制search_after 不再让协调节点丢弃前 N 条而是把“上一页最后一条的排序值”作为起点直接让每个分片从该位置之后取数据。这样每页的代价与页码无关始终是size量级。它的前提是排序必须全局唯一且可比较。常用做法是组合排序字段加一个唯一字段例如{size:10,query:{term:{status:PAID}},sort:[{created_at:desc},{order_id:asc}]}第一次查询正常执行拿到最后一条的sort值比如[1704067200000, ORDER-9981]。第二次查询把它放进 search_after{size:10,query:{term:{status:PAID}},sort:[{created_at:desc},{order_id:asc}],search_after:[1704067200000,ORDER-9981]}5.2 为什么它省掉了深分页代价对比 from/sizefrom100000, size10时每个分片都要返回 100010 条候选而 search_after 每次都只要求分片返回 10 条条件是排序值大于上一页末尾。分片内部可以借助排序索引doc values快速定位起点。不过要注意search_after 并不能直接跳到第 5000 页。它只知道“从某个排序值之后继续”不知道“跳过多少条”。如果你确实需要页码只能通过业务侧缓存每页的首尾排序值来实现有界跳转。5.3 完整 Java 示例search_after 顺序扫描下面这个示例演示如何用 Elasticsearch Java API Client 顺序读取全部已支付订单避免深分页报错。前置环境JDK 17、Maven 引入co.elastic.clients:elasticsearch-java本地或远程 ES 8.x 已启动索引orders存在且含字段order_id、created_at。importco.elastic.clients.elasticsearch.ElasticsearchClient;importco.elastic.clients.elasticsearch.core.SearchResponse;importco.elastic.clients.elasticsearch.core.search.Hit;importco.elastic.clients.json.jackson.JacksonJsonpMapper;importco.elastic.clients.transport.rest_client.RestClientTransport;importorg.apache.http.HttpHost;importorg.elasticsearch.client.RestClient;importjava.util.ArrayList;importjava.util.List;publicclassSearchAfterDemo{publicstaticvoidmain(String[]args)throwsException{RestClientrestClientRestClient.builder(newHttpHost(localhost,9200)).build();ElasticsearchClientclientnewElasticsearchClient(newRestClientTransport(restClient,newJacksonJsonpMapper()));ListStringallOrderIdsnewArrayList();ListObjectsearchAfternull;intpageSize1000;longtotalScanned0;try{while(true){SearchResponseVoidrespclient.search(s-{s.index(orders).size(pageSize);s.query(q-q.term(t-t.field(status).value(PAID)));s.sort(so-so.field(f-f.field(created_at).order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)));s.sort(so-so.field(f-f.field(order_id).order(co.elastic.clients.elasticsearch._types.SortOrder.Asc)));if(searchAfter!null){s.searchAfter(searchAfter);}returns;},Void.class);ListHitVoidhitsresp.hits().hits();if(hits.isEmpty()){break;}for(HitVoidhit:hits){allOrderIds.add(hit.id());}totalScannedhits.size();searchAfterhits.get(hits.size()-1).sort();}System.out.println(scanned total totalScanned);}finally{restClient.close();}}}关键步骤说明每轮只取 pageSize 条用上一轮最后一条的sort值作为下一轮起点当某轮返回空列表时结束。预期结果是totalScanned等于满足条件的订单总数内存中只保留结果 ID 列表不会出现深分页异常。容易改错的地方排序字段必须能唯一定位否则相邻页可能重复或漏数据searchAfter数组的元素顺序必须与sort声明顺序完全一致。6. PIT让多次分页查询共享同一份数据视图6.1 为什么有了 search_after 还需要 PITsearch_after 解决了“深”的问题但没有解决“一致性”的问题。如果在两次 search_after 请求之间发生了段合并或新的写入排序位置可能漂移导致漏数据或重复。PITPoint In Time时间点的作用是先创建一个数据视图之后所有 search_after 请求都绑定这个 PIT从而在多次请求间看到同一份数据行为上接近 scroll 的快照但保持无状态游标的低开销。6.2 创建与使用 PIT先创建 PIT得到一个pit_id和一个注意点PIT 有存活时间keep_alive。curl-XPOSThttp://localhost:9200/orders/_pit?keep_alive2m使用 PIT 查询时不再指定索引名而是通过pit参数引用{size:10,query:{term:{status:PAID}},pit:{id:46ToAwMHb3JkZXJzFm...,keep_alive:2m},sort:[{created_at:desc},{order_id:asc}],search_after:[1704067200000,ORDER-9981]}用完及时关闭curl-XDELETEhttp://localhost:9200/_pit-HContent-Type: application/json-d { id: 46ToAwMHb3JkZXJzFm... }6.3 PIT 与 scroll 的差异维度scrollPIT search_after状态位置服务端维护完整上下文服务端保存轻量数据视图是否支持跳页否否但可结合客户端缓存做有界跳转一致性快照快照段合并影响上下文存活期内阻止合并回收同样需要保留相关段但开销更小推荐程度老版本全量导出新版本推荐的一致性深分页方式PIT 不是“免费的一致性”。keep_alive越长、并发 PIT 越多分片需要保留的资源越多。工程上应根据单次导出耗时估算 keep_alive并在 finally 块中关闭 PIT。6.4 完整 Java 示例PIT search_after 一致性导出这个示例把 PIT 和 search_after 组合起来适合导出对一致性有要求的订单数据。前置环境ES 8.x、Java API Client、索引orders存在。importco.elastic.clients.elasticsearch.ElasticsearchClient;importco.elastic.clients.elasticsearch.core.OpenPointInTimeResponse;importco.elastic.clients.elasticsearch.core.SearchResponse;importco.elastic.clients.elasticsearch.core.search.Hit;importco.elastic.clients.json.jackson.JacksonJsonpMapper;importco.elastic.clients.transport.rest_client.RestClientTransport;importorg.apache.http.HttpHost;importorg.elasticsearch.client.RestClient;importjava.util.ArrayList;importjava.util.List;publicclassPitExportDemo{publicstaticvoidmain(String[]args)throwsException{RestClientrestClientRestClient.builder(newHttpHost(localhost,9200)).build();ElasticsearchClientclientnewElasticsearchClient(newRestClientTransport(restClient,newJacksonJsonpMapper()));OpenPointInTimeResponsepitclient.openPointInTime(o-o.index(orders).keepAlive(t-t.time(2m)));StringpitIdpit.id();ListStringidsnewArrayList();ListObjectsearchAfternull;try{while(true){StringcurrentPitpitId;SearchResponseVoidrespclient.search(s-{s.size(500);s.pit(p-p.id(currentPit).keepAlive(t-t.time(2m)));s.sort(so-so.field(f-f.field(created_at).order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)));s.sort(so-so.field(f-f.field(order_id).order(co.elastic.clients.elasticsearch._types.SortOrder.Asc)));if(searchAfter!null){s.searchAfter(searchAfter);}returns;},Void.class);ListHitVoidhitsresp.hits().hits();if(hits.isEmpty()){break;}for(HitVoidhit:hits){ids.add(hit.id());}searchAfterhits.get(hits.size()-1).sort();}System.out.println(exported ids.size());}finally{client.closePointInTime(c-c.id(pitId));restClient.close();}}}关键步骤先开启 PIT循环中把 PIT ID 带入 search_after 查询最后在 finally 中关闭 PIT。预期结果是导出的 ID 数量稳定且多次请求之间不会因为并发写入而漂移。容易改错的地方忘记关闭 PIT 会导致资源泄漏PIT ID 在每次响应中可能被刷新需要关注客户端返回的最新 ID。7. 一次请求完整走一遍从客户端到分片再回来下面用文本时序图把 from/size 和 search_after 的差异放在同一张图里看from/size 查询流程 search_after 查询流程 客户端: from9900, size10 客户端: search_after[ts, id], size10 | | v v 协调节点: 计算需取 N9910 协调节点: 每片只需取 10 条 | | -- Shard0: 排序取 Top9910 -- Shard0: 按排序定位起点取 10 条 -- Shard1: 排序取 Top9910 -- Shard1: 按排序定位起点取 10 条 -- Shard2: 排序取 Top9910 -- Shard2: 按排序定位起点取 10 条 | | v v 协调节点: 归并 3*9910 条取 [9900, 9910) 协调节点: 归并 3*10 条取前 10 条 | | v v 返回客户端 返回客户端并附带新的 sort 值这张图想传达两个事实第一from/size 的代价随 from 线性增长第二search_after 的代价基本恒定但代价是失去了随机跳页能力并且需要客户端保存游标。7.1 排序字段选择的影响如果排序字段没有被用作 doc values 或没有被索引那么分片在做局部排序时可能需要加载字段值性能会明显下降。生产上常见做法是时间排序用date类型配合doc_values默认开启唯一性兜底字段用keyword或数字类型避免用text字段排序因为它是分词的无法做全局比较。7.2 分片数对归并的影响为了便于理解可以做一个简化类比协调节点像是一个收银员每个分片是一个收银台。from 越大每个收银台交上来的“候选清单”越长收银员要排序的清单也越长。增加收银台分片虽然每个台的清单短了但台数多了收银员要合并的清单总数可能更多。这个类比能帮助理解归并放大但它不能替代真实机制真实的归并还涉及网络传输、序列化反序列化和优先队列实现。8. 常见误区这些说法只对了一半8.1 “把 max_result_window 调大就万事大吉”调大只是把报错阈值抬高并没有降低归并代价。如果 from 到了几十万协调节点很可能先 OOM。正确做法是浅页用 from/size深页换成 search_after 或 PIT。8.2 “scroll 返回的是实时数据”scroll 固定的是创建时刻的段视图之后的写入不会反映在这次 scroll 里。如果业务需要看到最新数据scroll 是错误选择。8.3 “search_after 可以替代所有分页”search_after 不支持随机跳页。如果你的 UI 必须有页码导航就要在客户端维护每页起点排序值或者接受“只能上一页/下一页”的交互约束。8.4 “PIT 就是升级版 scroll”两者都提供一致性视图但 PIT 更轻量、更适合与 search_after 搭配做增量遍历scroll 更适合一次性的全量导出且在新版本中官方更推荐 PIT。选择要看是否需要保留完整上下文。8.5 “副本能解决深分页”副本提升的是并发读吞吐和可用性不改变协调节点的归并成本。深分页请求打到副本上归并阶段一样要付出代价。9. 生产实践建议按业务形态选方案9.1 面向用户的列表页用户通常只看前几页随机跳页需求真实存在。建议使用 from/size并把max_result_window保持在默认或适当范围在 UI 上限制最大页码或引导用户用更精确的筛选条件缩小结果集对高频筛选条件做缓存减少重复查询。9.2 数据导出与离线分析优先使用 PIT search_after兼顾一致性和低开销老版本或简单场景可用 scroll每批size建议在 500 到 2000 之间过大反而增加单次响应体积和 GC 压力必须设置合理的 keep_alive并在 finally 中关闭。9.3 需要稳定顺序的增量同步把 search_after 的游标值排序字段组合持久化到业务存储中下次从该值继续。这种方式天然支持断点续传但要处理更新导致排序值变化的情况。9.4 参数选择参考表场景推荐方案每批 size是否需要一致性后台前 50 页from/size10 到 50否全量导出PIT search_after500 到 1000是老版本全量导出scroll500 到 1000是增量同步search_after100 到 500是游标持久化实时报表翻页from/size10 到 100否10. 排障清单深度分页出问题先查这七项确认报错是Result window is too large还是超时。前者是from size超限后者多是归并或 GC 问题。检查索引主分片数。分片数过多会放大归并量分片数过少会增加单分片排序成本。查看协调节点堆内存和 GC 日志确认是否在归并阶段出现大量对象分配。用_nodes/stats/thread_pool观察search线程池是否出现队列积压和拒绝。检查是否有 scroll 或 PIT 上下文长期未清理通过_nodes/stats/indices/search查看open_contexts数量。检查排序字段类型确认不是text并确认doc_values没有被关闭。压测时对比 from 递增和 search_after 递增的耗时曲线确认瓶颈是否在归并阶段。# 查看当前打开的 scroll 上下文数量curl-XGEThttp://localhost:9200/_nodes/stats/indices/search?pretty|grep-A2open_contexts# 查看 search 线程池队列情况curl-XGEThttp://localhost:9200/_cat/thread_pool/search?vhnode_name,active,queue,rejected上面的命令输入是运行中的 ES 集群输出分别用于判断上下文泄漏和线程池压力。适用场景是生产巡检和故障定位边界是这些指标只反映当前状态不能替代慢查询日志分析。11. 面试与复盘问题为什么max_result_window限制的是from size而不是总命中数scroll 上下文为什么会影响段合并search_after 为什么要求排序字段全局唯一如果不唯一会发生什么PIT 和 scroll 都能提供一致性视图如何根据场景选择在协调节点归并阶段哪个数据结构决定了内存开销如果业务必须支持跳页同时数据量很大你会怎么设计这些问题的共同指向是分页方案的选择本质上是“一致性、随机访问、资源开销”三者的权衡。12. 总结把选择收回到一张决策清单最后把全文收回到一张决策清单遇到真实需求时按顺序判断需要随机跳页 |-- 是 -- from size 是否可能超过 max_result_window | |-- 否 -- 用 from/size | |-- 是 -- 限制页码 / 缩小筛选集 / 业务侧缓存排序值 | |-- 否 -- 是否需要跨请求一致视图 |-- 是 -- PIT search_after新版本推荐 | 老版本或简单导出可用 scroll |-- 否 -- search_after无状态低开销再给出一句话结论浅分页用 from/size顺序深分页用 search_after需要一致性时加 PIT全量离线导出可考虑 scroll调大 max_result_window 只是掩盖症状不是解决方案。13. 参考资料Elasticsearch 官方文档Search API 中 from/size、search_after 的说明Elasticsearch 官方文档Scroll API 与 Point in TimePIT说明Elasticsearch 官方文档index.max_result_window索引设置Elasticsearch 官方文档_nodes/stats与_cat/thread_pool监控接口Elasticsearch Java API Client 官方文档Lucene 官方文档段合并Segment Merging与 doc values 相关说明