MySQL 深分页优化:LIMIT 为什么越来越慢
在后台管理、订单查询、文章列表等业务中,分页几乎是最常见的 SQL 场景之一。
最常见的写法是:
SELECT id, title, created_at
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000;
刚开始数据量比较小时,这种分页方式没有明显问题。但随着数据量增长,翻到后面的页码后,查询速度可能明显下降。
很多人会简单地认为:
LIMIT OFFSET会越来越慢,因为数据库扫描了很多数据。
这个说法没错,但还不够准确。真正需要理解的是:数据库为了找到第 1000001 条记录,通常仍然需要定位并跳过前面的 1000000 条符合条件的记录。
因此,深分页优化的核心并不是简单地给 LIMIT 加索引,而是要改变分页数据的定位方式。
1. 什么是深分页
假设 articles 表有 1000 万条数据:
SELECT id, title, created_at
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 0;
查询第一页:
跳过 0 条
读取 20 条
查询第 100 页:
LIMIT 20 OFFSET 1980;
需要跳过约 1980 条记录。
而查询第 50000 页:
LIMIT 20 OFFSET 999980;
就需要跳过接近 100 万条记录。
如果继续向后翻:
LIMIT 20 OFFSET 9999980;
就需要处理接近 1000 万条记录。
因此:
OFFSET 越大
↓
需要跳过的数据越多
↓
查询成本增加
↓
深分页越来越慢
这里需要注意,OFFSET 并不是简单地让 MySQL “读取所有数据后再返回 20 条”。实际执行方式还取决于 WHERE 条件、索引、排序方式以及执行计划。
但对于典型的:
ORDER BY id DESC
LIMIT 20 OFFSET N
随着 N 增大,需要定位并跳过越来越多记录是深分页的主要问题。
2. 为什么有索引仍然会慢
很多开发者遇到深分页时,第一个反应是:
“是不是没有索引?”
假设已经存在主键:
PRIMARY KEY (id)
并且执行:
SELECT id, title
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000;
MySQL 可以利用主键索引按照 id 倒序访问。
但是:
OFFSET 1000000
并不会让 B+Tree 直接跳到“第 1000001 条逻辑记录”。
索引能够帮助 MySQL 快速找到数据,并按照索引顺序访问,但 OFFSET 本身仍然意味着需要跳过前面的结果。
可以简单理解为:
B+Tree 索引
↓
按照 id DESC 顺序访问
↓
第 1 条
第 2 条
第 3 条
...
第 1000000 条 ← 跳过
第 1000001 条 ← 开始返回
所以:
有索引可以降低分页成本,但不能从根本上消除 OFFSET 带来的深分页问题。
如果 SQL 还包含其他 WHERE 条件、非覆盖索引或者复杂排序,实际成本可能更高。
3. 第一种方案:普通 LIMIT 分页
普通分页并不是不能用。
例如:
SELECT id, title, created_at
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 100;
对于后台管理系统来说,这种方式通常完全够用。
如果数据量只有几十万,用户最多查看几十页,没必要为了“理论上的性能问题”把分页系统设计得很复杂。
适合:
后台管理系统
运营管理页面
数据量较小的列表
用户不会翻很深的页面
需要直接跳转到指定页
因此深分页优化并不是:
“所有 OFFSET 都应该被禁止。”
而应该根据实际数据规模和访问场景决定。
4. 第二种方案:延迟关联
如果必须保留传统页码分页,可以考虑延迟关联(Deferred Join)。
假设文章表:
CREATE TABLE articles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
content LONGTEXT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
);
原始 SQL:
SELECT id, title, content, created_at
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000;
如果表比较大,并且需要读取大量非索引字段,那么可以先利用索引找到需要的主键:
SELECT id
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000;
然后再根据这些 ID 查询完整记录:
SELECT id, title, content, created_at
FROM articles
WHERE id IN (...);
也可以写成:
SELECT a.id, a.title, a.content, a.created_at
FROM articles a
JOIN (
SELECT id
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000
) t ON t.id = a.id
ORDER BY a.id DESC;
它的核心思想是:
先通过索引定位 ID
↓
只取需要的 20 个 ID
↓
再回表获取完整数据
这种方式主要减少深分页过程中不必要的回表成本。
但要注意:
延迟关联并没有消除 OFFSET 本身的扫描成本。
如果已经需要跳过 100 万条索引记录,它仍然需要处理这部分索引记录。
因此延迟关联更适合:
必须保留页码分页
SELECT 字段较多
数据量较大
存在明显回表成本
它不是深分页的终极解决方案。
5. 第三种方案:Keyset Pagination
对于数据量较大的列表,通常更推荐 Keyset Pagination,也叫:
Cursor Pagination
Seek Pagination
游标分页
假设按照 id DESC 排序。
第一页:
SELECT id, title, created_at
FROM articles
ORDER BY id DESC
LIMIT 20;
假设最后一条记录:
id = 999981
下一页不再使用:
LIMIT 20 OFFSET 20;
而是:
SELECT id, title, created_at
FROM articles
WHERE id < 999981
ORDER BY id DESC
LIMIT 20;
继续下一页:
WHERE id < 999961
这样数据库就可以利用主键索引直接定位到:
id < 999981
的位置,然后继续向后读取 20 条。
查询方式变成:
第一页
↓
记录最后一个 ID = 999981
↓
下一页 WHERE id < 999981
↓
记录最后一个 ID = 999961
↓
下一页 WHERE id < 999961
而不是:
第 1 页
↓
第 2 页
↓
第 3 页
↓
...
第 50000 页
这就是 Keyset Pagination 的核心优势:
分页位置由索引中的“值”确定,而不是由 OFFSET 指定需要跳过多少条记录。
6. Keyset Pagination 的正确设计
实际项目中不能简单地认为:
WHERE id < ?
永远够用。
因为业务通常需要按照:
ORDER BY created_at DESC
排序。
如果 created_at 存在大量相同值,就需要增加一个唯一字段作为稳定排序条件。
例如:
SELECT id, title, created_at
FROM articles
WHERE
created_at < ?
OR (
created_at = ?
AND id < ?
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
对应索引:
KEY idx_created_id (created_at, id)
假设上一页最后一条:
created_at = '2026-09-09 10:00:00'
id = 1000
下一页:
SELECT id, title, created_at
FROM articles
WHERE
created_at < '2026-09-09 10:00:00'
OR (
created_at = '2026-09-09 10:00:00'
AND id < 1000
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这里的 id 就是稳定排序的 tie-breaker。
因此,Keyset Pagination 通常要求:
排序字段具有较好的索引支持
+
排序顺序稳定
+
最好包含唯一字段作为最终排序条件
7. Keyset Pagination 的局限
Keyset Pagination 并不是所有场景都适合。
最大的区别是:
OFFSET Pagination
可以:
第 1 页
第 100 页
第 500 页
第 10000 页
直接跳转。
而 Keyset Pagination 更适合:
下一页
下一页
下一页
这种连续浏览模式。
例如:
新闻流
商品流
订单列表
消息列表
时间线
文章列表
非常适合 Keyset Pagination。
但如果业务要求:
输入页码 → 直接跳到第 5000 页
那么 Keyset Pagination 就不太适合单独使用。
所以实际项目中经常采用:
后台管理系统
→ OFFSET Pagination
用户端无限滚动列表
→ Keyset Pagination
大数据量连续列表
→ Keyset Pagination
必须跳页 + 数据量很大
→ 根据业务设计混合方案
8. 复杂查询中的深分页
真实业务通常不会只有:
ORDER BY id DESC
例如订单列表:
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = ?
AND status = ?
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
如果采用 Keyset Pagination,可以转换成:
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = ?
AND status = ?
AND (
created_at < ?
OR (
created_at = ?
AND id < ?
)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
同时根据实际查询设计索引,例如:
KEY idx_user_status_created_id (
user_id,
status,
created_at,
id
)
但联合索引是否应该采用这个顺序,不能仅凭经验判断,还需要结合:
WHERE 条件
ORDER BY
数据分布
选择性
实际 SQL
EXPLAIN
进行验证。
因此深分页优化实际上和索引设计、执行计划分析密切相关。
9. 实战中的选择
可以把几种方案简单归纳为:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
LIMIT OFFSET | 简单、支持跳页 | 深分页性能下降 | 普通后台列表 |
| 延迟关联 | 减少深分页回表成本 | OFFSET 成本仍然存在 | 大表传统分页 |
| Keyset Pagination | 深分页性能稳定 | 不适合直接跳页 | 无限滚动、大数据列表 |
实际项目中,可以遵循几个原则:
第一,小数据量不要过度优化。
几十万数据、用户只浏览几十页时,普通分页完全可以满足需求。
第二,发现深分页问题后先看执行计划。
例如:
EXPLAIN
SELECT ...
FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 1000000;
重点观察:
type
key
rows
Extra
不要在没有执行计划的情况下直接修改索引。
第三,大数据量连续分页优先考虑 Keyset Pagination。
尤其是:
Feed
时间线
订单
消息
日志
文章
这类按照固定顺序不断向后读取数据的场景。
第四,排序必须稳定。
推荐:
ORDER BY created_at DESC, id DESC
而不是只依赖一个可能重复的时间字段。
第五,Keyset Pagination 的索引必须和查询条件匹配。
分页优化最终仍然是:
SQL
↓
索引
↓
执行计划
↓
实际数据量
↓
性能验证
而不是单纯把:
LIMIT 20 OFFSET 1000000
替换成:
WHERE id < ?
就结束了。
10. 总结
MySQL 深分页的核心问题,可以概括为:
OFFSET 分页
↓
OFFSET 越大
↓
需要跳过的数据越多
↓
查询成本增加
解决方案则可以分成三个层次:
普通分页
↓
数据量较小时直接使用
延迟关联
↓
减少深分页过程中的回表成本
Keyset Pagination
↓
通过索引值直接定位分页位置
其中,Keyset Pagination 是大数据量连续分页场景下更值得优先考虑的方案。
但分页优化不能脱离具体业务。是否需要优化、选择哪种方案,都应该建立在实际数据量、查询方式、索引设计以及 EXPLAIN 分析的基础上。
真正成熟的分页设计不是追求某一种“最优写法”,而是在:
业务体验
+
SQL 复杂度
+
索引成本
+
数据规模
+
分页方式
之间取得合理平衡。