MySQL深分页越往后越慢,是因为执行LIMIT offset, size时必须扫描并丢弃前offset行,实际处理offset+size行数据;即使有索引,仍需遍历B+树定位偏移位置,导致IO和CPU开销随offset增大而剧增。

为什么 LIMIT offset, size 越往后越慢
MySQL 并不会跳过前 offset 行直接读取,而是:先按排序条件扫描出至少 offset + size 条记录,再丢弃前 offset 条,只返回后面 size 条。比如 LIMIT 1000000, 20,实际要扫描并处理 1000020 行——哪怕最终只用 20 条。
更糟的是,如果 ORDER BY 字段没走索引,或查询涉及回表(例如二级索引 + SELECT *),扫描成本会指数级上升。EXPLAIN 中 rows 值飙升、Extra 出现 Using filesort 或 Using temporary,基本就是深分页在拖后腿。
加索引只能缓解,不能根治
联合索引能减少扫描范围,但无法绕过“跳过大量行”这个本质动作。例如:
SELECT * FROM meeting_record WHERE user_id = 10001 ORDER BY id DESC LIMIT 20 OFFSET 100000;
建 INDEX idx_user_id_id ON meeting_record(user_id, id) 后,EXPLAIN 显示 key 用了该索引、rows 降到 100020,看起来好了——但数据库仍需定位到第 100001 条记录,B+ 树遍历深度增加,IO 次数和 CPU 开销依然可观。
常见误区:
- 只给
ORDER BY字段加单列索引,忽略WHERE条件字段,导致索引失效 - 在
WHERE子句里对索引列用函数(如DATE(create_time) = '2026-01-01'),触发全表扫描 - 用
SELECT *配合非聚簇索引,强制回表查所有字段,放大 I/O 压力
用主键 ID 做游标替代 OFFSET
如果业务允许“只支持下一页”,游标分页是最直接有效的方案。核心是把 OFFSET 的逻辑转为 WHERE id 。
第一页:
SELECT * FROM meeting_record ORDER BY id DESC LIMIT 20;
拿到结果中最小的 id(假设是 998000),下一页就查:
SELECT * FROM meeting_record WHERE id < 998000 ORDER BY id DESC LIMIT 20;
要点:
- 必须依赖单调递增/递减的有序主键(
id或带时间戳的create_time),且该字段有索引 - 排序方向要和游标条件严格一致(
ORDER BY id DESC对应WHERE id ) - 不能跳页,不支持“跳到第 500 页”,否则得重新计算游标链
- 并发写入可能导致漏数据(新插入的
id落在游标区间内),需配合应用层幂等或版本号控制
用子查询 + JOIN 绕过大 OFFSET
当必须支持任意页码跳转时,可用“先查 ID,再关联取全量”来避免回表和重复扫描。
原慢查询:
SELECT * FROM user WHERE create_time > '2022-07-03' ORDER BY id DESC LIMIT 100000, 20;
优化写法:
SELECT u.* FROM user u INNER JOIN ( SELECT id FROM user WHERE create_time > '2022-07-03' ORDER BY id DESC LIMIT 100000, 20 ) t ON u.id = t.id;
关键点:
- 子查询只查
id,走覆盖索引,几乎不回表 - 外层
JOIN用主键等值匹配,走聚簇索引,效率高 - 不能用
IN (SELECT ...),MySQL 8.0 以前不支持子查询含LIMIT,会报错 - 若
WHERE条件区分度低(如status = 'active'占 80% 数据),子查询仍可能扫描大量行,此时需结合业务加更细粒度过滤
游标分页适合流式加载场景,子查询 + JOIN 适合管理后台跳页需求;但两者都依赖合理索引和主键设计。最容易被忽略的是:深分页问题本质不是 SQL 写法,而是数据访问模式与存储引擎特性的冲突——偏移量越大,越偏离 B+ 树的局部性优势。真要支撑千万级数据的随机页码访问,得考虑导出到 Elasticsearch 或预聚合到宽表。


















