Elasticsearch 分页方式详解:from + size

基本概念

from + size 是 Elasticsearch 中最直观的分页方式,也是最容易理解的分页机制。其中:

  • from 参数:表示从结果集的第几条记录开始返回(从0开始计数)
  • size 参数:表示每次返回的记录数量

示例查询:

{
  "query": {
    "match_all": {}
  },
  "from": 10,
  "size": 5,
  "sort": [
    {"timestamp": "desc"}
  ]
}

这个查询表示:从查询结果中跳过前10条记录,然后返回接下来的5条记录,并按timestamp字段降序排列。

实现原理详解

from + size 分页方式的底层实现涉及多个步骤:

  1. 查询分发阶段

    • 协调节点(接收请求的节点)将查询请求分发到索引的所有相关分片上
    • 每个分片都是独立且平等的,都会收到相同的查询请求
  2. 分片处理阶段

    • 每个分片独立执行查询,在自己的数据中查找匹配的文档
    • 每个分片会返回该分片上排名前(from + size)的文档
      • 例如,from=10000,size=10时,每个分片需要返回10010个文档
    • 分片会根据查询的排序规则(如_score或指定字段排序)对结果进行预排序
  3. 结果合并阶段

    • 协调节点收集所有分片返回的结果
    • 将所有分片的结果合并成一个全局排序列表
    • 这个合并过程需要:
      • 维护一个大小为(from + size)的优先队列
      • 比较来自不同分片的文档的排序值
      • 确保全局排序的正确性
  4. 结果截取阶段

    • 从合并后的全局排序列表中
    • 跳过前from条记录
    • 取出接下来的size条记录作为最终结果

性能特点

优点

  • 实现简单直观,易于理解
  • 对于前几页的小规模分页(如from < 1000)性能良好
  • 支持随机跳页(可以直接指定from值)

局限性

  1. 深度分页问题

    • 当from值很大时(如from=10000),性能会显著下降
    • 原因:
      • 每个分片需要构建一个大小为(from + size)的优先级队列
      • 协调节点需要处理大量数据(N个分片 × (from + size)个文档)
      • 内存消耗与from + size成正比
  2. 资源消耗

    • 需要占用大量堆内存来存储中间结果
    • 网络传输开销大(分片→协调节点传输大量数据)
  3. 最大限制

    • Elasticsearch默认限制from + size ≤ 10000
    • 可通过index.max_result_window设置调整,但不推荐

适用场景

from + size分页最适合:

  • 前几页的小规模分页(如用户界面上的前几页)
  • 不需要深度分页的应用场景
  • 结果集较小的查询

对于需要深度分页的场景(如from > 1000),建议考虑使用其他分页方式如search_after或scroll API。

在Elasticsearch中,当需要获取的文档位置超过1000条时(即from参数值大于1000),传统的from/size分页方式会面临严重的性能问题。这是因为:

  1. 内存消耗大:Elasticsearch需要在协调节点上构建一个包含(from + size)条结果的优先队列
  2. 排序开销高:对于深页查询,系统需要对大量文档进行排序和过滤
  3. 分布式计算限制:跨多个分片处理时,协调节点需要收集并排序所有分片的匹配文档

推荐的替代分页方案

1. search_after 分页方式

search_after是Elasticsearch 5.x版本引入的高效分页机制,特别适合深度分页场景:

GET /your_index/_search
{
    "size": 10,
    "query": {
        "match_all": {}
    },
    "sort": [
        {"timestamp": "desc"},
        {"_id": "asc"}  // 必须包含至少一个唯一值字段作为排序条件
    ],
    "search_after": [1463538857, "654323"]  // 上一页最后一条记录的sort值
}

 

最佳实践

  • 仅在前几页(from < 1000)使用
  • 结合过滤条件减少结果集
  • 避免在排序字段上有高基数(high cardinality)
  • 考虑使用"index.max_result_window"设置适当的值(默认10000)

方式二:scroll分页详解

scroll分页概述

scroll是一种基于游标的分页方式,专门设计用于高效遍历Elasticsearch中的大量数据(可能达到数百万甚至数十亿条记录)。与传统的from+size分页方式不同,scroll不需要在每次请求时重新计算整个搜索,而是通过维护一个持久化的搜索上下文来实现高效的数据遍历。

实现原理深度解析

scroll分页方式的底层实现机制与数据库游标(cursor)概念类似,其核心工作原理包含以下关键环节:

  1. 搜索上下文初始化

    • 当执行带有scroll参数的搜索查询时,Elasticsearch会在协调节点创建一个搜索上下文(search context)
    • 该上下文会对当前索引状态创建一个一致性视图(类似于数据库的快照隔离级别)
    • 上下文存储的内容包括:原始查询条件、排序方式、聚合参数、highlighting设置等所有搜索相关配置
  2. 分布式数据收集

    • 协调节点会将查询分发到所有相关分片
    • 每个分片会在本地执行查询并缓存匹配的文档ID和排序值(不立即获取完整文档)
    • 协调节点收集所有分片的初步结果并合并排序
  3. 结果返回机制

    • 系统返回第一批结果(基于size参数指定的数量)
    • 同时生成一个加密的scroll_id,该标识符包含:
      • 搜索上下文ID
      • 当前游标位置
      • 分片路由信息
      • 时间戳等元数据
  4. 后续结果获取

    • 客户端使用scroll_id请求更多结果时,系统会:
      • 解密scroll_id恢复上下文信息
      • 从各分片缓存中获取下一批文档ID
      • 按需加载完整文档内容(_source)
      • 更新游标位置
    • 此过程可以重复执行直到遍历完所有匹配文档

详细使用方式

初始化scroll搜索

POST /my_index/_search?scroll=5m
{
    "size": 500,
    "query": {
        "range": {
            "timestamp": {
                "gte": "2023-01-01",
                "lte": "2023-12-31"
            }
        }
    },
    "sort": [
        {"timestamp": "asc"},
        {"_doc": "desc"}
    ],
    "_source": ["field1", "field2"],
    "track_total_hits": true
}

关键参数说明:

  • scroll=5m:设置上下文存活时间为5分钟(支持的时间单位:d=天,h=小时,m=分钟,s=秒)
  • size:每批次返回文档数(建议值:对于中小型文档500-1000,大型文档50-100)
  • sort:推荐包含_doc排序以提高性能(避免评分计算)
  • _source:限制返回字段减少网络传输
  • track_total_hits:精确统计总命中数(默认上限10000)

处理scroll响应

典型响应结构:

{
    "_scroll_id": "FGluY2x1ZGVfY29udGV4dF91dW...",
    "took": 45,
    "timed_out": false,
    "terminated_early": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 125000,
            "relation": "eq"
        },
        "max_score": null,
        "hits": [
            {
                "_index": "my_index",
                "_type": "_doc",
                "_id": "1",
                "_score": null,
                "_source": {
                    "field1": "value1",
                    "field2": "value2"
                },
                "sort": [  // 当使用自定义排序时包含
                    1672531200000,
                    "1"
                ]
            }
            // 更多文档...
        ]
    }
}

后续scroll请求

POST /_search/scroll 
{
    "scroll": "5m",
    "scroll_id": "FGluY2x1ZGVfY29udGV4dF91dW..."
}

注意事项:

  • 每次scroll请求都应传递相同的scroll参数值
  • 建议使用最新收到的scroll_id(系统可能返回新标识符)
  • 当hits数组为空时表示已遍历完所有结果

清理scroll资源

完成遍历后应主动释放资源:

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

或清除所有scroll上下文:

DELETE /_search/scroll/_all

性能优化建议

  1. 上下文存活时间

    • 根据数据量和网络状况合理设置(通常5-30分钟)
    • 太短会导致频繁超时,太长会占用过多堆内存
  2. 分片策略优化

    • 对于时间序列数据,按时间范围分片可以提高scroll效率
    • 避免在scroll过程中进行分片重平衡(rolling重启)
  3. JVM堆内存

    • 每个活跃的scroll上下文约占用1-10KB堆内存
    • 监控indices.search.scroll.current指标
  4. 并行scroll

    • 对大索引可以启动多个scroll并行处理不同数据段
    • 使用切片查询(slice query)实现:
POST /my_index/_search?scroll=10m
{
    "slice": {
        "id": 0,
        "max": 4
    },
    "query": {...}
}

典型应用场景

  1. 数据导出

    • 将ES数据导出到数据仓库(如Hive、HDFS)
    • 配合Logstash的elasticsearch输入插件
  2. 批量处理

    • 需要遍历所有匹配文档进行离线计算
    • 机器学习特征工程的数据准备阶段
  3. 索引迁移

    • 使用_reindex API内部实现就是基于scroll
    • 跨集群数据同步场景
  4. 全量分析

    • 需要对整个数据集执行复杂分析(不适用聚合时)
    • 法律合规性审查等场景

与search_after的比较

特性 scroll search_after
实时性 快照视图 实时数据
资源消耗 高(维护上下文)
排序要求 需要稳定排序 必须指定唯一排序
跳页能力 仅顺序遍历 可模拟跳页
最大深度 无限制 无限制
适用场景 全量导出/迁移 深度分页/实时查询

  

 

方式三:search_after 深度详解

search_after 概述

search_after是一种基于排序值的分页方式,它允许我们根据上一页的最后一条数据的排序值来获取下一页的数据。这种方式特别适合处理大数据量的深度分页场景(如第100页后的数据),相比传统from+size方式有显著的性能优势。

实现原理详解

search_after 分页方式的原理是基于上一次查询的结果来确定下一次查询的起始位置。具体工作流程如下:

  1. 排序和返回结果

    • Elasticsearch首先执行查询并按照指定的排序字段对结果进行排序
    • 返回第一批结果(如每页10条记录)
    • 排序字段通常需要组合多个字段以确保唯一性,如[价格,创建时间]
  2. 确定下一次查询的起始位置

    • 客户端记录最后一条结果的排序字段值
    • 例如,对于排序字段[price,created_at],记录最后一条的[129.99,"2023-10-23T12:00:00Z"]
  3. 获取下一页数据

    • 将记录的排序值作为search_after参数提交给下一次查询
    • Elasticsearch会跳过所有排序值"小于"该值的文档
    • 直接返回该位置之后的文档,无需计算全局偏移量

性能优势

  • 不需要像from+size那样合并和排序所有分片返回的结果
  • 不需要像scroll那样维护搜索上下文和快照
  • 查询响应时间不受页码深度影响,第2页和第200页性能相当

详细使用方式

1. 索引结构设计

以电商产品索引为例,建议设计如下字段:

{
  "mappings": {
    "properties": {
      "product_id": {
        "type": "keyword"  // 产品唯一标识
      },
      "product_name": {
        "type": "text",
        "fields": {
          "keyword": {"type": "keyword"}
        }
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100  // 精确到小数点后两位
      },
      "created_at": {
        "type": "date",
        "format": "strict_date_optional_time||epoch_millis"
      },
      "category": {
        "type": "keyword"  // 产品分类
      }
    }
  }
}

2. 初始查询(第一页)

获取按价格降序和创建时间升序排列的第一页产品:

GET /products/_search
{
  "size": 10,
  "query": {
    "bool": {
      "must": [
        {"match": {"category": "electronics"}}  // 查询条件示例
      ]
    }
  },
  "sort": [
    {"price": {"order": "desc"}},
    {"created_at": {"order": "asc"}},
    {"product_id": {"order": "asc"}}  // 确保排序唯一性
  ]
}

3. 处理响应示例

典型响应片段:

{
  "hits": {
    "hits": [
      // ...前9个文档...
      {
        "_index": "products",
        "_id": "prod_10086",
        "_score": null,
        "_sort": [
          129.99,
          "2023-10-23T12:00:00Z",
          "prod_10086"
        ],
        "_source": {
          "product_id": "prod_10086",
          "product_name": "Premium Headphones",
          "price": 129.99,
          "created_at": "2023-10-23T12:00:00Z",
          "category": "electronics"
        }
      }
    ]
  }
}

4. 获取下一页

使用最后一篇文档的_sort值作为search_after参数:

GET /products/_search
{
  "size": 10,
  "query": {
    "bool": {
      "must": [
        {"match": {"category": "electronics"}}  // 保持相同查询条件
      ]
    }
  },
  "sort": [
    {"price": {"order": "desc"}},
    {"created_at": {"order": "asc"}},
    {"product_id": {"order": "asc"}}
  ],
  "search_after": [
    129.99,
    "2023-10-23T12:00:00Z",
    "prod_10086"
  ]
}

5. 客户端实现示例(Python)

from elasticsearch import Elasticsearch

es = Elasticsearch()

def get_products_page(category, last_sort=None, size=10):
    body = {
        "size": size,
        "query": {
            "match": {"category": category}
        },
        "sort": [
            {"price": {"order": "desc"}},
            {"created_at": {"order": "asc"}},
            {"product_id": {"order": "asc"}}
        ]
    }
    
    if last_sort:
        body["search_after"] = last_sort
    
    response = es.search(index="products", body=body)
    hits = response["hits"]["hits"]
    
    if hits:
        last_sort = hits[-1]["_sort"]
        return hits, last_sort
    return [], None

# 获取第一页
page1, last_sort = get_products_page("electronics")
print(f"第一页获取到{len(page1)}条记录")

# 获取第二页
if last_sort:
    page2, _ = get_products_page("electronics", last_sort)
    print(f"第二页获取到{len(page2)}条记录")

优缺点深度分析

优点

  1. 深度分页性能优异

    • 测试数据显示,获取第1000页数据时,search_after比from+size快20倍以上
    • 内存消耗恒定,不随页码增加而增长
  2. 实时性较好

    • 每次查询都是实时执行,能看到最新索引的数据
    • 相比scroll的上下文快照,数据更新可见性更好
  3. 灵活的分页控制

    • 可以支持"无限滚动"UI模式
    • 允许跳转到特定位置(如果保存了相应的sort值)

缺点及解决方案

  1. 排序字段要求严格

    • 问题:排序字段组合必须能唯一确定文档位置
    • 解决方案:添加唯一ID字段作为最后排序条件
  2. 数据变更的影响

    • 问题:如果在分页过程中有文档被修改或删除,可能导致某些文档被跳过或重复
    • 解决方案:使用不可变数据模型,或添加version字段参与排序
  3. 客户端复杂度

    • 问题:需要客户端维护sort值状态
    • 解决方案:封装统一的分页组件处理逻辑

三种分页方式对比矩阵

特性 from+size scroll search_after
性能 浅分页快,深分页慢 大数据量稳定 深度分页性能最佳
内存消耗 随from增大而增加 需要维护上下文 恒定
实时性 实时 非实时(快照) 实时
使用复杂度 简单 中等 需要处理sort值
典型应用场景 前几页浏览 数据导出/批量处理 深度分页/无限滚动
最大返回记录数 10000(默认限制) 无硬性限制 无硬性限制
是否支持跳页 是(需保存sort值)
推荐分页深度 <100页 不适用常规分页 任意深度

最佳实践建议

  1. 排序设计

    • 至少使用一个唯一字段作为最后排序条件(如ID)
    • 常用组合:[业务排序字段,时间戳,唯一ID]
  2. 性能优化

    • 将排序字段设置为doc_values=true
    • 避免使用文本字段排序
  3. 错误处理

    • 处理空结果集(已到最后一页)
    • 处理数据变更导致的边界情况
  4. UI集成

    • 无限滚动:持续使用最后一个文档的sort值
    • 传统分页:需要存储每页的起始sort值
  5. 监控指标

    • 记录分页查询响应时间
    • 监控深页码访问频率

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐