MySQL深分页优化:LIMIT大偏移的根因分析与五种解法对比

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

上个月运营同学给我发了张截图。她想找最早的一批订单,在订单列表上点了「末页」,页面转了二十多秒才出来。她问我是不是数据库坏了。我第一反应是索引没建对。打开表结构一看,created_at 上有索引,该有的都有。

我把那条 SQL 单独拎出来跑了一遍。确实慢。但慢的地方不在索引。

先把这条 SQL 摆出来

sql 复制代码
-- 订单列表最后一页,每页 10 条
SELECT id, order_no, user_id, amount, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 9999990, 10;

表里 1000 万行,一共 100 万页。这个查询要跑二十多秒。翻到第一页的时候,结构一模一样的 SQL,是毫秒级的。

我以前一直以为,写了 LIMIT 数据库就不会多干活。这个理解只对了一半。LIMIT 能让它提前结束,但没法让它跳过去。MySQL 没有直接跳到第 999 万行的能力。它得从索引第一行开始,一行行往下数。数到第 999 万零一行,才开始取你要的那 10 条。

所以真正被读的行数是 999 万零 10。前面那 999 万行读完就被丢了,纯粹白读。

还有个更隐蔽的坑。你去翻 EXPLAIN,rows 那一列未必能反映真实扫描量。LIMIT 会影响优化器的估算,它觉得反正只要 10 行,代价不高。我那时候就是被这一列骗了,以为计划没问题。想看清真实读取量,得看 Handler 变量。

sql 复制代码
-- 执行前后各看一次,差值才是真实读取量
SHOW SESSION STATUS LIKE 'Handler_read_next';

解法一:覆盖索引加延迟关联

深分页最贵的地方,其实不是数那 999 万行,是数的时候还要回表。索引上只有 created_at 和主键 id,你要的 order_no、amount 都不在里面。每数一行,就得拿着 id 回主键索引捞一次完整数据。999 万次随机读,慢就慢在这儿。

sql 复制代码
SELECT o.id, o.order_no, o.user_id, o.amount, o.created_at
FROM orders o
INNER JOIN (
    SELECT id FROM orders
    ORDER BY created_at DESC
    LIMIT 9999990, 10
) AS t ON o.id = t.id
ORDER BY o.created_at DESC;

子查询里只要 id,而 InnoDB 的二级索引叶子节点上本来就存着主键。所以这 999 万行的扫描全程都在索引里完成,一次回表都不用。等到最后只剩 10 条了,才回表取完整字段。这条改完,二十多秒降到七八秒。

这招省掉的是回表,不是扫描。999 万条索引项该读还是得读。所以它能把"没法用"救成"能用",但它救不成"快"。这个区别很重要,很多文章不说清楚,照着抄完发现还是慢,就以为是自己的问题。

解法二:keyset,直接换掉分页方式

sql 复制代码
-- 上一页最后一条的 created_at 和 id,由前端传回来
SELECT id, order_no, user_id, amount, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-01 10:00:00', 9999990)
ORDER BY created_at DESC, id DESC
LIMIT 10;

这招是真的快,因为它不做扫描。B+树本身是有序的,给它一个位置,它能二分定位下去,然后顺着链表读 10 行就走。读取量从 999 万变成了 10,耗时也回到了毫秒级。

代价是它只能一页一页地翻。用户想直接跳到第 500 页,做不到。这是它唯一的限制,也是我最后选它的原因,后面说。

要让它生效,索引得建对。(created_at, id) 这样的复合索引,顺序要跟排序方向对得上。

解法三到五:还有三条路

第三条路是把总数和翻页解耦。列表顶上那句"共 XX 条"最坑。COUNT(*) 在 1000 万行的表上本身就是个大开销。深分页的时候,你也根本用不上精确总数。再加上"最多翻 100 页"的限制,很多问题直接消失。

第四条路是产品层的筛选。给列表加上时间范围、订单状态这些筛选项。用户就从"翻到末页"变成了"筛出 8 月 1 日之后的订单"。翻页深度直接掉到个位数。这条改动不写一行 SQL,效果却最好。

第五条路是把列表要展示的字段直接做进索引。这样查询全程在索引里完成,一次回表都没有。

sql 复制代码
-- 宽索引:列表要的字段全放进去
ALTER TABLE orders ADD INDEX idx_list
(created_at, id, order_no, user_id, amount);

这招很暴力,代价也实在。索引宽度翻了十几倍,占空间,写入还得同步维护它。只有读远多于写的列表才划得来。我们后来没在订单表上用,挪到了另一张配置表上。

方案 真实读取量 支持跳页 要改接口 适合什么场景
原始 LIMIT 999 万行,且每行都回表 支持 不用 浅分页,前几页
延迟关联 999 万条索引项,只回表 10 次 支持 不用 要跳页,但页数有限
keyset 游标 10 行 不支持 要改 顺序翻页,越深越划算
解耦总数 + 页数上限 看配合哪种方案 部分 要改 所有深分页场景
产品层筛选 大幅下降 不需要 要改 最该先做的一步
宽索引全覆盖 999 万条索引项,不回表 支持 不用 读多写少,字段少

避坑清单

ORDER BY 的字段上没有索引,前面这些招式全都白搭。子查询本身就要排序,那就得走 filesort,临时文件一落盘,延迟关联也救不了。动手前先用 EXPLAIN 确认排序字段走的是索引。

用时间做游标,必须带上一个唯一列当第二排序键。我踩过这个坑。一开始只用 created_at 做游标,同一秒里进来两条订单,翻页的时候少了一条,怎么查都查不出来。后来把 id 加进去才对齐。这个坑很隐蔽,因为它在数据少的时候根本不出现。

别让前端还能一键翻到末页。这既是性能问题,也是在纵容一个没人用的功能。我们后来加了页数上限,还顺手把返回的精确总数改成了"约 XX 条",省掉一次全表计数。

写在最后

这五条路试完,我最大的收获不是学会了哪一招,是搞清楚了一个分水岭:用户到底需不需要跳页。

需要跳页,就在延迟关联外面加一刀,把最大页数限制住。不需要跳页,就直接上 keyset,把读取量从千万级压到个位数。而绝大多数场景里,能一键跳到末页是产品设计出来的,不是用户真的想看。真去翻日志,你会发现点过末页的只有那一次,而且是为了给我截图。运营真正想要的是"最早的订单",这件事本来该有一个时间筛选器。

所以顺序上,我会先问产品一句,这个列表用户真的会翻到底吗。问了以后再决定是改 SQL 还是改产品。先动 SQL,常常是把力气花在了错的地方。

你们系统的列表分页,现在能翻到多深?评论区聊聊。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关推荐
SelectDB1 小时前
一条大查询把集群打挂之后:Apache Doris 稳定性处置与资源隔离速查
大数据·数据库·数据分析
这个DBA有点耶1 小时前
数据库部署架构深潜:集中式、分布式、云原生的技术原理、演进逻辑与选型框架
数据库·架构
yunwei371 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei371 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei371 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
鸽芷咕1 小时前
MySQL 数据库管理工具用惯了?切金仓数据库,这篇帮你把家伙事儿配齐
数据库
mmsx1 小时前
Android GIS系列 栅格怎么存、格网怎么归算:两个容易混淆的 GIS 链路
android·性能优化·app
Flynt1 小时前
Java 27 悄悄改了 3 个默认值,我在 1 核小机器上逐个验证了一遍
java·jvm·性能优化