深度分页是指 查询大页码数据(如 LIMIT 100000, 10,查询第 10001~100010 行数据)时,MySQL 性能急剧下降的场景。核心矛盾是:InnoDB 需扫描大量无关数据后丢弃,导致 I/O 和 CPU 开销激增。结合 InnoDB 索引结构和查询原理,下面从「问题本质→解决方案→场景选型→避坑要点」展开说明。
一、先搞懂:深度分页为什么慢?
以常见的分页 SQL 为例(user 表,主键 id,二级索引 idx_age,按 age 排序分页):
-- 慢查询:查询第 10001 页,每页 10 条(跳过前 100000 行)
SELECT id, username, age FROM user WHERE age > 20 ORDER BY age LIMIT 100000, 10;
慢查询的核心原因(InnoDB 执行流程):
- 索引扫描 + 回表:因查询字段(
username)不在二级索引idx_age中,InnoDB 需先通过idx_age扫描前100000+10条满足age>20的记录(获取主键id); - 丢弃无效数据:根据主键
id回表查询完整数据后,丢弃前 100000 条,仅返回最后 10 条; - 关键瓶颈:扫描大量无关数据(100010 条)+ 频繁回表(100010 次随机 I/O),导致性能暴跌(页码越大,扫描行数越多,越慢)。
补充:无索引的情况更糟
若 age 无索引,InnoDB 会进行 全表扫描(扫描整张表的所有行),再排序、跳过前 100000 行,性能会差一个数量级。
二、深度分页的 5 种解决方案(按优先级排序)
方案 1:基于「主键 / 有序索引」的游标分页(最优,推荐优先使用)
核心原理:
利用 InnoDB 索引的 有序性,通过「上一页的最后一个索引值」替代 OFFSET,直接定位到目标数据的起始位置,避免扫描前 N 行。本质是「用索引值定位」替代「跳过 N 行」,扫描行数 = 每页大小(如 10 行),性能恒定高效。
适用场景:
- 主键自增(
INT AUTO_INCREMENT)或有序索引(如时间戳create_time); - 不需要跳页(仅支持「上一页 / 下一页」,如 APP 列表下拉加载);
- 排序字段是唯一索引或有序字段(避免重复 / 遗漏数据)。
实现示例(按主键 id 分页):
-- 第 1 页(初始页,无上次主键值)
SELECT id, username, age FROM user WHERE age > 20 ORDER BY id LIMIT 10;
-- 假设最后一条数据的 id=100010(记录此值作为游标)
-- 第 2 页(用上次的 id 作为条件,直接定位)
SELECT id, username, age FROM user WHERE age > 20 AND id > 100010 ORDER BY id LIMIT 10;
-- 记录新的最后 id=100020,以此类推...
按二级索引 create_time 分页(需保证有序):
-- 第 1 页
SELECT id, username, create_time FROM user WHERE status=1 ORDER BY create_time DESC LIMIT 10;
-- 记录最后一条的 create_time='2025-11-12 10:00:00',id=100010(避免时间重复导致漏数据)
-- 第 2 页(用时间+主键双重条件,防止时间相同的记录遗漏)
SELECT id, username, create_time FROM user
WHERE status=1 AND create_time <= '2025-11-12 10:00:00' AND id < 100010
ORDER BY create_time DESC LIMIT 10;
优点:
- 性能极致(扫描行数 = 每页大小,无无效扫描);
- 无
OFFSET带来的性能损耗,支持无限深度分页。
缺点:
- 不支持跳页(如直接跳转到第 100 页);
- 排序字段必须有序且唯一(或配合主键确保唯一性)。
方案 2:索引覆盖 + 延迟关联(支持跳页,次优)
核心原理:
- 索引覆盖:先通过「包含排序字段 + 主键」的联合索引,查询目标页的主键
id(无需回表,因索引已包含所需字段); - 延迟关联:用查询到的主键
id关联原表,获取完整数据(仅回表 10 次,而非 100010 次)。核心是「减少回表次数」,将扫描 + 回表的开销从offset+size降至size。
适用场景:
- 需要跳页(如后台管理系统的分页查询);
- 可创建「排序字段 + 筛选字段 + 主键」的联合索引。
实现示例(优化前文慢查询):
-- 步骤 1:创建联合索引(包含筛选字段 age、排序字段 age、主键 id)
CREATE INDEX idx_age_id ON user(age, id); -- 覆盖索引,无需回表查 id
-- 步骤 2:延迟关联查询(先查 id,再关联原表)
SELECT u.id, u.username, u.age
FROM user u
-- 子查询:通过覆盖索引快速获取目标页的 id(仅扫描 100010 行索引,无回表)
JOIN (
SELECT id FROM user WHERE age > 20 ORDER BY age LIMIT 100000, 10
) AS t ON u.id = t.id;
关键优化点:
- 联合索引必须包含「筛选字段(WHERE 条件)+ 排序字段(ORDER BY)+ 主键(id)」,确保子查询是「索引覆盖查询」(
EXPLAIN显示type=range,Extra=Using index); - 子查询仅返回主键
id,数据量小,关联效率高。
优点:
- 支持跳页,兼容传统分页场景;
- 性能比原生
LIMIT offset, size提升 10~100 倍(取决于 offset 大小)。
缺点:
- 需维护额外的联合索引(增加写入开销);
- offset 极大时(如
LIMIT 1000000, 10),仍需扫描大量索引行(但无回表开销,比原生 SQL 快)。
方案 3:预计算分页锚点(适合超大数据量 + 固定跳页)
核心原理:
提前计算并存储「分页锚点」(如每 100 页的最后一个主键 / 索引值),查询时直接通过锚点定位到目标页的起始位置,避免 OFFSET 扫描。
适用场景:
- 超大数据量(千万级 +),需要支持跳页(如报表系统);
- 数据更新频率低(锚点无需频繁更新)。
实现步骤:
-
创建锚点表:存储分页维度、页码区间、锚点值;
CREATE TABLE page_anchor ( id INT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(50) NOT NULL, -- 目标表名(如 user) filter_condition VARCHAR(100) NOT NULL, -- 筛选条件(如 "age>20") page_step INT NOT NULL, -- 锚点间隔(如 100 页一个锚点) anchor_value BIGINT NOT NULL, -- 锚点值(主键 id 或索引值) anchor_page INT NOT NULL, -- 锚点对应的页码(如 100 页) UNIQUE KEY uk_table_filter_step (table_name, filter_condition, page_step) ); -
定时更新锚点:通过定时任务(如 cron)计算锚点值并插入锚点表;
-- 示例:计算 user 表 age>20 条件下,每 100 页的锚点(id) INSERT INTO page_anchor (table_name, filter_condition, page_step, anchor_value, anchor_page) SELECT 'user', 'age>20', 100, id, 100 FROM user WHERE age>20 ORDER BY id LIMIT 9999, 1; -- 第 100 页的最后一个 id -
查询时使用锚点:
-- 需求:查询第 150 页(page=150,size=10) -- 步骤 1:查询第 100 页的锚点值(anchor_value=10000) SELECT anchor_value FROM page_anchor WHERE table_name='user' AND filter_condition='age>20' AND anchor_page=100; -- 步骤 2:通过锚点定位第 150 页(偏移 50 页) SELECT id, username, age FROM user WHERE age>20 AND id > 10000 ORDER BY id LIMIT 50*10, 10; -- 仅偏移 500 行,而非 14990 行
优点:
- 支持超大数据量跳页,性能接近游标分页;
- 锚点可复用,降低重复计算开销。
缺点:
- 需维护锚点表和定时任务,增加系统复杂度;
- 数据更新频繁时,锚点易失效(需频繁重建)。
方案 4:分库分表(水平拆分,大数据量终极方案)
核心原理:
当单表数据量超千万级,即使优化索引,深度分页性能仍会瓶颈,此时需将表按「主键范围」或「哈希」水平拆分(如拆分为 user_01~user_10 10 张表),查询时:
- 按拆分规则定位到目标数据所在的分表;
- 在分表内执行分页查询(分表数据量小,
OFFSET开销可接受)。
适用场景:
- 单表数据量超千万级,且需要支持跳页;
- 高并发读写场景(分库分表同时提升并发性能)。
实现示例(按主键范围分表):
-
分表规则:
user_01(id 11000000)、2000000)...;user_02(id 1000001 -
分页查询第 10001 页(id 100001~100010):
-- 定位分表:id 100001~100010 属于 user_01 SELECT id, username, age FROM user_01 WHERE age>20 ORDER BY id LIMIT 100000, 10;
优点:
- 彻底解决单表数据量过大的问题,分页性能稳定;
- 支持高并发和无限深度分页。
缺点:
- 系统复杂度高(需处理分表路由、分布式事务等);
- 需依赖中间件(如 Sharding-JDBC)简化分表操作。
方案 5:禁用深度跳页(业务妥协,最简单)
核心思路:
从业务层面限制「深度跳页」(如仅允许查询前 100 页),引导用户通过「筛选条件缩小范围」(如按时间、地区筛选)后再分页,从根源上避免 OFFSET 过大的问题。
适用场景:
- 大部分 C 端产品(如 APP、网站),用户极少需要查询 100 页以后的数据;
- 业务允许用户通过筛选条件缩小查询范围。
实现示例:
-- 限制最大 offset(如仅允许 offset < 10000)
SELECT id, username, age FROM user WHERE age>20 ORDER BY age LIMIT 10000, 10;
-- 业务层判断:若用户输入页码 > 1000(1000*10=10000),返回「请缩小筛选范围」
优点:
- 无需修改数据库,实现成本极低;
- 引导用户规范查询行为,减少无效请求。
缺点:
- 不支持深度跳页,灵活性差;
- 不适用于 B 端管理系统、报表系统等场景。
三、常见坑与避坑要点
坑 1:用非有序字段排序(如 UUID 主键、随机字符串)
- 问题:UUID 是随机值,索引无序,
LIMIT offset, size无法利用索引有序性,导致全索引扫描; - 解决:排序字段必须是「有序索引」(如自增主键、时间戳、连续数字字段)。
坑 2:联合索引顺序错误,导致索引失效
-
问题:联合索引需遵循「最左前缀原则」,若筛选字段不在索引最左侧,索引失效;
-- 错误:筛选字段 age 在索引第 2 位,索引失效 CREATE INDEX idx_id_age ON user(id, age); SELECT id FROM user WHERE age>20 ORDER BY age LIMIT 100000, 10; -- 无法使用 idx_id_age -- 正确:筛选字段 age 在索引第 1 位 CREATE INDEX idx_age_id ON user(age, id); -- 可使用索引 -
解决:联合索引顺序 =「筛选字段(WHERE)→ 排序字段(ORDER BY)→ 主键(id)」。
坑 3:忽略排序字段的重复值,导致数据遗漏
-
问题:若排序字段(如
create_time)有重复值,LIMIT offset, size可能跳过重复值对应的记录; -
解决:排序时增加主键(唯一值),确保排序唯一:
-- 正确:create_time + id 双重排序,避免重复 SELECT id, username FROM user ORDER BY create_time DESC, id DESC LIMIT 100000, 10;
坑 4:过度依赖 OFFSET,未做索引优化
- 问题:直接使用
LIMIT 100000, 10且无覆盖索引,导致大量回表; - 解决:优先使用「索引覆盖 + 延迟关联」优化,减少回表开销。
四、方案选型对比表(快速决策)
| 方案 | 支持跳页 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 游标分页(主键 / 索引) | 否 | 最优 | 低 | C 端 APP 下拉加载(无跳页需求) |
| 索引覆盖 + 延迟关联 | 是 | 次优 | 中 | B 端管理系统(需跳页,数据量百万级) |
| 预计算分页锚点 | 是 | 优 | 中高 | 报表系统(超大数据量,低更新频率) |
| 分库分表 | 是 | 最优 | 高 | 单表千万级 +,高并发读写 |
| 禁用深度跳页 | 否 | 无 | 极低 | C 端产品(用户无深度分页需求) |
五、总结
MySQL 深度分页的核心优化思路是:减少扫描行数 + 避免无效回表,优先选择「无 OFFSET」的方案(游标分页),其次是「优化 OFFSET 扫描」的方案(索引覆盖 + 延迟关联),超大数据量则需考虑分库分表。
实际开发中:
- 若无需跳页(C 端下拉加载),直接用「游标分页」(性能最优,实现最简单);
- 若需跳页(B 端管理系统),先尝试「索引覆盖 + 延迟关联」(无需改造业务,仅需加索引);
- 单表数据量超千万级,且需频繁跳页,再考虑「分库分表」或「预计算锚点」。
避免过度设计:大多数场景下,「索引覆盖 + 延迟关联」已能满足需求,无需直接上分库分表