ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Elasticsearch深分页优化:Search After实战与新版Java SDK避坑指南

Elasticsearch深分页优化:Search After实战与新版Java SDK避坑指南

搞 ES 的人多半都吃过深分页的亏。我之前在某个订单查询接口上第一次踩到时,线上现象非常典型:业务方反馈“导出第 2000 页的数据一直转圈”,我本地一查,from=100000, size=20这个查询耗时从几十毫秒一路涨到 3 秒多,集群 CPU 和 GC 曲线直接抬头。后来把方案改成新版 Java SDK 的search_after,同样翻到十万条以后,单页耗时基本稳定在几十毫秒级别。今天这篇文章就把深分页为什么慢、新版 Java SDK 怎么用Search After解决、有哪些坑必须躲开,一次性讲清楚。适合正在维护 ES 查询接口、被from+size翻页折磨,或者想从老客户端迁到新客户端的同学参考。

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默认是 10000,from=10000之后直接被拦下来。很多人第一反应是调大这个参数,但我想说:调大它是饮鸩止渴,并没有解决深分页真正昂贵的问题。

1.2from + size的成本模型:越深越贵的数学题

ES 的分布式查询是两阶段完成的。协调节点收到请求后,会把查询广播到目标索引的所有分片;每个分片独立执行 Query 和 Sort,然后各返回from + size条结果给协调节点;协调节点把所有分片的结果合并,做一次全局排序,再丢掉前from条,最后取size条返回。

当你查询from=100000, size=20时,假设这个索引有 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。新客户端底层基于RestClient,API 风格完全重构,大量使用构造器模式,响应对象全都是类型安全的生成类,写起来舒服很多。

更重要的是,search_after在新客户端里是一等公民。老的RestHighLevelClient也能传searchAfter,但类型处理比较粗,很容易写出“Object 散装值”的代码。新客户端里,SearchRequest.Builder直接提供了searchAfter相关方法,配合响应里的Hit.sort()返回值,可以非常干净地实现深分页循环。

依赖也很简单,Maven 坐标如下:

<dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.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(List<OrderDoc> items, List<String> nextSearchAfter) { } public class OrderScrollService { private final ElasticsearchClient client; public OrderScrollService(ElasticsearchClient client) { this.client = client; } public PageResult queryOrders(String userId, int pageSize, List<String> 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); } SearchResponse<OrderDoc> response = client.search(requestBuilder.build(), OrderDoc.class); List<Hit<OrderDoc>> hits = response.hits().hits(); if (hits.isEmpty()) { return new PageResult(List.of(), null); } List<OrderDoc> docs = hits.stream().map(Hit::source).collect(Collectors.toList()); List<String> next = toSearchAfterValues(hits.get(hits.size() - 1)); return new PageResult(docs, next); } private List<String> toSearchAfterValues(Hit<?> lastHit) { List<FieldValue> 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 翻页循环的退出条件

有了单页查询方法,循环就很简单了:

List<String> 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 开始支持了 PIT(Point In Time)。你可以把它理解成给索引拍了一张“快照”,后续所有携带同一个 PIT ID 的查询都在这个快照上执行,不受期间新增、删除、更新操作的影响。

4.2 代码怎么加:开启 PIT、携带 PIT、关闭 PIT

PIT 的 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% 的翻页坑提前暴露出来。我自己每次改完翻页逻辑,都会用这套用例走一遍,表现很稳定。

RELATED READING

延伸阅读

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