MySQL 深分页查询优化:从 17 秒到 300ms 的一次记录

作者:old wang 发布时间: 2026-05-23 阅读量:2 评论数:0

有一张财务流水表,数据量接近千万级,未做分库分表。

原始分页查询使用了 LIMIT,优化前耗时接近 17 秒:

16 s 938 ms

调整 SQL 后,耗时降到 300ms 左右:

347 ms

优化方式并不复杂:

先在子查询中只查主键 ID,再通过主键关联回原表查询完整字段。

也就是把原来的查询:

SELECT 各种字段
FROM table_name
WHERE 各种条件
LIMIT 0, 10;

改成类似:

SELECT main_table.各种字段
FROM table_name main_table
JOIN (
    SELECT id
    FROM table_name
    WHERE 各种条件
    LIMIT 0, 10
) temp_table ON temp_table.id = main_table.id;

这类优化的核心目的,是减少不必要的回表。

本文基于一个示例,记录一下这个优化思路背后的原因。

一、测试环境与表结构

示例 MySQL 版本:

SELECT VERSION();

结果:

5.7.17

测试表结构如下:

DESC test;

结果:

+--------+---------------------+------+-----+---------+----------------+
| Field  | Type                | Null | Key | Default | Extra          |
+--------+---------------------+------+-----+---------+----------------+
| id     | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| val    | int(10) unsigned    | NO   | MUL | 0       |                |
| source | int(10) unsigned    | NO   |     | 0       |                |
+--------+---------------------+------+-----+---------+----------------+

其中:

  • id 是自增主键;

  • val 是普通非唯一索引;

  • source 是普通字段。

测试数据量:

SELECT COUNT(*) FROM test;

结果:

5242882

也就是 500 多万条数据。

二、问题 SQL:深分页 LIMIT

原始 SQL 如下:

SELECT *
FROM test
WHERE val = 4
LIMIT 300000, 5;

执行结果类似:

5 rows in set (15.98 sec)

这类 SQL 的问题在于:

LIMIT 300000, 5

它不是直接跳到第 300001 条数据开始取 5 条。

MySQL 需要先找到满足条件的前 300005 条记录,然后丢弃前 300000 条,只返回最后 5 条。

也就是说,虽然最终只返回 5 条数据,但中间需要扫描和处理大量无用数据。

三、优化 SQL:子查询先查主键

优化后的写法:

SELECT *
FROM test a
INNER JOIN (
    SELECT id
    FROM test
    WHERE val = 4
    LIMIT 300000, 5
) b ON a.id = b.id;

执行耗时明显下降:

5 rows in set (0.38 sec)

这条 SQL 的核心变化是:

子查询只查询主键 id,外层再根据主键查完整数据。

子查询部分:

SELECT id
FROM test
WHERE val = 4
LIMIT 300000, 5

因为只查询 id,而 val 是索引字段,InnoDB 的二级索引叶子节点中本身就包含主键值。

所以这一步可以尽量只在索引层完成扫描,不需要对前面被丢弃的大量记录做完整回表。

外层再通过主键:

ON a.id = b.id

只对最终需要的 5 条记录进行回表查询。

四、为什么原 SQL 慢

原 SQL:

SELECT *
FROM test
WHERE val = 4
LIMIT 300000, 5;

查询过程可以简化理解为:

1. 通过 val 索引找到满足 val = 4 的索引记录
2. 根据索引记录中的主键 id 回表查询完整行数据
3. 重复处理前 300005 条记录
4. 丢弃前 300000 条
5. 返回最后 5 条

这里最浪费的地方是:

前 300000 条记录最终不会返回,但仍然做了回表查询。

因为查询的是:

SELECT *

需要返回所有字段。

普通二级索引 val 中并不包含 source 等完整字段,所以 MySQL 需要根据主键回到聚簇索引中读取完整行。

这就是回表。

当偏移量很大时,大量回表操作都是无效的。

五、为什么优化 SQL 快

优化 SQL:

SELECT *
FROM test a
INNER JOIN (
    SELECT id
    FROM test
    WHERE val = 4
    LIMIT 300000, 5
) b ON a.id = b.id;

可以理解为两步。

第一步:

SELECT id
FROM test
WHERE val = 4
LIMIT 300000, 5;

这一步只需要 valid

由于二级索引 val 的叶子节点中包含主键 id,所以可以通过索引扫描拿到需要的主键。

第二步:

SELECT *
FROM test a
JOIN temp_table b ON a.id = b.id;

这一步只对最终 5 个主键做回表。

所以回表次数从原来的大量无效回表,变成只对最终结果回表。

这也是性能差距明显的主要原因。

六、用 Buffer Pool 观察回表差异

为了验证上面的推论,原文通过观察 InnoDB Buffer Pool 中的数据页变化来做对比。

思路是:

如果原 SQL 做了大量回表,那么它会把更多聚簇索引数据页加载到 Buffer Pool。
如果优化 SQL 只对最终少量记录回表,那么加载的数据页应该明显更少。

1. 执行原 SQL 前

先查询 Buffer Pool 中是否已有测试表相关页:

SELECT index_name, COUNT(*)
FROM information_schema.INNODB_BUFFER_PAGE
WHERE INDEX_NAME IN ('val', 'PRIMARY')
  AND TABLE_NAME LIKE '%test%'
GROUP BY index_name;

结果为空:

Empty set

说明当前 Buffer Pool 中没有测试表相关页。

2. 执行原 SQL

SELECT *
FROM test
WHERE val = 4
LIMIT 300000, 5;

执行耗时:

26.19 sec

再次查看 Buffer Pool:

SELECT index_name, COUNT(*)
FROM information_schema.INNODB_BUFFER_PAGE
WHERE INDEX_NAME IN ('val', 'PRIMARY')
  AND TABLE_NAME LIKE '%test%'
GROUP BY index_name;

结果类似:

+------------+----------+
| index_name | count(*) |
+------------+----------+
| PRIMARY    |     4098 |
| val        |      208 |
+------------+----------+

可以看到,PRIMARY 聚簇索引数据页加载了很多。

这说明原 SQL 做了大量回表。

七、优化 SQL 的 Buffer Pool 表现

为了避免上一次测试影响,需要重启 MySQL 或清空 Buffer Pool。

然后再次确认测试表相关页为空:

Empty set

执行优化 SQL:

SELECT *
FROM test a
INNER JOIN (
    SELECT id
    FROM test
    WHERE val = 4
    LIMIT 300000, 5
) b ON a.id = b.id;

执行耗时:

0.09 sec

再次查看 Buffer Pool:

+------------+----------+
| index_name | count(*) |
+------------+----------+
| PRIMARY    |        5 |
| val        |      390 |
+------------+----------+

可以看到:

  • 原 SQL 加载了大量 PRIMARY 数据页;

  • 优化 SQL 只加载了少量 PRIMARY 数据页。

这和前面的分析一致。

优化 SQL 的回表次数明显更少。

八、这个优化的本质

这个优化的本质不是 JOIN 本身更快。

真正的关键是:

子查询只查索引中已有的数据,尽量避免对被丢弃的记录回表。

原 SQL 的问题是:

SELECT *

导致每扫描一条候选记录,都可能需要回表读取完整行。

而优化后的子查询:

SELECT id

只拿主键,能够尽量利用二级索引完成扫描。

等确定最终需要的少量主键后,再回表查询完整字段。

所以性能提升来自:

减少无效回表
减少随机 I/O
减少 Buffer Pool 污染

九、Buffer Pool 污染问题

深分页原 SQL 除了慢,还有一个隐藏问题:

它会把大量不需要返回的数据页加载到 Buffer Pool。

这些数据页可能只是为了跳过前面 300000 条记录而被读取。

但它们进入 Buffer Pool 后,会占用缓存空间,挤掉原本更热的数据页。

这就是 Buffer Pool 污染。

对于高并发系统来说,这可能影响其他正常查询的缓存命中率。

所以深分页慢 SQL 不只是影响当前查询,也可能影响整个数据库实例的缓存表现。

十、什么时候适合这种优化

这种优化方式适合以下场景:

1. 深分页

例如:

LIMIT 300000, 10
LIMIT 1000000, 20

偏移量越大,原 SQL 需要丢弃的数据越多,优化收益越明显。

2. 查询字段较多

如果原 SQL 是:

SELECT *

或者查询了很多非索引字段,就容易产生大量回表。

3. WHERE 条件能走索引

例如:

WHERE val = 4

并且 val 上有索引。

子查询只查索引字段和主键时,效果更明显。

4. 最终返回行数较少

例如:

LIMIT 300000, 5

最终只需要少量记录。

这种情况下,先定位主键再回表比较合适。

十一、需要注意的问题

1. 子查询要尽量只查主键

优化的关键是:

SELECT id

不要在子查询里继续写:

SELECT *

否则又会回到原来的问题。

2. WHERE 和 ORDER BY 要匹配索引

分页查询通常会带排序。

例如:

WHERE status = 1
ORDER BY create_time DESC
LIMIT 300000, 10

这种场景需要结合索引设计。

常见索引可能是:

(status, create_time, id)

否则即使用子查询,也可能因为排序或过滤不走索引,导致效果不理想。

3. JOIN 类型要写清楚

原文示例中有 RIGHT JOIN 写法。

实际写 SQL 时,更常见也更直观的是:

SELECT main_table.*
FROM (
    SELECT id
    FROM table_name
    WHERE ...
    LIMIT ...
) temp_table
JOIN table_name main_table ON main_table.id = temp_table.id;

或者:

SELECT main_table.*
FROM table_name main_table
JOIN (
    SELECT id
    FROM table_name
    WHERE ...
    LIMIT ...
) temp_table ON temp_table.id = main_table.id;

只要语义清晰即可。

4. 深分页本身要尽量避免

如果业务允许,深分页最好从产品和接口设计上规避。

例如使用基于游标的翻页:

SELECT *
FROM test
WHERE val = 4
  AND id > #{lastId}
ORDER BY id
LIMIT 10;

这种方式不依赖大 offset,性能通常更稳定。

适合“下一页”这种连续翻页场景。

5. 需要结合 EXPLAIN 分析

不同表结构、索引、数据分布下,优化效果可能不同。

实际改 SQL 前,建议先使用:

EXPLAIN

查看执行计划。

重点关注:

  • 是否走索引;

  • 扫描行数;

  • 是否 Using filesort;

  • 是否 Using temporary;

  • 是否覆盖索引;

  • 是否回表。

十二、优化前后对比

方案

SQL 特点

问题

耗时示例

原始 SQL

SELECT * ... LIMIT 300000, 5

大量无效回表

15s+

优化 SQL

子查询先查 id,再回表

只回表最终结果

0.3s 左右

优化前:

SELECT *
FROM test
WHERE val = 4
LIMIT 300000, 5;

优化后:

SELECT *
FROM test a
INNER JOIN (
    SELECT id
    FROM test
    WHERE val = 4
    LIMIT 300000, 5
) b ON a.id = b.id;

结论

MySQL 深分页慢的一个重要原因是:

LIMIT offset, size 在 offset 很大时,需要先扫描并丢弃大量记录。

如果查询字段不在索引中,MySQL 还可能对这些最终会被丢弃的记录执行大量回表操作。

通过子查询先只查询主键,再关联原表查询完整字段,可以减少无效回表。

核心思路是:

先用索引定位最终需要的主键
再根据主键回表查询完整数据

这种方式可以把大量无意义的回表操作,减少到只对最终结果集回表。

深分页查询不要一上来就 SELECT *
能先用覆盖索引查出主键,再回表查完整字段,通常会更稳定,也更容易避免大量无效 I/O。

评论