基于本文回答
0
评论

Elasticsearch 是如何处理深度分页(Deep Paging)?

知识点图片

Elasticsearch 在处理分页时,主要面临的挑战是分布式存储结果排序带来的性能损耗。

针对不同的场景(浅分页、深度分页、全量导出),Elasticsearch 提供了三种主要的解决方案:

  1. from + size(适用于浅分页)
  2. Scroll API(适用于全量导出/离线处理,不再推荐用于实时搜索)
  3. Search After(适用于深度分页,实时滚动,官方推荐)
  4. Point In Time (PIT) + Search After(解决深度分页的一致性问题)

1. 为什么深度分页是性能杀手?

在了解解决方案之前,必须理解为什么简单的 from + size 在深度分页时会崩溃。

假设你有 5 个分片(Shards),想要获取第 10,000 页的数据(每页 10 条),即 from=99990, size=10

Elasticsearch 的处理流程如下:

  1. 协调节点(Coordinating Node) 接收请求。
  2. 协调节点向 所有 5 个分片 发送请求。
  3. 每个分片 必须查询并排序前 99990 + 10 = 100,000 条数据(因为它不知道全局的前 10 条在哪里)。
  4. 每个分片将这 100,000 条数据(通常只包含 ID 和排序字段)返回给协调节点。
  5. 协调节点收到 5 * 100,000 = 500,000 条数据。
  6. 协调节点在内存中对这 50 万条数据进行全局排序
  7. 丢弃前 99,990 条,只保留最后的 10 条。
  8. 协调节点根据这 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)。
  • 流程:
    1. 第一页正常查询,获取结果中最后一条的 sort 值。
    2. 第二页查询时,将该 sort 值放入 search_after 参数中。
    3. 每个分片只需要从该值之后开始扫描 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)。

右滑查看面试常问