ES深度分页查询优化
在 Elasticsearch的日常应用中,分页查询是高频需求——无论是用户浏览商品列表、后台系统查看数据记录,还是数据分析中的批量提取,都离不开分页。但随着数据量增长,当用户翻到几十甚至上百页后,查询响应会急剧变慢,甚至出现集群崩溃风险。
# 一、深度分页:为什么会成为性能陷阱?
# 1.1 什么是深度分页?
通常我们将「页码较深的分页查询」称为深度分页。例如,用户查询第 100 页数据(from=990,size=10),或直接跳转到第 500 页,都属于深度分页场景。此时,ES 的查询性能会显著下降,甚至触发内置保护机制。
# 1.2 from+size:浅分页的“甜蜜陷阱”
日常开发中,我们习惯用 from+size 实现分页,语法简单直观:
{
"query": { "match_all": {} },
"from": 10, // 跳过前10条
"size": 20 // 返回20条
}
2
3
4
5
这种方式在浅分页(如前 10 页)或数据量较小时表现良好,但在深度分页场景下会暴露严重问题。
# 1.3 分布式架构下的聚合陷阱
ES 是分布式存储,索引数据分散在多个分片上。当使用 from+size 时,查询流程如下:
- 分片查询:每个分片需返回
from+size条数据(而非size条)。例如from=9000,size=20时,每个分片需返回 9020 条数据; - 协调节点聚合:协调节点收集所有分片的
9020条数据,在内存中合并、排序,最终截取第 9000~9020 条返回。
随着 from 增大,每个分片返回的数据量剧增,导致:
- 网络传输量暴增(如 3 个分片时,
from=10w需传输 3*(10w+size) 条数据); - 协调节点内存消耗飙升,排序耗时指数级增长;
- 接口 P90 响应时间大幅延长(90% 的请求响应变慢)。
# 1.4 官方的“保命机制”:max_result_window
为避免深度分页拖垮集群,ES 默认设置 max_result_window=10000(可通过索引设置修改)。当 from+size 超过该值时,ES 会直接报错:
{
"error": {
"root_cause": [
{ "type": "query_phase_execution_exception", "reason": "Result window is too large..." }
]
}
}
2
3
4
5
6
7
这意味着,即使你想“硬扛”深度分页,ES 也会主动限制。
# 二、深度分页解决方案全解析
针对深度分页问题,ES 提供了三种方案,各有适用场景。
# 方案一:from+size 浅分页(业务限制法)
核心思路:从业务层面限制分页深度,避免触发 max_result_window 和性能问题。
# 适用场景
- 数据量较小(总数据量 < 10w);
- 需支持跳页(如用户直接点击第 5 页);
- 对实时性要求高(如最新数据查询)。
# 实现方式
- 保留
from+size语法,但限制最大页码(如仅允许查询前 100 页); - 前端展示时截断总条数(避免用户尝试翻到过深页码)。
代码示例:
// Java 后端:限制 from 不超过 990(即第 100 页,size=10)
int maxFrom = 990;
int from = (page - 1) * size;
if (from > maxFrom) {
throw new RuntimeException("最多支持查询前 100 页");
}
SearchRequest esRequest = new SearchRequest(indexName);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery())
.sort("id", SortOrder.ASC)
.from(from)
.size(size);
esRequest.source(sourceBuilder);
// 前端:截断总条数,避免显示过深页码
this.total = response.total > 1000 ? 1000 : response.total;
// 假设 size=10,1000 对应 100 页
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 优缺点
- 优点:实现简单,支持跳页,实时性好;
- 缺点:无法支持深度分页(如超过 100 页),依赖业务限制。
# 方案二:Scroll API(快照分页法)
核心思路:创建数据快照,通过 scroll_id 分批获取数据,避免重复计算。
# 适用场景
- 批量数据导出(如导出 10w 条历史数据);
- 全量数据遍历(如数据迁移、索引重建);
- 不要求实时性,且无需跳页(仅支持顺序分页)。
# 实现原理
- 创建快照:首次查询时,ES 在所有分片上创建数据快照(查询时的静态数据,新增/修改的数据不会被包含),并返回
scroll_id; - 分批获取:后续分页通过
scroll_id从快照中获取下一页数据,scroll_id有效期可设置(如 1 分钟); - 清理资源:查询结束后需主动清理
scroll_id,避免占用集群内存。
# 代码示例
// 1. 初始化 Scroll(设置快照有效期为 1 分钟)
Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1));
SearchRequest request = new SearchRequest(indexName);
request.scroll(scroll);
// 2. 首次查询,生成 scroll_id
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery())
.sort("id", SortOrder.ASC)
.size(100); // 每次返回 100 条
request.source(sourceBuilder);
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
String scrollId = response.getScrollId();
SearchHit[] hits = response.getHits().getHits();
// 3. 循环获取下一页
List<HKAddressDTO> result = new ArrayList<>();
while (hits != null && hits.length > 0) {
// 处理当前页数据
for (SearchHit hit : hits) {
result.add(JSON.parseObject(hit.getSourceAsString(), HKAddressDTO.class));
}
// 用 scroll_id 获取下一页
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
scrollRequest.scroll(scroll); // 重置有效期
response = esClient.scroll(scrollRequest, RequestOptions.DEFAULT);
scrollId = response.getScrollId();
hits = response.getHits().getHits();
}
// 4. 清理 scroll_id(关键!避免内存泄漏)
ClearScrollRequest clearRequest = new ClearScrollRequest();
clearRequest.addScrollId(scrollId);
esClient.clearScroll(clearRequest, RequestOptions.DEFAULT);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 优缺点
- 优点:支持海量数据分页,性能稳定(基于快照,无需重复计算);
- 缺点:
- 数据非实时(快照创建后的数据变更不生效);
- 不支持跳页(只能顺序往后翻);
scroll_id占用内存,需及时清理,否则可能导致节点宕机。
# 方案三:Search After(实时游标分页法)
核心思路:基于上一页最后一条数据的排序值(游标)定位下一页,彻底摆脱 from 的限制,是 ES 5+ 推荐的深度分页方案。
# 适用场景
- 深度分页且需实时性(如用户无限滚动加载数据);
- 仅需顺序分页(不支持跳页,但可实现前后翻页);
- 数据量极大(超过 10w 甚至 100w)。
# 实现原理
- 排序字段要求:必须指定唯一且稳定的排序字段(如主键
id),避免排序冲突导致分页错乱; - 游标定位:首次查询时,记录最后一条数据的
sort值(游标);下一页查询时,通过search_after参数传入该游标,ES 直接从游标位置往后查询; - 分布式协调:每个分片独立维护有序数据,协调节点将
search_after条件下发到分片,分片仅返回排序值大于游标的前size条数据,大幅减少传输和计算量。
# 代码示例(基础版:仅往后翻页)
List<HKAddressDTO> queryBySearchAfter(Object[] lastSortValue, int size) {
SearchRequest request = new SearchRequest(indexName);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 1. 构建查询(必须指定唯一排序字段)
sourceBuilder.query(QueryBuilders.matchAllQuery())
.sort("id", SortOrder.ASC); // 以 id 正序排序
// 2. 非首次查询时,设置上一页的最后一个游标
if (lastSortValue != null && lastSortValue.length > 0) {
sourceBuilder.searchAfter(lastSortValue);
}
// 3. 设置每页大小
sourceBuilder.size(size);
request.source(sourceBuilder);
// 4. 执行查询并处理结果
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
SearchHit[] hits = response.getHits().getHits();
List<HKAddressDTO> result = new ArrayList<>();
Object[] currentLastSortValue = null;
for (SearchHit hit : hits) {
result.add(JSON.parseObject(hit.getSourceAsString(), HKAddressDTO.class));
currentLastSortValue = hit.getSortValues(); // 更新当前页最后一个游标
}
// 返回数据和当前页最后一个游标(供下一页使用)
return result;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 进阶:实现往前翻页
Search After 本身不支持跳页,但可通过“保存首尾游标 + 反向排序”实现往前翻页:
- 保存游标:每次查询时,记录当前页第一条和最后一条数据的
sort值(如firstSort和lastSort); - 往前翻页:当用户点击“上一页”时,以
firstSort为游标,使用反向排序查询size条数据。
示例:
当前页数据排序值为 [20, 21, ..., 30](正序),往前翻页时:
// 反向排序(id 倒序),以当前页第一条的 sort 值为游标
sourceBuilder.query(QueryBuilders.matchAllQuery())
.sort("id", SortOrder.DESC) // 倒序
.searchAfter(firstSortValue); // firstSortValue = 20
2
3
4
查询结果为 [19, 18, ..., 10],反转后即为上一页数据 [10, 11, ..., 19]。
# 优缺点
- 优点:
- 无
max_result_window限制,支持无限深度分页; - 基于实时数据(非快照),数据最新;
- 性能优异(仅传输当前页所需数据);
- 无
- 缺点:
- 不支持直接跳页(需顺序翻);
- 依赖排序字段的唯一性和稳定性;
- 往前翻页需额外开发逻辑(保存首尾游标)。
# 三、方案对比与选择建议
| 方案 | 适用场景 | 支持跳页 | 实时性 | 性能(深度分页) | 限制 |
|---|---|---|---|---|---|
| from+size | 浅分页、需跳页 | 是 | 实时 | 差(随深度下降) | 受 max_result_window 限制 |
| Scroll API | 批量导出、全量遍历 | 否 | 快照 | 优 | 占用内存,需清理 scroll_id |
| Search After | 深度分页、实时性要求高 | 否(可顺序翻) | 实时 | 优 | 依赖唯一排序字段 |
选择建议:
- 后台管理系统(需跳页,数据量小)→
from+size+ 页码限制; - 数据导出/全量同步 →
Scroll API; - 移动端无限滚动、海量数据分页 →
Search After。
# 四、总结
ES 深度分页的核心矛盾是分布式架构下的聚合成本。from+size 适合简单场景但受限于深度,Scroll API 适合批量处理但牺牲实时性,Search After 则是平衡实时性和深度分页的最优解。