深分页慢的本质是mysql必须扫描并丢弃前offset行,如limit 1000000,20需扫描1000020行;优化方案包括延迟关联(先查id再join)和游标分页(where排序字段>上页末值)。

深分页慢不是配置问题,也不是服务器性能瓶颈,而是 MySQL 执行 LIMIT offset, size 时必须扫描并丢弃前 offset 行——哪怕你只想要 20 条数据,offset = 1000000 就得扫 100 万零 20 行。绕不开这个逻辑,就只能换思路。
为什么 LIMIT 1000000, 20 会扫 100 万行?
MySQL 不支持“跳到第 N 行直接读”,它实际执行流程是:
- 按
ORDER BY字段(比如id)从头开始逐行扫描 - 每扫一行就计数,直到累计满足
offset + size行 - 把前
offset行全部丢弃,只返回后面size行
如果排序字段没走索引,或者走了二级索引但需要回表取全字段,还会额外触发大量随机 IO —— 这就是为什么加了索引有时仍慢。关键不在“有没有索引”,而在“是否避免回表+是否减少扫描行数”。
延迟关联(Deferred Join):最通用的 SQL 层优化
核心是两步分离:先用覆盖索引快速拿到目标页的主键,再用这些主键精准回查。前提是主键或排序字段有索引(最好是聚簇索引,如 id 主键)。
错误写法(子查询带 LIMIT 直接 IN):
SELECT * FROM orders WHERE id IN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 20);
MySQL 5.7+ 会报错:This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'。
正确写法(嵌套一层,强制物化子查询):
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 20 ) tmp ON o.id = tmp.id;
注意事项:
- 子查询里只选
id(或排序字段+主键组合),确保走覆盖索引 - 外层
JOIN比IN更稳定,尤其在id有重复或 NULL 时 - 如果排序依据是复合字段(如
created_at, id),索引必须匹配:ADD INDEX idx_created_at_id (created_at, id)
游标分页(Keyset Pagination):性能最稳,但不支持跳页
放弃 OFFSET,改用上一页最后一条记录的排序值作为下一页起点。适用于“加载更多”“无限滚动”场景。
假设第 1 页查出最后一条 id = 1005:
SELECT * FROM orders WHERE id > 1005 ORDER BY id LIMIT 20;
优点和限制:
- 无论翻到第几万页,都只扫描 20 行,耗时恒定
- 必须依赖唯一、单调递增(或严格有序)的排序字段,
id最稳妥;created_at要加id做第二排序,防时间重复 - 不能实现“跳转到第 500 页”,用户点击页码时需退回到第 1 页重新算游标
- 删除数据会导致游标“跳空”,但不影响查询正确性,只是可能少返回几条
容易被忽略的底层陷阱
很多优化失败,不是 SQL 写错了,而是踩了这几个坑:
-
ORDER BY字段没索引,或索引顺序与查询不一致(比如建了(a,b)索引,却ORDER BY b, a) - WHERE 条件里对索引字段用了函数,比如
WHERE DATE(create_time) = '2026-01-01'→ 索引失效 - 字符集或 collation 不一致导致隐式转换,让
WHERE user_id = 123(数字)匹配VARCHAR字段时走不了索引 - 用
SELECT *回表时,大字段(TEXT、BLOB)拖慢传输和内存占用,能只查必要字段就别全查
真正决定深分页快慢的,从来不是“怎么写 LIMIT”,而是“能不能让 MySQL 少扫几行、少回几次表”。方案选哪个,取决于你的产品形态:要跳页就用延迟关联,只要上下页就用游标,而所有方案的前提,都是索引设计没硬伤。











