深分页三大方案
深分页三大方案:from/size → scroll → search_after → PIT
"用户想看第 500 页的订单数据。"
你在 ES 里执行 from: 10000, size: 20——报错了:Result window is too large。把 max_result_window 调到 50000,能跑了,但翻到第 3000 页时耗时超过 5 秒,集群 CPU 被打满。
深分页是 ES 搜索里最经典的"看似简单,实则巨坑"问题。这篇把四种分页方案讲透:普通分页、滚动分页、游标分页、以及 ES 7.10 后的终极方案 PIT。
为什么 ES 怕深分页
假设一个索引有 3 个分片,你请求 from: 1000, size: 10:
协调节点 → 每个分片必须取出 Top 1010 条文档 → 协调节点收到 3030 条
→ 排序后丢弃前 1000 条 → 返回 10 条
你要的只是 10 条,但 ES 需要取 3030 条,在协调节点做排序,再丢弃 1000 条。
翻到 from: 100000 时,每个分片需要取 100010 条——单次查询处理 30 万条文档,CPU 和内存双双爆表。
这就是 max_result_window 默认 10000 的原因:超过这个值,性能断崖式下跌。 调大这个值不解决根本问题。
方案 1:from + size(正常分页)
GET /orders/_search
{
"from": 0,
"size": 20,
"query": { "match": { "status": "paid" } },
"sort": [{ "created_at": "desc" }]
}
| 适用 | 不适用 |
|---|---|
| 浅分页(< 1000 条) | 深分页 |
| 用户需要跳页(Go to page N) | 数据实时变化时(翻页过程中数据插入/删除会导致重复/遗漏) |
原则:from + size 只给管理员后台用,且不超过 max_result_window(默认 10000)。
方案 2:Scroll(遍历全量数据)
Scroll 不是分页——它是"快照 + 游标"遍历。ES 在第一次请求时创建一个数据快照,之后每次 scroll 请求在这个快照上滑动。
// 初始化 scroll
GET /orders/_search?scroll=2m
{
"size": 1000,
"query": { "match": { "status": "paid" } },
"sort": [{ "_doc": "asc" }] // 按内部顺序遍历最快
}
// 返回:{ "_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAA...", "hits": ... }
// 翻下一页(用 scroll_id)
GET /_search/scroll
{
"scroll": "2m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAA..."
}
// 用完后清理
DELETE /_search/scroll
{
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAA..."
}
| 优点 | 缺点 |
|---|---|
| 遍历百万级数据非常高效 | 占用服务端资源(scroll 上下文) |
| 数据一致性有保证(快照) | 新写入的数据在 scroll 内不可见 |
| 不能跳页(只能往前翻) | |
| scroll 上下文超时会自动清理 |
使用场景:全量导出、离线 ETL、数据迁移。
Scroll 的最佳实践
- 用
_doc排序——按文档顺序遍历最快,不触发评分 - 设短超时——
scroll=2m,每次请求重置计时器 - 用完立刻删除 scroll_id——忘记删除会导致 ES 积压大量 scroll 上下文
- 用 sliced scroll 并行遍历:
GET /orders/_search?scroll=2m
{
"size": 1000,
"slice": { "id": 0, "max": 4 } // 分成 4 片,这是第 1 片
}
4 个线程各取 1/4 数据,并行度 4 倍。
方案 3:search_after(实时游标)
search_after 解决了 scroll 的最大痛点:不能实时看到新数据,且服务端需要维护上下文。
// 第 1 页
GET /orders/_search
{
"size": 20,
"query": { "match": { "status": "paid" } },
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" } // 必须加唯一排序字段作为 tiebreaker
]
}
// 返回最后一条的 sort 值:[1622505600000, "order_00200"]
// 第 2 页——传入上一页最后一条的排序值
GET /orders/_search
{
"size": 20,
"query": { "match": { "status": "paid" } },
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" }
],
"search_after": [1622505600000, "order_00200"]
}
search_after 的原理:ES 不再从 0 开始找,而是从 search_after 指定的位置之后开始找。无论翻到第几页,ES 只需要取 size 条数据。
| 对比 | Scroll | search_after |
|---|---|---|
| 实时性 | 快照,更新不可见 | 实时,每次请求都是当前数据 |
| 服务端状态 | 需要维护上下文 | 无状态 |
| 跳页 | ❌ | ❌ |
| 往前翻 | ❌ | 理论上可以(倒转排序方向) |
| 性能 | 好 | 更好 |
| 适用 | 全量遍历 | 无限滚动加载 |
原则:前端"加载更多"功能,一律用 search_after。这是目前最轻量的深翻页方案。
方案 4:PIT(Point in Time)+ search_after(终极方案)
ES 7.10 引入的 PIT(时间点查询)是 search_after 的升级版——提供轻量的数据一致性视图,但不像 scroll 那么重。
search_after 的问题是:翻页过程中如果数据被修改,同一条文档可能重复出现或丢失。PIT 解决了这个:
// 1. 创建 PIT(获得一个时间窗口 ID)
POST /orders/_pit?keep_alive=5m
// 返回:{ "id": "46ToAwMDaWR4BXV1aWQy..." }
// 2. 第 1 页搜索(传入 pit.id,不传 index)
GET /_search
{
"size": 20,
"query": { "match": { "status": "paid" } },
"pit": {
"id": "46ToAwMDaWR4BXV1aWQy...",
"keep_alive": "5m"
},
"sort": [
{ "created_at": "desc", "format": "strict_date_optional_time_nanos" },
{ "_shard_doc": "asc" }
]
}
// 3. 第 2 页——search_after 同上
GET /_search
{
"size": 20,
"query": { "match": { "status": "paid" } },
"pit": {
"id": "46ToAwMDaWR4BXV1aWQy...",
"keep_alive": "5m"
},
"sort": [
{ "created_at": "desc", "format": "strict_date_optional_time_nanos" },
{ "_shard_doc": "asc" }
],
"search_after": [1622505600000, 256]
}
// 4. 用完后关闭 PIT
DELETE /_pit
{
"id": "46ToAwMDaWR4BXV1aWQy..."
}
注意排序的 tiebreaker 从 _id 变成了 _shard_doc(分片内文档序号)——这是 PIT 的最佳搭配,比 _id 排序快很多。
search_after 的两个关键约束
- sort 字段必须唯一:如果有两条
created_at完全相同的文档,search_after不知道在哪停,会漏数据。解决方案是加上_id或_shard_doc作为二级排序。 - 不能跳页:只能一页一页往下翻。这是搜索引擎的本质限制——不遍历前面就无法知道后面的偏移量。
四种方案选型决策树
需要分页 →
需要跳页(Go to page N)?
YES → from + size(限制 depth < 10000)
NO →
需要导出全量数据?
YES → Scroll(或 sliced scroll 并行导出)
NO →
需要数据一致性(翻页期间数据不变)?
YES → PIT + search_after
NO → search_after
总结
| 方案 | 一句话 | 记住 |
|---|---|---|
| from + size | 浅分页(< 10000) | 深了就慢,别调大 max_result_window 自欺欺人 |
| Scroll | 全量遍历的快照游标 | 用完就删,别让它占着服务端资源 |
| search_after | 无状态的实时游标 | 前端无限滚动的最佳拍档 |
| PIT + search_after | 带一致性保证的轻量游标 | ES 7.10+ 的首选 |
一个原则:用户不需要"第 500 页"。他们需要的是好的搜索和过滤。深分页的问题,大多数时候用更好的搜索体验来解决,而不是用更复杂的分页技术。