mysql深分页变慢的根本原因是执行器必须逐行扫描并丢弃前offset行,无法跳过,即使有索引也需读取offset+size行再舍弃前offset行,导致io与cpu线性增长。

直接说结论:MySQL里LIMIT大分页变慢,不是因为SQL写错了,而是MySQL必须扫描并丢弃前offset行——哪怕你只想要20条,它也得先把前面100万行读出来再扔掉。这不是索引没建好就能解决的,是执行模型决定的硬伤。
为什么LIMIT 100000, 20会扫100020行
MySQL不会跳过前100000行,它按ORDER BY字段顺序逐行取数据,每取一行就判断“够不够offset”,直到累计跳过100000行,才开始收集结果。这个过程无法用B+树直接定位第N条,因为B+树节点存的是键值范围,不是行号。
- 如果排序字段有索引(比如
idx_create_time),MySQL能按索引顺序扫描,避免Using filesort,但回表操作仍要执行100000次 - 如果
SELECT *,每次回表都要从聚簇索引加载整行,IO放大严重 - 用
SHOW STATUS LIKE 'Innodb_rows_read'前后对比,差值就是实际扫描行数,往往等于offset + limit
主键范围分页:WHERE id
这是滚动翻页(上一页/下一页)最稳的方案,前提是排序字段和主键强相关,且业务允许不支持随机跳页。
- 第一页查完记下最小
id(比如id = 9990),下一页就用WHERE id - 必须确保
id严格递增、无重复、不被删除(或软删后逻辑一致) - 不能用
WHERE create_time 替代,时间字段可能重复,会导致漏数据或重复 - 索引必须包含排序字段+查询字段,例如
KEY idx_id_created (id, create_time)可覆盖查询
延迟关联优化:JOIN子查询只取ID
当你必须支持跳页(比如用户手动输页码),又不想改前端逻辑时,用这个方案兼容性最好。
- 先用子查询走索引只拿
id:SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 20 - 再用
JOIN查完整数据:SELECT o.* FROM orders o JOIN (...) t ON o.id = t.id - 关键点:子查询不回表,只读索引页;外层
JOIN只做20次回表,而不是100020次 - 注意
EXPLAIN里子查询的type要是index或range,不能是ALL
覆盖索引+只查必要字段
很多慢查询其实卡在SELECT *上——你列表只展示订单号、金额、状态,却把10个字段全查出来,网络+内存+IO三重浪费。
- 建复合索引覆盖所有查询字段,例如
KEY idx_create_status (create_time, status, order_no, amount) - SQL改成
SELECT order_no, amount, status FROM orders WHERE ... ORDER BY create_time DESC LIMIT 100000, 20 - 这样整个查询都在索引里完成,零回表,
Extra显示Using index - 缺点:索引体积变大,写入性能略降,且字段一变就要重建索引
真正容易被忽略的一点:深分页问题从来不是单点SQL优化能根治的。前端传page=5000这种请求,后端不该照单全收——要么限流拦截,要么自动降级为游标分页,要么返回缓存快照。数据库不是搜索引擎,别让它干跳着找第5000页这种事。











