在Elasticsearch的实际应用中,高效的分页查询是开发者经常面临的挑战。当数据量达到百万甚至千万级别时,传统的from + size分页方式会遭遇严重的性能瓶颈。本文将全面剖析Elasticsearch提供的两种深度分页解决方案:Search After和Scroll API,从原理到实践,帮助您彻底掌握这两种技术。
一、传统分页的致命缺陷
1. from + size分页的工作原理
GET /products/_search
{
"from": 10000, // 从第10000条开始
"size": 10, // 获取10条记录
"query": {
"match_all": {}
}
}
2. 分布式系统中的执行流程
- 查询分发:协调节点向索引的所有分片(假设5个)发送查询请求
- 分片处理:每个分片必须本地计算并返回10010条数据(from + size)
- 结果聚合:协调节点收集所有分片的结果(5 × 10010 = 50050条)
- 全局排序:对50050条数据进行排序
- 结果截取:最终返回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解决方案
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 查询过程是这样的:
-
首次查询阶段:
- 执行查询时,ES 会找到所有 5600 条匹配文档
- 对这些文档进行全局排序(按
_score降序) - 保存这 5600 条文档的元数据(主要是在各分片上的位置信息和排序后的顺序)形成快照
- 然后从快照中提取前 1000 条文档的完整数据返回给客户端
-
循环滚动阶段:
- 第一次循环:通过 scrollId 从快照中提取 1001-2000 条文档数据
- 第二次循环:提取 2001-3000 条文档数据
- 第三次循环:提取 3001-4000 条文档数据
- 第四次循环:提取 4001-5000 条文档数据
- 第五次循环:提取 5001-5600 条文档数据(最后一批 600 条)
-
核心特点:
- 首次查询的主要开销是生成包含 5600 条文档位置信息的快照
- 后续循环不再执行完整查询,只是从已生成的快照中按顺序提取数据
- 每次提取的数量由
Size(1000)控制,但总提取量是所有匹配的 5600 条
Scroll 查询生成的快照(包含文档位置信息、排序状态等元数据)保存在 Elasticsearch 集群的节点内存中,而不是客户端本地机器。
3. 关键技术细节
性能优化方案
-
并行加速(Sliced Scroll) :
POST /products/_search?scroll=5m { "slice": { "id": 0, // 分片编号(0到max-1) "max": 4 // 总分片数(通常等于主分片数) }, "size": 500, "sort": ["_doc"] } -
资源管理最佳实践:
-
设置合理的scroll超时(通常2-10分钟)
-
使用完成后立即释放scroll资源
-
监控打开的scroll上下文数量:
GET /_nodes/stats/indices/search
-
内存管理警告
- 每个scroll上下文会占用约1MB堆内存
- 1000个scroll = 1GB堆内存占用
- 必须实现超时自动清理机制
4. 使用场景
- 全量数据导出(ETL流程)
- 大数据量的后台批处理
- 不需要实时性的数据分析
5.对比
| 场景 | Scroll 查询(scroll_id) | from+size分页 |
|---|---|---|
| 数据量 | 大量数据(如 1 万 +),需要全量导出 | 少量数据(如 1 万以内),分页浏览 |
| 实时性要求 | 低(基于快照,不反映后续数据变化) | 高(每次查询反映最新数据状态) |
| 典型用途 | ETL 数据迁移、批量导出、全量数据分析 | 网页 / APP 列表分页、用户交互式查询 |
| 分页深度 | 支持极深分页(如 100 万条分 1000 批) | 仅支持浅分页(from过大时性能骤降) |
三、Search After解决方案
1. 核心设计思想
Search After采用游标式分页设计:
- 基于上一页最后一条的排序值作为"书签"
- 完全避免
from参数的深度翻页计算 - 保持实时查询能力
完整过程
search_after 每次拿数据的过程可概括为「锚点定位→范围过滤→局部取数→全局聚合」四步,核心是用前一页末尾的排序值作为 “游标”,精准定位下一页起点:
-
首次查询(无锚点)
- 按排序规则(如
_score降序 +_seq_no降序)对匹配文档排序,直接取前Size条(如 5000 条)。 - 记录最后一条文档的排序值(如
_score=3.2,_seq_no=15000)作为 “锚点”。
- 按排序规则(如
-
后续查询(带锚点)
- 传入锚点:以上次记录的
_score和_seq_no作为search_after参数。 - 范围过滤:ES 自动生成条件(如
_score < 3.2或_score=3.2且_seq_no < 15000),仅匹配锚点之后的文档。 - 局部取数:各分片按排序规则,从过滤结果中取前
Size条。 - 全局聚合:协调节点汇总分片结果,再按全局排序取前
Size条返回,更新锚点。
- 传入锚点:以上次记录的
-
终止条件
- 当返回结果为空(
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. 关键技术细节
排序字段选择原则
-
必须包含唯一字段(如
_id)作为最后排序条件 -
避免使用评分
_score(可能因分片不同而变化) -
推荐使用日期+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 After | Scroll API |
|---|---|---|
| 实时性 | ✔️ 实时获取最新数据 | ❌ 基于快照创建时的数据状态 |
| 内存效率 | ✔️ 仅需维护当前批次数据 | ❗ 需在服务端维护搜索上下文 |
| 排序灵活性 | ❗ 必须指定稳定排序规则 | ✔️ 支持任意排序(但_doc最快) |
| 跳页能力 | ❌ 只能顺序翻页 | ❌ 只能顺序遍历 |
| 最大深度 | ✔️ 理论上无限制 | ✔️ 理论上无限制 |
| 资源管理 | ✔️ 自动管理 | ❗ 需手动清理 |
| 典型QPS | ✔️ 高(适合C端接口) | ❗ 低(适合后台任务) |