Elasticsearch 深度分页完整详解
一、什么是深度分页问题
ES原生分页使用 from + size 参数:from代表跳过多少条,size代表本次返回多少条。
示例:
from=9990,size=10,代表跳过9990条,取后面10条,也就是第1000页。
ES是分布式架构,索引数据会拆分成多个分片存储 ,from+size在深分页时底层执行流程:
- 协调节点把查询请求转发给索引每一个分片;
- 每个分片各自查询出
from + size条文档,返回给协调节点; ⚠️重点:不是只查10条,每个分片都要拿到前(from+size)条数据 。比如索引有5个分片,查第10000页,每个分片都要返回10010条文档,协调节点一共收到5*10010条数据。 - 协调节点把所有分片返回的数据全部收集到内存,做全局排序;
- 在内存中舍弃前面from条记录,仅仅留下size条返回给客户端。
带来三大负面影响
- 高内存消耗:协调节点需要把大量文档加载到堆内存做排序。页码越深,内存存放的数据越多,堆内存占用急剧上涨。
- 查询性能低下:分片检索、网络传输、协调节点全局排序都要消耗CPU,页码越深响应时间越长。
- 集群稳定性风险:高并发深度分页请求,会把集群CPU、内存打满,严重直接节点OOM崩溃。
默认限制 index.max_result_window=10000
ES做保护:from + size > 10000,直接抛出异常拒绝查询。
❗千万不要直接调大这个参数! 调参数只是把报错屏蔽,底层分片查询、内存排序的资源消耗一点不会减少,只是把风险隐藏,线上极易引发OOM,生产环境禁止修改。
底层本质:ES倒排索引擅长条件过滤检索;但是跳过大批量文档,没有捷径,必须读取数据、全局排序,资源开销巨大。
二、主流解决方案详解
1. Scroll API(滚动游标)
Scroll 的核心是索引快照 。第一次执行scroll查询的时候,生成一份索引当时的静态快照,产出scroll_id。这个快照固定住查询那一刻索引的数据视图。后续每次请求带上scroll_id,就持续从快照位置读取下一批数据。
response = es.scroll(
scroll='2m', # 快照存活时间,2分钟内保留这份视图
scroll_id=scroll_id
)
✅优点:适合大批量遍历,性能稳定,不受10000条限制。
❌缺点:
- 快照会占用集群内存、磁盘资源;到达
scroll超时时间才会释放,如果程序异常中断,快照不会主动回收,持续占用资源。 - 读取静态快照数据:在scroll会话期间,索引新增、修改、删除的文档完全看不到,拿不到实时最新数据。
- 没有分页概念,只适合全量拉取,官方明确不推荐用于前端业务分页。
✅适用场景:数据导出、索引重建迁移、全量数据同步,后台一次性批量任务,不用于用户端业务。
2. search_after(排序游标分页)
search_after 没有页码、没有from参数 。原理:拿上一页最后一条文档的排序字段的值作为游标,告诉ES从这个位置往后读取数据。
{
"search_after": [1463538857, "log1234"],
"size":10,
"sort": [{"create_time":"desc"},{"_id":"asc"}]
}
⚠️非常关键:
sort排序字段组合必须全局唯一 。 如果sort字段组合存在重复值,ES无法精准定位断点,会出现文档重复读取、漏读。 实践技巧:业务排序字段 +_id(文档唯一id)组合排序,保证组合一定全局唯一。
原生search_after的致命缺陷
单纯使用search_after没有快照保护 。 ES底层会定期执行段合并(segment merge),同时业务有新增、修改、删除文档,会改变文档排序顺序。分页过程中索引发生变化,文档顺序偏移,就会出现漏数据、重复读到同一条数据。
ES7最佳实践:search_after + PIT(Point‑in‑Time 时间点快照)
PIT,时间点快照,专门解决search_after的数据错乱问题。 完整流程:
- 先创建PIT快照,设置
keep_alive存活时长,拿到pit.id;创建快照那一刻固定索引视图; - 每一次翻页请求,都携带同一个
pit.id,整个分页会话都读取这份快照里面的数据;业务新增修改删除不会影响本次分页结果,保证分页一致性; - 分页全部完成之后,必须手动调用接口删除PIT,释放资源。如果不删除,PIT快照会一直占用内存,造成内存泄漏。
✅优点:突破10000条限制,性能稳定,适合线上用户业务。 ❌缺点:只能向后顺序翻页,不能随机跳转到指定页码,适合下拉加载更多的信息流场景,不能实现"跳转到第500页"。
对比Scroll:PIT是会话级轻量快照;Scroll偏向大批量离线导出;线上业务滚动分页优先
search_after+PIT。
3. 业务层限制分页深度
不改动ES查询逻辑,从产品交互层面规避深度分页请求。 示例:
- UI限制用户最多只能查看前100页;超过最大页码提示"请增加筛选条件缩小范围";
- 抛弃数字页码组件,改用下拉加载更多交互。
✅优点:改动成本最低,从源头杜绝深度分页请求,减轻集群压力。 ❌缺点:产品需要做出妥协,无法满足直接跳转到大页码的需求。
三、各分页方案对比
| 方案 | 支持深度分页 | 支持随机跳页 | 数据一致性 | 适用场景 | 主要缺点 |
|---|---|---|---|---|---|
| from + size | ❌ | ✅ | 实时最新数据 | 浅分页小列表 | 深分页内存暴涨,默认上限10000条 |
| Scroll API | ✅ | ❌ | 快照静态数据 | 全量导出、索引迁移 | 占用集群资源,看不到实时数据,不适合业务搜索 |
| search_after(无PIT) | ✅ | ❌ | 无快照,容易错乱 | 几乎不会变更的静态数据列表 | 索引增删改、段合并会漏、重复数据 |
| search_after + PIT | ✅ | ❌ | 快照保证会话内一致 | 业务信息流、全文检索滚动分页 | PIT占用资源,不支持跳页 |
四、生产环境选型总结
- 用户端全文检索、APP下拉滚动信息流 :优先使用
search_after + PIT;会话结束务必删除PIT,防止内存泄漏。 - 后台大批量导出数据、索引迁移同步:使用Scroll API。
- 后台管理系统,必须支持页码跳转:不要在ES做深度分页,把查询下沉到MySQL做分页处理。
- 产品允许的前提下,优先业务层限制最大页码,从根源规避深分页。
补充:ES深度分页 VS MySQL深度分页(面试高频对照)
- MySQL
limit offset,size:offset很大,数据库需要扫描offset条记录然后丢弃; 优化手段:子查询书签分页、keyset游标分页;MySQL没有快照机制,分页过程中数据发生删除新增,会出现分页错乱。 - MySQL keyset游标分页 ≈ ES search_after;
- ES额外提供PIT快照能力,可以在分页会话内固定数据视图,规避数据变更带来的错乱,MySQL原生没有这个能力。
区别重点:MySQL游标分页读取的是实时数据;ES搭配PIT可以读取快照视图,会话内屏蔽数据变更。
面试口述速记
ES深度分页问题来源于
from+size分布式分页机制。查询深页码时,每个分片都要查询from+size条数据,全部汇总到协调节点内存做全局排序,内存CPU消耗巨大,容易OOM,ES默认限制总和不能超过10000,不建议调大参数。 Scroll API依靠scroll_id快照,适合离线全量导出,看不到实时数据,不适合线上业务。 search_after拿上一页排序结果作为游标,没有页码;单独使用,索引变更会造成分页错乱。ES7推荐搭配PIT时间点快照,固定会话的数据视图保证一致性,用完手动释放PIT资源;但只能顺序向后翻页,不能随机跳页。 还可以业务层限制最大页码。后台需要跳页码的场景,把查询交给MySQL处理。
面试拓展追问
Q:PIT和Scroll快照有什么区别? A:
- Scroll面向大批量全量导出,数据量可以很大;PIT偏向业务分页,轻量级会话快照。
- Scroll使用scroll_id;PIT使用pit.id。
- Scroll快照会保存完整的读取进度;PIT只固定索引视图,读取进度交给search_after游标维护。
- Scroll超时自动释放;PIT建议业务代码手动删除,避免内存泄漏。
Q:为什么sort字段必须全局唯一? A:如果sort字段存在多条文档排序值完全一样,ES无法精准定位search_after断点,无法区分两条数据,就会出现重复读取或者跳过文档。业务通常用业务排序字段拼接_id保证组合唯一。
Q:那PIT里面的数据是旧的,用户看不到新数据怎么办? A:PIT是单页翻页会话内部保持一致性;用户重新刷新页面,会创建全新PIT快照,读取最新索引数据。只有同一次下拉翻页会话期间,才使用旧快照视图,不会影响刷新首页。