做过数据导出或者全量同步的同学基本都遇到过这个场景:索引里有几百万条数据,需要一次性拉出来做迁移、比对或者离线分析。这时候如果还坚持用from加size的方式翻页,翻到深处的性能会急剧下降,甚至直接被Elasticsearch拒绝。Scroll API正是为这种需要持续遍历大量结果的场景设计的,它相当于在服务端维护了一个数据的快照游标,让客户端可以分批把数据取回来。

一、为什么from加size翻页撑不住深分页
先看普通分页的问题所在。Elasticsearch是分布式架构,一个索引通常被切分成多个分片。当你执行一次from=10000, size=10的查询时,协调节点需要向每个分片都请求前10010条文档,然后在节点层面做归并排序,再丢弃前面的10000条,只返回最后10条。分页越深,每个分片需要取回并排序的数据就越多,内存和网络开销呈线性甚至更糟糕的增长。
正因为如此,Elasticsearch默认设置了max_result_window=10000这个保护阈值,一旦from加size超过这个值,查询会直接报错:
{
"error": {
"root_cause": [
{
"type": "search_phase_execution_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000]"
}
]
}
}虽然可以通过修改索引设置临时调大这个窗口,但这只是掩盖问题而不是解决问题,生产环境强烈不建议这么做。正确的做法是根据场景选择scroll或者后面会提到的search_after。scroll的核心思路是:第一次查询时让服务端生成一个快照,后续只需要拿着快照ID继续取数据,不需要重复执行完整的搜索流程,这样每一批的代价都是稳定的。
二、Scroll API的工作原理与参数详解
scroll的工作机制可以概括为三步。第一步发起初始搜索,在请求中带上scroll参数指定快照的存活时间,比如1m表示1分钟。服务端会为这次搜索创建一个search context(搜索上下文),它是结果集的一份快照视图,并把一个scroll_id返回给客户端。
第二步循环取数,客户端每次携带scroll_id和新的存活时间调用_search/scroll接口,拿到下一批数据,直到返回的hits数组为空表示遍历完成。第三步清理上下文,调用_search/scroll的DELETE方法显式释放资源。这里要特别注意scroll参数的含义:它不是整个遍历过程的总时长,而是两次请求之间允许的最大间隔。如果两次取数之间的间隔超过了这个时间,search context就会失效,再拿旧的scroll_id去请求会报错。
还有一个容易被忽视的点:快照的一致性。scroll基于的是初始搜索时刻的数据视图,遍历期间的写入、更新、删除对这次scroll是不可见的。这对导出场景通常是好事,能保证导出数据的相对一致,但如果你的业务需要实时反映最新变更,scroll就不合适了。另外,search context是占用集群资源的,默认每个节点能持有的context数量有限,不及时释放会堆积并拖慢集群。
三、完整调用示例
先用curl看一下最基本的流程。初始查询,指定快照存活1分钟,每批返回1000条:
curl -X POST "localhost:9200/my_index/_search?scroll=1m" \
-H 'Content-Type: application/json' \
-d '{
"size": 1000,
"query": {
"match_all": {}
},
"sort": ["_doc"]
}'返回结果中会包含_scroll_id字段。后续每次用这个ID继续取下一批,注意请求体里的scroll参数每次都要带,相当于给快照续期:
curl -X POST "localhost:9200/_search/scroll" \
-H 'Content-Type: application/json' \
-d '{
"scroll": "1m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1Qg=="
}'遍历结束后主动清理,这一步千万别省:
curl -X DELETE "localhost:9200/_search/scroll" \
-H 'Content-Type: application/json' \
-d '{"scroll_id": ["DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1Qg=="]}'再看Java的写法,这里以RestHighLevelClient为例,展示一个完整的遍历模板,包含循环取数和最终的清理动作:
SearchRequest searchRequest = new SearchRequest("my_index");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
sourceBuilder.size(1000);
// 按_doc排序性能最好,纯遍历场景推荐使用
sourceBuilder.sort("_doc", SortOrder.ASC);
searchRequest.source(sourceBuilder);
// 设置快照存活时间为60秒
searchRequest.scroll(TimeValue.timeValueMinutes(1));
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
String scrollId = response.getScrollId();
List<SearchHit> hits = Arrays.asList(response.getHits().getHits());
while (hits != null && !hits.isEmpty()) {
for (SearchHit hit : hits) {
// 处理每条文档,hit.getSourceAsString()可拿到JSON字符串
process(hit.getSourceAsString());
}
// 继续取下一批
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
scrollRequest.scroll(TimeValue.timeValueMinutes(1));
response = client.scroll(scrollRequest, RequestOptions.DEFAULT);
scrollId = response.getScrollId();
hits = Arrays.asList(response.getHits().getHits());
}
// 遍历结束,显式释放search context
ClearScrollRequest clearRequest = new ClearScrollRequest();
clearRequest.addScrollId(scrollId);
client.clearScroll(clearRequest, RequestOptions.DEFAULT);代码里有几个细节值得强调。一是sort("_doc"),这是按照文档在索引中的内部顺序遍历,省去了排序开销,是全量导出的推荐姿势;二是处理逻辑要尽量轻量,如果单批处理时间可能超过keep_alive,要么调大存活时间,要么把数据先落队列异步处理;三是清理动作放在finally块里更稳妥,保证异常退出时也能释放资源。
四、scroll、from-size与search_after该怎么选
三种方式各有明确的应用边界,用一张表来对比:
| 方式 | 适用场景 | 深分页能力 | 实时性 | 并发开销 |
|---|---|---|---|---|
| from + size | 常规列表页、浅分页 | 受max_result_window限制 | 每次查询实时 | 页越深开销越大 |
| scroll | 全量导出、离线迁移、批量重建索引 | 可遍历全量数据 | 快照,不反映后续变更 | 占用search context,需及时释放 |
| search_after | 高并发实时深翻页、滚动加载 | 无限制但只能顺序翻页 | 每次查询实时 | 开销稳定 |
简单来说,如果是做一次性导出或者数据同步,选scroll;如果是用户界面上的无限滚动加载,需要实时看到新增数据,选search_after;普通的第几页展示,老老实实用from加size就够了。还有一点需要注意,官方在较新版本中推出了滚动API的替代品——sliced scroll,它可以把一个大任务切成多个分片并行遍历,导出速度能提升数倍,大数据量场景下非常值得使用。
最后总结几个常见的踩坑点:忘记调用clear_scroll导致search context堆积,可以通过GET _nodes/stats/indices/search观察open_contexts数量;keep_alive设置太短,处理慢的任务中途失败,可以把时间调到5m甚至更长,代价只是context多保留一会儿;以及误以为scroll期间能看到新写入的数据,实际它始终是初始查询时刻的快照。掌握这些细节,scroll API就能稳定地支撑各类大数据量的遍历任务了。
ElasticsearchScroll API深分页修改时间:2026-09-04 10:13:13