核心概念
深度分页问题指的是当分页查询的页码很大时(如第 10000 页),ElasticSearch 需要在每个分片上获取并排序大量数据,导致内存消耗大、性能急剧下降的问题。
问题原因
1. From + Size 的工作机制
// 查询第 100 页,每页 10 条
GET /products/_search
{
"from": 1000, // (100 - 1) * 10
"size": 10,
"query": { ... }
}
执行流程
假设索引有 3 个分片:
Step 1: Coordinating Node 将查询分发到 3 个分片
Step 2: 每个分片返回 (from + size) = 1010 条数据
- Shard 0 → 1010 条
- Shard 1 → 1010 条
- Shard 2 → 1010 条
Step 3: Coordinating Node 汇总 3030 条数据
Step 4: 在内存中排序 3030 条数据
Step 5: 取出第 1001-1010 条,返回 10 条
丢弃:3020 条(浪费资源)
2. 深度分页的性能问题
内存消耗
分页参数:from=10000, size=10
分片数:5
每个分片需要返回:10010 条
总数据量:5 * 10010 = 50050 条
Coordinating Node 需要在内存中处理 50050 条数据!
CPU 消耗
- 大量数据的排序操作
- 序列化和网络传输开销
ES 默认限制
# 默认限制:from + size <= 10000
index.max_result_window: 10000
# 超过限制会报错
Result window is too large, from + size must be less than or equal to: [10000]
解决方案
方案一:Scroll API(快照 + 游标)
1. 适用场景
- 全量数据导出(如 ES 数据迁移)
- 非实时数据遍历(数据不会频繁变化)
- 不关心排序(按照索引顺序返回)
2. 工作原理
- 创建快照(Snapshot),固定数据视图
- 使用游标(Scroll ID)分批获取数据
- 数据一致性高,但不反映最新写入
3. 使用示例
// Step 1: 初始化 Scroll
SearchRequest searchRequest = new SearchRequest("products");
searchRequest.scroll(TimeValue.timeValueMinutes(1L)); // 快照有效期 1 分钟
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
sourceBuilder.size(1000); // 每批 1000 条
searchRequest.source(sourceBuilder);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
String scrollId = response.getScrollId();
// Step 2: 循环获取数据
while (true) {
SearchHit[] hits = response.getHits().getHits();
if (hits.length == 0) {
break; // 数据获取完毕
}
// 处理数据
for (SearchHit hit : hits) {
// 处理文档
}
// 获取下一批数据
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
scrollRequest.scroll(TimeValue.timeValueMinutes(1L));
response = client.scroll(scrollRequest, RequestOptions.DEFAULT);
}
// Step 3: 清理 Scroll 上下文
ClearScrollRequest clearRequest = new ClearScrollRequest();
clearRequest.addScrollId(scrollId);
client.clearScroll(clearRequest, RequestOptions.DEFAULT);
4. 优缺点
优点:
- 不受
max_result_window限制 - 性能稳定,适合大数据量导出
缺点:
- 占用服务器资源(维护快照上下文)
- 不适合实时查询(数据是快照时刻的)
- 不适合跳页查询(只能顺序遍历)
方案二:Search After(推荐)
1. 适用场景
- 深度分页查询(类似”加载更多”)
- 实时数据(反映最新写入)
- 需要排序
2. 工作原理
- 每次查询返回最后一条数据的排序值(sort values)
- 下次查询以该值为起点,继续获取
3. 使用示例
// Step 1: 第一次查询(无 search_after)
SearchRequest searchRequest = new SearchRequest("products");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
sourceBuilder.size(10);
sourceBuilder.sort("created_at", SortOrder.DESC); // 必须指定排序字段
sourceBuilder.sort("_id", SortOrder.ASC); // 添加唯一字段,保证排序稳定
searchRequest.source(sourceBuilder);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
SearchHit[] hits = response.getHits().getHits();
// 获取最后一条数据的 sort values
Object[] lastSortValues = hits[hits.length - 1].getSortValues();
// Step 2: 第二次查询(使用 search_after)
sourceBuilder.searchAfter(lastSortValues); // 从上次的位置继续
searchRequest.source(sourceBuilder);
response = client.search(searchRequest, RequestOptions.DEFAULT);
// Step 3: 继续翻页...
4. REST API 示例
// 第一次查询
POST /products/_search
{
"size": 10,
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" }
]
}
// 返回结果(最后一条数据的 sort values)
{
"hits": {
"hits": [
{
"_id": "100",
"_source": { ... },
"sort": [1698825600000, "100"] // 保存这个值
}
]
}
}
// 第二次查询(使用 search_after)
POST /products/_search
{
"size": 10,
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" }
],
"search_after": [1698825600000, "100"] // 从上次的位置继续
}
5. 优缺点
优点:
- 性能稳定,不受分页深度影响
- 实时数据,反映最新写入
- 无需维护快照上下文,资源占用少
缺点:
- 不支持跳页(只能上一页/下一页)
- 必须指定排序字段
方案三:调整 max_result_window(不推荐)
# 临时调整最大窗口
PUT /products/_settings
{
"index.max_result_window": 50000
}
缺点
- 治标不治本,只是延缓问题
- 内存和性能问题依然存在
- 增加 OOM 风险
方案四:业务层优化
1. 禁止深度分页
- 限制最大页码(如最多显示前 100 页)
- 类似 Google 搜索(不会显示第 1000 页)
2. 使用”加载更多”替代分页
- 移动端常见方案
- 结合 Search After 实现
3. 引导用户缩小搜索范围
- 通过筛选条件减少结果集
- 提供时间范围、分类等过滤器
方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Scroll | 全量导出、离线分析 | 不受限制、性能稳定 | 快照数据、占用资源、不支持跳页 |
| Search After | 深度分页、实时查询 | 实时数据、性能好、资源占用少 | 不支持跳页 |
| 调大窗口 | 临时应急 | 简单 | 治标不治本、风险大 |
| 业务优化 | 所有场景 | 根本解决问题 | 需要改变产品逻辑 |
最佳实践
1. 常规分页(前 100 页)
- 使用
from + size
2. 深度分页(页码很大)
- 使用
search_after - 前端改为”加载更多”
3. 全量导出
- 使用
Scroll API - 或使用 Logstash、Spark 等工具
4. 实时数据流
- 使用
Point In Time (PIT)+search_after(ES 7.10+)
// 创建 PIT
OpenPointInTimeRequest pitRequest = new OpenPointInTimeRequest("products")
.keepAlive(TimeValue.timeValueMinutes(5));
OpenPointInTimeResponse pitResponse = client.openPointInTime(pitRequest, RequestOptions.DEFAULT);
String pitId = pitResponse.getPointInTimeId();
// 使用 PIT + search_after
SearchRequest searchRequest = new SearchRequest();
searchRequest.source(new SearchSourceBuilder()
.query(QueryBuilders.matchAllQuery())
.size(10)
.sort("_shard_doc", SortOrder.ASC)
.pointInTimeBuilder(new PointInTimeBuilder(pitId)));
// ... 循环查询
总结
ElasticSearch 深度分页问题本质是分布式排序导致的性能问题。解决方案选择:
- Scroll API:适合离线全量导出,不适合实时查询
- Search After:推荐方案,适合深度分页和实时查询
- 业务优化:从产品层面避免深度分页需求
面试要点:
- 说明
from + size在深度分页时的性能问题(N 个分片 × (from + size) 条数据) - 重点介绍 Search After 方案(推荐)
- 提及 Scroll API 的应用场景(数据导出)
- 可补充业务层优化思路(限制页码、加载更多)