一、什么是深分页
深分页(Deep Pagination)指的是在分页查询中,页码非常大、偏移量非常靠后的场景。例如用户翻到第 10000 页,每页 20 条,那么数据库需要跳过前 199980 条记录,再返回后面的 20 条。
以常见的 LIMIT offset, size 写法为例:
sql
SELECT id, title, created_at
FROM article
ORDER BY created_at DESC
LIMIT 199980, 20;
这条 SQL 看起来很简单,但执行代价并不小。数据库通常需要先按照排序条件扫描并丢弃前 199980 行,然后才能取到目标 20 行。偏移量越大,扫描和丢弃的成本越高,查询耗时也就越长。浪费了很多cpu的性能才查找到需要的数据,前面做的都是无效查询。
二、深分页为什么会慢
深分页的性能问题主要来自以下几个方面:
- 大量无效扫描 :
offset之前的数据虽然不返回,但仍需要被读取、排序或过滤,属于纯浪费的 I/O 和 CPU 开销。 - 回表次数多:如果查询字段不在索引中,每一行都需要回表取数据,偏移量越大回表次数越多。
- 排序成本高:当排序字段没有合适索引时,数据库可能需要对大量数据进行文件排序(filesort),内存不足时还会落盘。
- 结果不稳定:如果排序字段存在重复值且没有唯一键兜底,翻页过程中可能出现数据重复或遗漏。
一句话总结:深分页慢的本质是「为了拿到少量数据,却付出了扫描大量数据的代价」。
三、常见的解决方案
3.1 基于游标(Keyset)分页
游标分页也叫「书签分页」或「Seek 方法」,核心思路是记住上一页最后一条记录的位置,下一页从该位置之后继续取,而不是从头跳过 offset 条。
sql
-- 第一页
SELECT id, title, created_at
FROM article
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 后续页:以上一页最后一条的 created_at 和 id 作为游标
SELECT id, title, created_at
FROM article
WHERE (created_at, id) < ('2026-10-01 12:00:00', 10086)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这种方式的优势是每次查询都能利用索引直接定位到起始位置,扫描行数基本恒定,与页码无关。需要注意的是,它只支持「上一页/下一页」式的顺序翻页,无法直接跳到任意页码。
3.2 延迟关联(Deferred Join)
如果业务必须支持跳页,可以先只在索引上完成分页,拿到主键后再回表取完整数据,减少回表次数。
sql
SELECT a.id, a.title, a.created_at
FROM article a
INNER JOIN (
SELECT id
FROM article
ORDER BY created_at DESC
LIMIT 199980, 20
) t ON a.id = t.id
ORDER BY a.created_at DESC;
子查询只扫描索引列,避免了对宽表的频繁回表,通常能带来明显的性能提升。
3.3 覆盖索引
让查询涉及的字段全部包含在索引中,使数据库可以直接从索引返回结果,无需回表。例如为 (created_at, id, title) 建立联合索引,查询时只取这三列即可。
覆盖索引往往与延迟关联配合使用,效果更好,但要注意索引本身也会占用存储并增加写入成本。
3.4 限制最大可翻页数
很多产品并不需要真正支持无限翻页。可以在产品层面限制最大页码,例如只允许查看前 100 页,超出后引导用户使用筛选条件缩小范围。这是成本最低、见效最快的方案之一。
3.5 使用搜索引擎或专用存储
对于搜索、日志、商品列表等场景,可以把查询交给 Elasticsearch、OpenSearch 等搜索引擎。它们通过 search_after 参数实现游标式分页,避免深度遍历。
json
{
"size": 20,
"sort": [
{ "created_at": "desc" },
{ "id": "desc" }
],
"search_after": ["2026-10-01T12:00:00", 10086]
}
需要注意,Elasticsearch 的 from + size 同样存在深分页限制,默认 max_result_window 为 10000,超过后应改用 search_after 或 scroll。
3.6 预计算与数据冗余
对于数据量相对固定、查询模式稳定的场景,可以提前计算好分页结果并缓存,或者维护一张带连续序号的辅助表,把 offset 查询转化为按序号的范围查询。
四、方案对比与选型建议
| 方案 | 是否支持跳页 | 性能表现 | 适用场景 |
|---|---|---|---|
| 游标分页 | 否 | 优秀,与页码无关 | 信息流、时间线、无限滚动 |
| 延迟关联 | 是 | 较好 | 后台管理列表、需要跳页 |
| 覆盖索引 | 是 | 较好 | 查询字段较少且固定 |
| 限制最大页数 | 部分 | 优秀 | 大多数 C 端列表 |
| 搜索引擎 | 视实现而定 | 优秀 | 搜索、日志、海量数据 |
实际选型时,建议先明确业务是否真的需要「跳到第 N 页」。如果不需要,优先选择游标分页;如果必须支持跳页,再考虑延迟关联加覆盖索引,并配合最大页数限制兜底。
五、实践中的注意事项
- 排序字段要唯一:游标分页必须保证排序键唯一,通常用「业务字段 + 主键」组合,避免翻页时数据重复或丢失。
- 索引要匹配排序 :
ORDER BY的字段顺序应与联合索引顺序一致,否则仍可能触发文件排序。 - 避免 SELECT *:只查询必要字段,减少回表和网络传输开销。
- 关注总数统计 :
COUNT(*)在大表上同样昂贵,可以考虑近似统计或缓存总数。 - 做好压测:不同数据分布下执行计划可能差异很大,上线前应针对真实数据量做压测验证。
六、总结
深分页的本质是「大偏移量带来的无效扫描」。解决思路可以归纳为三类:一是用游标替代 offset,从根上消除偏移;二是通过延迟关联、覆盖索引降低单次查询成本;三是在产品层面限制翻页深度或引入搜索引擎。理解业务对「跳页」的真实需求,往往比单纯优化 SQL 更有效。