Elasticsearch 是如何处理深度分页(Deep Paging)?
Elasticsearch 在处理分页时,主要面临的挑战是分布式存储和结果排序带来的性能损耗。
针对不同的场景(浅分页、深度分页、全量导出),Elasticsearch 提供了三种主要的解决方案:
from+size(适用于浅分页)- Scroll API(适用于全量导出/离线处理,不再推荐用于实时搜索)
- Search After(适用于深度分页,实时滚动,官方推荐)
- Point In Time (PIT) + Search After(解决深度分页的一致性问题)
1. 为什么深度分页是性能杀手?
在了解解决方案之前,必须理解为什么简单的 from + size 在深度分页时会崩溃。
假设你有 5 个分片(Shards),想要获取第 10,000 页的数据(每页 10 条),即 from=99990, size=10。
Elasticsearch 的处理流程如下:
- 协调节点(Coordinating Node) 接收请求。
- 协调节点向 所有 5 个分片 发送请求。
- 每个分片 必须查询并排序前
99990 + 10 = 100,000条数据(因为它不知道全局的前 10 条在哪里)。 - 每个分片将这 100,000 条数据(通常只包含 ID 和排序字段)返回给协调节点。
- 协调节点收到
5 * 100,000 = 500,000条数据。 - 协调节点在内存中对这 50 万条数据进行全局排序。
- 丢弃前 99,990 条,只保留最后的 10 条。
- 协调节点根据这 10 条数据的 ID 去分片拉取完整的文档内容(Fetch 阶段)并返回给客户端。
后果:
- CPU 和内存消耗巨大: 随着页码越深,排序和传输的数据量呈指数级增长。
- OOM 风险: 容易导致节点 Out Of Memory。
- 硬限制: 为了保护集群,ES 默认设置了
index.max_result_window = 10000。如果from + size超过这个值,会直接报错。
2. 解决方案详解
方案一:From + Size(浅分页)
这是最标准的分页方式,类似于 SQL 的 LIMIT M, N。
- 原理: 直接跳过前 N 条。
- 适用场景: 用户只看前几页搜索结果(如 Google 搜索结果,很少有人翻到第 100 页)。
- 限制: 受
index.max_result_window(默认 10000) 限制。
json
GET /index/_search
{
"from": 0,
"size": 10
}
方案二:Scroll API(游标/快照)
Scroll 类似于数据库的游标(Cursor)。它会一次性生成所有匹配文档的快照(Snapshot),并保存在上下文中。
- 原理:
- 第一次查询生成快照,返回一个
scroll_id。 - 后续请求携带
scroll_id,ES 直接从快照中按顺序读取下一批数据,不需要重新排序。
- 第一次查询生成快照,返回一个
- 优点: 效率极高,适合遍历全量数据。
- 缺点:
- 非实时: 生成快照后,新写入的数据无法被查到。
- 资源占用: 维护 Scroll 上下文需要占用堆内存,并发高时会拖垮集群。
- 适用场景: 数据导出、数据重建索引(Reindex)、离线批处理。严禁用于前端实时用户分页。
json
// 初始化
GET /index/_search?scroll=1m
{
"size": 100
}
// 获取下一页
GET /_search/scroll
{
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2g...",
"scroll": "1m"
}
方案三:Search After(推荐的深度分页)
这是目前 ES 官方推荐的深度分页方式,类似于“瀑布流”或“无限加载”。
- 原理:
- 它不使用
from(无状态跳转),而是基于上一页最后一条数据的排序值来定位下一页的开始位置。 - 类似于 SQL:
SELECT * FROM table WHERE id > last_id ORDER BY id LIMIT 10。
- 它不使用
- 前置条件: 排序字段必须包含一个唯一值(通常是
_id),以确保排序结果的唯一性(Tie-breaker)。 - 流程:
- 第一页正常查询,获取结果中最后一条的
sort值。 - 第二页查询时,将该
sort值放入search_after参数中。 - 每个分片只需要从该值之后开始扫描 10 条数据,无需处理之前的 N 条数据。
- 第一页正常查询,获取结果中最后一条的
- 优点: 性能稳定,不会随着页数增加而变慢;内存占用小。
- 缺点:
- 不支持随机跳转: 只能一页一页往后翻,不能直接跳到第 100 页。
- 数据一致性问题: 在翻页过程中,如果有新数据插入且满足排序条件,可能会导致数据重复或遗漏(除非结合 PIT 使用)。
json
// 第一页
GET /index/_search
{
"size": 10,
"sort": [
{"timestamp": "desc"},
{"_id": "asc"} // 必须加唯一键作为 Tie-breaker
]
}
// 返回结果最后一条的 sort values 为 [1623456789, "abc-123"]
// 第二页
GET /index/_search
{
"size": 10,
"search_after": [1623456789, "abc-123"],
"sort": [
{"timestamp": "desc"},
{"_id": "asc"}
]
}
方案四:PIT (Point In Time) + Search After
为了解决 Search After 在高并发写入时的数据一致性问题,ES 7.10+ 引入了 PIT。
- 原理: PIT 创建一个轻量级的视图(View),类似于 Scroll 的快照,但比 Scroll 更轻量。
- 结合使用: 使用 PIT ID 进行
search_after查询,既能保证深度分页的高性能,又能保证翻页过程中数据视图的一致性(不会看到新插入的数据,也不会漏掉数据)。 - 适用场景: 用户端的深度分页、需要数据一致性的遍历。
总结与选型建议
| 特性 | From + Size | Scroll | Search After | PIT + Search After |
|---|---|---|---|---|
| 分页深度 | 浅 ( < 10,000) | 全量 | 深 | 深 |
| 随机跳转 | 支持 (跳到第N页) | 不支持 | 不支持 | 不支持 |
| 性能 | 随深度指数下降 | 高 | 高且稳定 | 高且稳定 |
| 实时性 | 实时 | 快照 (非实时) | 实时 (数据可能变动) | 视图一致性 |
| 资源消耗 | 高 (深页时) | 高 (维护上下文) | 低 | 中 (轻量上下文) |
| 推荐场景 | 普通搜索结果 | 全量导出/迁移 | 手机端无限滚动 | 一致性要求高的深分页 |
一句话总结:
如果是给用户看的前几页搜索结果,用 from+size;如果是手机端的无限下拉或者后台的大数据量浏览,用 Search After;如果要导出全部数据,用 Scroll(或 PIT)。
右滑查看面试常问