深度分页及其替代方案
1.深度分页
深度分页 指的是在 Elasticsearch 中查询结果集 非常靠后的页码(例如第 1000 1000 1000 页,每页 10 10 10 条数据,即 from=10000
)。它通常表现为使用 from + size
参数组合来获取远端的分页数据。例如:
GET /my_index/_search
{
"from": 10000, // 跳过前10000条
"size": 10, // 取10条
"query": { ... }
}
2.为什么不推荐深度分页
2.1 性能问题(核心原因)
Elasticsearch 的分页原理是:
- 协调节点 需要从所有分片(
shard
)收集满足条件的文档。 - 计算全局排序后,临时存储
from + size
范围内的所有文档。 - 最后返回
size
条结果。
问题:
- 当
from
值很大时(如from=100000
),每个分片需要 先查100000+size
条数据 到内存,再汇总排序。 - 数据量越大,内存和 CPU 消耗呈线性增长,可能导致 内存溢出。
2.2 资源消耗对比
分页方式 | 内存消耗 | 响应时间 | 适用场景 |
---|---|---|---|
浅分页(from=0 ) |
低(仅缓存 size 条) |
毫秒级 | 前几页数据 |
深度分页(from=10000 ) |
高(缓存 10000+size 条) |
秒级甚至超时 | 不推荐直接使用 |
2.3 实际限制
- Elasticsearch 默认限制:
from + size ≤ 10,000
(可通过index.max_result_window
调整,但 调高会加剧风险)。 - 即使调高限制,性能也会急剧下降。
3.深度分页的替代方案
3.1 方案一:Search After(推荐)
原理:
- 利用上一页的排序值(如
_shard_doc
或自定义字段)作为游标。 - 不跳过记录,直接定位到下一页的起始点。
示例:
// 第一页
GET /blog_posts/_search
{
"size": 10,
"sort": [ // 必须包含唯一性字段(如 _id)
{ "publish_date": "desc" },
{ "_id": "asc" }
]
}
// 后续页(用上一页最后一条的排序值)
GET /blog_posts/_search
{
"size": 10,
"search_after": ["2023-04-05", "abc123"], // 上一页最后记录的 publish_date 和 _id
"sort": [
{ "publish_date": "desc" },
{ "_id": "asc" }
]
}
优点:
- 性能稳定(不受页码影响)。
- 适合无限滚动、批量导出等场景。
3.1.1 为什么 Search After 性能更高
用一个生活中的例子来理解:假设你要在图书馆的 100万本书
中,每次找 10本
符合特定条件的书,并按出版日期排序。
- 传统分页(
from/size
)的做法:- 你要第 1000 页(即第 9991-10000 本书)。
- 图书管理员必须:
- 先取出前 10000 本书,堆在桌子上(内存)。
- 排序这 10000 本,扔掉前 9990 本。
- 最后给你第 9991-10000 本。
- 问题:每次翻到深页码,都要重复 “取书 + 排序 + 扔书” 的过程,桌子(内存)可能堆不下!
- Search After 的做法:
- 你记住 当前看到的最后一本书的出版日期和编号(例如:“
2023-05-01, 编号ABC123
”)。 - 下次直接告诉图书管理员:“
我要比【2023-05-01, ABC123】更早的10本书
”。 - 图书管理员:
- 直接定位到这本书的位置(通过索引)。
- 往后数 10 本给你。
- 优势:不需要临时存储前面的 9990 本书!
- 你记住 当前看到的最后一本书的出版日期和编号(例如:“
🚀 简而言之:Search After 快是因为它 不傻乎乎地从头数,而是像书签一样 “记住位置”,直接跳到下一页开始的地方。
3.1.2 技术原理简化
分页方式 | 工作方式 | 性能消耗 |
---|---|---|
from/size |
每次从头计算,临时存储 from+size 条数据 |
随 from 增大 指数级上升 |
search_after |
像书签一样记住位置,直接跳转 | 恒定(与页码无关) |
3.1.3 关键区别
from/size
:- 类似 “每次从第一页开始翻”
- 计算:
(from + size) × 分片数
→ 内存爆炸 💥
search_after
:- 类似 “记住看到哪里,接着往下读”
- 计算:
size × 分片数
→ 内存恒定 📊
3.1.4 适用场景
from/size
:- 前几页(如首页、搜索结果前 10 条)
search_after
:- 无限滚动(如微博、朋友圈)
- 深度分页(如第 100 页+)
3.2 方案二:Scroll API(适用于大批量导出)
原理:
- 创建快照(
snapshot
),保持查询上下文。 - 逐批获取数据,类似数据库游标。
示例:
// 1. 初始化 Scroll(保留1分钟)
GET /blog_posts/_search?scroll=1m
{
"size": 100,
"query": { "match_all": {} }
}
// 2. 后续获取(用返回的 scroll_id)
GET /_search/scroll
{
"scroll": "1m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gB..."
}
// 3. 最后手动清理
DELETE /_search/scroll
{
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gB..."
}
优点: 适合非实时、大数据量导出(如报表生成)。
缺点:
- 不支持实时数据(快照期间数据变化不会反映)。
- 占用服务器资源,用完需手动清理(
DELETE /_search/scroll
)。
3.2.1 详细解释
scroll=1m
的含义1m
=1 分钟
(时间单位,类似30s
=30秒
,2h
=2小时
)- 表示 Elasticsearch 会保留这次 Scroll 查询的上下文(如排序结果、快照数据)1 分钟。
- 超时后,Scroll 会自动失效,资源被释放。
- 为什么需要设置超时?
- Scroll 会占用服务器内存和资源,长时间不释放可能导致性能问题。
- 设置超时(如
1m
)是让 Elasticsearch 自动清理 不再使用的 Scroll 查询。
- 如何调整超时?
- 根据数据量调整,例如:
- 大数据导出:
scroll=5m
( 5 5 5 分钟) - 小批量查询:
scroll=30s
( 30 30 30 秒)
- 大数据导出:
- 根据数据量调整,例如:
- 续期 Scroll
如果处理时间较长,可以在每次请求时刷新超时时间:GET /_search/scroll { "scroll": "1m", // 重新设置为1分钟 "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVY..." }
3.2.2 类比理解
把 Scroll 想象成图书馆的 “临时书架”:
- 你告诉图书管理员:“我需要 1 分钟(
scroll=1m
)来挑书”。 - 1 分钟内,书架上的书(查询结果)保持不变,你可以慢慢选。
- 超时后,图书管理员会把书架清空(释放资源)。
- 如果你需要更多时间,可以喊:“再给我 1 分钟!”(续期 Scroll)。
3.2.3 注意事项
- 不要滥用长超时(如
scroll=1h
),否则可能导致集群内存压力。 - 用完务必清理(即使未超时):
DELETE /_search/scroll { "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gB..." }
- Scroll 适用于离线任务(如导出数据),不适用于实时分页。
3.3 方案三:基于时间范围的分页
原理:
- 用时间字段(如
publish_date
)作为分页条件。 - 每页查询限定时间范围。
示例:
GET /blog_posts/_search
{
"size": 10,
"query": {
"range": {
"publish_date": { "lte": "2023-03-01" } // 第二页改为 "lte": "上一页的最小日期"
}
},
"sort": [ { "publish_date": "desc" } ]
}
适用场景:
- 按时间线分页(如新闻列表)。
3.4 总结:如何选择分页方式
场景 | 推荐方案 | 原因 |
---|---|---|
用户前端分页(前几页) | from + size |
简单易用,性能可接受 |
深度分页(如第 100 100 100 页) | Search After |
避免内存爆炸,性能稳定 |
大数据量导出 | Scroll API |
适合离线处理,但需注意资源释放 |
按时间滚动 | 时间范围查询 | 利用业务字段天然分页 |
关键建议
- 禁止生产环境使用大
from
值(如from=100000
)。- 若必须调整
index.max_result_window
,需评估集群资源。- Search After 是深度分页的最佳实践,兼容大多数场景。