Elasticsearch深分页优化:Search After实战与新版Java SDK避坑指南 搞 ES 的人多半都吃过深分页的亏。我之前在某个订单查询接口上第一次踩到时线上现象非常典型业务方反馈“导出第 2000 页的数据一直转圈”我本地一查from100000, size20这个查询耗时从几十毫秒一路涨到 3 秒多集群 CPU 和 GC 曲线直接抬头。后来把方案改成新版 Java SDK 的search_after同样翻到十万条以后单页耗时基本稳定在几十毫秒级别。今天这篇文章就把深分页为什么慢、新版 Java SDK 怎么用Search After解决、有哪些坑必须躲开一次性讲清楚。适合正在维护 ES 查询接口、被fromsize翻页折磨或者想从老客户端迁到新客户端的同学参考。1. 深分页问题的本质为什么会慢到让人想掀桌子1.1 一个亲身踩过的超时案例那次事故背景很简单一个订单系统的管理端要按用户查历史订单前端做了一个“上一页/下一页”的表格组件用的是最常规的from size分页。数据量在几十万的时候一切正常等订单表增长到千万级问题就开始冒头了。一开始只是偶尔超时后来越来越频繁最后只要from超过 5 万接口基本必挂。最典型的报错是{ error: { root_cause: [ { type: illegal_argument_exception, reason: Result window is too large, from size must be less than or equal to: [10000] } ] } }这个报错对老 ES 玩家来说太熟悉了。max_result_window默认是 10000from10000之后直接被拦下来。很多人第一反应是调大这个参数但我想说调大它是饮鸩止渴并没有解决深分页真正昂贵的问题。1.2from size的成本模型越深越贵的数学题ES 的分布式查询是两阶段完成的。协调节点收到请求后会把查询广播到目标索引的所有分片每个分片独立执行 Query 和 Sort然后各返回from size条结果给协调节点协调节点把所有分片的结果合并做一次全局排序再丢掉前from条最后取size条返回。当你查询from100000, size20时假设这个索引有 5 个主分片那么每个分片都要先取出前 100020 条协调节点则要收到5 × 100020 ≈ 500100条记录做归并排序最后丢掉前 10 万条只给你 20 条。这个成本是随from线性增长的。表面上看你只是翻到了第 10 万条但 ES 实际干的活是“把所有分片的前 10 万条都翻出来比了一遍”。更麻烦的是这些中间结果全部在协调节点的堆内存里周转翻页越深内存压力越大GC 越频繁最终拖垮整个节点。所以深分页的问题不是“查询条件写得差”而是“机制上就要做大量无效计算”。只要继续用from size翻到十万条和翻到一百万条只是早死和晚死的区别。2. 从旧客户端到新版 Java SDK换工具的同时也换了思路2.1 为什么新版 SDK 值得换一次很多老项目还在用RestHighLevelClient这是 ES 7.x 时代的主力客户端。到了 ES 8.x官方主推的是新的elasticsearch-java客户端包名也从org.elasticsearch.client换成了co.elastic.clients。新客户端底层基于RestClientAPI 风格完全重构大量使用构造器模式响应对象全都是类型安全的生成类写起来舒服很多。更重要的是search_after在新客户端里是一等公民。老的RestHighLevelClient也能传searchAfter但类型处理比较粗很容易写出“Object 散装值”的代码。新客户端里SearchRequest.Builder直接提供了searchAfter相关方法配合响应里的Hit.sort()返回值可以非常干净地实现深分页循环。依赖也很简单Maven 坐标如下dependency groupIdco.elastic.clients/groupId artifactIdelasticsearch-java/artifactId version8.10.0/version /dependency初始化一个同步客户端只需要两行RestClient restClient RestClient.builder(new HttpHost(localhost, 9200, http)).build(); ElasticsearchClient client new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));如果你用的是 Spring Boot还要注意 ES 客户端的版本和项目里的jackson版本别打架我见过好几次因为依赖冲突导致 JSON 序列化异常排查起来非常头大。2.2search_after的核心理念记住位置而不是偏移量from size的做法是“你告诉我你要跳过多少条”search_after的做法是“你告诉我你上一次看到了哪一条我从那一条后面继续查”。这个区别很关键。前者要求 ES 从头算偏移后者只要求 ES 在全部排序结果里做一次二分定位然后接着往后取。等于说无论你翻到第 1 万页还是第 10 万页单次查询的负担都只和“单页大小 排序成本”有关和总偏移量基本无关。用生活里的例子理解from size相当于每次去图书馆找一本书都要数一遍这一层书架上有多少本书才能决定绕过多少本search_after则是上次离开时做了一个书签下次直接翻到书签后面那一本中间的书一页都不用翻。3. 手把手实现基于 Search After 的翻页读取完整落地方案3.1 排序字段选型先解决“唯一性”这个前置条件search_after最大的前提是排序结果必须稳定且唯一。如果你的排序字段有大量相同值那“上一条之后的下一条”到底是谁就说不清楚了翻页一定会丢数据。最简单的稳定方案是加 tiebreaker 字段。通常是两个字段组合排序第一个字段是业务排序维度第二个字段选一个全局唯一值。我常用的组合是业务排序字段 _id。示例场景某个模拟订单系统要从索引里导出某用户某段时间内的订单按订单号升序导出每页 1000 条。这里主排序用订单号orderNo它是 keyword 类型本身业务上就近似唯一再加_id做兜底保证不会出现并列。为什么我特意用 keyword 字符串字段做示例因为在真实项目里如果你想按createTime这种 long 类型字段排序处理search_after的返回值时要非常小心数值类型转换问题这个坑后面我会单开一节讲。用 keyword 字符串字段能让你先把机制跑通再考虑业务字段。3.2 分页服务代码实现以下代码基于elasticsearch-java8.10 版本编写。核心方法是“查一页”入参带着上一页的search_after值出参返回本页数据和下一轮的search_after。import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch.core.SearchRequest; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch.core.search.FieldValue; import org.elasticsearch.client.RestClient; import org.elasticsearch.client.RestClientTransport; import co.elastic.clients.json.jackson.JacksonJsonpMapper; import java.io.IOException; import java.util.List; import java.util.stream.Collectors; public record PageResult(ListOrderDoc items, ListString nextSearchAfter) { } public class OrderScrollService { private final ElasticsearchClient client; public OrderScrollService(ElasticsearchClient client) { this.client client; } public PageResult queryOrders(String userId, int pageSize, ListString searchAfter) throws IOException { SearchRequest.Builder requestBuilder new SearchRequest.Builder() .index(orders) .size(pageSize) .query(q - q.bool(b - b .filter(f - f.term(t - t.field(userId).value(userId))) .filter(f - f.range(r - r.field(createTime).gte(2024-01-01T00:00:00Z))) )) .sort(s - s.field(f - f.field(orderNo).order(SortOrder.Asc))) .sort(s - s.field(f - f.field(_id).order(SortOrder.Asc))) .trackTotalHits(t - t.enabled(false)); if (searchAfter ! null !searchAfter.isEmpty()) { requestBuilder.searchAfter(searchAfter); } SearchResponseOrderDoc response client.search(requestBuilder.build(), OrderDoc.class); ListHitOrderDoc hits response.hits().hits(); if (hits.isEmpty()) { return new PageResult(List.of(), null); } ListOrderDoc docs hits.stream().map(Hit::source).collect(Collectors.toList()); ListString next toSearchAfterValues(hits.get(hits.size() - 1)); return new PageResult(docs, next); } private ListString toSearchAfterValues(Hit? lastHit) { ListFieldValue sortValues lastHit.sort(); if (sortValues null || sortValues.isEmpty()) { return null; } return sortValues.stream() .map(v - v.isString() ? v.stringValue() : v.isNull() ? null : String.valueOf(v.toJson())) .collect(Collectors.toList()); } }几个细节值得展开第一页请求不传searchAfter从第二页开始把上一页返回的nextSearchAfter传进去。必须取“本页最后一条”的sort()不是第一条也不是任意一条。因为search_after的含义是从这条之后继续。trackTotalHits(false)是导出场景的优化点。如果不需要精确命中总数关掉它可以省掉一次全局计数查询响应会快不少。注意有些老版本 API 不支持这个写法我是在 8.10 上用的。3.3 翻页循环的退出条件有了单页查询方法循环就很简单了ListString after null; int page 0; int pageSize 1000; while (true) { page; PageResult result queryOrders(userId, pageSize, after); // 这里把 result.items() 写入文件、消息队列或别的存储 process(result.items()); // 如果本页不足一页说明已经到末尾 if (result.items().size() pageSize) { break; } after result.nextSearchAfter(); if (after null || after.isEmpty()) { break; } // 防止异常场景死循环 if (page 10000) { throw new IllegalStateException(paging exceeded max page limit); } }退出条件有两个一是返回条数小于pageSize说明没有更多数据了二是nextSearchAfter为空可能是排序值缺失导致无法继续。现实项目中我会额外加一个最大页数保护避免因为某些脏数据导致死循环把集群拖垮。4. PIT Search After给翻页加一个“时间快照”4.1 为什么纯search_after还不够search_after本质上是一个无状态查询每次请求都是独立的。如果翻页过程中有新增数据或者索引里删除了数据那么排序结果的相对位置会变化最终可能漏掉一部分数据或出现重复。比如你要导出 20 万条数据翻到第 30 页时业务系统插入了一条排在最前面的新记录。由于search_after是从上一次的排序位置往后找这条新记录如果排在你已经扫过的区域你就永远看不到它如果它恰好排到还没扫描的区域又可能再被扫一次。这种不一致对导出报表来说很致命。好在 ES 从 7.10 开始支持了 PITPoint In Time。你可以把它理解成给索引拍了一张“快照”后续所有携带同一个 PIT ID 的查询都在这个快照上执行不受期间新增、删除、更新操作的影响。4.2 代码怎么加开启 PIT、携带 PIT、关闭 PITPIT 的 Java SDK API 也很直观import co.elastic.clients.elasticsearch.core.OpenPitRequest; import co.elastic.clients.elasticsearch.core.OpenPitResponse; import co.elastic.clients.elasticsearch.core.ClosePitRequest; import co.elastic.clients.elasticsearch._types.Time; OpenPitRequest openReq OpenPitRequest.of(b - b .index(orders) .keepAlive(Time.of(t - t.time(5m))) ); OpenPitResponse openResp client.openPit(openReq); String pitId openResp.pitId(); try { SearchRequest.Builder rb new SearchRequest.Builder() .size(pageSize) .pit(p - p.id(pitId)) // 使用 PIT 后不能再传 index .query(...) .sort(...); // 翻页循环逻辑不变 } finally { client.closePit(ClosePitRequest.of(b - b.pitId(pitId))); }使用 PIT 时有三个容易踩的细节传了pit后请求里不能再指定index否则会报错。keepAlive是滚动续期的每次使用 PIT 发起搜索ES 都会按 keepAlive 自动顺延。但如果你的翻页太慢超过 keepAlive 时间后 PIT 会被服务端清理后续查询全部失败。用完必须closePit否则 PIT 会一直占着索引的分片资源在数据量大的集群上能明显看到内存占用。在实际导出任务里我一般把keepAlive设置成 5 分钟同时监控一轮导出总耗时。如果确实要扫很久就改成“分段任务”每段跑完重新开一个新的 PIT而不是一个 PIT 撑全场。4.3 开启 PIT 后的排序建议ES 官方其实给了个配套建议使用 PIT 时tiebreaker 可以换成内置的_shard_doc字段。_shard_doc是分片内部的单调递增文档编号排序性能更好也更稳定。在查询里加一个字段排序就能做到.sort(s - s.field(f - f.field(_shard_doc).order(SortOrder.Asc)))注意_shard_doc不能跨 PIT 使用它依赖 PIT 生成时建立的分片视图。如果你不用 PIT老老实实用_id做 tiebreaker 就行。5. 深分页的经典翻车现场排序字段与数据变化5.1 排序字段撞车导致丢数据这是search_after最容易踩的坑排序字段没有唯一性。我曾经在另一个报表任务里做过一次“优化”只按createTime倒序做search_after。当时想得很简单时间精确到毫秒总该够了吧。结果导出数据对账时发现少了十几条一查原因同一毫秒内有多个订单排序值完全一样search_after直接跳过了一部分相同排序值的记录。所以业界标准做法是“业务字段 唯一字段”组合排序上面代码里的orderNo _id就是这种思路。如果你按时间排序就用createTime _id。还有个更隐蔽的问题如果主排序字段在文档里缺失ES 会把缺失值放到排序的头部还是尾部取决于missing参数。这直接影响search_after的定位。线上建议在 mapping 阶段就统一处理要么给字段设默认值要么在查询里显式声明missing策略尽量避免“同一批文档里有值没值混杂”的情况。5.2 数值排序字段的返回值类型坑新版 Java SDK 的searchAfter方法参数是字符串列表而响应里sort()返回的是FieldValue。如果你的排序字段是 long 类型FieldValue内部是一个 JSON 数字toString()转出来也是数字字符串。但传到下一个请求时SDK 会把它序列化成 JSON 字符串也就是带引号的数字。排序字段本身是数值search_after却是字符串轻则报类型转换错误重则返回错误位置的数据。我的规避方法是两个能用 keyword 字符串字段排序就尽量用字符串字段排序彻底绕开类型转换。必须用数值字段排序时单独写一个序列化函数把FieldValue还原成和原始排序字段一致的 JSON 类型而不是简单toString()。不同版本的 SDK 对FieldValue的封装有差异写完后一定要用构造出来的样例数据跑一遍翻页确认第二页返回结果正确。这个坑很隐蔽因为它不出现在第一页永远在你翻到第二页、第三页的时候才爆出来。5.3 新数据写入导致的偏移错乱纯search_after不提供一致性快照上一节已经说了。这里再补充一个实际表现如果在翻页过程中有旧的文档被删除排序位置会产生“空洞”search_after会指向一个已经被删掉的排序值后续查询可能直接终止也可能跳过本来应该返回的文档。所以结论是只读场景、对临时不一致不敏感的查询直接用search_after就行导出、报表、对账这种必须一致的场景一定要配 PIT。5.4max_result_window不是让你调大的有些人遇到from size must be less than or equal to [10000]第一反应就是调大max_result_window。我见过有人把它调到 100 万结果第二天集群 OOM。原因很简单max_result_window的本质是保护机制。它不限制你“能看多远”而是限制 ES “要为了这一次查询做多重的排序”。你把窗口调大等于允许每个分片带着更大的from size去执行协调节点合并的内存压力也跟着变大。这种配置只是把报错延后并没有把成本变低。更合理的解法就是本文标题说的改用search_after。因为search_after不走from所以压根不触发max_result_window的限制同时单次查询的内存压力也能控制住。6. 方案对比与选型别再对着一个分页功能纠结半天6.1from size、scroll、search_after怎么选很多同学在选分页方案时容易混乱这里给一张对比表平时做技术方案直接参考它就行。方案实现原理适合场景主要痛点from size协调节点合并全部分片结果后丢弃前 N 条页数较浅的传统 UI 分页深分页成本线性增长受max_result_window限制scroll创建快照和游标服务端保留上下文一次性全量遍历、离线导出游标长时间占用内存用完必须 clear不适合实时翻页search_after记录上一条排序值从该位置继续查无限加载、深度遍历、配合 PIT 导出只能逐页向后不能跳页排序字段必须唯一稳定scroll是老版本里处理大遍历的常用方案但它的缺点是服务端要为每个 scroll 请求保留状态。如果你开很多 scroll内存压力会很明显而且一旦忘记清理ES 节点的堆内存就能看到持续爬升。search_after没有这种状态查询失败重来也容易所以 8.x 时代我更推荐它来替代大部分老 scroll 场景。6.2 根据业务场景做决策如果你的业务是后台管理表格、带页码跳转那就老老实实用from size同时用max_result_window之外的手段限制可跳转页数比如前端只允许翻前 100 页或者在查询条件里强制加时间范围从源头减少总数据量。如果你的业务是 App 下拉加载更多、Feed 流无限加载search_after是天然匹配的用户也不需要知道总页数只要“往下拉还有数据”就够了。如果你的业务是全量导出、同步数据到数仓、跑批量对账建议直接用“PIT search_after”组合兼顾一致性和稳定性。如果单次任务量极大超过 PIT 存活时间就把任务拆成多段每一段重新开一个 PIT避免 keepAlive 过期导致中途失败。还有一个常见的错误想法用search_after实现“跳到第 N 页”。它做不到因为search_after只能从当前位置往后走不能跳过任意位置。真要支持深页跳转唯一的办法是别让用户跳那么深或者用其他业务手段重新设计。6.3 我在实际项目中定下的一套分页规矩踩过几次坑之后我给自己维护的 ES 查询代码定了几个原则分享出来仅供参考业务查询接口任何场景都禁止from size超过 10000超了就报错提醒改方案。新写的翻页接口默认使用search_after需要跳页的浅分页单独说明并评估数据规模。所有search_after查询必须有两个及以上排序字段其中至少一个是唯一值字段。导出类任务优先用 PIT不做一致性要求时才允许裸奔search_after。每个查询都要检查_source是否只返回必要字段减少协调节点的传输压力。这些规则看起来简单但在一个订单管理页面、一个报表导出任务、一个数据对账程序上同时落地之后线上再没出现过因为深分页导致的接口超时和节点 GC 飙升。最后再说一个排查技巧如果你上线后不确定search_after是否翻页正确可以在测试环境造一批带重复排序值、缺失排序值、中途插入新数据的数据然后完整跑一遍翻页导出把结果条数和内容对一遍。这种测试数据构造起来很便宜却能把 90% 的翻页坑提前暴露出来。我自己每次改完翻页逻辑都会用这套用例走一遍表现很稳定。