如何优化 MySQL 深分页 SQL

如何优化 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;

对于你的例子,如果上一次最后一条记录的 id4999990,那么下一页就是:

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。
相关推荐
awu的Android笔记1 小时前
网络闪断 + DNS 故障:Android弱网模拟中最容易被忽视的两个场景
android·tcp/ip
超梦dasgg1 小时前
工作中 MySQL 读写分离主从延迟:成因、影响、落地方案、生产实战处理
数据库·mysql
Flynt2 小时前
Android 17内存限制:我是怎么发现App被系统悄悄干掉的
android·性能优化
疯狂热爱代码的00后2 小时前
入门必看! MySQL增删改查全套示例SQL 直接复制运行
mysql
huipeng9262 小时前
企业级微服务开发实战(二):微服务基础设施搭建与中间件部署
java·redis·mysql·spring cloud·微服务·nacos·rabbitmq
可乐ea2 小时前
【知识获取与分享社区项目 | 项目日记第 24 天】终章总结:从认证、发布、计数、Feed、搜索到 RAG:完整复盘一个知识社区后端系统
java·spring boot·redis·mysql·elasticsearch·ai·kafka
消失的旧时光-19433 小时前
Kotlin 协程设计思想(七):为什么 Kotlin 要设计 SupervisorJob 和 supervisorScope?
android·开发语言·kotlin
小小编程路3 小时前
MySQL9.0|融合向量的新一代关系数据库安装配置教程
mysql
故渊at3 小时前
第一板块:Android 系统基石与运行原理 | 第五篇:Context 上下文与资源配置体系
android·人工智能·opencv·context·上下文·资源配置体系