MySQL 深分页实测:LIMIT 500000,20 要 0.8 秒,游标分页 0.3ms;但 (created_at, id) > (?, ?) 这种写法照样全扫
在 MySQL 8.0.46 测试库(orders 100 万行自造数据)上实测深分页:主键 LIMIT 900000,20 读 90 万行 179–192ms;二级索引 + SELECT * 的 LIMIT 500000,20 每跳过一行都要回表,716–868ms;延迟关联降到 72–77ms;游标(keyset)分页 0.28–0.30ms。踩坑两个:行构造器 (created_at, id) > (?, ?) 不走范围扫描,仍要 678–719ms;游标只记 created_at 会漏行,翻完 59.9 万行漏了约 1 万行。附只读排查 SQL 和可复跑脚本。
MySQL 8.0.46 实测(测试库、自造数据):orders 100 万行,WHERE status=1 ORDER BY created_at LIMIT 500000,20,执行计划要沿 idx_status_created 读 500020 行、每行回表,三次 716 / 825 / 868ms;改成延迟关联(子查询在覆盖索引里翻页只取 id)72–77ms;改成游标分页 WHERE status=1 AND (created_at > ? OR (created_at = ? AND id > ?)) 只读 20 行,0.28–0.30ms。同一个游标写成行构造器 (created_at, id) > (?, ?),MySQL 不做范围扫描,678–719ms。游标里不带 id,翻完全部 599996 行只拿到 590051 行。
适用对象
- LIMIT M,N 要先读出 M+N 行再丢掉前 M 行,偏移量越大越慢:本次主键顺序 LIMIT 900000,20 读了 900020 行,179–192ms。
- 按二级索引排序又 SELECT * 时,被跳过的每一行也要回表:LIMIT 500000,20 要 716–868ms;延迟关联先在覆盖索引里翻页、只回表 20 行,72–77ms。
- 真正不随页数变慢的是游标(keyset)分页:记住上一页最后一行的 (created_at, id),下一页从它后面开始读,本次 0.28–0.30ms。
- 游标条件要写成 created_at > ? OR (created_at = ? AND id > ?);写成 (created_at, id) > (?, ?) 在 MySQL 8.0.46 上不走范围扫描,678–719ms。排序列有重复时游标必须带上 id,否则会漏行。
LIMIT 900000,20 为什么慢?前 90 万行一行不少都读了
最简单的情况:SELECT * FROM orders ORDER BY id LIMIT M,20,按主键顺序扫。EXPLAIN ANALYZE 里 Index scan on orders using PRIMARY 的 actual rows 恰好是 M+20,SHOW SESSION STATUS 里 Handler_read_next 也是 M+19。也就是说,偏移量多大,就读多少行,再由 Limit/Offset 节点丢掉。
主键是聚簇索引,读一行就是读到完整的行,所以这已经是最便宜的深分页了:90 万行 179–192ms,大约每 10 万行 20ms。
| 偏移量 M | 扫描行数(actual rows) | Handler_read_next | 第 1 轮 | 第 2 轮 |
|---|---|---|---|---|
| 0 | 20 | 19 | 0.036 ms | 0.032 ms |
| 100000 | 100020 | 100019 | 19.5 ms | 22.6 ms |
| 500000 | 500020 | 500019 | 97.6 ms | 101 ms |
| 900000 | 900020 | 900019 | 192 ms | 179 ms(脚本复跑 187 ms) |
按二级索引排序的深分页更慢:跳过的每一行都要回表
业务里更常见的是 WHERE status = 1 ORDER BY created_at LIMIT 500000,20。EXPLAIN 显示 type=ref、key=idx_status_created、Extra 为空(没有 filesort),看起来很健康;EXPLAIN ANALYZE 才看出代价:Index lookup on orders using idx_status_created (status=1) actual rows=500020,三次 825 / 716 / 868ms。
同样读 50 万行,比主键顺序(约 100ms)慢 7–8 倍。原因是 SELECT * 要的列不在二级索引里,InnoDB 每从 idx_status_created 读一行,都要拿 id 回聚簇索引取整行,而这 500000 次回表的结果最后全被丢掉。
我先怀疑优化器选错了索引,加了 FORCE INDEX(idx_status_created),计划一模一样,626 / 709ms,说明不是选索引的问题。偏移量小的时候完全看不出来:LIMIT 1000,20 只要 1.39 / 1.65ms,这也是很多列表页上线时没问题、数据量涨上来后被人一路翻到很后面才暴露的原因。
| 写法 | 执行计划要点 | Handler_read_next | 第 1 轮 | 第 2 轮 | 脚本复跑 |
|---|---|---|---|---|---|
| LIMIT 1000,20 | Index lookup + 回表 | 1019 | 1.39 ms | 1.65 ms | — |
| LIMIT 500000,20 | Index lookup + 回表 500020 行 | 500019 | 825 ms | 716 ms | 868 ms |
| 同上 + FORCE INDEX | 同上 | 500019 | 626 ms | 709 ms | — |
| 延迟关联(子查询只取 id) | Covering index lookup 500020 行 + 主键查 20 次 | 500019 | 77.1 ms | 71.6 ms | 73 ms |
| 游标:行构造器 (created_at, id) > (?, ?) | Index lookup status=1 + Filter,不走范围 | 500019 | 678 ms | 719 ms | 705 ms |
| 游标:created_at > ? OR (created_at = ? AND id > ?) | Index range scan,读 20 行 | 19 | 0.299 ms | 0.292 ms | 0.284 ms |
翻到第 500001 行时取 20 行(第 2 轮,ms)
LIMIT 500000,20
716 ms
行构造器游标
719 ms
延迟关联
71.6 ms
展开写法游标
0.29 ms
MySQL 8.0.46,测试库、自造数据,数据全在 buffer pool 内;磁盘读多的生产库差距通常更大(经验判断,本次未测)。
延迟关联:先在覆盖索引里翻页拿 id,再回表 20 行
写法:SELECT o.* FROM orders o JOIN (SELECT id FROM orders WHERE status = 1 ORDER BY created_at, id LIMIT 500000, 20) t ON t.id = o.id ORDER BY o.created_at, o.id。子查询只要 id,而 InnoDB 二级索引的叶子节点本来就带主键,所以计划里是 Covering index lookup,不回表;外层只对这 20 个 id 做 Single-row index lookup on o using PRIMARY(loops=20)。
结果 71.6–77.1ms,比原来快 10 倍左右,但它仍然读了 500020 行索引项(Handler_read_next 500019),页数越往后还是越慢,只是每行变便宜了。我的取舍(经验判断):前端必须支持「跳到第 N 页」、又不能改接口时,用延迟关联;能改成「下一页」的,直接上游标。
注意外层要再写一遍 ORDER BY:派生表 JOIN 回来的顺序没有保证,本次计划里外层多了一个 Sort(20 行,可以忽略)。
游标分页我第一次写成 (created_at, id) > (?, ?),结果和 LIMIT 500000 一样慢
游标分页的思路是:前端把上一页最后一行的 (created_at, id) 带回来,下一页从它后面开始读 20 行,不管翻到第几页都只读 20 行。我先写的是最简洁的行构造器:WHERE status = 1 AND (created_at, id) > ('2026-01-03 21:31:04', 535661) ORDER BY created_at, id LIMIT 20。
结果 678 / 719ms,脚本复跑 705ms。EXPLAIN ANALYZE 里是 Index lookup on orders using idx_status_created (status=1),外面套一层 Filter: ((orders.created_at, orders.id) > (...)),actual rows=500020:只用上了 status = 1 这一个等值前缀,然后从头一行行读、一行行过滤,直到攒够 20 行。
MySQL 8.0 官方文档《Row Constructor Expression Optimization》写的正是这种情况:行构造器没有覆盖索引前缀时(这里前面还有个 status = 1),优化器只会用到前面的列,建议改写成等价的非行构造器表达式。改成 created_at > ? OR (created_at = ? AND id > ?) 之后,计划变成 Index range scan on orders using idx_status_created over (status = 1 AND created_at = … AND 535661 < id) OR (status = 1 AND … < created_at),只读 20 行,0.28–0.30ms。展开写法取到的 20 个 id 和 LIMIT 500000,20 取到的完全一致(两边 md5 相同)。
这一点和 PostgreSQL 不同:我在同一台机器的 PostgreSQL 17.11 测试库上建 (created_at, id) 索引后验证,(created_at, id) > (…) 直接成为 Index Cond,Index Scan 只读 20 行、0.18ms。从 PostgreSQL 迁到 MySQL、或者两边共用一套 ORM 分页代码的,最容易踩到。
游标只记 created_at:翻完 599996 行,漏了约 1 万行
另一种常见写法是游标只记时间:WHERE status = 1 AND created_at > ? ORDER BY created_at LIMIT 20。问题在于排序列有重复:如果一页正好在某一秒的中间截断,这一秒剩下的行在下一页会因为 created_at > ? 被整体跳过。
我写了两个存储过程,从头到尾把 status = 1 的 599996 行翻一遍、数一数拿到多少行。每页 20 行时只拿到 589810 行(漏 10186 行),每页 100 行时拿到 590051 行(漏 9945 行);游标带上 id、用上面的展开写法,每页 20 行和 100 行都正好拿到 599996 行。本次 status=1 平均每个时间值约 2.5 行,真实业务里批量导入、定时任务写入的同一时间戳更多,漏得会更多。
排序列本身唯一(比如自增 id、雪花 id)时不需要再加列;否则就把主键放在排序和游标的最后一列,索引 (status, created_at) 后面隐含的就是主键 id,不用额外建索引。
本节判断
- (created_at, id) 那两行的总耗时比只记 created_at 多一倍,是因为我的存储过程每页查了两次(一次计数、一次取最后一行),不是游标写法本身慢;单页查询见上一节 0.28–0.30ms。
- 游标分页的代价:不能直接跳到第 N 页、总页数要另算;只要「下一页 / 上一页 / 加载更多」的场景都适合。
| 游标列 | 每页行数 | 页数 | 取到行数 | 漏掉 | 总耗时 |
|---|---|---|---|---|---|
| created_at | 20 | 29491 | 589810 | 10186 | 12.6 s |
| created_at | 100 | 5901 | 590051 | 9945 | 2.7 s(脚本复跑同为 590051 行) |
| (created_at, id) | 20 | 30000 | 599996 | 0 | 26.1 s |
| (created_at, id) | 100 | 6000 | 599996 | 0 | 5.4 s(脚本复跑同为 599996 行) |
在自己的库里怎么找深分页:看每返回 1 行读了多少行
只读 SQL 4 段,只查 performance_schema 和 information_schema。第 1 段在 events_statements_summary_by_digest 里找带偏移量的 LIMIT,按 SUM_ROWS_EXAMINED 排序,并算 examined_per_sent(读的行数 ÷ 返回的行数);第 2 段找用行构造器写游标条件的语句;第 3 段列出每张表的索引列顺序;第 4 段列表的大概行数和体积。
测试库上的输出:LIMIT 500000,20 那条语句 rows_examined 500020、rows_sent 20、examined_per_sent 25001;行构造器那条 examined_per_sent 同样是 25001,avg 573ms。
踩到的小坑:digest 会把 LIMIT 500000, 20 规整成 LIMIT ?, ...,偏移量的大小在 digest 文本里看不到,只能看 examined_per_sent;另外把深分页包在派生表里(例如延迟关联、SELECT … FROM (SELECT … LIMIT 500000,20) a)时,本次 digest 记的 rows_examined 只有外层的 20 或 60 行,子查询读的 50 万行没算进去,这类语句要看 avg_ms,不能只看 examined_per_sent。
我处理深分页的顺序
- 01
1
找语句
跑只读 SQL 第 1 段,examined_per_sent 上千的带 LIMIT 语句先列出来,再结合慢日志看实际偏移量。
- 02
2
问需求
确认前端是否真的需要「跳到第 N 页」。后台导出、数据同步、App 下拉加载都不需要,直接改游标。
- 03
3
选改法
必须跳页:延迟关联 + 限制最大页数;可以只给下一页:游标,排序列不唯一时带上主键。
- 04
4
验证写法
EXPLAIN ANALYZE 看是不是 Index range scan、actual rows 是否约等于页大小;行构造器写法改成 OR 展开。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
MySQL LIMIT 偏移量很大为什么这么慢?
LIMIT M,N 要先读出 M+N 行再丢掉前 M 行。本次 MySQL 8.0.46、100 万行测试表,主键顺序 LIMIT 900000,20 读了 900020 行、179–192ms;按二级索引排序且 SELECT * 时还要逐行回表,LIMIT 500000,20 要 716–868ms。
延迟关联和游标分页哪个好?
本次延迟关联 72–77ms,游标分页 0.28–0.30ms。延迟关联保留「跳到第 N 页」,但仍随页数变慢;只需要下一页 / 加载更多时用游标分页。
MySQL 游标分页 (a, b) > (x, y) 能走索引吗?
本次 MySQL 8.0.46 上,status = 1 AND (created_at, id) > (?, ?) 只用上了 status 前缀,仍读了 500020 行、678–719ms。改写成 created_at > ? OR (created_at = ? AND id > ?) 后走范围扫描,只读 20 行。
游标分页为什么会漏数据?
排序列有重复值、游标又只记这一列时,一页截断在同一个值中间,剩下的行会被 > 条件跳过。本次只记 created_at 翻完 599996 行漏了约 1 万行;游标带上主键 id 后一行不漏。
这些毫秒数能直接当生产结论吗?
不能。这是本机测试库、自造数据、数据全在内存里,只说明机制和量级。请在自己的测试环境用 EXPLAIN ANALYZE 复跑,生产库磁盘读多时差距通常更大。
下一步
推荐动作
你提供慢日志里带 LIMIT 的语句、EXPLAIN ANALYZE 输出和这份只读 SQL 的结果(可脱敏),我们判断该用延迟关联、游标分页还是改索引,并给出改写后的验证步骤;生产变更由你方授权执行。