MySQL LIMIT 深分页优化
面试频率:★★★★★
工作频率:★★★★★
⚡ 30 秒速记
普通分页:
SELECT *
FROM orders
ORDER BY id
LIMIT 0, 10;
深分页:
SELECT *
FROM orders
ORDER BY id
LIMIT 5000000, 10;
很多人会误以为:
直接跳到第500万条
↓
取10条
实际上不能这么理解。
LIMIT 5000000, 10 可以简单理解为:
扫描 / 处理前5000010条
↓
丢掉前5000000条
↓
返回最后10条
所以:
OFFSET 越大,需要扫描和丢弃的前置记录越多,深分页通常越慢。
常见优化:
方案1:覆盖索引 + 延迟关联
先通过索引找到最终需要的主键
↓
再根据主键获取完整数据
↓
减少昂贵的数据访问
更好的方案:
方案2:游标分页
WHERE id > lastId
ORDER BY id
LIMIT 10
核心:
大 OFFSET
↓
扫描大量无用数据
游标分页
↓
通过索引直接定位
↓
继续向后读取
一、什么是分页?
实际开发非常常见:
SELECT *
FROM orders
LIMIT 0, 10;
表示:
从第0条开始
↓
取10条
下一页:
SELECT *
FROM orders
LIMIT 10, 10;
再下一页:
SELECT *
FROM orders
LIMIT 20, 10;
语法:
LIMIT offset, size;
其中:
offset
=
跳过多少条
size
=
返回多少条
例如:
LIMIT 100, 20;
表示:
跳过前100条
↓
返回20条
二、什么是深分页?
例如:
SELECT *
FROM orders
ORDER BY id
LIMIT 10, 10;
offset:
10
很小。
但是:
SELECT *
FROM orders
ORDER BY id
LIMIT 5000000, 10;
offset:
5000000
非常大。
这种:
OFFSET非常大的分页
通常就叫:
深分页
三、为什么 LIMIT 深分页慢?
假设:
LIMIT 5000000, 10;
不要理解成:
MySQL瞬间跳到第500万条
↓
读取10条
可以简单理解成:
找到 / 扫描 / 处理前5000010条
↓
丢掉前5000000条
↓
只返回最后10条
也就是说:
前500万条
虽然:
最终不会返回给客户端
但数据库仍然需要处理大量前置记录。
所以:
OFFSET越大
↓
需要跳过的数据越多
↓
查询通常越慢
四、举个最简单的例子
假设:
orders表
=
1000万条数据
查询:
SELECT *
FROM orders
ORDER BY id
LIMIT 0, 10;
只需要:
从前面开始
↓
读取10条
↓
停止
非常快。
但是:
SELECT *
FROM orders
ORDER BY id
LIMIT 5000000, 10;
需要:
处理大量前置记录
↓
跳过500万条
↓
再留下10条
所以:
第1页
和:
第50万页
即使:
每页都是10条
性能也可能完全不一样。
五、为什么有索引也可能慢?
很多人会问:
id 是主键,有索引,为什么 LIMIT 5000000,10 还是慢?
因为索引可以帮助:
快速查找某个确定的值 / 范围
例如:
WHERE id = 5000000;
B+ Tree 可以:
直接定位
但是:
LIMIT 5000000, 10;
表达的是:
跳过结果集前500万条
它没有直接告诉 MySQL:
从哪个id开始
所以:
有索引
并不代表:
大OFFSET没有成本
六、深分页和 B+ Tree 的关系
假设主键索引:
1
2
3
4
5
...
5000000
5000001
5000002
...
执行:
SELECT *
FROM orders
WHERE id > 5000000
ORDER BY id
LIMIT 10;
这里有明确条件:
id > 5000000
B+ Tree 可以:
定位到5000000附近
↓
向后扫描
↓
读取10条
↓
结束
但是:
SELECT *
FROM orders
ORDER BY id
LIMIT 5000000, 10;
没有:
id > xxx
这样的索引定位条件。
所以:
OFFSET分页
和:
索引范围查询
不是一回事。
七、深分页遇到二级索引
假设:
CREATE INDEX idx_create_time
ON orders(create_time);
查询:
SELECT *
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
二级索引可以简单理解:
create_time
+
主键id
而:
SELECT *
需要:
完整订单数据
完整数据在:
聚簇索引
里面。
因此最终可能涉及:
二级索引扫描
↓
获得主键
↓
根据执行计划获取完整行
↓
大量前置记录最终又被OFFSET丢弃
这种场景下:
大OFFSET
+
完整行访问成本
可能让查询更加昂贵。
注意:
具体执行方式最终由优化器决定,不能简单认为所有深分页 SQL 都一定会对 OFFSET 前的每一行进行同样形式的回表。
但优化思想是一样的:
尽量不要为了最终不要的数据
获取大量完整行
八、优化方案一:覆盖索引 + 延迟关联
原 SQL:
SELECT *
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
可以考虑:
SELECT o.*
FROM orders o
JOIN (
SELECT id
FROM orders
ORDER BY create_time
LIMIT 5000000, 10
) t
ON o.id = t.id
ORDER BY o.create_time;
这里最重要的不是:
死记这条SQL
而是理解:
为什么这么做
九、延迟关联原理
假设索引:
(create_time)
InnoDB 二级索引叶子记录可以简单理解:
create_time + 主键id
子查询:
SELECT id
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
需要:
create_time
+
id
这些信息:
索引中就有
所以有机会:
通过覆盖索引扫描
先得到最终:
10个id
然后:
10个id
↓
JOIN主表
↓
获取10条完整数据
核心思想:
第一阶段:
只处理较小的索引记录
↓
完成OFFSET
第二阶段:
只对最终需要的数据
↓
获取完整行
十、为什么叫“延迟关联”?
因为:
获取完整数据
这个动作:
被推迟了
原来的思路:
扫描候选数据
↓
获取完整行
↓
OFFSET丢弃大量数据
↓
留下10条
优化思路:
先通过索引
↓
找到最终10个主键
↓
最后才关联主表
↓
获取10条完整数据
所以叫:
延迟关联
可以记成:
能晚点拿完整行,就不要太早拿完整行。
十一、它和覆盖索引有什么关系?
你之前已经学过:
覆盖索引
核心:
查询需要的数据
↓
索引里面全部都有
↓
不需要回表
延迟关联就是利用这个思想:
第一阶段
↓
只查询id
↓
尽量利用覆盖索引
↓
完成深分页
然后:
第二阶段
↓
根据最终少量id
↓
获取完整数据
所以:
覆盖索引
+
延迟关联
是深分页经典优化方式之一。
十二、但是 OFFSET 本身消失了吗?
没有。
这是非常重要的。
即使:
SELECT id
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
MySQL 仍然:
需要处理大量索引记录
因为:
OFFSET还是5000000
所以延迟关联:
不是让500万OFFSET消失
而是:
让处理这 500 万条前置记录的成本尽量变低。
也就是:
原来:
处理大量完整行
变成:
尽量只处理较小的索引记录
所以:
延迟关联
=
降低深分页成本
而不是:
彻底消灭深分页
十三、更好的方案:游标分页
如果业务允许:
不要使用大OFFSET
可以记录:
上一页最后一条记录
例如上一页:
id
10001
10002
10003
...
10010
最后:
lastId = 10010
下一页:
SELECT *
FROM orders
WHERE id > 10010
ORDER BY id
LIMIT 10;
这就是:
游标分页
也经常叫:
Keyset Pagination
十四、游标分页为什么快?
因为:
WHERE id > 10010
是:
索引范围查询
主键 B+ Tree:
...
10008
10009
10010
10011
10012
10013
...
MySQL:
通过B+ Tree
↓
定位到10010附近
↓
从10011继续扫描
↓
读取10条
↓
停止
不需要:
从结果集开头
↓
跳过前面几百万条
所以:
OFFSET分页
LIMIT 5000000,10
↓
大量前置数据被扫描 / 丢弃
而:
游标分页
WHERE id > lastId
LIMIT 10
↓
索引定位
↓
向后读取10条
十五、两种分页对比
OFFSET 分页
SELECT *
FROM orders
ORDER BY id
LIMIT 5000000, 10;
流程:
处理大量前置记录
↓
丢掉500万条
↓
返回10条
特点:
可以直接跳页
例如:
第1页
第100页
第50000页
但是:
页数越深
↓
通常越慢
游标分页
SELECT *
FROM orders
WHERE id > 5000000
ORDER BY id
LIMIT 10;
流程:
通过索引定位
↓
向后扫描
↓
取10条
特点:
深度增加以后
性能通常更加稳定
但是:
需要知道上一页游标
十六、游标分页的缺点
游标分页并不是所有业务都适合。
例如前端:
1
2
3
4
5
...
10000
用户点击:
第8000页
如果使用:
WHERE id > lastId
你需要知道:
第7999页最后一个id
所以:
任意跳页
不是游标分页最擅长的场景。
十七、哪些业务适合游标分页?
非常适合:
朋友圈
微博 / 信息流
短视频列表
订单列表的“下一页”
日志查询
加载更多
无限滚动
因为用户操作:
第一批
↓
下一批
↓
下一批
↓
继续加载
不需要:
直接跳到第50000页
十八、如果按照时间排序怎么办?
实际业务经常:
SELECT *
FROM orders
ORDER BY create_time DESC
LIMIT 20;
下一页不能永远只依赖:
id > lastId
因为:
真正排序字段是create_time
应该让:
游标条件
与:
ORDER BY
保持一致。
例如:
create_time
如果可能重复,实际设计中经常使用:
create_time + id
作为稳定排序键。
例如:
ORDER BY create_time DESC, id DESC
索引可以考虑:
INDEX(create_time DESC, id DESC)
游标也应该基于:
上一页最后一条的create_time
+
上一页最后一条的id
继续查询。
核心原则:
游标分页的游标,要和排序规则对应。
十九、为什么经常加 id?
假设:
create_time
只有:
2026-09-08 10:00:00
可能:
100条订单
时间完全一样。
如果只按照:
create_time
分页:
排序可能不够唯一
所以:
create_time
+
id
可以形成:
稳定、唯一的排序顺序
例如:
ORDER BY create_time DESC, id DESC
这样分页更加稳定。
二十、深分页和 ORDER BY 的关系
之前已经学过:
ORDER BY索引优化
深分页 SQL:
SELECT *
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
如果:
create_time没有合适索引
还可能出现:
Using filesort
那么问题就可能变成:
大量数据
+
额外排序
+
大OFFSET
性能可能更差。
所以分析深分页:
不仅看LIMIT
还要看:
ORDER BY能不能利用索引
二十一、深分页和覆盖索引的关系
原 SQL:
SELECT *
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
SELECT *:
需要完整行
优化:
SELECT id
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
如果:
create_time + id
都可以从索引获得:
覆盖索引
就可以:
先利用较小的索引记录
↓
完成深分页
↓
得到少量id
↓
再获取完整行
所以你之前学的:
覆盖索引
在这里直接派上用场。
二十二、深分页和回表的关系
二级索引:
create_time + id
完整数据:
聚簇索引
如果查询:
SELECT *
最终:
可能需要获取完整行
而深分页优化的重要思路之一就是:
不要过早获取大量完整行
尽量:
先在二级索引中
↓
找到最终需要的数据
↓
再取完整行
所以:
深分页
↓
覆盖索引
↓
延迟关联
↓
减少昂贵的完整行访问
这些知识是连在一起的。
二十三、深分页和 EXPLAIN
遇到:
SELECT *
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
不要直接猜。
使用:
EXPLAIN
SELECT *
FROM orders
ORDER BY create_time
LIMIT 5000000, 10;
重点看:
type
访问方式。
key
实际用了哪个索引。
rows
预计需要检查多少记录。
Extra
看看有没有:
Using filesort
等信息。
所以你之前学的:
EXPLAIN
就是拿来验证:
SQL到底怎么执行
而不是:
只靠背优化规则
二十四、实际开发怎么选?
可以简单分三种情况。
情况一:数据量不大
例如:
几千条
普通:
LIMIT offset, size
完全可以。
不要为了:
几千条数据
把代码搞得特别复杂。
情况二:需要任意跳页
例如:
1 2 3 4 5 ... 1000
业务要求:
直接跳第500页
可以继续使用:
OFFSET分页
如果深分页性能出现问题:
考虑覆盖索引
+
延迟关联
情况三:只需要下一页 / 加载更多
优先考虑:
游标分页
例如:
WHERE id > lastId
ORDER BY id
LIMIT 20;
通常比:
LIMIT 5000000, 20
更加适合。
二十五、三种方案总结
普通OFFSET分页
优点:
简单
支持跳页
缺点:
深分页慢
覆盖索引 + 延迟关联
优点:
降低大OFFSET下获取完整行的成本
仍可以保留OFFSET分页
缺点:
OFFSET本身仍然存在
SQL更加复杂
游标分页
优点:
避免大OFFSET
利用索引直接定位
深分页性能通常更稳定
缺点:
不方便任意跳页
需要维护游标
二十六、一张图记住深分页
LIMIT 5000000, 10
↓
处理大量前置记录
↓
丢弃500万条
↓
返回10条
↓
OFFSET越大
↓
通常越慢
优化一:
覆盖索引
↓
先查最终需要的id
↓
延迟获取完整行
↓
降低深分页成本
优化二:
WHERE id > lastId
↓
B+ Tree直接定位
↓
向后读取10条
↓
避免大OFFSET
二十七、和前面的知识全部串起来
现在:
MySQL索引
↓
B+ Tree
↓
聚簇索引 / 二级索引
↓
回表
↙ ↘
覆盖索引 ICP
↓ ↓
避免回表 减少回表
↓
深分页优化
↓
覆盖索引 + 延迟关联
另外:
ORDER BY
↓
利用索引有序性
↓
减少额外排序
↓
配合LIMIT
最终:
深分页
↓
能不能不用OFFSET?
↓
┌───────┴───────┐
↓ ↓
能 不能
↓ ↓
游标分页 覆盖索引
+ 延迟关联
🎤 面试回答
问:
MySQL LIMIT 深分页为什么慢?怎么优化?
答:
MySQL 使用
LIMIT offset,size进行深分页时,offset 很大并不意味着数据库可以直接跳到目标位置。数据库仍然需要扫描或处理大量 offset 之前的记录,然后将这些记录丢弃,只返回后面的 size 条,所以 offset 越大,查询通常越慢。如果查询还涉及获取完整行,大量无效数据访问会进一步增加成本。
常见优化方式有两种:第一种是利用覆盖索引先完成分页,只得到最终需要的主键,再通过延迟关联获取完整数据,从而降低完整行访问成本;第二种是如果业务允许,使用基于主键或排序键的游标分页,例如
WHERE id > lastId ORDER BY id LIMIT 10,通过 B+ Tree 直接定位到上一次的位置继续扫描,从根本上避免大 OFFSET。
🎯 面试追问
Q1:什么是深分页?
答:
OFFSET非常大的分页
例如:
LIMIT 5000000, 10;
Q2:LIMIT 5000000,10 是直接跳到第500万条吗?
不是。
可以简单理解:
处理前5000010条
↓
丢掉前5000000条
↓
返回10条
Q3:为什么 OFFSET 越大越慢?
因为:
需要扫描 / 处理
更多前置记录
而这些记录:
最终又会被丢弃
Q4:有主键索引为什么深分页还是可能慢?
因为:
LIMIT 5000000,10
只是:
告诉数据库跳过结果集前500万条
并没有提供:
id > 某个确定值
这样的索引范围定位条件。
Q5:什么是延迟关联?
先:
通过覆盖索引
↓
完成深分页
↓
得到最终少量主键
再:
根据主键
↓
获取完整行
把:
获取完整数据
延迟到:
分页完成以后
Q6:延迟关联能彻底解决 OFFSET 问题吗?
不能。
因为:
OFFSET仍然存在
它主要是:
降低处理大量前置记录时
获取完整行的成本
Q7:什么是游标分页?
例如:
SELECT *
FROM orders
WHERE id > ?
ORDER BY id
LIMIT 10;
记录:
上一页最后一个id
下一页:
从这个id继续
Q8:为什么游标分页快?
因为:
WHERE id > lastId
可以:
利用B+ Tree范围定位
↓
从目标位置继续扫描
↓
只读取需要的数据
避免:
大OFFSET
Q9:游标分页有什么缺点?
主要:
不方便任意跳页
它更适合:
下一页
加载更多
无限滚动
Q10:按照 create_time 排序还能只用 id 做游标吗?
不应该简单这么做。
游标应该:
和ORDER BY规则对应
如果:
ORDER BY create_time DESC, id DESC
那么通常应该基于:
create_time
+
id
继续分页。
Q11:为什么 create_time 经常和 id 一起作为游标?
因为:
create_time可能重复
而:
create_time + id
可以形成:
稳定、唯一的排序顺序
减少分页过程中:
重复
遗漏
顺序不稳定
等问题。
Q12:深分页和覆盖索引有什么关系?
可以:
先只查询索引中的字段
↓
完成OFFSET
↓
得到最终少量主键
↓
再获取完整数据
降低深分页成本。
Q13:深分页和 ORDER BY 有什么关系?
如果:
ORDER BY没有合适索引
还可能:
Using filesort
形成:
大量数据
+
额外排序
+
大OFFSET
性能可能更差。
Q14:怎么分析深分页 SQL?
使用:
EXPLAIN
重点看:
type
key
rows
Extra
同时结合:
实际数据量
实际执行时间
判断。
⚠️ 易错点
1. LIMIT 大 OFFSET 不是直接跳过去
错误:
LIMIT 5000000,10
↓
直接定位第500万条
正确:
需要处理大量前置记录
↓
然后丢弃
2. 有索引 ≠ 深分页一定快
索引可以:
帮助定位
帮助排序
但是:
大OFFSET本身仍然有成本
3. 延迟关联没有消灭 OFFSET
延迟关联:
降低前置记录的处理成本
而不是:
让OFFSET消失
真正避免大 OFFSET 的典型方案:
游标分页
4. 游标分页不一定只能用 id
应该根据:
ORDER BY字段
设计游标。
例如:
ORDER BY create_time DESC, id DESC
通常使用:
create_time + id
作为分页依据。
5. 游标分页不适合所有业务
如果必须:
任意跳到第10000页
游标分页:
不方便
它最适合:
下一页
加载更多
无限滚动
6. 不要看到 LIMIT 就一定优化
例如:
总共只有5000条数据
普通 OFFSET:
完全可能够用
优化一定要结合:
数据量
访问频率
实际执行时间
业务需求
❓ 自测
-
LIMIT 的两个参数分别表示什么?
-
什么是深分页?
-
为什么
LIMIT 5000000,10会比较慢? -
MySQL 会直接跳到第500万条吗?
-
OFFSET 越大为什么通常越慢?
-
有主键索引为什么深分页仍然可能慢?
-
深分页和 B+ Tree 有什么关系?
-
什么是延迟关联?
-
为什么延迟关联通常会先查询 id?
-
延迟关联和覆盖索引有什么关系?
-
延迟关联能彻底消灭 OFFSET 吗?
-
什么是游标分页?
-
WHERE id > lastId为什么比大 OFFSET 更适合连续翻页? -
游标分页利用了 B+ Tree 的什么能力?
-
游标分页最大的缺点是什么?
-
哪些业务适合游标分页?
-
哪些业务不适合单纯使用游标分页?
-
如果按照 create_time 排序,游标应该怎么设计?
-
为什么经常使用
create_time + id? -
深分页和 ORDER BY 有什么关系?
-
如果 ORDER BY 没有索引,可能看到什么?
-
Using filesort是什么意思? -
深分页和回表有什么关系?
-
深分页和覆盖索引有什么关系?
-
EXPLAIN 分析深分页时重点看哪些字段?
-
普通 OFFSET、延迟关联、游标分页分别有什么优缺点?
-
为什么说“延迟关联是降低 OFFSET 成本,而游标分页是避免大 OFFSET”?
-
LIMIT 0,10和LIMIT 5000000,10为什么性能可能差很多? -
如果业务只需要“下一页”,你更推荐哪种分页?
-
如果业务必须任意跳页,应该如何选择分页方案?