分页功能几乎存在于所有后台管理系统和C端列表页中,最常见的写法就是LIMIT offset, size。数据量小的时候一切正常,可当表里的数据涨到几百万甚至上千万行,运营反馈“翻到第几千页就转圈”时,问题就暴露出来了。这篇文章围绕MySQL深度分页慢的根本原因展开,给出几种经过生产验证的优化方案,并对比它们的适用条件。

一、为什么LIMIT越往后再翻越慢
要理解深度分页的性能问题,得先看MySQL处理LIMIT 1000000, 10时到底做了什么。它并不是直接跳到第100万行开始取数据,而是老老实实从头开始扫描,把前100万零10行都找出来,然后丢弃前100万行,只把最后10行返回给客户端。也就是说,实际扫描的行数和偏移量成正比,偏移量每翻一倍,扫描量也跟着翻倍。
如果查询列上没有合适的索引,情况会更糟。MySQL只能走全表扫描,一百万行的偏移量意味着至少一百万行的磁盘IO和CPU消耗。即使走了二级索引,如果查询的列不全在索引里,每一行还要回表到主键索引取完整数据,而这些回表操作大部分是白费的,因为对应行最后会被丢弃。
用EXPLAIN可以直观验证这一点:
EXPLAIN SELECT id, name, created_at FROM orders ORDER BY id LIMIT 1000000, 10; -- rows列会显示接近1000010,type为ALL或index -- 说明优化器预计要扫描约一百万行才能返回10条数据
扫描行数巨大、大量无效回表,这两点叠加起来就是深度分页变慢的核心原因。优化的思路也就顺着这两条展开:要么减少扫描行数,要么避免无效回表。
二、延迟关联:覆盖索引加JOIN的经典方案
延迟关联(Deferred Join)是解决深度分页最常用的手段。它的核心想法是先用一个只查主键的子查询把目标行的ID定位出来,由于子查询只需要访问索引,不涉及回表,扫描速度极快;拿到10个ID之后再和原表JOIN,此时回表只发生10次。
-- 原始写法:扫描1000010行且每行都回表
SELECT id, order_no, user_id, amount, created_at
FROM orders
ORDER BY created_at
LIMIT 1000000, 10;
-- 延迟关联写法:子查询只扫索引,回表仅10次
SELECT o.id, o.order_no, o.user_id, o.amount, o.created_at
FROM orders o
INNER JOIN (
SELECT id
FROM orders
ORDER BY created_at
LIMIT 1000000, 10
) t ON o.id = t.id;
要让子查询走覆盖索引,需要保证ORDER BY的列和id都在同一个索引里,比如建一个idx_created_at(created_at),二级索引的叶子节点天然包含主键值,正好满足要求。子查询在索引上顺序扫描定位到第100万行附近,速度远快于回表一百万次。
这个方案的优势是不需要改动业务代码结构,SQL改一改就能上线,兼容跳页场景。但它并不能完全消除扫描成本,子查询依然要在索引上跳过前100万条记录,只是跳得快了很多。在亿级数据量下,翻到极端靠后的页依然会有可感知的延迟,这时就要考虑下面更彻底的方案。
三、游标分页:彻底告别大偏移量
如果业务允许调整交互方式,游标分页(也叫书签分页、Keyset Pagination)是效率最高的方案。它不再传页码,而是把上一页最后一条记录的排序值作为游标传入,下一页查询直接从这个位置往后取:
-- 第一页 SELECT id, order_no, amount, created_at FROM orders ORDER BY created_at DESC LIMIT 10; -- 后续页:客户端记住上一页最后的created_at和id SELECT id, order_no, amount, created_at FROM orders WHERE created_at < '2024-01-15 10:30:00' OR (created_at = '2024-01-15 10:30:00' AND id < 10086) ORDER BY created_at DESC, id DESC LIMIT 10;
这里的条件写法有个细节要注意:如果排序字段不唯一,只用created_at做游标会丢数据或重复数据,所以必须加上id作为第二排序条件,组合成一个唯一的定位点。对应的索引建议建成idx_created_id(created_at, id),让WHERE条件和ORDER BY都能走索引,整个查询只需扫描10行。
游标分页的性能与页码深度完全无关,无论第一页还是第一亿条之后的一页,耗时都是稳定的毫秒级。它的代价是牺牲了随机跳页能力——用户只能上一页下一页地翻,不能直接跳到第500页。对于信息流、消息列表这类天然按时间顺序浏览的场景,这个限制几乎无感;但对后台报表这种需要精确跳页的场景,就需要配合其他方案了。
四、业务层优化与方案选型建议
除了SQL层面的改造,很多深分页问题其实可以在业务设计阶段就规避掉。常见做法包括:限制最大可翻页码,比如搜索引擎普遍只展示前几十页结果;把导出类需求改成异步任务,用游标批量拉取而不是分页;对中间页提供近似跳转,落地后再用ID修正。这些手段看似简单,却往往是性价比最高的。
选型时可以参考下面的对比:
| 方案 | 查询性能 | 是否支持跳页 | 改造成本 |
|---|---|---|---|
| 直接LIMIT分页 | 随偏移量线性变差 | 支持 | 无 |
| 延迟关联 | 明显提升,极端深处仍偏慢 | 支持 | 低,只改SQL |
| 游标分页 | 恒定毫秒级 | 不支持 | 中,需改接口协议 |
| 限制跳页加异步导出 | 从源头消除问题 | 受限 | 中,需业务配合 |
实践中比较稳妥的组合是:普通后台列表用延迟关联兜底,保证任意页码可用;面向C端的时间线类列表用游标分页追求极致体验;导出和批量同步场景一律走异步游标任务。上线前记得用EXPLAIN确认执行计划,重点看rows估算值和Extra列是否出现Using index,避免优化了个寂寞。深度分页本身没有银弹,理解扫描和回表的代价来源,再结合业务形态选择方案,才能真正把这类查询稳定压在毫秒级。