海量数据的深度分页查询方案
深度分页的概念和场景
深度分页指的是在数据库或搜索引擎中,当需要访问结果集中非常靠后的数据页时(例如第 1000 页,每页 10 条,即第 10000 条记录之后),系统性能会显著下降的现象。
核心问题在于: 大多数数据库的实现方式并不是直接跳到第 N 页,而是需要先获取并跳过前 (N-1) * SIZE 条记录,这会导致巨大的资源消耗。
以MySQL的 LIMIT 偏移查询为例:
1 | # 查询第 1000 页的10条数据 |
查询到硬盘:根据
WHERE条件和ORDER BY排序,去硬盘读取满足条件的记录。构建完整结果集:在服务器端,MySQL 需要先构建一个包含
(10000 + 20)条记录的临时结果集。跳过偏移量:MySQL 会顺序遍历这个临时结果集,前 10000 条记录会被直接丢弃,不返回给客户端。
返回最终数据:从第 10001 条开始,取 20 条记录返回给客户端。
性能瓶颈:
OFFSET越大,需要读取和丢弃的数据就越多,计算和I/O成本就越高,性能自然急剧下降。
巨大的浪费:只想要 20 条数据,但 MySQL 服务端需要准备、排序、然后丢弃前面 10000 条数据。这是一个
O(N)的线性扫描过程,OFFSET越大,需要跳过的记录就越多,IO 和 CPU 消耗就越大。IO 压力:如果表很大或无法有效使用索引,这个“准备”过程可能涉及大量的磁盘随机读写。
深度分页常用解决方案
以Mysql 为例说明
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 游标分页 | 记录上一页末尾的位置作为起点 | 性能最好,数据一致 | 无法跳页 | 无限滚动、流式数据 |
| 子查询 | 覆盖索引的优化,不需要二次查询 | 简单高效 | 连表查询,不稳定 | 数据量少的简单可跳页查询 |
| 覆盖索引 | 通过索引避免回表,减少I/O | 比纯LIMIT快 |
优化有限,需两次查询 | 查询数据少的简单分页 |
| 限制深度 | 业务上不允许访问太深的页 | 简单有效 | 功能受限 | 搜索引擎、后台管理系统 |
ES search_after |
类似游标分页 | 专业引擎原生支持 | 引擎特定 | Elasticsearch/Solr |
对于新项目或需要高性能的场景,强烈推荐使用游标分页。虽然牺牲了直接跳转页码的功能,但换来了稳定且高效的性能,非常适合现代应用(如手机APP)的交互方式。如果必须支持页码跳转,可以考虑将游标分页与限制最大分页深度结合使用。
游标分页(推荐)
游标分页(Cursor-based Pagination),也叫“键集分页”,是解决深度分页最有效的方法。它不记录页码,而是记录上一页最后一条记录的位置,并以此为起点获取下一页。
比如数据根据ID倒序排列,点击下一页时,携带上一页中最小的ID,作为下一页最大值的过滤值。
1 | SELECT * |
minId就是上一页中的最小主键Id。
注意事项:
必须排序:要求结果集有稳定的排序顺序。通常使用自增主键
id或时间戳create_time等唯一或高区分度的列。多列排序:为了确保顺序绝对唯一,避免因排序字段值相同导致分页记录重复或丢失,最好使用组合排序,如
ORDER BY create_time DESC, id DESC。对应的WHERE条件也要处理多列。前端配合:前端需要记住“上一页最后一条记录的游标”(即排序字段的值),并在请求下一页时传给后端。它不支持直接跳到任意页码,只能“上一页”、“下一页”。
优点:
-
性能极佳:无论翻到第几页,查询速度都一样快,因为
WHERE id < 1000可以利用索引快速定位,无需扫描和跳过之前的记录。 -
数据一致性:适合实时性高的数据流(如社交媒体动态),在分页过程中即使有新增或删除数据,也不会出现重复或丢失记录的问题(传统 LIMIT 分页可能会)。
缺点:
-
无法直接跳转到指定页码:用户只能“上一页”或“下一页”地浏览,不能直接跳到第 100 页。
-
需要客户端配合:客户端需要维护
cursor状态。
适用场景: 无限滚动(Infinite Scroll)的列表、新闻流、时间线等。
子查询(常用)
先根据条件分页查询出id(覆盖索引),再根据id连表查询出所需数据。
1 | SELECT * |
工作原理:
- 子查询
(SELECT id ... LIMIT 60000, 20)只查询id字段。因为id和create_time都在索引idx_create_time上(InnoDB 二级索引会包含主键值),所以这个子查询可以完全在索引中完成,速度非常快。找到第 60000 条记录对应的id。- 外层查询再根据这个
id作为起点,通过主键索引快速取出完整的 20 条记录。
**优点:**比原始深度分页快很多,因为子查询的“偏移”过程在索引中进行,效率更高。
缺点:
-
依赖于覆盖索引。
-
效果不如游标分页稳定,查询期间有数据新增或删除则会受影响。
-
仍然有
OFFSET,只是将其转移到了更快的索引扫描上,当OFFSET极大时(如百万级),子查询本身还是会变慢。
直接覆盖索引(可用)
确保分页查询的数据都包含在索引里,这样只需要查询索引即可,而无需回表,从而减少磁盘 I/O。
除非要查询的数据量很少,且都符合建立索引的条件下可以使用。
1
2
3 SELECT id, order_id, create_time FROM user_order
WHERE create_time>'2020-8-5 00:00:00'
ORDER BY id LIMIT 10000, 10;
**优点:**在一定程度上减轻了深度分页的压力。
缺点:
-
优化有限,
OFFSET非常大时,索引扫描范围依然很大。 -
只能查询有限的数据列,且需要创建大量索引,占据磁盘。
间接覆盖索引(可用)
让查询只需要扫描索引,而无需回表,从而减少磁盘 I/O。
通过以下两步查询完成:
-
先从覆盖索引中获取目标记录的主键
1
2
3SELECT id FROM user_order
WHERE create_time>'2020-8-5 00:00:00'
ORDER BY id LIMIT 10000, 10;假设
(create_time, id)是一个联合索引,这个查询可以完全在索引上完成,速度较快 -
再根据这些主键ID,回表查询获取完整数据
1
2
3
4SELECT *
FROM user_order
WHERE id IN (10001, 10002, ..., 10020); -- 上一步得到的主键ID
ORDER BY id
**优点:**在一定程度上减轻了深度分页的压力。
缺点:
-
优化有限,
OFFSET非常大时,索引扫描范围依然很大。 -
仅适用于查询数据较少的情况,否则回表时会有大量ID集合数据需要过滤。
-
需要需要两次查询,占据IO 和 长事务问题。
限制分页深度(可用)
业务层面可以限制分页深度,这是一种“防患于未然”的策略,从产品设计上避免用户进入深度分页。
-
提供精确的搜索和筛选功能:让用户通过条件(如时间范围、标签、关键词)来缩小结果集,而不是无脑地翻页。
-
限制最大可访问页码:例如,搜索引擎通常只允许查看前 100 页的结果。直接在前端或后端限制
offset的最大值(如max_offset = 2000)。
简单有效,从根本上避免了性能问题。
提升用户体验,用户通常也不愿意翻上百页。
使用 id 分区(不实用)
如果表的主键 id 是自增且连续的,没有空洞,可以通过 id 范围进行分页。
1 | -- 假设每页20条,要查第100页 |
-
不实用:要求
id绝对连续,这在有删除操作的表中是几乎不可能的。 -
排序不符:结果顺序是
id顺序,如果业务要求按其他字段(如时间)排序,此方法无效。
使用 IN 查询(不推荐)
1 | SELECT * FROM test |
-
IN子句中的子查询性能并不稳定,MySQL 优化器可能不会很好地优化。 -
同样存在深度
OFFSET问题。
利用 Redis 扩展实现
利用其内存和对应的数据结构。
-
使用 zset 结构,将数据的 id 作为 value,将数据的排序依据(比如时间、热度、评分等)作为 score,然后根据用户的分页请求,使用
ZRANGE或者ZREVRANGE命令来获取指定范围的 value,再根据 value 获取具体数据。优点:减轻数据库压力,提高查询效率,保证数据一致性,动态地添加或删除数据,可以控制 zset 长度,防止一直增长。
缺点:需要维护额外的 zset 数据结构,可能占用更多的内存空间,并且需要注意数据同步和更新的问题。
-
使用 hash 结构,将数据以 id 为 key 缓存,然后把数据 id 和排序存到 redis 的 zset 里;当查找数据时,先从 redis 里的 skip list 取出对应的分页数据,得到 id 列表;用 multi get 从 redis 上一次性把 id 列表里的所有数据都取出。
优点:可以利用 hash 的高效存储和查询特性,减少内存开销,利用 zset 的排序和范围查询特性,提高分页效率。缺点:需要维护两个数据结构,可能增加复杂度,并且需要注意数据同步和更新的问题。
-
使用 list 结构,将数据按照顺序存储到 list,根据用户分页请求,使用
LINDEX或者LRANGE命令来获取指定范围的数据。优点:简单易实现,并且可以利用 list 的有序和可变特性,动态地添加或删除数据。缺点:需要扫描所有的数据,查询效率低下,并且不能支持多种排序方式。
有序集合
-
使用 Redis 有序集合存储文章的评分/时间戳作为 score
-
通过
ZREVRANGE命令实现高效分页 -
根据获取到的 ID 再去 MySQL 查询完整数据
ZREVRANGE基于跳表实现,无论取第1页还是第1000页,性能都是 O(logN + M)。
1 | // 添加文章到有序集合 |
游标分页
结合 Redis 的游标能力和 MySQL 的数据存储。记住上一页最后一条的 score,从该点继续查询。
1 | public PageResult<Long> getArticlesByCursor(Double lastScore, int size) { |
ID 列表分页
预先生成所有文章的ID列表,通过 LRANGE 分页。
1 | // 初始化ID列表 |
ES 深度分页解决方案
ES 提供三种分页查询,其中以下两种为常用深度分页的解决方案:
| 特性 | search_after |
scroll |
|---|---|---|
| 设计目的 | 实时、高效的深度分页 | 大批量数据的离线检索和导出 |
| 数据一致性 | 实时当前索引状态(Point-in-Time 可增强) | 快照(Snapshot in Time) |
| 资源占用 | 低(无状态,每次查询独立) | 高(在上下文存活期间占用资源) |
| 生命周期 | 无状态,无需维护 | 有状态,需要显式创建和销毁 |
| 性能 | 每次查询开销类似常规搜索 | 首次创建开销大,后续遍历非常快 |
| 客户端 | 需要维护排序值(sort values) | 需要维护 scroll_id |
| 返回结果 | 返回一页数据 | 返回一批数据和一个新的 scroll_id |
scroll(游标-非实时)
一种基于游标的分页方式,它允许我们遍历大量数据而不需要在每次请求时重新计算整个搜索。
对某次查询生成一个游标 scroll_id , 后续的查询只需要根据这个游标去取数据,直到结果集中返回的 hits 字段为空,就表示遍历结束。scroll_id 的生成可以理解为建立了一个临时的历史快照,在此之后的增删改查等操作不会影响到这个快照的结果。
实现原理
scroll 会为第一次搜索创建一个快照(snapshot) 和一个搜索上下文(search context)。后续请求使用返回的 scroll_id 来从这个固定的快照中获取下一批结果。它本质上是一个有状态的游标。
当你执行一个带有
scroll参数的搜索查询时:
- 初始化搜索上下文:Elasticsearch会为这次搜索创建一个快照(snapshot),并存储相关的搜索上下文(search context)。这个上下文包括查询本身、排序方式、聚合等所有与搜索相关的信息。
- 返回初始结果:Elasticsearch会像普通搜索一样返回第一批结果,并附带一个
scroll_id。这个scroll_id是唯一标识这次搜索上下文的。- 使用 scroll_id 获取更多结果:客户端可以使用这个
scroll_id来请求更多的结果。Elasticsearch会基于之前存储的搜索上下文,从快照中检索更多的结果,并返回给客户端。这个过程可以重复多次,直到所有的结果都被检索完或搜索上下文过期。
由于 scroll 只需要在开始时计算一次搜索上下文,并在之后基于这个上下文来获取结果,因此它在处理大量数据时通常比 from + size 更快。但是,它也会消耗更多的服务器资源来维护搜索上下文和快照。
1 | // 初始搜索 |
优点:
极高的遍历效率:一旦上下文创建,后续的滚动请求非常快,因为是从快照中获取,无需再次排序和计算排名(如果不需要评分,可设置
"sort": ["_doc"]达到最大速度)。数据一致性:看到的是查询发起时刻的数据快照,在整个滚动过程中,结果集不会改变,不会受新数据写入影响。
缺点:
默认情况下,scroll请求会保持一段时间(如1分钟)的上下文,如果在这段时间内没有新的请求,上下文将被自动清除。
非实时(Stale Data):由于
scroll是基于数据快照的,看到的是历史数据,无法获取滚动开始后新写入的文档。资源消耗大:
scroll会消耗大量的服务器资源来维护游标和数据快照,搜索上下文会占用大量的堆内存和文件句柄,直到超时被自动清除。同时处理大量滚动查询会消耗集群大量资源。非随机性:不支持随机访问页面,只能顺序获取数据。
有状态:需要服务器和客户端共同维护
scroll_id和上下文生命周期,如果忘记清理,会导致资源泄漏。
适用场景:.
适用于需要遍历大量数据、实时性要求不高的场景,如日志导出、数据迁移等。
离线数据处理和大批量数据导出(例如将整个索引或大量查询结果导出到文件或数据库)。
索引重建(reindex) 等后台任务。
需要对大量数据进行全量分析(不关心最新数据,只关心某一时刻的状态)。
search_after(推荐-排序值唯一)
一种基于排序值的分页方式,它允许我们根据上一页的最后一条数据的排序值来获取下一页的数据。需要配合sort字段使用。
实现原理
search_after 分页方式的原理是基于上一次查询的结果来确定下一次查询的起始位置。当你执行一个带有 search_after 参数的搜索查询时,Elasticsearch 会:
排序和返回结果:首先,Elasticsearch会像普通搜索一样执行查询,并根据指定的排序字段对结果进行排序。然后,它会返回第一批结果。
确定下一次查询的起始位置:客户端可以选择结果集中的任意一条记录作为下一次查询的起始位置。这通常是通过记录该条记录的排序字段值来实现的。
使用 search_after 获取更多结果:在下一次查询时,客户端会指定
search_after参数,并将上一次查询的起始位置(即排序字段值)作为该参数的值。Elasticsearch会基于这个值来确定下一次查询的起始位置,并返回该位置之后的结果。
由于 search_after 不需要像 from + size 那样合并和排序所有分片返回的结果,也不需要像 scroll 那样维护搜索上下文和快照,因此它在深度分页时通常比这两种方式更高效。但它要求排序字段的值必须是唯一的,以确保能够准确地确定下一次查询的起始位置。
1 | // 初始查询 |
优点:
实时性 (Real-time):默认查询的是最新的索引状态,比
scroll更具实时性。无状态 (Stateless):服务端不保存任何上下文。每次查询都是独立的,资源占用低,易于扩展。
适用于实时分页:是替代
from + size(深度分页)的理想方案,用户可前后翻页(需客户端缓存多组search_after值)。
缺点:
无法跳页:和所有游标分页一样,只能一页一页顺序往下走,不能直接跳到任意页码。
需要稳定的排序:排序字段必须能够唯一确定文档顺序(通常需要至少一个唯一字段如
_id作为第二排序条件),需要依赖一个或多个排序字段确定下一页的位置。否则可能导致分页结果重复或丢失。
- 如果排序字段的值不是唯一的,可能导致查询结果不准确。
- 如果排序字段的值发生更改(如文档被更新或删除),可能会导致结果不一致。
实时性:虽然
search_after比scroll更实时,但它可能仍然无法获取到查询发起后的最新数据。这取决于刷新机制,
- Elasticsearch 默认每 1秒 自动刷新一次索引(可配置
refresh_interval)。- 只有被 refresh 后的文档,才能被搜索到。
search_after查询的是 当前已 refresh 的数据快照,不是实时写入的文档。如果你在查询发起后,有新文档写入(
index或update),但在下一次refresh之前,这些数据 不会出现在search_after查询结果中。一旦索引刷新,后续的search_after查询就有可能查到这些新数据,客户端逻辑稍复杂:客户端需要存储和传递每页的排序值。
适用场景:
适用于需要深度分页、实时性要求相对较高、且排序字段唯一,支持顺序向后翻页访问的场景。
- 用户界面的无限滚动(Infinite Scroll)
- 需要实时反映数据变化的深度分页需求
- 任何需要替代
from + size(特别是当from > 10,000时)的高性能分页
关于随机翻页
如果你有随机跳页的需求(例如,用户直接在界面上输入页码并跳转),正确的解决方案是 扩展**search_after**。
scroll API 完全不支持随机访问或跳页。 这是由其核心设计理念和工作机制决定的。
为什么 Scroll 不支持随机跳页?
-
顺序遍历器,而非随机存取器
scroll被设计成一个单向的、顺序的流式读取器。你可以把它想象成一个只能从前往后读的磁带,或者一个只能调用next()方法的迭代器 (Iterator)。它唯一的操作是 “获取下一批(next batch)”。系统内部维护着一个指针,指向下一批要返回的数据的起始位置。你无法让这个指针突然跳到任意位置。
-
基于快照的上下文
当你初始化一个scroll请求时,Elasticsearch 会为当时的查询条件创建一个数据快照(snapshot) 和搜索上下文(search context),这个快照是固定的。后续所有的scroll请求都是用同一个scroll_id,在这个固定的快照上顺序地获取下一批数据。它没有为“跳到第 N 页”这种操作建立任何索引或映射。 -
scroll_id的含义
scroll_id不是一个指向页码的标记,而是一个包含了整个查询状态、排序顺序和当前读取位置的上下文句柄。服务器根据这个句柄知道“你已经拿到前 1000 条了,下一次应该从第 1001 条开始”。
举例说明
假设你有一个 10000 条结果的
scroll查询,每次返回 100 条。
- 第一次请求 (
scroll=1m): 你得到结果 1-100 和一个scroll_id: ABC。- 第二次请求 (带上
scroll_id: ABC): 你得到结果 101-200。- …
- 你想直接拿到第 50 页(结果 4901-5000): 这是不可能的。你没有任何一个
scroll_id能让你直接从 4901 开始。你唯一的办法是从头开始,顺序地进行大约 49 次scroll请求,丢弃掉前 4900 条结果,才能拿到你想要的那一页。这不仅完全失去了scroll的性能优势,其性能甚至会比深度分页更差,因为你还额外维护了一个沉重的上下文。
基于 search_after 的随机翻页设计
search_after 的核心也不完全支持随机翻页,但通过巧妙的设计,可以实现“有限度的”随机分页。 它无法像传统数据库 LIMIT offset, size 那样直接跳到任意页码,但比 scroll 灵活得多。
和
scroll一样,search_after的核心也是一个顺序遍历器。它的参数是上一页最后一条记录的排序值。Elasticsearch 利用这些值在排序索引中快速定位,并获取“接下来”的一批数据。它没有也不维护任何关于“页码”的概念。要跳到第 N 页,系统必须知道第 (N-1) 页最后一条记录的排序值是什么。而获取这个值,恰恰就是深度分页要解决的难题本身。
策略一:客户端缓存(最实用、最常用)
这是实现“前后翻页”和“跳回已浏览页”的最主流方案。
客户端(浏览器、APP)或服务端会话(Session)在用户浏览过程中,缓存每一页的“页边界”(即每一页最后一条记录的排序值)。
当用户点击“下一页”时,使用当前页的排序值。
当用户点击“上一页”或想跳回之前看过的某一页(例如第3页)时,从缓存中取出第2页的排序值,作为
search_after参数去查询,得到的就是第3页的结果。
优点:
-
性能极佳,每次查询都是高效的。
-
可以实现完美的前后导航和跳回已浏览页面。
缺点:
-
无法跳转到未浏览的页面。你不能直接跳到第100页,除非你之前已经一页一页地浏览并缓存了第99页的排序值。
-
需要客户端逻辑来管理这个缓存。
策略二:服务端预计算(复杂,有代价)
如果需要支持跳转到未浏览的页面,需要一个更强大的服务端架构。
构建“页索引”服务:有一个后台任务或服务,预先为常见的排序和查询条件计算好每一页的边界排序值,并将其存储起来(例如在Redis中)。
查询流程:当用户请求跳转到第N页时,API先向这个“页索引”服务请求获取第(N-1)页的排序值,然后再用这个值去执行
search_after查询。
优点: 理论上可以实现真正的随机跳页。
缺点:
-
极其复杂:引入了新的服务和系统复杂性。
-
维护成本高:数据有任何增删改,都可能使预计算的页边界失效,需要重新计算,维护数据一致性挑战巨大。
-
不灵活:只能针对少数几个固定的排序方式和查询条件做预计算。
策略三:近似跳页(牺牲精确度)
如果你的一个排序字段是均匀分布的(如自增ID、均匀分布的时间戳),你可以进行估算。例如,你想跳转到第50页(每页20条),你可以估算一个 search_after 值。然后让用户在这个大致的结果集里进行微调。
1 | { |
**缺点:**结果不精确,用户体验差,适用场景有限。
行业标准做法(强烈推荐)
像Google、Amazon、Netflix这样的大型互联网应用,它们直接从产品设计上规避了“深度随机跳页”的需求。
提供强大的搜索和筛选器:让用户通过加条件(如时间范围、分类、标签)来缩小结果集,使其变得很少,无需深度分页。
只提供“上一页/下一页”:这是无限滚动的标准模式,完美契合
search_after的优势。限制最大可访问范围:即使提供了页码,通常也只显示前10页或前20页的页码选项,并提示“请优化搜索以查看更多结果”。
