1. 千万级 MySQL 分页查询超时现场还原
先说触发点:一个交易订单管理后台,表里实际存量 1800 多万行,某天晚上运营反馈说“订单查询页面第 3 万页以后基本打不开”。抓了线上慢查询日志,有一条 SQL 稳定执行 7 秒以上,再往后翻甚至 15 秒,客户端直接报网关超时。这条 SQL 看起来人畜无害,就是一个常规的分页查询加上一个业务类型过滤,但问题就出在 MySQL 的 offset 深翻页逻辑上。
我当时第一反应也是“加个索引就好了”,但真正做完一轮优化后发现,索引只能把 7 秒压到几百毫秒,要稳定到 10ms 级别,必须把分页方式本身改掉。如果你手头也有千万级数据表的分页慢查询,或者面试中被人问过 MySQL 深分页怎么优化,这篇记录应该能给你一条可以直接落地的优化路径。
表结构其实不复杂,核心字段大致如下:
sql复制CREATE TABLE `t_trade_order` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` bigint unsigned NOT NULL,
`biz_type` tinyint unsigned NOT NULL COMMENT '业务类型:1 现金交易 2 退款…',
`order_status` tinyint unsigned NOT NULL,
`amount` decimal(12,2) NOT NULL,
`pay_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_biz_type` (`biz_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
线上订单按业务类型做了索引,也就是 idx_biz_type。页面传入第 35001 页,每页 20 条,业务代码就把页码换算成 offset,最终执行的 SQL 长这样:
sql复制SELECT id, order_no, user_id, biz_type, order_status, amount, pay_time, create_time
FROM t_trade_order
WHERE biz_type = 1
ORDER BY create_time DESC
LIMIT 700000, 20;
取出第 35001 页,等价于 offset = (35001 - 1) * 20 = 700000。这条 SQL 一开始只走了 idx_biz_type 索引去定位业务类型,等于要先把业务类型为 1 的所有记录都过一遍,再按 create_time 做文件排序,最后扔掉前 70 万条,工作量和深翻页叠加,7 秒超时一点不奇怪。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL 深分页慢在哪三个关键点
2.1 LIMIT 700000 是“先取出,再丢弃”的伪定位
理解深分页优化的第一步,是看清 LIMIT offset, row_count 的底层语义。很多人以为 MySQL 能像数组一样直接跳转到第 700000 条,但 InnoDB 本质是一棵 B+ 树,索引页和聚簇索引页之间通过双向链表连接,并没有“按下标直接取第 N 条”的能力。
当引擎执行 LIMIT 700000, 20 时,实际流程是:从满足条件的记录集合里,从第一条开始扫描,扫描到第 700020 条,然后丢弃前 700000 条,最后把剩下的 20 条返回给客户端。中间被丢弃的每一行,都要承担“被扫描”的成本。
用打电话簿来类比:你想找一本 1000 页电话簿的第 700 页,但你没有“翻到第 700 页”的能力,只能从第 1 页开始一页一页数过去,数到第 700 页才停下来。用户每往下翻一页,这个“数页码”的动作就要重来一遍。
这就是为什么第 1 页只需要几十毫秒,第 35001 页却要好几秒:前 70 万条记录虽然没进最终结果,但每一行都参与了排序、遍历或回表的开销。
2.2 排序、回表和随机 IO 被一起放大了
单独说 LIMIT 700000 还不够,这条慢 SQL 还有一个隐藏成本是 ORDER BY create_time DESC。
在初始状态,表里只有 idx_biz_type 这个索引。idx_biz_type 能快速找到业务类型为 1 的记录,但索引排列顺序是 biz_type 的等值区间,不是按 create_time 排的。MySQL 必须把所有满足条件的记录取出来,在 sort buffer 里按照 create_time 降序排序。如果一次排序装不下几十万上百万行,还要转成临时文件落到磁盘。
磁盘 filesort 是很贵的操作。假如业务类型为 1 的记录有 900 万行,虽然最终只需要 20 行,但排序阶段理论上要处理大量数据。有些场景里 MySQL 能通过 LIMIT 700000,20 做优化,只保留最小的 offset+row_count 堆,避免真正排序 900 万行,但因为每条记录还要回表读取整行,开销依然很大。
回表也需要解释一下。InnoDB 的普通二级索引叶子节点只保存索引列和主键值,不保存整行数据。像 idx_biz_type 这个索引,叶子节点只有 (biz_type, id),你要拿到 order_no、amount、pay_time、create_time 等字段,就只能拿着 id 回到主键索引,再去定位完整记录。
这条 SQL 在排序和 offset 过程中涉及的海量记录都要回表,读取的页是随机分布的,Buffer Pool 命中率只要稍有波动,磁盘随机 IO 就会立刻拖垮接口。所以 7 秒超时并不是某一个原因造成的,而是“不可下推的 offset + 无索引排序 + 回表放大”三者叠加。
2.3 Redis 缓存不能解决源头问题
很多同事一听分页慢,第一反应是把分页结果放到 Redis。这个思路只能救一部分热度高的固定页,比如“前 100 页”这种,没法覆盖以业务类型筛选、带 create_time 排序、还可能任意跳到 2 万页以后的管理后台。
真正的分页慢是查询计划问题,不是数据访问频率问题。Redis 缓存缓存的是结果,不能缓解 MySQL 每次翻页都要“先数 70 万行”的硬开销。只要数据一变,缓存还面临一致性难题。所以下面两条路才是核心:一是让排序走索引,减少临时排序;二是把 offset 深翻页从 SQL 里彻底拿掉。
3. 第一轮止血:组合索引和延迟关联的真实效果
3.1 加上 (biz_type, create_time) 组合索引
第一步先解决 filesort。复合索引 (biz_type, create_time) 同时满足等值查询业务类型和排序字段,这样查询业务类型为 1 的记录时,索引里已经天然按 create_time 排好序,InnoDB 可以直接反向扫描索引拿到最新的记录,不需要再做临时排序。
sql复制ALTER TABLE t_trade_order
ADD INDEX idx_biz_create_time (biz_type, create_time);
我再跑一次 EXPLAIN,能看到 Extra 里不再出现 Using filesort,执行计划变成走 idx_biz_create_time 的 ref 访问:
text复制type: ref
key: idx_biz_create_time
rows: 9000000
Extra: NULL
注意,这里的 rows 是估算值,并不代表真的只扫描 9 行。因为索引确实能按 create_time 倒序返回记录,MySQL 从最新记录开始往前走,一路跳过 70 万条满足条件的记录,最后取 20 条。但 SELECT 后面的字段绝大多数不在二级索引里,所以每跳过一条记录,大概率就要回表一次。线上实测这条 SQL 从 7.3 秒降到了 600ms 左右。
加这个索引解决了排序问题,但 offset 带来的遍历和回表没有解决。第 35001 页依然要访问前面 700000 条记录的二级索引项,并且为了拿到业务字段,还要回表多次。
3.2 延迟关联:先用覆盖索引拿主键,再回表外层
既然回表是成本大头,我们可以改写 SQL,让内层只查主键 id,外层再按主键去回表拿整行。由于内层查询的过滤条件、排序字段、主键都能在 idx_biz_create_time 二级索引里直接命中,不需要回表,这就是覆盖索引扫描。
改造后的延迟关联 SQL 如下:
sql复制SELECT t.id, t.order_no, t.user_id, t.biz_type, t.order_status,
t.amount, t.pay_time, t.create_time
FROM (
SELECT id
FROM t_trade_order
WHERE biz_type = 1
ORDER BY create_time DESC
LIMIT 700000, 20
) AS tmp
JOIN t_trade_order t ON t.id = tmp.id
ORDER BY t.create_time DESC;
这段 SQL 里,内层子查询 SELECT id ... WHERE biz_type = 1 ORDER BY create_time DESC 在索引 idx_biz_create_time 上扫描,需要的 biz_type、create_time、id 全部在索引里,Extra 会显示 Using index,不需要回表。内层拿到 20 个主键
