当你用 Elasticsearch 实现类似“淘宝订单列表”或者“新闻资讯历史”这类需要翻到第100页、第1000页之后的功能时,你可能已经掉进了一个著名的性能陷阱——深度分页。
这个问题如此经典,以至于几乎每个使用 ES 的开发者都会在面试或实践中遇到。今天,我们就来彻底拆解它,不仅告诉你“是什么”和“为什么”,更给你一套完整的“怎么办”的解决方案。
一、 什么是深度分页?一个看似简单的需求
假设你的 ES 索引中有 1000 万条订单记录。前端需要一个分页列表,每页 10 条。当用户输入页码,点击“跳转”时,会发生什么?
用户点击 第 1 页:系统返回第 1-10 条记录。
用户点击 第 100 页:系统返回第 990-1000 条记录。
用户点击 第 10000 页:系统返回第 99990-100000 条记录。
这个“跳转到非常靠后的页面”的行为,就是深度分页。
二、 为什么深度分页是个“问题”?—— 理解 from + size 的工作原理
绝大多数初学者实现分页,会毫不犹豫地使用 from 和 size 参数:
GET /my_index/_search{"from": 9990, // 从第9990条记录开始"size": 10, // 取10条"query": { ... }}
正是这个看似无害的 from + size,成了性能的杀手。
ES 的分页原理和 MySQL 的 LIMIT offset, size 有本质区别。它的工作流程分为两个阶段:
Query 阶段:
协调节点将请求广播到所有相关分片(假设有 5 个主分片)。
每个分片需要在本地独立地执行查询,并各自计算出满足条件的前
from + size条数据的排序结果。对于
from=9990, size=10,每个分片需要生成自己分片内的前 10000 条数据(9990 + 10)的排序结果,而不仅仅是10条!然后,每个分片将这 10000 条数据的元数据(
_id,_score等)返回给协调节点。Fetch 阶段:
协调节点收到 5 个分片 × 10000 条数据 = 50000 条数据的元数据。
然后,它需要对这 50000 条数据进行全局排序,找出排名前 10000 条(从第9990到第10000条)。
最后,协调节点再根据这最终10条结果的元数据,去对应的分片上拉取完整的文档数据(
_source),返回给客户端。
问题的核心在于:
资源消耗与
from + size成正比。你想翻到第 10000 页,就需要在每个分片排序和保存大量的中间结果。巨大的网络开销:协调节点需要收集和排序海量的元数据。
内存压力:这些中间结果通常需要存储在内存中(JVM Heap),当并发多个深度分页请求时,极易导致协调节点 OOM(内存溢出) 而崩溃。
因此,ES 默认通过 index.max_result_window 参数(通常为 10000)来限制 from + size 的深度,这是一种保护机制。
三、 解决方案:如何优雅地实现深度分页?
面对这个难题,我们有多种武器,关键是根据场景选择最合适的。
方案一:Scroll API —— 快照式遍历(适用于后台大量数据导出)
Scroll 的设计初衷并非为了实时分页,而是为了一次性处理大量数据(如全量导出、数据迁移、离线分析)。
工作原理:第一次查询时,ES 会创建一个数据快照,并返回一个
scroll_id。后续的分页不再使用from,而是通过这个scroll_id来不断获取下一批结果,就像游标一样。优点:避免了重复排序和计算,性能极高。
缺点:
非实时:快照建立后,对索引的新增、修改、删除不会反映到快照中。
占用资源:快照会一直占用资源,直到超时释放。不适合高并发实时请求。
代码示例:
// 1. 初始化,创建快照POST /my_index/_search?scroll=5m // 快照保持5分钟{"size": 100}// 返回一个 `scroll_id`// 2. 后续遍历POST /_search/scroll{"scroll": "5m","scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAA..."}
方案二:Search After —— 实时游标分页(推荐用于代替 from+size)
这是目前解决实时深度分页的最佳实践。它的思想是:“既然不能跳页,那我们就一页一页地往下翻”。
工作原理:使用上一页结果中的一组排序值(Sort Values)作为“游标”,来定位下一页的起始位置。
优点:
实时:能够查询到最新写入的数据。
高性能:避免了
from + size的全局排序问题,每次只取size条数据。缺点:
只能逐页下行:不支持“跳转到任意页码”,类似手机App上的“上拉加载更多”。这是它最大的限制。
要求排序值唯一:排序条件中必须包含一个唯一字段(如
_id),以防止翻页时出现结果重复或丢失。代码示例:
// 第一页GET /my_index/_search{"size": 10,"sort": [{"timestamp": "desc"},{"_id": "asc"} // 唯一性保证]}// 返回结果中会包含一个 "sort" 数组// 第二页及以后,使用上一页最后一条的 sort 值GET /my_index/_search{"size": 10,"search_after": [1640995200000, "abc123"], // 上一页最后一条的 sort 值"sort": [{"timestamp": "desc"},{"_id": "asc"}]}
方案三:业务层面优化 —— 禁止任意跳页(产品设计上规避)
这是最简单、最有效的方案。在很多场景下,用户根本不需要跳到第 10000 页。
“无限滚动”:像抖音、微博那样,不断下拉加载,本质上就是
Search After的 UI 体现。“分段跳页”:Google 搜索那样,只允许你跳转到附近的前后10页。超出范围则提示用户使用更精确的搜索条件。
“仅提供上一页/下一页”:很多后台管理系统采用此方式。
四、 总结与选型建议
|
|
|
|
|
|---|---|---|---|
from + size |
|
|
|
Scroll |
|
|
|
Search After |
|
实时深度分页、App/Web无限加载 |
|
| 业务优化 |
|
绝大多数C端用户场景 |
|
黄金法则:
首先考虑产品设计,能否避免深度跳页?
如果必须深度分页,且是顺序浏览,首选
Search After。如果是一次性的海量数据拉取,使用
Scroll。永远不要在生产环境对用户请求使用大数值的
from + size。
理解了这些,下次当产品经理提出“我们要支持订单列表跳转到任意页”时,你就可以自信地走上前,和他聊聊用户体验与技术实现之间的平衡艺术了。
文末福利
《2025软件测试全套资料》
我整理了3份独家资料助你起步:
1️⃣ 《软件测试学习路线图》:按天拆解学习计划,拒绝迷茫
2️⃣ 《软件测试视频教程》:少走半年弯路
3️⃣ 《软件测试面试文档》:针对性补充技能
扫描下方二维码免费领取
![]()

