如何优化 MySQL 深分页 SQL?
假设我们有这样一条 SQL:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
这条 SQL 执行需要 5 秒左右,并且越往后翻页越慢,这就是典型的 MySQL 深分页问题。
所谓深分页,就是 LIMIT offset, size 中的 offset 非常大。比如:
sql
LIMIT 4999990, 10
它的含义不是从 id = 4999990 开始查,而是:
在满足
batch_id = '1830889785603571980'的数据中,按照id排序,跳过前 4,999,990 条,然后取 10 条。
也就是说,MySQL 需要先找到并跳过大量数据,最后只返回 10 条。越往后翻页,需要跳过的数据越多,查询也就越慢。
在讲优化之前,我们先看几个基础概念。
一、几个基础概念
1. 索引
索引可以理解成书的目录。
如果没有目录,想找某一页内容,只能从头一页一页翻,非常慢。
如果有目录,就可以先通过目录找到目标内容所在的位置,再直接翻过去,速度会快很多。
在数据库中,索引的作用也是类似的:帮助 MySQL 更快定位数据,避免全表扫描。
2. 聚簇索引 / 主键索引
在 InnoDB 存储引擎中,表数据本身就是按照主键组织起来的。
如果表的主键是 id,那么 InnoDB 会以 id 建立一棵 B+ 树,这棵 B+ 树的叶子节点中存储的是完整行数据。
也就是说:
text
主键索引的叶子节点 = 完整行数据
所以 InnoDB 中的主键索引也叫 聚簇索引。
3. 二级索引
除了主键索引以外,我们自己创建的普通索引,一般叫二级索引。
比如:
sql
CREATE INDEX idx_batch_id_id
ON t_coupon_task_fail(batch_id, id);
这个索引里面大致存的是:
text
batch_id + id
它不会存储整行数据,比如 json_object 这种字段通常不在这个索引中。
4. 回表
假设有索引:
sql
idx_batch_id_id(batch_id, id)
如果执行:
sql
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 10;
这个查询只需要 id,而 id 已经在 idx_batch_id_id 索引中了,所以不需要再去主键索引中查完整行。
这种情况叫 覆盖索引。
但是如果执行:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 10;
由于 SELECT * 需要完整行数据,而二级索引中没有完整行,所以 MySQL 需要先从二级索引中找到主键 id,再根据 id 去主键索引中查完整行数据。
这个过程就叫 回表。
简单理解:
text
二级索引找到 id
再根据 id 去主键索引拿完整数据
这个过程就是回表
二、原始 SQL 为什么慢?
原始 SQL 是:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
它慢的核心原因是:
text
需要跳过前 4,999,990 条满足条件的数据,最后只返回 10 条。
如果执行过程中还发生大量回表,那么性能会更差。
下面分几种索引情况来看。
三、不同索引情况下的执行过程
1. 没有合适索引
如果没有合适索引,执行过程可能是:
text
1. 全表扫描;
2. 一行一行判断 batch_id 是否等于目标值;
3. 找出所有符合条件的数据;
4. 按 id 排序;
5. 跳过前 4,999,990 条;
6. 返回后面的 10 条。
这种情况性能最差,因为需要扫描大量数据,还可能需要额外排序。
2. 只有主键索引 id
如果表中只有主键索引:
sql
PRIMARY KEY(id)
由于主键索引本身是按照 id 排序的,MySQL 可能会选择按照主键顺序扫描。
执行过程大致是:
text
1. 从主键索引最小的 id 开始扫描;
2. 读取完整行数据;
3. 判断 batch_id 是否等于目标值;
4. 如果不等于,丢弃;
5. 如果等于,计数加 1;
6. 一直数到第 4,999,990 条符合条件的数据;
7. 再取后面的 10 条。
这种方式可以避免额外排序,因为主键本身就是按 id 有序的。
但是它不能快速定位某个 batch_id 的数据。如果目标 batch_id 的数据分布很散,MySQL 可能需要扫描大量无关数据。
3. 有 batch_id 单列索引
假设有索引:
sql
CREATE INDEX idx_batch_id
ON t_coupon_task_fail(batch_id);
MySQL 可以通过这个索引先找到 batch_id = '1830889785603571980' 的数据。
执行过程大致是:
text
1. 通过 idx_batch_id 找到 batch_id 等于目标值的索引记录;
2. 因为 SELECT * 需要完整行,所以根据主键 id 回表;
3. 再根据 ORDER BY id 返回有序结果;
4. 跳过前 4,999,990 条;
5. 返回 10 条。
这里有一个细节:在 InnoDB 中,二级索引的叶子节点会包含主键值。因此,对于 idx_batch_id(batch_id) 来说,同一个 batch_id 下的记录,通常也会按照主键值有序排列。
所以它不一定每次都需要额外 filesort。
但是单列索引 idx_batch_id(batch_id) 的表达不如联合索引 (batch_id, id) 清晰。对于这类:
sql
WHERE batch_id = ?
ORDER BY id
的查询,更推荐使用联合索引:
sql
(batch_id, id)
4. 有联合索引 (batch_id, id)
假设有索引:
sql
CREATE INDEX idx_batch_id_id
ON t_coupon_task_fail(batch_id, id);
这个索引的排序规则是:
text
先按 batch_id 排序;
batch_id 相同的情况下,再按 id 排序。
所以对于下面的 SQL:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
这个索引非常合适。
执行过程大致是:
text
1. 通过 idx_batch_id_id 定位到 batch_id 等于目标值的索引范围;
2. 在这个范围内按照 id 顺序扫描;
3. 跳过前 4,999,990 条;
4. 找到目标页的 10 条记录;
5. 根据这 10 条记录的主键 id 回表查询完整行;
6. 返回结果。
这个索引的好处是:
text
1. 可以快速定位 batch_id;
2. batch_id 相同的数据天然按照 id 排序;
3. 不需要额外排序;
4. 如果只查 id,可以使用覆盖索引;
5. 即使 SELECT *,也能尽量减少不必要的扫描和排序成本。
但是注意,普通深分页即使有 (batch_id, id) 索引,也仍然需要跳过前面大量索引记录。它只是把成本降低了,并没有彻底消除深分页的问题。
四、为什么 (batch_id, id) 比 (id, batch_id) 更适合?
对于这条 SQL:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
推荐索引是:
sql
CREATE INDEX idx_batch_id_id
ON t_coupon_task_fail(batch_id, id);
而不是:
sql
CREATE INDEX idx_id_batch_id
ON t_coupon_task_fail(id, batch_id);
原因是联合索引是有顺序的。
(batch_id, id) 的含义是:
text
先按 batch_id 排序;
batch_id 相同的情况下,再按 id 排序。
这样 MySQL 可以先定位到某个 batch_id 的范围,然后在这个范围内按 id 顺序扫描。
而 (id, batch_id) 的含义是:
text
先按 id 排序;
id 相同的情况下,再按 batch_id 排序。
这种情况下,batch_id = '1830889785603571980' 的数据会分散在整个索引中,MySQL 不能快速定位到某个 batch_id 的连续范围。
所以对于:
sql
WHERE batch_id = ?
ORDER BY id
更适合:
sql
(batch_id, id)
可以简单记一句:
text
等值过滤字段放前面,排序字段放后面。
五、深分页优化方案
下面假设我们已经创建了联合索引:
sql
CREATE INDEX idx_batch_id_id
ON t_coupon_task_fail(batch_id, id);
接下来介绍几种常见的深分页优化方案。
方案一:子查询先定位起点 id
原始 SQL 是:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
可以改成:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id >= (
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 1
)
ORDER BY id
LIMIT 10;
这个优化的核心是:
text
先通过子查询找到目标页的起点 id;
然后从这个 id 开始,向后取 10 条完整数据。
子查询部分:
sql
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 1;
由于只查询 id,并且有 (batch_id, id) 索引,所以可以使用覆盖索引。也就是说,MySQL 可以只在索引中扫描,不需要对前面几百万条数据回表。
外层查询再根据起点 id 查询完整数据:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id >= 起点id
ORDER BY id
LIMIT 10;
这样可以避免对前面大量无用数据回表。
但是这个方案仍然需要扫描前面 4,999,990 条索引记录,所以它只是减少回表成本,并没有彻底消除深分页的 offset 成本。
方案二:延迟关联
延迟关联,也叫延迟 JOIN。
SQL 如下:
sql
SELECT
t1.id,
t1.batch_id,
t1.json_object
FROM t_coupon_task_fail t1
INNER JOIN (
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10
) AS t2 ON t1.id = t2.id
ORDER BY t1.id
LIMIT 10;
它的执行思路是:
text
1. 子查询先通过覆盖索引找到目标页的 10 个 id;
2. 外层再根据这 10 个 id 回表查询完整数据。
子查询:
sql
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
由于只查 id,可以走 (batch_id, id) 覆盖索引。
它先拿到目标页的 10 个 id,比如:
text
4999991
4999992
4999993
...
5000000
然后外层通过 JOIN 回表查询完整行。
这个方案的核心优势是:
text
前面几百万条数据只扫描索引,不回表;
最终只对目标页的 10 条数据回表。
方案一是先找到起点 id,再从起点 id 往后查 10 条。
方案二是直接找到目标页的 10 个 id,再根据这 10 个 id 查询完整数据。
两者本质上都利用了覆盖索引减少回表,只是写法不同。
方案三:游标分页
游标分页,也叫书签分页。
它的思路是:记录上一页最后一条数据的 id,下一页查询时直接从这个 id 后面开始查。
第一页:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 10;
假设第一页最后一条数据的 id 是:
text
100
那么第二页可以这样查:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id > 100
ORDER BY id
LIMIT 10;
如果第二页最后一条数据的 id 是:
text
200
第三页就可以这样查:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id > 200
ORDER BY id
LIMIT 10;
对于你的例子,如果上一次最后一条记录的 id 是 4999990,那么下一页就是:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id > 4999990
ORDER BY id
LIMIT 10;
这里一定要注意,用的是:
sql
id > last_id
而不是:
sql
id >= last_id
如果使用 >=,上一页最后一条数据会在下一页重复出现。
游标分页的优点是非常快,因为它不需要跳过前面几百万条数据,而是直接从上一次的位置继续向后查。
如果有 (batch_id, id) 索引,执行过程大致是:
text
1. 定位到 batch_id 对应的索引范围;
2. 在这个范围内找到 id > last_id 的位置;
3. 向后取 10 条;
4. 返回结果。
它的缺点是不能直接跳转到某一个具体页码。
比如普通分页可以直接写:
sql
LIMIT 4999990, 10
表示跳到很后面的某一页。
但是游标分页必须知道上一页最后一条数据的 id,所以它更适合:
text
下一页
加载更多
滚动加载
信息流
不适合:
text
直接跳转到第 500000 页
六、三种优化方案对比
| 方案 | 核心思路 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 子查询定位起点 id | 先找到目标页起点 id,再向后查 10 条 | 减少大量回表 | 仍然需要扫描大量 offset 索引记录 | 需要跳页的场景 |
| 延迟关联 | 先查目标页的 10 个 id,再 JOIN 查完整行 | 只对最终 10 条数据回表 | 仍然需要扫描大量 offset 索引记录 | 需要跳页的场景 |
| 游标分页 | 记录上一页最后一条 id,下次从该 id 后面查 | 性能最好 | 不能直接跳转任意页 | 下一页、加载更多、滚动分页 |
七、最终建议
对于这条 SQL:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10;
首先应该建立合适的联合索引:
sql
CREATE INDEX idx_batch_id_id
ON t_coupon_task_fail(batch_id, id);
如果业务需要支持直接跳转到某一页,可以使用子查询或者延迟关联优化:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id >= (
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 1
)
ORDER BY id
LIMIT 10;
或者:
sql
SELECT
t1.id,
t1.batch_id,
t1.json_object
FROM t_coupon_task_fail t1
INNER JOIN (
SELECT id
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
ORDER BY id
LIMIT 4999990, 10
) AS t2 ON t1.id = t2.id
ORDER BY t1.id
LIMIT 10;
如果业务不需要跳转到具体页码,只需要上一页、下一页、加载更多,那么优先使用游标分页:
sql
SELECT *
FROM t_coupon_task_fail
WHERE batch_id = '1830889785603571980'
AND id > #{lastId}
ORDER BY id
LIMIT 10;
一句话总结:
text
深分页慢,是因为 MySQL 需要跳过大量数据。
子查询和延迟关联的优化点,是让前面大量被跳过的数据尽量只走索引,不回表。
游标分页的优化点,是直接从上一次的位置继续查,避免大 offset。