先说说我为什么想写这篇。前两天有个同事跑过来问我,ES线上一个列表接口,翻到第200页突然报错,一看日志是Result window is too large,from+size默认只能查10000条。这个问题其实特别典型,几乎所有用ES做列表查询的团队都会撞上一次。我跟他聊完,发现大多数人只知道ES分页有上限,但并不知道这个限制是怎么来的,也不清楚深度分页到底该怎么选型。今天就把这件事掰开揉碎聊清楚,包括ES分页的内部过程、深度分页的几种方案、它们的适用场景和坑,以及我这边沉淀下来的实操写法。
1. 先从一次报错说起:ES分页到底触发了什么
1.1 “Result window is too large”是怎么出现的
我们先用一个最基础的场景入手。假设有个订单索引,里面有500万条文档,测试环境数据量不大时没人会注意分页问题,直到线上某个运营后台要导数据或者翻页,你随手写了这么一段查询:
{ "from": 100000, "size": 20, "query": { "match_all": {} } }然后ES直接甩给你一段异常,核心是这么一句:
Result window is too large, from + size must be less than or equal to: [10000] but was [100020]
这时候不少人第一反应是:把index.max_result_window调大不就行了?比如调到100万。确实能解决眼下的报错,但这个操作其实是在给后面的性能问题埋雷,因为你没有理解ES为什么限制这个窗口。
ES之所以默认限制 from+size 的窗口为10000,核心原因是from+size分页在分布式环境下的成本是指数级的。它不是为了恶心你,是在保护你的集群。我先从一次正常分页查询在ES内部是怎么跑的讲起,你就能理解这个限制背后的代价了。
1.2 一次分页查询在ES内部的完整过程
假设索引有5个主分片,你发起一个from=990, size=10的查询,期望拿到第991到第1000条数据。这个请求会先打到协调节点(Coordinating Node),然后ES做这么几件事:
- 协调节点把请求改写并广播到5个分片。
- 每个分片独立执行查询,先在本地筛选出命中的文档,再对from+size这条数据范围进行排序,也就是每个分片都要排前1000条。
- 每个分片把自己排好序的前1000条文档ID返回给协调节点,注意这里只返回文档ID和排序字段,不返回完整文档。
- 协调节点拿到5个分片的结果后,总共会有 5×1000 = 5000 条文档ID,在内存里做一次全局归并排序。
- 排序完成后,协调节点丢弃掉前990条,只取第991到第1000条对应的文档ID,再发一次请求去各分片拉取完整的文档内容返回给客户端。
这个过程中最容易被忽视的问题是第2步和第4步。你以为查的是第990页的10条数据,但每个分片实际排序的文档数量是from+size而不是 size。也就是说查询损耗跟页号近似成正比,页号越深,每个分片要排序的数据就越多,协调节点内存里要归并的ID也就越多。
如果把max_result_window直接调到100万,你就允许客户端一次性指定 from=90万,这时候5个分片每个都要排序90万条文档,协调节点要归并450万条文档ID。一旦查询并发上来,协调节点堆内存直接被这些排序结果打爆。这还是在match_all这种轻量查询的情况下,如果查询里有复杂的should条件、算分逻辑或者大字段排序,代价更高。
所以这里想先传达一个核心观点:ES深分页问题的本质不是“ES不支持”,而是“from+size这种翻页机制天然不适合深分页”。你需要的不是调大窗口,而是换一种更适合深分页的机制。
1.3 两个容易忽略的细节:算分和排序的副作用
再来补充两个实际会踩到的细节。
第一个是算分问题。ES默认的查询算分是基于词频、文档频率等因子计算的,而分片在本地算分时,IDF(逆文档频率)是基于当前分片内的统计值算的,所以不同分片对同一篇文档的得分可能不同。正常浅分页无所谓,但是一旦你要做深度翻页,尤其是按相关度排序的搜索场景,跨分片归并时可能出现排序不稳定、重复数据等问题。这也是为什么我在做深分页方案时,如果排序字段是_score,基本会建议换成其他确定性更强的字段作为 tie-breaker(比如_doc或者时间戳)。
第二个是超时和资源占用。翻页越深,每个分片的查询耗时越长,协调节点等待所有分片返回的时间就越长。如果某个分片因为GC或者磁盘压力变慢,整页查询都会被拖住。我见过一个运营后台翻到5000页时,单个查询直接把协调节点的CPU打到满载,最后不得不限流。
2. 深度分页的三条主流路线:Scroll、Search After、PIT
2.1 Scroll:适合导出,不适合前端翻页
先聊聊大家最熟悉的 Scroll。早期ES版本里的深度分页方案就是它,使用方式也很简单,第一次查询带上 scroll 参数,ES会返回一个 scrollId,之后每次通过 scrollId 向后拉取。
POST /order/_search?scroll=1m { "size": 1000, "query": { "match_all": {} } }拿到 scrollId 后,继续用这个ID拉下一页:
POST /_search/scroll { "scroll": "1m", "scroll_id": "DnF1ZXJ5VGhlbkZldGNo..." }用完之后要清理:
DELETE /_search/scroll { "scroll_id": "DnF1ZXJ5VGhlbkZldGNo..." }Scroll的原理可以理解成:ES在第一次查询时,为这个查询条件建立一个快照上下文,并把这次快照的完整结果集对应到一个游标上,之后每次翻页都是从这个已经生成的快照中顺序读取下一批数据。
听起来很美好,但它的代价也很明确:
- Scroll 创建后,ES 会在内存中保留这个查询对应的段和上下文,即使数据已经变了,快照结果也不变。这意味着它是一个“时间点”视图,适合导出批量数据,但不适合用户实时翻页看最新数据。
- 如果Scroll没有正确清理,段和上下文会一直占用堆内存和文件句柄。你可以在
GET /_nodes/stats/indices/search里看到 open_contexts 的数量,排查泄漏。 - Scroll 的游标只能顺序往后走,不能跳页,不能往前翻。用户想从第1页跳到第100页,Scroll做不到。
所以我的经验总结是:Scroll 适合“一次性拉大量数据回去”,比如定时任务同步数据、数据导出、Reindex 场景,不适合ToC接口的实时分页展示。在ES 7.x之后,官方甚至建议用 Search After 替代Scroll大部分场景。
2.2 Search After:游标分页的正确打开方式
Search After 是现在最推荐做深度分页的方案。它的思路跟Scroll有点像,也是“记住上一页最后一条文档的位置”,但实现方式完全不同。
Search After 不依赖快照上下文,而是通过排序值作为游标。举个例子,我按订单金额和文档ID排序,每页查10条:
{ "size": 10, "query": { "match_all": {} }, "sort": [ { "amount": "desc" }, { "_id": "asc" } ] }第一页正常返回。ES在返回结果里会带上每个文档的 sort 值,比如最后一笔订单的 amount=2999,_id="1048576"。接下来我翻第二页,只需要把这两个值塞进 search_after:
{ "size": 10, "query": { "match_all": {} }, "search_after": [2999, "1048576"], "sort": [ { "amount": "desc" }, { "_id": "asc" } ] }它的内部原理是:上一页最后一条文档的排序值就是下一页的起始位置,ES拿到这个位置之后,直接跳到对应分片的排序位置上继续往后取数据,而不是像 from+size 那样把前面所有数据都排序一遍再丢弃。
这个方案的优点非常明显:
- 性能跟页号无关。也就是说第1页和第100万页的查询成本是一样的,因为压根不需要跳过前面的数据。
- 实时性强。它没有快照上下文,每次查询都是基于当前索引的最新数据,新增或者删除的文档在下一次查询时都能体现。
但缺点同样需要认识清楚:
- 不能跳页。Search After 只能顺序翻页,用户想直接点到第500页是不行的,因为它没有“页号”的概念,只有“游标位置”。
- 排序字段必须全局唯一,否则翻页可能丢数据。比如按金额排序,如果两条文档金额相同、其他排序字段不稳定,就会出现下一页重复或遗漏。我在生产环境一律要求排序里加
_id或者_shard_doc做兜底,确保严格有序。 - 使用 Search After 时,两个相同条件的查询之间如果数据发生变化,可能导致中间出现数据错位。比如新插入一条排在前面,上一页最后一条在下一页里又出现一次或者漏了一条。这在纯前向翻页时很难完全避免,所以一般也只适合“加载更多”这类场景。
2.3 PIT:把“一致性”和“游标”合二为一
到了ES 7.10之后,官方推出了 PIT(Point In Time)机制,全称是搜索时间点快照。它解决的核心问题是:Search After 在翻页过程中,如果索引有数据变更,不同分片返回的数据可能不在同一个时间点上,导致结果不一致。
PIT 的用法分三步。第一步,创建一个快照:
POST /order/_pit?keep_alive=1m返回一个 pit_id。第二步,查询时把这个 pit_id 带上,同时使用 search_after:
{ "size": 10, "query": { "match_all": {} }, "pit": { "id": "oG46ZUlNc2ljc2FsdGNvbVBpdElkOjE2ODk4...", "keep_alive": "1m" }, "sort": [ { "amount": "desc" }, { "_id": "asc" } ] }第三步,翻页时除了传 search_after,还要继续传同一个 pit_id,并且每次都要刷新 keep_alive。
PIT 与 Scroll 的区别在于,Scroll 是直接给整个结果集拍快照,而 PIT 是给索引的底层 Lucene 段拍快照,查询条件不变,你可以在同一个快照上做不同的 query 和聚合。与 Search After 的区别在于,Search After 每次查询的视图是“当前时刻”,而 PIT 是在一个固定的时间点视图上翻页,避免新增数据对中间页造成插入或偏移。
注意一个细节,使用 PIT 之后,index 参数就不能出现在查询里了,因为 PIT 已经锁定了索引。路径上可以直接写/_search。
如果项目用的ES版本是7.10以上,我的排位是:PIT + Search After > Search After > Scroll。但很多公司线上可能还是ES 7.6、7.8甚至6.x,所以下面我按实际可落地场景把方案选型再整理一遍。
2.4 三种方案核心对比
| 维度 | Scroll | Search After | PIT + Search After |
|---|---|---|---|
| 是否支持实时数据 | 否,快照视图 | 是 | 快照视图,但可配合新查询 |
| 是否支持跳页 | 否 | 否 | 否 |
| 查询性能 | 第一次开销大,后续快 | 每次开销一致 | 每次开销一致稍微多一点 |
| 资源占用 | 持续占用上下文 | 低 | 需管理PIT生命周期 |
| 适合场景 | 批量导出、Reindex | 前端“加载更多” | 需要一致性快照的深度分页 |
| 版本要求 | 各版本都支持 | 2.x之后就支持 | 需要7.10+ |
3. 实操:基于Spring Boot的深度分页代码怎么写
3.1 常规 from+size 的问题代码还原
先看一段很常见但线上会出事的写法。假设有个订单查询接口,用RestHighLevelClient(ES 7.x)实现:
public SearchResponse searchOrders(int page, int size, String keyword) throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .from(page * size) .size(size) .query(QueryBuilders.matchQuery("remark", keyword)); SearchRequest request = new SearchRequest("order") .source(sourceBuilder); return restHighLevelClient.search(request, RequestOptions.DEFAULT); }这段代码在数据量小的时候跑的挺好,一旦订单量突破10万、有人翻到5000以上,就会触发我开头说的Result window is too large异常。而且还有另一个隐患:即使max_result_window被调大,from值很大的情况下,协调节点需要归并大量文档ID,响应时间会指数增长。
我给团队的硬性要求是:搜索接口禁止直接用 from 做深翻页,前端只允许游标翻页或者限制最大页号。下面给两套可以直接抄的写法。
3.2 方案一:前端“加载更多”场景,用 Search After
假设业务是移动端或后台列表的“点击加载更多”,这种场景不需要跳页,用 Search After 是最合适的。第一步是写一个查询方法,第一次调用时没有游标:
public PageResult<OrderDTO> searchOrdersBySearchAfter(String keyword, Object[] searchAfter, int size) throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .query(QueryBuilders.matchQuery("remark", keyword)) .size(size) // 排序字段必须稳定,_id兜底保证全局唯一 .sort(SortBuilders.fieldSort("amount").order(SortOrder.DESC)) .sort(SortBuilders.fieldSort("_id").order(SortOrder.ASC)); if (searchAfter != null && searchAfter.length > 0) { sourceBuilder.searchAfter(searchAfter); } SearchRequest request = new SearchRequest("order").source(sourceBuilder); SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT); return convertToPageResult(response); }响应转换时一定要把排序值透出给前端:
private PageResult<OrderDTO> convertToPageResult(SearchResponse response) { SearchHit[] hits = response.getHits().getHits(); List<OrderDTO> list = new ArrayList<>(); Object[] lastSortValues = null; for (SearchHit hit : hits) { OrderDTO dto = parseHit(hit); list.add(dto); // 保存每一条的排序值,翻页只用最后一条 lastSortValues = hit.getSortValues(); } PageResult<OrderDTO> result = new PageResult<>(); result.setList(list); result.setHasMore(list.size() >= size); result.setSearchAfter(lastSortValues); // 前端下次带上这个 return result; }前端逻辑就变成了:
第一次请求:POST /api/orders { keyword: "xx", size: 20 } 下一次请求:POST /api/orders { keyword: "xx", size: 20, search_after: [2999, "1048576"] }这里有几个关键点值得强调:
search_after数组的顺序必须跟sort的顺序一致,而且类型也要一致。比如金额是keyword类型,你排序值是字符串,那数组里第一个也必须是字符串。类型不匹配会直接报错。- 一定要给排序加稳字段。如果只按
amount排序,多个文档金额相同,ES可能因为分片间的顺序不一致导致翻页漏数据。加_id是为了引入唯一排序依据。 - 如果查询里不关心相关性打分,可以把
track_scores关掉,减少不必要的算分开销。
3.3 方案二:需要一致性快照,用 PIT + Search After
如果业务场景是“运营后台导出一批满足条件的订单,分多次拉取,要求整个过程数据视图一致”,这时候就用 PIT + Search After。
先封装一个创建PIT的方法:
public String createPit(String index, long keepAliveSeconds) throws IOException { CreatePitRequest pitRequest = new CreatePitRequest(index, TimeValue.timeValueSeconds(keepAliveSeconds)); CreatePitResponse pitResponse = restHighLevelClient.createPit(pitRequest, RequestOptions.DEFAULT); return pitResponse.getId(); }再封装翻页查询:
public SearchResponse searchByPit(String pitId, long keepAliveSeconds, Object[] searchAfter, int size) throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .size(size) .sort(SortBuilders.fieldSort("amount").order(SortOrder.DESC)) .sort(SortBuilders.fieldSort("_id").order(SortOrder.ASC)) .searchAfter(searchAfter); SearchRequest request = new SearchRequest() .source(sourceBuilder) .pit(new SearchRequest.Pit(pitId, TimeValue.timeValueSeconds(keepAliveSeconds))); return restHighLevelClient.search(request, RequestOptions.DEFAULT); }注意,使用了 PIT 后,SearchRequest不再指定index,索引信息已经在创建PIT时绑定了。PIT模式有一个很强的优势:当你创建一个PIT之后,就算这个PIT创建后索引里有新数据写入、有文档删除,你在这个PIT视图里看到的仍然只是创建时刻的索引状态。非常适合对账、统计、导出这种需要“时间点一致性”的场景。
PIT 用完了记得删除:
public void deletePit(String pitId) throws IOException { DeletePitRequest deletePitRequest = new DeletePitRequest(pitId); restHighLevelClient.deletePit(deletePitRequest, RequestOptions.DEFAULT); }3.4 版本兼容问题:低版本ES怎么办
很多团队还在用ES 6.x或者ES 7.0~7.9,这种情况没有PIT可以用,但又想避免Scroll占用上下文的问题,我一般建议退而求其次:直接使用 Search After,不要Scroll。
在ES 6.x上,Scroll还有一个比较坑的地方:它生成的_scroll_id在集群重启后会失效,你整个导出任务就可能断掉。Search After是纯查询语义,天然没有这种状态问题,最多就是查到中间数据变了,但不会报一个让你摸不着头脑的“scroll id失效”错误。
另外,ES 6.x的RestHighLevelClient里 SearchSourceBuilder 就有searchAfter方法了,用法基本一样,只是没有PIT可以配合,需要注意翻页过程中数据变更引起的轻微错位,通常这种场景下可接受。
4. 常见问题与调优心得
4.1 深度分页时的“排序不稳定”怎么破
先给结论:排序不稳定的原因是排序字段不是唯一键。最常见的就是按_score排序,低版本ES中不同分片算分时基于的 term 频率是分片内的统计值,跨分片归并后顺序确实可能会有细微差异。
解决办法有两个。
一是排序字段增加 tie-breaker。不要只按_score排,而是同时加_id:
{ "sort": [ { "_score": "desc" }, { "_id": "asc" } ] }这样即使两个文档的_score完全相同,后续排序也会因为有唯一的_id而保持确定顺序。
二是如果业务上不依赖相关度排序,干脆用纯字段排序,比如时间戳加_id,开销更小也更稳定。我这边生产环境最常用的是(business_date desc, _id asc)或者(amount desc, _id asc)。
4.2max_result_window到底能不能调
能调,但要明确调它解决的是什么问题。index.max_result_window默认10000,它限制的是 from+size 模式允许的最大窗口。如果你有非改不可的存量代码,临时调到10万、50万作为过渡,这可以理解。但我必须提醒你两个后果:
- 调大之后,深分页的慢查询会开始消耗协调节点内存,慢查询堆积可能导致集群整体响应变差,甚至拖垮节点。
max_result_window是基于索引级别的配置,你可以单独给某个索引临时调大,不要把集群里所有索引都放开。
我见过一个比较合理的过渡做法:先定位所有用到深翻页的接口,把它们改成 Search After 游标模式,改完再恢复默认限制。如果存量接口太多,优先改消耗最大的那批接口。
PUT /order/_settings { "index": { "max_result_window": 50000 } }如果又要保留 from+size 模式,又想防止性能黑洞,折中方案是限制业务层的最大页号,比如超过10000就提示“数据量过多,请使用筛选条件缩小范围”,这在面对数据量和检索场景可控的中小团队里也算是一种务实的取舍。
4.3 慢查询排查:怎么确定分页拖慢了集群
出现性能问题时,第一步不是看代码,而是先确认慢查询到底来自哪里。我常用的排查方法有这三条:
- 打开慢日志,查看哪些查询的
took时间异常高。ES慢日志分index.search.slowlog和search.slowlog,把阈值设置到500ms或1s就能抓到深分页查询。
PUT /order/_settings { "index.search.slowlog.threshold.query.warn": "1s", "index.search.slowlog.threshold.fetch.warn": "1s", "index.search.slowlog.level": "warn" }- 在 Kibana 的监控页面看 Search 的 p99、p95延迟曲线,如果某个时间点开始曲线骤升,多半是有人触发了深分页慢查询。
- 检查协调节点的堆内存曲线。深度分页引起的OOM或频繁GC,最开始都体现在堆内存的“上涨-不回落”模式上。你可以用
GET /_nodes/stats/jvm观察heap_used_percent以及 GC 次数。
一旦确认是某条深分页查询导致的,直接给这个查询加限制(限定窗口/限制页号),不要靠事后调参维护集群稳定。事后处理只是救火,事前在设计查询语义时就把分页模式定下来才是治本。
4.4 Scroll 上下文泄漏的检查与清理
如果你已经在用Scroll,但没注意关闭,定时任务每次跑完都会留下一批 scroll 上下文。检查方式:
GET /_nodes/stats/indices/search响应里看open_contexts数量。如果这个数字持续增长,而且你的任务并没有同时那么多,那基本就是上下文泄漏了。
清掉所有未关闭的scroll上下文:
DELETE /_search/scroll/_allJava代码里正确关闭写法(try-with-resources或者finally块):
try { // 循环拉取数据 } finally { ClearScrollRequest clearRequest = new ClearScrollRequest(); clearRequest.addScrollId(scrollId); restHighLevelClient.clearScroll(clearRequest, RequestOptions.DEFAULT); }4.5 批次大小到底设多少合适
Scroll 和 Search After 的 size 不是越大越好。size设置太大,单次请求返回的数据量会变大,网络和反序列化开销跟着涨;size太小,请求次数变多,吞吐量受限。
我自己的经验值:
- Scroll 批量导出场景,size 设置在 1000~5000 之间比较合适。取决于单条文档大小,如果文档里有大文本字段,建议压到1000;如果全是短字段,5000也能接受。核心限制是ES默认
max_result_window不影响 scroll 和 search_after,所以size大一点不会触发那根红线,但内存压力还是有的。 - 前端列表加载更多的场景,size 一般 10~50 就够了,后端不要给得太大。
- 使用 Search After 做同步任务时,size 可以到 500~1000,按每条文档平均1KB估算,500条就是500KB,内存和网络都还好。
另外一个容易被忽略的点:如果配合 Scroll导出大量数据,尽量使用_source过滤,只拉需要的字段,可以显著降低响应体大小和GC压力。
4.6 被热词坑过:非分页缓冲池和非分页缓冲池占用
这个标题相关热词里有个“非分页缓冲池占用很高怎么解决”,虽然不是ES分页的核心话题,但它在搜索ES分页问题时经常一起出现,我也顺手记录一下。
在Linux上跑ES时,如果发现free命令显示 cached/buffers 占用很高,这是正常现象。ES重度依赖文件系统缓存,Lucene 的段文件都希望被缓存到 Page Cache 里,这样查询不用每次都做物理磁盘IO。buff/cache占用高通常不需要慌张,反而是机器内存没有真正利用上的时候值得关注。
但有一个场景需要警惕:如果buff/cache始终满,同时磁盘IO持续很高,这可能说明索引段文件太大、内存不够用,或者查询都在扫全量数据。排查思路分几步:先看iostat确认读写IO,再看elasticsearch.log里有没有 merge 或 flush 相关的慢日志,再分析查询是否每次都在深层翻页导致大量page cache吞吐。大多数情况下,问题不在ES把内存吃满了,而在于你的查询模式让它无法有效利用缓存,比如大范围扫全表式的查询、不分片裁剪的聚合、以及慢查询堆积导致并发IO放大。
如果你确认是ES对文件系统缓存需求过高,而机器内存确实紧张,能做的优化通常包括:减少分片数避免重复读取段文件、把经常做范围查询的字段改成更紧凑的编码、给数据量大的热索引做 rollover 压缩,以及从业务侧限制深分页查询并发量。盲目清drop_caches没用,因为清了之后 Lucene 重新缓存反而更慢。
5. 场景化选型:到底什么时候用哪种分页
很多人在后台问我,能不能直接给一个决策依据。我整理了一张基于业务场景的选型逻辑,可以照着套:
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 前端列表页,自动翻页到几十页以内 | from+size | 简单直观,能高效缓存LRU排序结果;前端限制页码即可 |
| 前端信息流/搜索页,“点击加载更多” | Search After | 支持实时新数据,性能稳定,不会越翻越慢 |
| 后端定时同步、数据导出、全量比对 | Scroll 或 PIT+Search After | 需要游标顺序读取,避免分页窗口限制 |
| 对账、报表,要求翻页过程中数据视图一致 | PIT + Search After(7.10+) | 在某个时间点固化了索引视图 |
| ES低于7.10,但需要一致性视图 | Scroll | 虽然占用上下文,但快照语义最接近需求 |
还有两个特殊场景要提一下。
第一种:需要跳页的深度分页。比如一个后台管理表格,用户确实需要从第10000页直接跳到第20000页,这时候游标类方案都不可用。我的建议是换个思路:这种深跳页需求本身就不应该由ES直接扛。你可以在业务层加筛选条件缩小范围,提前聚合出符合条件的数据量,把翻页深度限制在可控范围内。如果实在必须支持,那就接受性能损耗,把max_result_window调高,但配合限流和超时保护,并且单独隔离这个接口,避免影响其他查询。
第二种:给用户提供搜索词命中统计的场景。比如“搜索”结果的关联词展示需要全量聚合结果而不是分页数据,这种其实也不该用分页,而应该用复合聚合(composite aggregation)或者直接离线预计算。分页是给列表页用的,别用它来干“取全部”的活,方法论错了再换任何分页方案都没用。
6. 我踩过的几个坑,想再说一遍
最后聊几个不太容易被文档提到的细节,都是我实际踩过之后才想明白的,给你省点时间。
第一个是Search After 的第一页和后续页要保证 sort 字段一致。我见过有人第一页只按时间排序,第二页突然也想加_id做稳定排序,结果查询报错,或者更隐蔽一些,返回了重复数据。标准做法是后端封装统一SearchSourceBuilder,不要让前端传排序字段。
第二个是Search After 的游标值类型千万别搞错。比如amount字段在ES里是keyword类型,那么排序结果值是字符串"2999",你拿到Java里解析成Integer再传回去,就会报错。建议hit.getSortValues()取到什么就原样传回给前端,前端下次带过来时保持原始类型。
第三个是Scroll第一次查询上下文创建的开销非常大,别用Scroll做高频短查询。如果你在循环里每秒创建10个Scroll,每次只需要几百条数据,那等于自己制造GC压力。正确用法是单个Scroll拉完所有数据,或者直接用Search After。
第四个是ES 8.x开始,类型概念消失,但分页层面的行为没有变化。如果你用的是新客户端elasticsearch-java,API风格变了,但分页原理和选型逻辑一模一样,不要把新旧客户端的差异理解成分页机制的差异。
第五个是不要把 Scroll 和 Search After 混着用。两者的游标格式完全不同,Scroll用的是scroll_id,Search After用的是排序值数组,你在同一个服务里要做封装隔离,不然维护成本会翻倍。
我个人的经验是,新项目里默认使用 Search After 或 PIT + Search After,遇到真正大批量导出的场景才单独用 PIT,并且给PIT设置合理的 keep_alive 并强制释放。把分页这件事理解为“数据访问模式的匹配问题”,而不是“加参数调整就能搞定的配置问题”,很多ES踩坑都能提前规避。分页上限10000不是ES的缺陷,而是一个提醒——它在告诉你,业务设计该停一下,认真考虑你到底需要什么样的数据访问方式了。