列表页翻到第 1000 页,接口响应从 20 毫秒涨到 2 秒。SQL 加了索引,查询也没走全表扫描,问题出在哪。这类性能问题大多源于对 LIMIT 执行机制的误解:分页不是”跳过”,而是”扫描后丢弃”。本文拆解 LIMIT 的执行过程,解释深分页变慢的根因,再对比生产中常用的几种分页方案,说明各自适用场景与代价。
前置知识
理解 InnoDB 的聚簇索引与二级索引、回表机制,见 MySQL 索引原理与失效分析
能读懂
EXPLAIN输出,见 MySQL 查询优化与慢排查
一、LIMIT 的执行机制
理解深分页问题,先要清楚 LIMIT offset, count 在 InnoDB 里到底做了什么。
1.1 “跳过”是扫描后丢弃
LIMIT 100000, 20 看起来是”跳过 10 万行取 20 行”,但 InnoDB 没有直接跳到第 100000 行的能力。它的实际执行过程是:从索引或数据的第一行开始,逐行扫描,扫描够 offset 行后才真正收集要返回的 count 行。
-- 典型深分页查询SELECT * FROM orders ORDER BY id LIMIT 100000, 20;这条语句的代价由两部分构成:
| 阶段 | 行为 | 代价 |
|---|---|---|
| 扫描 offset 行 | 从头遍历 100000 行 | 10 万次读取,但仅判断后丢弃 |
| 收集 count 行 | 扫到第 100001 行起,收集 20 行 | 20 次读取并返回 |
问题不在于返回的 20 行,而在于前面被丢弃的 10 万行。LIMIT 的扫描量是 offset + count,不是 count。offset 越大,无用的扫描越多,深分页越慢。
1.2 为什么扫描了还要回表
如果查询只走覆盖索引,扫描 100020 行丢弃 10 万行,代价是 10 万次顺序读索引页,虽有开销但不致命。致命的是带 SELECT * 的查询:
SELECT * FROM orders ORDER BY created_at LIMIT 100000, 20;SELECT * 需要返回所有列,二级索引 idx_created_at 不覆盖全部列。当优化器选择走 idx_created_at 时,执行计划是:沿 idx_created_at 的叶子链表顺序扫描,每扫到一行就回表聚簇索引取完整行,扫够 100020 行后返回最后 20 行。
前 10 万行全部回表,每次回表是一次聚簇索引的随机 I/O,最终这 10 万次回表全部丢弃。深分页慢的根因就在这里:offset 区间内的回表完全是白做的。
执行计划是否回表,取决于 EXPLAIN 的 Extra 列是否出现 Using index。Using index 表示覆盖索引,不回表;没有这一项意味着每行都要回表。深分页优化的第一步,往往就是看这一列。另外,当 offset 很大、索引选择率低时,优化器可能放弃索引扫描,改走全表扫描加 Using filesort,这时既没有索引扫描也没有逐行回表。要复现本节描述的回表路径,需要用 FORCE INDEX 强制走索引,具体在 1.3 节展开。
1.3 用 EXPLAIN 确认代价
EXPLAIN SELECT * FROM orders ORDER BY created_at LIMIT 100000, 20;重点看两列:
rows:优化器估算的扫描行数,深分页场景这个值会接近offset + countExtra:若出现Using filesort,说明排序没走索引,是另一类问题(先建排序索引,再谈分页优化)
确认 rows 远大于 count 且 Extra 没有出现 Using filesort,才是典型的深分页回表问题,适用本文后续方案。注意区分两种 Extra:走索引扫描但需回表时,Extra 通常没有 Using index(覆盖索引才有)也没有 Using filesort;而优化器放弃索引、改走全表扫描加排序时,Extra 会出现 Using filesort,这属于行 70 提到的另一类问题,得先解决排序再谈分页优化。实际复现中,当 ORDER BY 列的索引选择率低(扫描行数占比高)时,优化器会判定”沿索引扫大量行再回表”比”全表扫描加 filesort”代价更高,从而选后者,这时 FORCE INDEX 才能强制走回表路径。
二、方案一:子查询延迟回表
2.1 思路
既然 offset 区间的回表是白做的,就把”扫描 offset 行”这一步限定在索引内完成,只对最终要返回的 count 行回表。用子查询先在覆盖索引上定位到需要的 20 个主键,再回表:
SELECT * FROM ordersWHERE id IN ( SELECT id FROM ( SELECT id FROM orders ORDER BY created_at LIMIT 100000, 20 ) tmp);子查询只查 id(主键),可以走覆盖索引,扫描 100020 行但不回表,只在最外层对 20 个 id 回表。回表次数从 10 万次降到 20 次。
MySQL 不允许 IN/ALL/ANY/SOME 子查询内部直接带 LIMIT,直接写 WHERE id IN (SELECT id FROM orders ORDER BY created_at LIMIT 100000, 20) 会报 ERROR 1235 (42000): ... LIMIT & IN/ALL/ANY/SOME subquery。必须把带 LIMIT 的子查询再包一层派生表(derived table),如上例中的 tmp。这是 MySQL 的语法级限制,与行数无关。
2.2 适用与限制
| 优点 | 限制 |
|---|---|
| 回表次数从 offset+count 降到 count | 子查询仍需扫描 offset 行索引 |
| 改造成本低,只改 SQL | IN 子查询的结果集需先排序去重,count 大时额外开销 |
| 适合二级索引排序的深分页 | 排序列需有索引,否则子查询也走 filesort |
这个方案对”深而窄”的分页(offset 大、count 小,比如翻页只取 20 条)效果最好。当 count 也很大时,IN 子查询的 20 变成几百上千,去重和回表的开销会重新上升。
三、方案二:延迟关联
3.1 思路
延迟关联是子查询方案的演进。把子查询和主表 join,而不是用 IN,规避 IN 子查询对结果集大小敏感的问题:
SELECT t.* FROM orders tINNER JOIN ( SELECT id FROM orders ORDER BY created_at LIMIT 100000, 20) tmp ON t.id = tmp.id;子查询 tmp 在覆盖索引上定位 20 个主键,外层 join 用主键等值匹配回表取完整行。本质和方案一相同,但 join 的执行计划对 MySQL 优化器更友好,count 较大时稳定性优于 IN。
3.2 与子查询方案的对比
| 维度 | 子查询(IN) | 延迟关联(JOIN) |
|---|---|---|
| 回表次数 | count 次 | count 次 |
| count 较小时 | 简单直接 | 略繁但无差别 |
| count 较大时 | IN 去重开销上升 | join 更稳定 |
| 优化器支持 | 各版本表现不一 | 5.6+ 均支持良好 |
生产中 count 不固定或可能变大的分页接口,倾向用延迟关联。
四、方案三:覆盖索引消除回表
4.1 思路
如果返回的列不多,可以直接建一个覆盖这些列的联合索引,让查询完全不回表:
-- 列表页只需 id、标题、创建时间SELECT id, title, created_at FROM ordersORDER BY created_atLIMIT 100000, 20;-- 建覆盖索引CREATE INDEX idx_created_at_cover ON orders (created_at, id, title);查询沿 idx_created_at_cover 的叶子链表顺序扫描,所需列全在索引里,全程不回表。扫描 10 万行的代价还在,但每次扫描是顺序读紧凑的索引页,没有随机 I/O,比回表快一个量级。
4.2 适用边界
| 优点 | 限制 |
|---|---|
| 彻底消除回表 | 索引要覆盖所有返回列,索引体积大 |
| offset 区间只做顺序读 | 写入需维护额外索引,影响写入吞吐 |
| 适合列少且固定的列表 | 返回列经常变时不适用 |
这个方案用空间换时间。适合列表页返回字段稳定且不多(比如管理后台的订单列表只展示 5~6 个字段)的场景。返回列多到接近整行时,索引体积逼近数据本身,不如用延迟关联。
五、方案四:游标分页(keyset pagination)
5.1 思路
前三种方案都在优化”扫描 offset 行后丢弃”这件事,但 offset 区间的扫描本质上无法消除。游标分页换了个思路:不再用 offset,改用上一页最后一条记录的值作为下一页的起点。
-- 第一页SELECT id, created_at FROM ordersORDER BY created_at, idLIMIT 20;
-- 第二页起:传入上一页最后一条的 (created_at, id)SELECT id, created_at FROM ordersWHERE created_at > '2026-07-01 10:00:00' OR (created_at = '2026-07-01 10:00:00' AND id > 12345)ORDER BY created_at, idLIMIT 20;下一页的查询条件变成了 WHERE 范围扫描,直接从上一页终点开始,扫描量恒为 count,与页码无关。翻到第 1000 页和翻到第 2 页,单页查询代价相同。
5.2 为什么排序要带上主键
created_at 可能有大量同值记录。如果排序条件只有 created_at,游标 created_at > 上一页值 会漏掉同一秒内排在后面的记录,或重复返回。加上 id 作为 tiebreaker,排序键变成 (created_at, id) 唯一,游标定位精确,分页不重不漏。
5.3 适用与限制
| 优点 | 限制 |
|---|---|
| 深分页代价恒定,不随页码增长 | 只能”上一页/下一页”,不能跳到任意页 |
| 无 offset 扫描,性能最优 | 需要稳定排序键(唯一且单调) |
| 适合信息流、时间线类列表 | 客户端需缓存上一页末尾值 |
游标分页是性能最优的方案,代价是牺牲”跳页”能力。微博时间线、朋友圈这类只能往下刷的场景,天然适合。需要提供页码跳转的后台管理界面,则不适合。
六、方案对比与选型
五种方案(含原始 LIMIT offset)各有取舍,选型看三个维度:是否需要跳页、返回列多少、offset 量级。
| 方案 | 深分页性能 | 能否跳页 | 改造成本 | 适用场景 |
|---|---|---|---|---|
LIMIT offset | 差,随 offset 线性退化 | 能 | 无 | 小表、浅分页 |
| 子查询延迟回表 | 中,省 offset 区间回表 | 能 | 低 | 二级索引排序、count 小 |
| 延迟关联 | 中,同上但更稳定 | 能 | 中 | count 不固定 |
| 覆盖索引 | 良,消除回表 | 能 | 中 | 返回列少且固定 |
| 游标分页 | 优,恒定 | 不能 | 高 | 信息流、时间线 |
选型建议:
- offset 不大(几千以内):原始
LIMIT offset够用,别过度优化 - 需要跳页且 offset 大:优先延迟关联,count 大时比子查询稳;返回列固定且少可上覆盖索引
- 不需要跳页:游标分页,性能一劳永逸
七、两个工程问题
7.1 排序的稳定性
分页要求数据顺序稳定:同一查询翻页时,记录不会因为并发增删而在页间漂移。ORDER BY 的列如果不唯一,MySQL 对同值行的顺序不保证稳定,游标分页会因此漏数据或重复。解决方法是排序键加主键保证唯一,前文的 (created_at, id) 就是这个目的。任何分页方案,排序键不唯一都是隐患。
7.2 分页总数与总数展示
列表页常显示”共 12800 条,第 5/640 页”。算总数用 SELECT COUNT(*),在大表上是全表或全索引扫描,代价不低。几种取舍:
- 不显示精确总数:只显示”加载更多”或”上一页/下一页”,配合游标分页
- 显示近似总数:用
EXPLAIN的rows估算值,或缓存定期刷新的总数 - 显示精确总数:只能
COUNT(*),大表建议加缓存,避免每次翻页都算
精确总数与深分页往往冲突:游标分页没有总页数的概念,强制显示精确总数会抵消游标分页的性能优势。这是产品需求和技术成本的取舍,不是纯技术问题。
参考资料
- MySQL 8.0 Reference Manual: LIMIT Optimization - 官方对优化器处理 LIMIT 的说明(结合 ORDER BY 提前终止排序、用索引代替扫描等)
- MySQL 8.0 Reference Manual: SELECT Statement - LIMIT 语法与执行语义
- Markus Winand: Pagination Done the PostgreSQL Way - keyset pagination 的系统性论述,方案四的理论依据
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






