ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

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

Elasticsearch深分页限制破解:三种方案对比与实战 Elasticsearch 搜索数量不能超过10000的解决方案但凡用 Elasticsearch 做过搜索或者导出功能的同学大概率都撞上过这面墙明明索引里有几十万条数据一个match_all查下去hits.total却只给你 10000再往后翻就报Result window is too large的错。第一次遇到的时候我也挺懵查了一下文档才发现是index.max_result_window的默认值在作怪。这其实不是 ES 在故意给你使绊子而是它在用这个限制保护集群的内存和响应速度。但业务可不管这些产品经理一句“全量导出”你就得老老实实想办法绕过去。这篇文章我就把这几年来用过的、踩过的坑整理成一套完整的方案。不是单纯告诉你“调参数就行”而是把为什么会有 10000 这个数、各种方案分别适合什么场景、参数怎么算、代码怎么写一次讲清楚。不管你是刚接触 ES 的新手还是已经被深分页折磨过的老手这篇应该都能帮你省下不少试错的时间。1. 为什么搜索数量被锁死在100001.1 从深分页的代价说起ES 的搜索本质上是一个“分散收集”的过程。一个索引通常有多个分片你发一个查询请求协调节点会把请求广播到所有分片每个分片各自完成排序然后只返回自己排名靠前的from size条结果给协调节点协调节点再对这些结果做全局排序。问题就出在这里。如果from是 100000协调节点就必须先从每个分片取回 100000 条文档哪怕最终只需要 10 条。分片数量越多这个中间结果集就越庞大内存开销呈线性甚至超线性增长。在深分页的情况下整个集群的资源会被这种无意义的计算白白吃掉。可以粗算一笔账假设索引有 5 个分片查询from100000, size10那么协调节点需要接收 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?scroll1m { 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, queryNone, batch_size5000): 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( indexself.index, bodyself.query, scroll5m, sizeself.batch_size, _sourceTrue ) 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_idscroll_id, scroll5m) finally: self.es.clear_scroll(scroll_idscroll_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 ListMapString, 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); ListMapString, Object list new ArrayList(); for (SearchHit hit : response.getHits().getHits()) { MapString, Object map hit.getSourceAsMap(); map.put(_doc_id, hit.getId()); list.add(map); } return list; }注意我用的是sort(_doc)而不是sort(_id)。_doc是 ES 内置的“输入顺序”排序它不额外计算字段值性能极高而_id排序走字段反转索引需要额外内存。两者选谁取决于你的场景如果数据几乎只追加不更新_doc完全够用如果会有排序波动风险就回到_id兜底方案。调用示例ListMapString, 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 生产级搜索封装思路说实话把从 fromsize 切换到 search_after 的过程做成“透明无感”才是工程上最优雅的做法。我在一个通用查询服务里封装了一个分页工具对外暴露PageRequest对象public class PageRequest { private int pageSize; private Object[] searchAfter; private boolean useSearchAfter; // 第一页useSearchAfterfalsepageSize20 // 下一页useSearchAftertruesearchAfter上一页最后一个排序值 }代码层面从第一页翻到第一百页的时候前端传回来的是一个“游标”字符串后端把它反序列化成排序数组塞进 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 连接的你设置scroll1m后如果两个批次之间处理耗时超过了 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全量导出用 scrolltotal 一直显示 10000track_total_hits 未开启需要精确总数时开启但要评估性能开销scroll 中途报 context not found游标超时调大 scroll 时间或减少单批处理耗时search_after 结果有重复/遗漏排序字段不唯一或会变动增加“业务主键 _id”双字段排序fromsize 深分页内存暴涨协调节点需要合并全量中间结果立即切换到 search_after并评估查询成本调大 setting 后仍报错别名/模板未覆盖到优先检查索引真实 setting 和别名指向5. 我的最终建议与选型路径如果是新项目我的默认路径是这样的普通业务列表、后台管理系统、运营平台 → 直接用search_after不解释。全量数据导出、索引重建、数仓同步 → 直接用scroll并且每批处理完清游标。不能改代码的临时救急 → 调max_result_window同时评估风险并尽快补方案。没有深翻页需求、确认总量小 → 继续用 fromsize但建议 size 不超过 100。我个人在实际操作中最深的体会是不要在分页技术上纠结“哪种写法最牛”而要先想明白你的场景是“用户看数据”还是“系统搬数据”。这两个场景对分页的要求完全不同选型错了再优化也都别扭。还有一个小技巧无论用 scroll 还是 search_after都要在索引上精心设计排序字段最好单独加一个data_id作为稳定排序字段别省这一步。后面你会感谢自己当时加了它。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表