深分页三大方案

22 May 2026 – wusfe · 4 min read

深分页三大方案: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 的两个关键约束

  1. sort 字段必须唯一:如果有两条 created_at 完全相同的文档,search_after 不知道在哪停,会漏数据。解决方案是加上 _id_shard_doc 作为二级排序。
  2. 不能跳页:只能一页一页往下翻。这是搜索引擎的本质限制——不遍历前面就无法知道后面的偏移量。

四种方案选型决策树

需要分页 →
  需要跳页(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 页"。他们需要的是好的搜索和过滤。深分页的问题,大多数时候用更好的搜索体验来解决,而不是用更复杂的分页技术。