Elasticsearch:深度分页解决方案

557 阅读9分钟

在Elasticsearch的实际应用中,高效的分页查询是开发者经常面临的挑战。当数据量达到百万甚至千万级别时,传统的from + size分页方式会遭遇严重的性能瓶颈。本文将全面剖析Elasticsearch提供的两种深度分页解决方案:Search AfterScroll API,从原理到实践,帮助您彻底掌握这两种技术。

一、传统分页的致命缺陷

1. from + size分页的工作原理

GET /products/_search
{
  "from": 10000,  // 从第10000条开始
  "size": 10,     // 获取10条记录
  "query": {
    "match_all": {}
  }
}

2. 分布式系统中的执行流程

  1. 查询分发:协调节点向索引的所有分片(假设5个)发送查询请求
  2. 分片处理:每个分片必须本地计算并返回10010条数据(from + size)
  3. 结果聚合:协调节点收集所有分片的结果(5 × 10010 = 50050条)
  4. 全局排序:对50050条数据进行排序
  5. 结果截取:最终返回10000-10010条的结果

3. 性能瓶颈分析

数据量分页深度分片数内存消耗计算响应时间
100万条第1000页5(1000×10 + 10)×5 ≈ 5万条800ms
1000万条第1万页5(10000×10 + 10)×5 ≈ 50万条5s+
1亿条第10万页5(100000×10 + 10)×5 ≈ 500万条OOM风险

核心问题:内存消耗与(from + size) × 分片数成正比,深度分页会导致集群内存溢出

二、Scroll API解决方案

image.png

1. 核心设计思想

Scroll创建搜索上下文快照

  • 初始化时建立数据快照(类似数据库事务快照)
  • 通过游标(cursor)批量获取结果
  • 适合离线的批处理操作

2. 完整使用示例

初始化Scroll(创建快照)

POST /products/_search?scroll=5m  // 快照保留5分钟
{
  "size": 500,      // 每批获取500条
  "sort": ["_doc"], // 最优性能排序方式
  "query": {
    "range": {
      "price": {"gte": 100}
    }
  },
  "_source": ["id", "name"]  // 只返回必要字段
}

获取后续批次

POST /_search/scroll
{
  "scroll": "5m",   // 每次续期5分钟
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAA..."
}

必须手动释放资源

DELETE /_search/scroll
{
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAA..."
}

实现个go代码

func ScrollQuery(ctx context.Context, query interface{}, callback func(interface{}) error) error {
    // 初始化滚动查询
    scroll := _client.Scroll().Index(_index)
    
    // 执行初始查询
    res, err := scroll.Query(query).
        Scroll("1m").
        Sort("_score", false).
        Size(1000).
        Do(ctx)
    if err != nil {
        logs.CtxWarn(ctx, "查询执行失败: %v", err)
        return err
    }
    
    // 确保最终清理滚动上下文
    defer func(initialScrollId string) {
        if initialScrollId != "" {
            _, err := _client.ClearScroll(initialScrollId).Do(ctx)
            logs.CtxInfo(ctx, "释放滚动ID: %v", err)
        }
    }(res.ScrollId)
    
    scrollId := res.ScrollId
    
    // 处理结果的函数
    processHits := func(hits interface{}) error {
        return extractAndCallback(hits, callback)
    }
    
    // 处理初始结果
    if err := processHits(res.Hits); err != nil {
        logs.CtxWarn(ctx, "处理结果失败: %v", err)
        return err
    }
    
    // 滚动获取后续结果
    for {
        res, err = _client.Scroll("1m").ScrollId(scrollId).Do(ctx)
        if err != nil {
            return err
        }
        
        // 处理滚动ID变化
        if res.ScrollId != scrollId {
            logs.CtxInfo(ctx, "滚动ID变化: %s -> %s", scrollId, res.ScrollId)
            _, err := _client.ClearScroll(scrollId).Do(ctx)
            logs.CtxInfo(ctx, "释放旧滚动ID: %v", err)
        }
        
        scrollId = res.ScrollId
        
        // 处理当前批次结果
        if err := processHits(res.Hits); err != nil {
            return err
        }
    }
}

// 抽象的结果提取和回调处理函数
func extractAndCallback(hits interface{}, callback func(interface{}) error) error {
    // 实际项目中需要实现具体的结果提取逻辑
    // 这里仅保留框架
    return nil
}

Size(1000) 仅表示每次 Scroll 查询返回的文档数量(每批 1000 条)

当 ES 中存在 5600 条匹配文档时,整个 Scroll 查询过程是这样的:

  1. 首次查询阶段

    • 执行查询时,ES 会找到所有 5600 条匹配文档
    • 对这些文档进行全局排序(按_score降序)
    • 保存这 5600 条文档的元数据(主要是在各分片上的位置信息和排序后的顺序)形成快照
    • 然后从快照中提取前 1000 条文档的完整数据返回给客户端
  2. 循环滚动阶段

    • 第一次循环:通过 scrollId 从快照中提取 1001-2000 条文档数据
    • 第二次循环:提取 2001-3000 条文档数据
    • 第三次循环:提取 3001-4000 条文档数据
    • 第四次循环:提取 4001-5000 条文档数据
    • 第五次循环:提取 5001-5600 条文档数据(最后一批 600 条)
  3. 核心特点

    • 首次查询的主要开销是生成包含 5600 条文档位置信息的快照
    • 后续循环不再执行完整查询,只是从已生成的快照中按顺序提取数据
    • 每次提取的数量由Size(1000)控制,但总提取量是所有匹配的 5600 条

Scroll 查询生成的快照(包含文档位置信息、排序状态等元数据)保存在 Elasticsearch 集群的节点内存中,而不是客户端本地机器。

3. 关键技术细节

性能优化方案

  1. 并行加速(Sliced Scroll)

    POST /products/_search?scroll=5m
    {
      "slice": {
        "id": 0,        // 分片编号(0到max-1)
        "max": 4        // 总分片数(通常等于主分片数)
      },
      "size": 500,
      "sort": ["_doc"]
    }
    
  2. 资源管理最佳实践

    • 设置合理的scroll超时(通常2-10分钟)

    • 使用完成后立即释放scroll资源

    • 监控打开的scroll上下文数量:

      GET /_nodes/stats/indices/search
      

内存管理警告

  • 每个scroll上下文会占用约1MB堆内存
  • 1000个scroll = 1GB堆内存占用
  • 必须实现超时自动清理机制

4. 使用场景

  • 全量数据导出(ETL流程)
  • 大数据量的后台批处理
  • 不需要实时性的数据分析

5.对比

场景Scroll 查询(scroll_idfrom+size分页
数据量大量数据(如 1 万 +),需要全量导出少量数据(如 1 万以内),分页浏览
实时性要求低(基于快照,不反映后续数据变化)高(每次查询反映最新数据状态)
典型用途ETL 数据迁移、批量导出、全量数据分析网页 / APP 列表分页、用户交互式查询
分页深度支持极深分页(如 100 万条分 1000 批)仅支持浅分页(from过大时性能骤降)

三、Search After解决方案

1. 核心设计思想

Search After采用游标式分页设计:

  • 基于上一页最后一条的排序值作为"书签"
  • 完全避免from参数的深度翻页计算
  • 保持实时查询能力

完整过程

search_after 每次拿数据的过程可概括为「锚点定位→范围过滤→局部取数→全局聚合」四步,核心是用前一页末尾的排序值作为 “游标”,精准定位下一页起点:

  1. 首次查询(无锚点)

    • 按排序规则(如 _score 降序 + _seq_no 降序)对匹配文档排序,直接取前 Size 条(如 5000 条)。
    • 记录最后一条文档的排序值(如 _score=3.2_seq_no=15000)作为 “锚点”。
  2. 后续查询(带锚点)

    • 传入锚点:以上次记录的 _score 和 _seq_no 作为 search_after 参数。
    • 范围过滤:ES 自动生成条件(如 _score < 3.2 或 _score=3.2且_seq_no < 15000),仅匹配锚点之后的文档。
    • 局部取数:各分片按排序规则,从过滤结果中取前 Size 条。
    • 全局聚合:协调节点汇总分片结果,再按全局排序取前 Size 条返回,更新锚点。
  3. 终止条件

    • 当返回结果为空(len(hits)=0),表示所有数据已取完。

核心优势:每次仅处理 “锚点后的数据”,避免全局偏移量计算,性能不随分页深度下降,且保持实时性。

我的理解

search_after的过程是这样的:假设查询语句设置成根据score降序、seq_no降序,每次返回500条。第一次查询时,每个分片会按条件排序(score降序、seq_no降序)对匹配文档排序,并非严格拉取500条,而是返回多于500条的结果以确保全局排序需求;协调节点收集所有分片结果后进行全局排序,最终截取前500条返回给客户端。第二次查询时,客户端带上上一页最后一条文档的score值和seq_no值作为锚点,各分片先按锚点过滤出符合条件的文档(score小于锚点score,或score等于锚点score且seq_no小于锚点seq_no),再按排序规则返回多于500条的结果,经协调节点全局排序后取前500条返回,后续查询以此类推。

2. 完整使用示例

首次查询(必须指定稳定排序)

GET /orders/_search
{
  "size": 10,
  "sort": [
    {"order_date": "desc"},  // 主排序字段
    {"_id": "asc"}           // 确保排序唯一性的辅助字段
  ],
  "track_total_hits": false  // 禁用总命中数计算提升性能
}

获取下一页

GET /orders/_search
{
  "size": 10,
  "search_after": ["2023-05-20T08:00:00", "order123"], // 上一页最后结果的排序值
  "sort": [
    {"order_date": "desc"},  // 必须与首次查询一致
    {"_id": "asc"}
  ]
}

3. 关键技术细节

排序字段选择原则

  1. 必须包含唯一字段(如_id)作为最后排序条件

  2. 避免使用评分_score(可能因分片不同而变化)

  3. 推荐使用日期+ID组合

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

性能优化技巧

  • 禁用总命中数计算"track_total_hits": false

  • 合理设置批次大小:通常50-500条/页

  • 使用docvalue_fields替代_source

    {
      "docvalue_fields": ["order_date", "status"],
      "_source": false
    }
    

4. 适用场景

  • 用户界面的实时分页浏览
  • 需要反映最新数据的搜索场景
  • 顺序翻页需求(不支持随机跳页)

四、深度对比与选型指南

1. 技术特性对比表

特性维度Search AfterScroll API
实时性✔️ 实时获取最新数据❌ 基于快照创建时的数据状态
内存效率✔️ 仅需维护当前批次数据❗ 需在服务端维护搜索上下文
排序灵活性❗ 必须指定稳定排序规则✔️ 支持任意排序(但_doc最快)
跳页能力❌ 只能顺序翻页❌ 只能顺序遍历
最大深度✔️ 理论上无限制✔️ 理论上无限制
资源管理✔️ 自动管理❗ 需手动清理
典型QPS✔️ 高(适合C端接口)❗ 低(适合后台任务)

2. 选型决策树

image.png