分页与深度分页:from/size、search_after、PIT 与 scroll

系统讲解 Elasticsearch 的分页机制:from/size 与 max_result_window 上限的由来、search_after 排序游标的用法与限制、PIT 如何保证跨页一致性、scroll 在导出场景的适用边界,以及四种方案的选型依据。

用户翻到第 1000 页时,系统在背后做了什么?答案是:每个分片都取出前 10020 条文档,汇聚到协调节点排序,再丢掉前 10000 条。翻页越深,浪费越大,这就是深度分页问题的本质。Elasticsearch 给出了三条绕开它的路:search_after 用排序游标逐页推进,PIT 为跨页查询提供一致性视图,scroll 则为全量导出设计。它们各有明确的使用边界,混用会带来内存泄漏、结果重复或一致性问题。本文把这四种分页方式讲透。

1. 分页的代价

一句话总结: 深度分页的代价来自「每个分片都要取出前 from+size 条再全局归并」,深度与分片数相乘放大开销。

1.1 查询的执行流程

一个带 from 与 size 的搜索请求是这样执行的:协调节点把请求发给索引的每一个分片,每个分片取出自己本地排序后的前 from + size 条文档,返回给协调节点;协调节点把 分片数 × (from + size) 条文档归并排序,取出第 from 到 from + size 条返回。

1.2 开销的量化

假设索引有 5 个分片,用户请求 from=10000, size=10。每个分片要返回 10010 条文档,协调节点要归并 50050 条,最终只返回 10 条。99.98% 的计算被丢弃。分片数越多,放大倍数越大——这是为什么深度分页问题在分片多的集群上尤其严重。

1.3 内存与超时风险

归并排序需要把这么多文档的排序字段与 ID 放在协调节点的内存里。深度越大,堆压力越大,可能触发 Data too large 的断路器异常,或者因为超时直接失败。这不是理论风险,from 到几万时几乎必然出问题。

1.4 三种解法概览

  • search_after:用上一页最后一条的排序值作为下一页的起点,不计算跳过的文档。
  • PIT:point in time,为查询固定一个数据视图,配合 search_after 保证跨页一致性。
  • scroll:为全量遍历设计的游标,一次性持有搜索上下文,适合导出而非交互式分页。

2. from/size 与 max_result_window

一句话总结: max_result_window 默认 10000,是保护协调节点内存的硬上限,调大它只是把问题推后而不是解决。

2.1 基本用法

GET /articles/_search
{
  "from": 0,
  "size": 20,
  "query": { "match": { "title": "Elasticsearch" } },
  "sort": [ { "_score": "desc" }, { "_id": "asc" } ]
}

from 从 0 开始。翻页时 from = 页码 × size。

2.2 上限从哪来

默认情况下,from + size 超过 index.max_result_window(默认 10000)会直接报错:

{
  "error": {
    "type": "illegal_argument_exception",
    "reason": "Result window is too large, from + size must be less than or equal to: [10000]"
  }
}

这个上限不是随意定的,它是「协调节点能承受的归并规模」的经验值。调大它会直接放大每个请求的内存占用。

2.3 调整上限的代价

PUT /articles/_settings
{
  "index.max_result_window": 50000
}

调大之后,from=49990, size=10 的请求会让每个分片返回近 5 万条文档。少数几个这样的并发请求就能把协调节点的堆打满。如果确实需要,建议只对数据量小、查询频率低的索引调整,并配合监控。

2.4 跳页需求的特殊性

搜索 UI 常常需要「跳到第 N 页」。from/size 是唯一能随机跳页的方案,search_after 只能顺序翻页。所以真实产品里常见的做法是:前若干页(如前 100 条结果)允许随机跳页,更深的页面只提供「下一页」。这是一个产品层面的取舍,而不是技术限制。

3. search_after

一句话总结: search_after 用上一页最后一条的排序值作为起点,跳过所有已读文档,代价是只能顺序翻页且排序字段必须唯一。

3.1 基本用法

GET /articles/_search
{
  "size": 20,
  "query": { "match": { "title": "Elasticsearch" } },
  "sort": [
    { "published_at": "desc" },
    { "_id": "asc" }
  ]
}

响应里每条文档带 sort 数组,下一页请求把最后一页的 sort 值填进 search_after:

GET /articles/_search
{
  "size": 20,
  "query": { "match": { "title": "Elasticsearch" } },
  "sort": [
    { "published_at": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [ 1696118400000, "article-8421" ]
}

3.2 排序键必须唯一

如果排序字段有重复值(比如同一天发布的文章),仅用 published_at 排序时,边界处的文档可能在两页中重复出现或被跳过。解决办法是加一个唯一的决胜字段(_id 或 _shard_doc)作为最后一个排序键。这是 search_after 最容易踩的坑。

3.3 性能优势

search_after 不计算被跳过的文档:每个分片只需要返回「排序值大于起点」的前 size 条。无论翻到第几页,每个分片的工作量都是 size 条,而不是 from + size 条。这是它相对 from/size 的根本优势。

3.4 限制

  • 不能随机跳页:必须顺序推进,因为下一页依赖上一页的 sort 值。
  • 不能返回总数:需要总数时得额外跑一次 track_total_hits 的查询,成本不低。
  • 排序字段不能是 _score 独占:打分排序下文档顺序可能变化,需要加决胜键。
  • 翻页期间的写入:新文档插入会改变后续页的内容,需要 PIT 才能保证一致性。

4. PIT 与跨页一致性

一句话总结: PIT 为查询固定一个时间点视图,配合 search_after 保证翻页期间数据不漂移,使用后必须显式关闭。

4.1 为什么需要 PIT

没有 PIT 时,翻页期间发生的 refresh 会让新文档进入后续页、被删除的文档从后续页消失,导致结果重复或遗漏。PIT 通过保留查询时刻的段引用,让整个翻页过程看到一致的数据快照。

4.2 创建与使用

POST /articles/_pit?keep_alive=5m

响应返回 id。后续查询用 pit 替代索引名,并去掉 from:

GET /_search
{
  "size": 20,
  "query": { "match": { "title": "Elasticsearch" } },
  "pit": { "id": "48my...", "keep_alive": "5m" },
  "sort": [ { "published_at": "desc" }, { "_shard_doc": "asc" } ],
  "search_after": [ 1696118400000, 8421 ]
}

使用 PIT 时推荐用 _shard_doc 作为决胜排序键,它是 PIT 场景下的标准做法。

4.3 生命周期管理

keep_alive 是滑动窗口:每次使用 PIT 查询都会把过期时间往后推。翻页结束后必须显式关闭:

DELETE /_pit
{
  "id": "48my..."
}

忘记关闭的 PIT 会一直占用段引用,阻止段合并,最终导致磁盘与堆压力上升。这是 PIT 最常见的事故来源——务必放在 try/finally 里关闭。

4.4 与分页的配合

PIT 加 search_after 是官方推荐的深度分页方案。它同时解决了两个问题:性能不随深度劣化,以及跨页结果一致。唯一的代价是需要维护 PIT 的生命周期,并在客户端保存每次翻页的 sort 游标。

5. scroll 与导出场景

一句话总结: scroll 一次性快照并顺序遍历全部结果,适合离线导出,不适合交互式分页,且必须显式清理。

5.1 基本用法

POST /articles/_search?scroll=5m
{
  "size": 1000,
  "query": { "range": { "published_at": { "gte": "now-1y" } } },
  "sort": [ "_doc" ]
}

响应返回 _scroll_id,后续用 POST /_search/scroll 携带该 ID 逐批取数据,直到返回的 hits 为空。

5.2 适用与不适用

scroll 会为整个查询维持一个搜索上下文,占用堆内存与段引用,且不支持并发翻页(顺序消费)。它适合:

  • 全量导出到文件或数据仓库。
  • 批量重索引(reindex 的底层也用类似机制)。
  • 一次性遍历做离线计算。

它不适合交互式分页——用户翻页的节奏无法预测,scroll 上下文会长时间占用资源。

5.3 与 PIT 的取舍

新版本推荐用 PIT 加 search_after 替代 scroll:PIT 不需要维持一个「顺序游标」的语义,可以在同一个 PIT 上做多次独立查询,且资源占用更可控。scroll 的主要价值在于「批量拉取全量数据」这一单一场景。

5.4 资源清理

scroll 上下文同样必须显式清理:

DELETE /_search/scroll
{
  "scroll_id": ["cXVlcnlUaGVuRmV0Y2g..."]
}

也可以用 _nodes/stats/indices/search 观察 open_contexts 数量,如果持续增长,说明有 scroll 或 PIT 泄漏。

6. 方案对比与选型

一句话总结: 浅分页用 from/size,深度分页用 PIT 加 search_after,全量导出用 scroll,按场景选而不是按偏好选。

6.1 四种方案对比

方案随机跳页深度性能一致性适用场景
from/size支持随深度劣化无保证浅分页、跳页
search_after不支持恒定需配 PIT深度顺序翻页
PIT 加 search_after不支持恒定快照一致推荐方案
scroll不支持恒定快照一致全量导出

6.2 选型判断

  • 结果页数在 100 页以内:直接用 from/size,简单可靠。
  • 需要无限滚动或深度翻页:PIT 加 search_after。
  • 需要导出百万级数据:scroll,或用 PIT 加 search_after 分批拉取。
  • 需要总数与跳页:from/size 加 track_total_hits,但要把深度限制在产品层面。

6.3 常见组合

最实用的组合是「前 N 页用 from/size 支持跳页,超过 N 页切换成 search_after」。用户几乎不会翻到很深的页,但导出脚本会。把两种方案按深度切换,既保住了体验,又避免了性能事故。

6.4 总条数的代价

track_total_hits 默认在命中超过 10000 时不再精确计数。开启精确计数(track_total_hits: true)会让每个分片统计全部匹配文档,对高命中查询是显著开销。分页 UI 上「共 N 条结果」这个数字的代价,往往被低估。

7. 生产实践与常见坑

一句话总结: 深度分页的坑集中在游标丢失、上下文泄漏与排序不唯一三点,都需要在客户端实现时提前设计。

7.1 游标的状态管理

search_after 的游标是一个排序值数组,客户端必须保存并在下一页回传。在 Web 场景里,这个游标通常编码进 URL 或响应体,而不是存在服务端 session 里——服务端无状态才能水平扩展。

7.2 上下文泄漏排查

监控 open_contexts 与 PIT 数量。常见泄漏场景:

  • 翻页中途用户离开,客户端没有关闭 PIT。
  • 导出脚本异常退出,scroll 上下文未清理。
  • 异常分支里 try/finally 写漏。

设置较短的 keep_alive(如 1 到 5 分钟)能限制泄漏的影响范围。

7.3 排序稳定性

任何分页方案都要求排序键唯一。用 published_at 单字段排序时,同时间戳的文档顺序在分片间不确定,翻页必然出问题。永远加一个唯一的决胜键,这是铁律。

7.4 常见坑清单

  • from 超过 10000:直接报错,不要在代码里硬编码大 from。
  • search_after 缺少决胜键:边界文档重复或丢失。
  • PIT 未关闭:段合并被阻塞,磁盘占用不降。
  • 翻页期间数据变化:不用 PIT 就会看到重复或遗漏。
  • scroll 用于交互式分页:上下文长期占用,集群压力大。
  • track_total_hits: true 滥用:高命中查询的额外开销被忽略。
  • 并发翻页共享 scroll:scroll 不支持并发消费,必须顺序。

7.5 与聚合的分页

深度分页问题在聚合里同样存在,只是形式不同。terms 聚合的 size 参数决定返回多少个桶,而 composite 聚合提供了类似 search_after 的 after_key 机制,用于遍历全部桶。做多维度报表时,composite 是比调大 terms.size 更正确的选择。

8. 总结

环节要点
深度代价每分片取 from+size 条,再全局归并丢弃
窗口上限max_result_window 默认 10000,保护协调节点
search_after用排序值做游标,深度无关,须唯一决胜键
PIT固定快照视图,保证跨页一致,须显式关闭
scroll全量遍历专用,上下文昂贵,不用于交互分页
选型原则浅分页 from/size,深分页 PIT 加 search_after
总条数track_total_hits 精确计数代价高
状态管理游标进 URL,上下文靠短 keep_alive 兜底

分页看似是个 UI 问题,实则是分布式排序问题的外显。理解了「每个分片都要取出前 N 条再归并」这个执行模型,所有方案的取舍就都顺理成章了。当数据规模增长到单集群难以承载时,还有一条路是把冷数据搬到对象存储上仍保留查询能力——这正是可搜索快照与冻结层要解决的问题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「elasticsearch」更多文章

  1. 可搜索快照与冻结层:把冷数据放进对象存储还能查
  2. 嵌套与父子关联查询:nested、join 字段与性能取舍
  3. 磁盘水位与容量规划:三档水位、分片规划与扩容决策