ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Elasticsearch深分页限制破解:三种方案对比与实战

Elasticsearch深分页限制破解:三种方案对比与实战

Elasticsearch 搜索数量不能超过10000的解决方案

但凡用 Elasticsearch 做过搜索或者导出功能的同学,大概率都撞上过这面墙:明明索引里有几十万条数据,一个match_all查下去,hits.total却只给你 10000,再往后翻就报Result window is too large的错。第一次遇到的时候我也挺懵,查了一下文档才发现是index.max_result_window的默认值在作怪。这其实不是 ES 在故意给你使绊子,而是它在用这个限制保护集群的内存和响应速度。但业务可不管这些,产品经理一句“全量导出”,你就得老老实实想办法绕过去。

这篇文章我就把这几年来用过的、踩过的坑整理成一套完整的方案。不是单纯告诉你“调参数就行”,而是把为什么会有 10000 这个数、各种方案分别适合什么场景、参数怎么算、代码怎么写,一次讲清楚。不管你是刚接触 ES 的新手,还是已经被深分页折磨过的老手,这篇应该都能帮你省下不少试错的时间。

1. 为什么搜索数量被锁死在10000

1.1 从深分页的代价说起

ES 的搜索本质上是一个“分散收集”的过程。一个索引通常有多个分片,你发一个查询请求,协调节点会把请求广播到所有分片,每个分片各自完成排序,然后只返回自己排名靠前的from + size条结果给协调节点,协调节点再对这些结果做全局排序。

问题就出在这里。如果from是 100000,协调节点就必须先从每个分片取回 100000 条文档,哪怕最终只需要 10 条。分片数量越多,这个中间结果集就越庞大,内存开销呈线性甚至超线性增长。在深分页的情况下,整个集群的资源会被这种无意义的计算白白吃掉。

可以粗算一笔账:假设索引有 5 个分片,查询from=100000, size=10,那么协调节点需要接收 5 × 100000 = 500000 条文档,每条文档就算只保留_id、_score和排序字段,也有几百字节,这一轮下来就是几百 MB 的内存暂用。如果业务 QPS 再高一点,协调节点的堆内存直接被撑爆不是玩笑。

所以 ES 提供了max_result_window这个保护机制。默认 10000,也就是说from + size的最大值不允许超过 10000。它的本质是:默认场景下,ES 只支持你在前 10000 条结果里翻页,再深就不玩了。

1.2 实际场景中谁会被这个限制卡住

很多人以为“搜索”最多就看前几页,10000 条完全够用。但在真实业务中,撞墙的场景远比想象中多:

  • 后台管理系统的数据导出:运营要导出一份半年内的订单明细,总量可能 20 万条,但导出接口基本都是一个scroll或者search_after就能解决的事。
  • 定时任务的全量同步:把 ES 里的数据同步到数仓、或者重灌另一个索引,动不动几十万上百万条。
  • 业务上的“下一页”遍历:某些检索产品的翻页深度确实能超过几百页。
  • 聚合分析的前置查询:先查出一批文档 ID 再做后续处理,为了避免 deep pagination,很多团队直接把size设成 Integer.MAX_VALUE,结果就是直接报错。

不管是哪种情况,解决思路本质上只有两类:第一类是把窗口调大,让 ES 允许你翻得更深;第二类是换一种遍历方式,压根不走深分页。下面挨个说。

2. 方案横向对比:每个思路的优缺点和适用场景

2.1 直接调大 index.max_result_window

最直观的办法。一条命令就能搞定:

PUT /your_index/_settings { "index.max_result_window": "1000000" }

也可以用通配符批量设置:

PUT /my_logs_*/_settings { "index.max_result_window": "1000000" }

这个方案的优点是简单、立即生效、不需要改代码,适合某些紧急情况下的快速止血。但缺点也非常明显:它只是把“墙”挪远了,并没有消除深度分页的内存开销。如果你把窗口调到 100 万,一旦有查询真的翻到 50 万页,协调节点的内存照样会被瞬间打满,整个集群可能因此进入 GC 长暂停甚至宕机。

我的建议是:这个方案只适合“临时应急”或者“明确知道数据量很小”的场景。比如一个索引上限就是两万条,那调到 50000 完全没毛病。如果是用来解决全量导出的问题,千万不要靠它硬撑——你这是在拿集群的稳定性做赌注。

2.2 使用 Scroll 游标

Scroll 是 ES 专门为“遍历全部数据”设计的 API,核心思路是:第一次查询时,ES 为这个查询生成一个快照游标,之后通过游标 ID 分批拉取数据,不再重复排序计算。

# 第一次请求,返回 _scroll_id POST /your_index/_search?scroll=1m { "size": 1000, "query": { "match_all": {} } } # 后续每次通过 scroll id 拉下一批 POST /_search/scroll { "scroll": "1m", "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gB..." }

Scroll 的优点很明显:它是官方设计的遍历方案,性能稳定,不会因为数据总量大而内存失控。但它也有几个让人头疼的副作用:

  • 生成快照是有成本的,如果索引在 scroll 期间发生写入、删除、更新,scroll 查询到的是快照建立那一刻的数据,不是“实时”的。
  • 游标会话需要占用服务端资源,超过scroll参数指定的时间后游标会被自动清理。用完还必须显式删除游标,否则资源泄漏。
  • 因为不支持跳页,你只能从头拉到尾,所以它根本不适合用在“用户翻页浏览”的交互场景里。

结论是:Scroll 最适合“一次性全量导出”“数据同步”“重索引”这类离线任务。当年我做某系统数据迁移,用 scroll 配合多线程并发消费,一次性搬了接近 200 万条数据,整个过程非常稳。但如果你是要做“支持跳到任意页”的后台列表,scroll 帮不了你。

2.3 使用 Search After 方案

Search after 是目前社区公认的“分页理想之选”。它的设计思路是:记录上一页最后一条数据的排序值,下一页查询时以这个排序值为起点,向后查找。典型的用法如下:

GET /your_index/_search { "size": 10, "query": { "match_all": {} }, "sort": [ { "id": "asc" }, { "_id": "asc" } ] }

拿到最后一条结果后,把 sort 数组里的值填进下一次请求:

GET /your_index/_search { "size": 10, "query": { "match_all": {} }, "search_after": [100024, "doc_id_value"], "sort": [ { "id": "asc" }, { "_id": "asc" } ] }

Search after 的原理是:不通过from来定位偏移量,而是拿取上一页末尾的排序值作为条件,直接让 ES 从对应位置开始扫描。这样每次查询只需获取下一页的数据,协调节点不需要保留前面所有页的中间结果,内存开销和查询深度没有直接关系。

它的优势非常契合交互式分页:稳定、高效、不占额外资源、能看到实时数据变化。缺点同样是无法自由跳页——你想从第 1 页直接跳到第 100 页,是做不到的,必须一页页顺着走。

那什么时候用 search_after 最合适?数据量几十万到上千万的后台列表、带“加载更多”的 C 端信息流、以及所有需要并行遍历数据的场景。我自己现在写后台列表基本直接默认 search_after,不再用 from + size。

2.4 顺带说一句 Track Total Hits

很多同学还遇到过另一个现象:hits.total显示为 10000,但数据其实远不止这些。这跟max_result_window是两码事,它是track_total_hits参数控制的。当它为默认的false时,ES 只精确统计前 10000 条命中数,超过就显示 10000。如果业务上需要知道全量命中数,可以在查询时设置:

GET /your_index/_search { "track_total_hits": true, "query": { "match_all": {} } }

注意:开启后会产生额外的统计开销,如果索引特别大、命中数量特别多,这个计数过程本身会消耗一定性能。建议只在“必须展示精确总数”的页面上开,并且配合size限制使用,不要和深分页同时开。

3. 深入剖析:从分页原理到 Search After 的完整实操

3.1 为什么排序字段决定了 Search After 的成败

Search after 依赖排序值的稳定性。如果排序字段存在重复值,那基于这个重复值的“起点定位”就会出问题:同一页数据可能出现重复或丢失。规避的方法是保证排序组合“全局唯一”。

最稳的组合是:一个业务主键(如自增 id 或时间戳)+_id兜底。_id是文档的唯一标识,天然不可能重复。只要业务主键相同,就靠_id来打破平局,保证每一页结果的顺序是严格确定的。

这里有个细节:如果你用_id做排序字段,ES 内部对_id的排序走的是keyword字典序,不是你以为的“数字序”。如果你的主键是纯数字的字符串,排序结果可能是 “1002”“1003”“999” 这种,和你预期的数字顺序不一致,需要留意。解决办法有两种:要么在 mapping 里新增一个long类型的字段存自增数字主键,要么设计排序时用日期 + 自增数字两个字段组合。

3.2 实操:用 Python 封装一个通用 Scroll 导出工具

数据导出的场景,我推荐直接用 scroll 来写。下面是一个封装好的通用导出函数,实测在数据量 50 万级别的索引上跑得很稳:

from elasticsearch import Elasticsearch, helpers import time class ESExporter: def __init__(self, hosts, index, query=None, batch_size=5000): self.es = Elasticsearch(hosts) self.index = index self.query = query or {"query": {"match_all": {}}} self.batch_size = batch_size def export_all(self): total = 0 start = time.time() resp = self.es.search( index=self.index, body=self.query, scroll="5m", size=self.batch_size, _source=True ) scroll_id = resp.get("_scroll_id") try: while resp["hits"]["hits"]: batch_docs = resp["hits"]["hits"] total += len(batch_docs) # 在这里把 batch_docs 写进文件、数据库或消息队列 self._process_batch(batch_docs) resp = self.es.scroll(scroll_id=scroll_id, scroll="5m") finally: self.es.clear_scroll(scroll_id=scroll_id) print(f"导出完成,共 {total} 条,耗时 {time.time() - start:.2f}s") def _process_batch(self, docs): # 示例:打印前 3 条数据的 id for doc in docs[:3]: print(doc["_id"])

几个容易踩的注意点:

  • scroll 参数的时长设置要合理。官方推荐“能覆盖两个批次处理间隔”即可,比如 5 分钟处理 5000 条,那设置5m就够。设太长会占着服务端资源不释放。
  • 每一批处理完,一定要记得用clear_scroll清理游标。虽然游标超时会自动失效,但在高并发场景下不主动清理会积累大量空闲游标,导致内存碎片增加。
  • 如果是要并发消费,不要在一个线程里串行 scroll,可以开多个 worker 线程,每个线程独立使用一个 scroll_id,这样能显著提升吞吐。我在搬数据时开了 8 个线程,速度大概提升了 4 倍左右。

3.3 实操:用 Java 实现 Search After 的深分页列表查询

国内用 Java 写 ES 查询的团队还是最多的,我直接给一套完整的 Java 实现:

public List<Map<String, Object>> searchAfterQuery( RestHighLevelClient client, String index, long lastId, String lastDocId, int size) throws IOException { SearchRequest request = new SearchRequest(index); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchAllQuery()); sourceBuilder.size(size); sourceBuilder.fetchSource(true); // 排序组合:用数字 id 做主排序,_id 做唯一性兜底 sourceBuilder.sort(new FieldSortBuilder("data_id").order(SortOrder.ASC)); sourceBuilder.sort("_doc"); if (lastId > 0) { sourceBuilder.searchAfter(new Object[]{lastId, lastDocId}); } request.source(sourceBuilder); SearchResponse response = client.search(request, RequestOptions.DEFAULT); List<Map<String, Object>> list = new ArrayList<>(); for (SearchHit hit : response.getHits().getHits()) { Map<String, Object> map = hit.getSourceAsMap(); map.put("_doc_id", hit.getId()); list.add(map); } return list; }

注意我用的是sort("_doc")而不是sort("_id")。_doc是 ES 内置的“输入顺序”排序,它不额外计算字段值,性能极高;而_id排序走字段反转索引,需要额外内存。两者选谁,取决于你的场景:如果数据几乎只追加不更新,_doc完全够用;如果会有排序波动风险,就回到_id兜底方案。

调用示例:

List<Map<String, Object>> page = searchAfterQuery(client, "order_index", 0, "", 20); String lastDocId = (String) page.get(page.size() - 1).get("_doc_id"); Long lastDataId = (Long) page.get(page.size() - 1).get("data_id"); // 下一次查询: page = searchAfterQuery(client, "order_index", lastDataId, lastDocId, 20);

3.4 核心参数的计算与选择逻辑

很多同学会问:size到底设多少合适?scroll 的batch_size呢?这个没有什么银弹,但可以给一套参考逻辑:

  • search_after 场景:size 设 10~50 都行,关键是排序字段稳定。如果信息流类产品,一次加载 20 条用户体验最好。
  • scroll 全量导出场景:批量大小推荐在 1000~5000 之间。批量太小,网络往返次数多,吞吐上不去;批量太大,单批次响应体过大,遇到脏数据可能导致 JSON 解析异常,重试代价高。我在生产环境压测过一个索引:批量 5000 条时吞吐最高、垃圾回收最平缓;批量调到 20000 时,反而触发了好几次老年代 GC,整体耗时反而上升。
  • 内存预估:一个粗略但实用的公式是size × 单条文档平均大小 × 分片数。如果单条文档 2KB、分片 5 个、size 5000,那么一个批次协调节点大约要暂用 50MB 内存。多线程并发时要按照线程数翻倍估算。

3.5 生产级搜索封装思路

说实话,把从 from+size 切换到 search_after 的过程做成“透明无感”,才是工程上最优雅的做法。我在一个通用查询服务里封装了一个分页工具,对外暴露PageRequest对象:

public class PageRequest { private int pageSize; private Object[] searchAfter; private boolean useSearchAfter; // 第一页:useSearchAfter=false,pageSize=20 // 下一页:useSearchAfter=true,searchAfter=上一页最后一个排序值 }

代码层面,从第一页翻到第一百页的时候,前端传回来的是一个“游标”字符串,后端把它反序列化成排序数组塞进 search_after 里。这样对前端而言和普通分页没区别,但后端从底层规避了深分页的问题。具体做法是:把上一页的sort数组用 Base64 编码后作为下一页的请求参数,这样既不需要用户记住页码,也天然支持无限深度翻页。

4. 常见问题与排查技巧实录

4.1 调大 max_result_window 后查询仍然报错

这种情况大概率是目标索引的设置没生效。一个容易忽略的点是:ES 7.x 之后索引名支持通配符,但通配符只能匹配完整的索引名,不能匹配别名。如果你通过别名查询,设置却打在物理索引上,那查询依然会报错。排查步骤:

# 查看当前索引的实际设置 GET /your_index/_settings/index.max_result_window # 确认别名指向哪些物理索引 GET /_alias/your_alias

还有一种可能是你改了模板,但只对新索引生效。老索引的 setting 是创索引时写死的,必须单独更新。

4.2 Scroll 明明设置了超时时间,连接却一直报错

最常见的原因是 scroll 游标在服务端是独立于 HTTP 连接的,你设置scroll=1m后,如果两个批次之间处理耗时超过了 60 秒(比如批处理器里调用了外部接口很慢),游标已经过期,你再拿着旧的 scroll_id 去请求就报No search context found。排查思路:把 scroll 时间放宽到一个有余量的值,比如10m,或者你可以在批处理逻辑里做多线程并行处理,缩短单批耗时。

还有一个低级错误:同一个 scroll_id 被两个线程并发消费。ES 的 scroll 游标不是线程安全的,并发消费同一个游标会导致结果错乱或者抛异常。正确做法是每个线程初始化独立的 scroll 查询。

4.3 为什么 search_after 有时会漏数据或者重复

这个坑我帮同事排查过,最后定位到原因:业务方用了一个时间戳字段做排序,但这个字段在写入后会被更新,导致排序值发生“跨页”移动。第一条记录本来排在第 5 页末尾,下一秒被更新到第 1 页前部,search_after 基于旧排序值往后查,就会漏掉这种“排序值变小”的记录。

解决方案很明确:

  • 排序字段选择写入后不变的字段,比如create_time、自增主键。
  • 如果业务无法保证不可变,就要接受 search_after 的“弱一致性”,同时结合业务规则去规避(比如只查三天前的数据,三天内不允许变动)。
  • 兜底方案是加一个_id参与排序,至少保证文档不会因为平局而重复/丢失。

4.4 深度分页时内存飙升,要不要换方案

如果你还在用 from + size,并且翻到了四五万页,发现协调节点内存异常飙升,你第一步要做的不是加内存,而是立刻把查询改成 search_after。同时通过慢日志查看是不是有高成本查询:

PUT /your_index/_settings { "index.search.slowlog.threshold.query.warn": "5s", "index.search.slowlog.threshold.fetch.warn": "3s" }

开启慢日志后,观察一段时间,往往能发现不只是分页方式的问题,还有可能是 sort 字段没建 doc_values、或者查询里带了脚本排序——这些都会放大内存开销。

4.5 常见问题速查表

问题现象可能原因推荐处理方式
翻页超过 10000 直接报错max_result_window 默认限制交互式分页用 search_after;全量导出用 scroll
total 一直显示 10000track_total_hits 未开启需要精确总数时开启,但要评估性能开销
scroll 中途报 context not found游标超时调大 scroll 时间,或减少单批处理耗时
search_after 结果有重复/遗漏排序字段不唯一或会变动增加“业务主键 + _id”双字段排序
from+size 深分页内存暴涨协调节点需要合并全量中间结果立即切换到 search_after,并评估查询成本
调大 setting 后仍报错别名/模板未覆盖到优先检查索引真实 setting 和别名指向

5. 我的最终建议与选型路径

如果是新项目,我的默认路径是这样的:

  • 普通业务列表、后台管理系统、运营平台 → 直接用search_after,不解释。
  • 全量数据导出、索引重建、数仓同步 → 直接用scroll,并且每批处理完清游标。
  • 不能改代码的临时救急 → 调max_result_window,同时评估风险并尽快补方案。
  • 没有深翻页需求、确认总量小 → 继续用 from+size,但建议 size 不超过 100。

我个人在实际操作中最深的体会是:不要在分页技术上纠结“哪种写法最牛”,而要先想明白你的场景是“用户看数据”还是“系统搬数据”。这两个场景对分页的要求完全不同,选型错了再优化也都别扭。还有一个小技巧:无论用 scroll 还是 search_after,都要在索引上精心设计排序字段,最好单独加一个data_id作为稳定排序字段,别省这一步。后面你会感谢自己当时加了它。

RELATED READING

延伸阅读

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