mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2953 字
8 分钟
MySQL 深分页原理与方案对比
2024-06-27

列表页翻到第 1000 页,接口响应从 20 毫秒涨到 2 秒。SQL 加了索引,查询也没走全表扫描,问题出在哪。这类性能问题大多源于对 LIMIT 执行机制的误解:分页不是”跳过”,而是”扫描后丢弃”。本文拆解 LIMIT 的执行过程,解释深分页变慢的根因,再对比生产中常用的几种分页方案,说明各自适用场景与代价。

前置知识#

Important

一、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 区间内的回表完全是白做的

Note

执行计划是否回表,取决于 EXPLAINExtra 列是否出现 Using indexUsing index 表示覆盖索引,不回表;没有这一项意味着每行都要回表。深分页优化的第一步,往往就是看这一列。另外,当 offset 很大、索引选择率低时,优化器可能放弃索引扫描,改走全表扫描加 Using filesort,这时既没有索引扫描也没有逐行回表。要复现本节描述的回表路径,需要用 FORCE INDEX 强制走索引,具体在 1.3 节展开。

1.3 用 EXPLAIN 确认代价#

EXPLAIN SELECT * FROM orders ORDER BY created_at LIMIT 100000, 20;

重点看两列:

  • rows:优化器估算的扫描行数,深分页场景这个值会接近 offset + count
  • Extra:若出现 Using filesort,说明排序没走索引,是另一类问题(先建排序索引,再谈分页优化)

确认 rows 远大于 countExtra 没有出现 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 orders
WHERE id IN (
SELECT id FROM (
SELECT id FROM orders
ORDER BY created_at
LIMIT 100000, 20
) tmp
);

子查询只查 id(主键),可以走覆盖索引,扫描 100020 行但不回表,只在最外层对 20 个 id 回表。回表次数从 10 万次降到 20 次。

Note

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 行索引
改造成本低,只改 SQLIN 子查询的结果集需先排序去重,count 大时额外开销
适合二级索引排序的深分页排序列需有索引,否则子查询也走 filesort

这个方案对”深而窄”的分页(offset 大、count 小,比如翻页只取 20 条)效果最好。当 count 也很大时,IN 子查询的 20 变成几百上千,去重和回表的开销会重新上升。

三、方案二:延迟关联#

3.1 思路#

延迟关联是子查询方案的演进。把子查询和主表 join,而不是用 IN,规避 IN 子查询对结果集大小敏感的问题:

SELECT t.* FROM orders t
INNER 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 orders
ORDER BY created_at
LIMIT 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 orders
ORDER BY created_at, id
LIMIT 20;
-- 第二页起:传入上一页最后一条的 (created_at, id)
SELECT id, created_at FROM orders
WHERE created_at > '2026-07-01 10:00:00'
OR (created_at = '2026-07-01 10:00:00' AND id > 12345)
ORDER BY created_at, id
LIMIT 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(*),在大表上是全表或全索引扫描,代价不低。几种取舍:

  • 不显示精确总数:只显示”加载更多”或”上一页/下一页”,配合游标分页
  • 显示近似总数:用 EXPLAINrows 估算值,或缓存定期刷新的总数
  • 显示精确总数:只能 COUNT(*),大表建议加缓存,避免每次翻页都算

精确总数与深分页往往冲突:游标分页没有总页数的概念,强制显示精确总数会抵消游标分页的性能优势。这是产品需求和技术成本的取舍,不是纯技术问题。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MySQL 深分页原理与方案对比
https://blog.souloss.cn/posts/middleware/db/mysql-pagination-deep-dive/
作者
Souloss
发布于
2024-06-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时