Elasticsearch 查询深分页:原理剖析与解决方案

2 阅读9分钟

关键词:Elasticsearch、深分页、from/size、Scroll、search_after、PIT 适合人群:有 ES 基础使用经验的后端开发者

一、什么是深分页问题?

分页查询在业务中无处不在——后台列表、日志检索、订单查询……ES 提供了最直观的 from + size 分页参数,用法和 MySQL 的 LIMIT offset, size 几乎一模一样。

但当 from 值越来越大时,查询会越来越慢,甚至直接报错:

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

这就是深分页问题(Deep Pagination)。要搞清楚它,先得理解 from/size 在分布式场景下的真实执行原理。

二、from/size 的执行原理——为什么会越深越慢?

ES 是分布式的,一个索引通常被拆分为多个 shard 分布在不同节点上。执行分页查询时,协调节点(Coordinating Node)需要从所有 shard 收集数据,再统一排序、截取。

下图展示了查询第 1000 页(from=9990, size=10)时,ES 内部发生了什么:image.png

核心逻辑很清楚:每个 shard 都要返回 from + size 条记录,协调节点再把所有 shard 的数据汇总到内存中进行排序,最终只取最后那 10 条给客户端。

这意味着:

  • from=9990, size=10,4 个 shard 的场景下,协调节点需要在内存中处理 4 × 10000 = 40000 条数据
  • 页数越深,内存消耗越大,延迟越高
  • ES 默认通过 index.max_result_window = 10000 硬限制来保护集群,超出直接报错

根本原因:分布式场景下,无法像单机数据库那样直接跳过前 N 条——每个 shard 不知道其他 shard 的排序情况,所以必须"先多捞,再裁剪"。

三、解决方案

方案一:调大 max_result_window(不推荐)

PUT /my_index/_settings
{
  "index.max_result_window": 100000
}

这只是把问题推后,不是解决问题。深分页的内存和性能消耗依然存在,数据量大时极易造成 OOM,生产环境强烈不建议

方案二:Scroll API——快照游标

scroll 的思路是:第一次查询时创建一个快照,后续每次通过 scroll_id 取下一批数据,类似游标。

请求示例:

# 第一次:初始化 scroll,保持快照 1 分钟
POST /my_index/_search?scroll=1m
{
  "size": 100,
  "query": { "match_all": {} },
  "sort": ["_doc"]
}

# 后续:用 scroll_id 翻页
POST /_search/scroll
{
  "scroll": "1m",
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2g..."
}

# 用完后主动释放
DELETE /_search/scroll
{
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2g..."
}

执行原理:

image.png

Scroll 的核心缺陷:

  • 快照期间,新写入/删除的数据对当前 scroll 不可见,数据不实时
  • 每个 scroll 上下文在服务端占用资源(segment 引用),大量并发 scroll 容易撑爆内存
  • 官方已不推荐用于实时翻页,仅推荐用于数据迁移、全量导出等一次性遍历场景

方案三:search_after——无状态游标(推荐)

search_after 是目前官方推荐的深分页方案。它的思路非常简单:用上一页最后一条数据的排序值作为起点,查询下一页,完全无状态,不需要服务端维护任何上下文。

请求示例:

# 第一页:正常查询,sort 字段必须唯一或组合唯一
POST /my_index/_search
{
  "size": 10,
  "query": { "match": { "status": "active" } },
  "sort": [
    { "created_at": "desc" },
    { "_id": "asc" }
  ]
}

# 第二页:取上一页最后一条的 sort 值
POST /my_index/_search
{
  "size": 10,
  "query": { "match": { "status": "active" } },
  "sort": [
    { "created_at": "desc" },
    { "_id": "asc" }
  ],
  "search_after": ["2024-03-01T10:00:00", "doc_id_abc"]
}

执行原理: search_after 的关键要点:

image.png

① sort 字段必须能保证全局唯一,否则翻页时会漏数据或重复。常见做法是用业务排序字段 + _id 联合排序。

② 不支持跳页,只能顺序向后翻。如果业务需要"跳到第 500 页",search_after 无法满足。

③ 数据实时性问题:两次请求之间若有新数据写入,可能导致排序变化、结果集漂移。这就引出了终极方案——PIT。

方案四:PIT + search_after——最佳实践(ES 7.10+)

PIT(Point In Time)是 ES 7.10 引入的能力,可以理解为一个轻量级的数据快照 ID。与 scroll 不同,PIT 只创建一个更轻量静态快照,不绑定查询参数,配合 search_after 就能做到:翻页过程中数据视图固定,同时查询参数灵活可变,且无状态

使用流程:

# Step 1:创建 PIT,保留 5 分钟
POST /my_index/_pit?keep_alive=5m
# 返回 pit.id

# Step 2:使用 PIT + search_after 查询
POST /_search
{
  "size": 10,
  "query": { "match": { "status": "active" } },
  "pit": {
    "id": "46ToAwMDaWR5...",
    "keep_alive": "5m"
  },
  "sort": [{ "created_at": "desc" }, { "_shard_doc": "asc" }],
  "search_after": ["2024-03-01T10:00:00", 1234]
}

# Step 3:翻页结束后删除 PIT(不手动删除也会自动删除的)
DELETE /_pit
{ "id": "46ToAwMDaWR5..." }

_shard_doc 是 ES 在 PIT 模式下自动生成的全局唯一排序字段,无需手动指定 _id,更高效。

四种种方案对比图:

image.png

四、如何选型?一张决策图

image.png

五、实战注意事项

1. search_after 的 sort 字段设计

sort 字段的组合必须能全局唯一标识一条文档,否则会有漏页或重复:

"sort": [
  { "create_time": "desc" },
  { "_id": "asc" }
]

如果使用 PIT,可以直接用 _shard_doc 替代 _id,性能更好。

2. PIT 的 keep_alive 设置

keep_alive 不是一次性的——每次查询都应该带上它以刷新超时时间。客户端一旦停止翻页,PIT 会在 keep_alive 时间后自动释放。

3. scroll 的资源清理

scroll 上下文在服务端保持 Lucene 段引用,一定要在用完后主动 DELETE,否则大量遗留的 scroll context 会导致内存持续增长。

4. 禁止在生产环境调大 max_result_window

调大 max_result_window 是最危险的做法,既不治本,还可能在某次大查询时直接触发节点 OOM。

5. 跳页妥协

跳页本质是随机访问,最终的目标都是查找数据。解决的思路:

  • 跳转浅页可依赖 from + size
  • 静态数据,如报表、榜单,可以提前生产分页数据写入redis,按页码直接读取,彻底绕开 ES 分页。
  • 其他情况,引导用户增加筛选条件缩小结果集,而不是翻到第 N 页去

六、总结

深分页的根源是分布式架构的固有约束:每个 shard 只能各自用优先队列选出本地 TOP N 再汇总,页码越深,堆越大,内存压力越高。理解了这一点,选型就没有歧义——from/size 适合浅分页,scroll 适合全量导出,search_after 解决实时深翻页,加上 PIT 保证翻页一致性。调大 max_result_window 不是解决问题,是推迟问题。遇到跳页需求,优先推动产品限制页码上限,分布式场景下任意跳页没有高效解法。


思考题

  1. 为什么 from/size 的性能问题随页码加深是线性恶化而不是固定开销?
参考答案
根源在于优先队列的容量随 from 线性增长
  • 堆大小:每个 shard 的 TopDocsCollector 持有容量为 from + size 的优先队列,from=9990 时堆容量是 from=90 时的 100 倍
  • 协调节点:汇总的数据量 = shard 数 × (from + size),页码越深内存消耗越大
  • 磁盘遍历量不变:命中文档的遍历量由查询条件决定,与页码无关,真正随页码膨胀的是内存压力
总结:深分页是典型的内存问题,不是 I/O 问题。
  1. scroll 和 PIT 同样基于快照机制,为什么官方推荐 PIT 而不推荐 scroll?
参考答案
两者的快照粒度和绑定方式完全不同
  • scroll 重:快照与查询强绑定,服务端需要维护完整的查询上下文,大量并发 scroll 容易撑爆堆内存,且 scroll 会阻止旧 segment 被合并释放,占用磁盘和文件句柄
  • PIT 轻:只固定数据视图,不绑定查询参数,服务端仅持有 segment 引用,开销极小,查询参数可以每次灵活变化
  • 实时性:scroll 不支持实时数据,PIT 配合 search_after 既保证翻页一致性,又支持实时索引
总结:PIT 把 scroll 的"重状态"拆解成了"轻快照 + 无状态查询",是架构上更合理的设计。
  1. search_after 的 sort 字段如果不能保证全局唯一,会出现什么问题?
参考答案
会导致翻页结果出现漏条或重复
  • 漏条:若多条文档的 sort 值相同,以其中某条作为锚点时,排在它后面但 sort 值相同的文档会被跳过
  • 重复:不同 shard 上存在相同 sort 值的文档,协调节点归并时截取边界不稳定,同一条文档可能出现在相邻两页
  • 正确做法:始终用业务排序字段 + _id 或 _shard_doc 联合排序,保证全局唯一性,翻页边界才能精确
总结:sort 唯一性是 search_after 正确性的基础,不是可选项。
  1. 为什么说"任意跳页在分布式场景下没有高效解法"?
参考答案
分布式排序的本质决定了跳页必须付出代价
  • 无法跳过:要确定第 N 页的起点,必须知道前 N-1 页所有文档的全局排序,而这需要每个 shard 上报足够多的候选数据给协调节点归并,无法绕过
  • 单机可以:MySQL 的 LIMIT offset 本质上也是扫描跳过,只是单机顺序读代价相对可控;ES 多 shard 并行放大了这个代价
  • 锚点缓存不现实:不同用户查询条件各异,锚点无法复用,按用户缓存存储成本过高
总结:跳页是 O(n) 复杂度的操作,分布式场景下 n 被 shard 数放大,这是架构层面的约束,不是工程实现的问题。