LIMIT offset, size 越往后越慢是因为MySQL需扫描 offset + size 行并回表,时间与空间复杂度均线性增长;应改用覆盖索引子查询或游标分页优化。

为什么 LIMIT offset, size 越往后越慢
MySQL 执行 LIMIT 1000000, 10 时,并不会“跳到第1000001行直接读”,而是从索引起点开始,逐条扫描、排序、累积计数,直到读满 1000000 + 10 行,再丢弃前100万条——所有这些行都要走完整个回表流程(哪怕只用主键)。这意味着:扫描行数 = offset + size,时间复杂度和空间开销都随 offset 线性增长。
常见错误现象包括:
- 前端翻到第500页后接口超时(
LIMIT 4990, 10还能忍,LIMIT 499990, 10就卡死) - 执行计划显示
type=ALL或rows高达百万级,即使ORDER BY字段有索引 - 慢查询日志里反复出现同一类
LIMIT语句,但WHERE条件其实很轻量
用覆盖索引 + 子查询绕过 offset 扫描
核心思路是:不靠跳过 N 行,而是先定位“第 N+1 条记录的排序键值”,再用该值做范围查询。前提是必须有确定的、可比较的排序字段(如 id 或 create_time),且该字段有索引。
实操建议:
- 确保
ORDER BY字段已建索引,例如CREATE INDEX idx_order_id ON orders(id);若含多字段排序(如ORDER BY status DESC, id ASC),需建对应复合索引 - 改写原查询
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10为:
SELECT * FROM orders WHERE id > ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 1 ) ORDER BY id LIMIT 10;
这个子查询只取一个 id 值,走索引且不回表,主查询则变成高效范围扫描。但注意:该方案要求排序字段严格递增/无重复,否则可能漏数据;若业务允许,优先用 id 这类主键而非时间字段(避免重复值)
游标分页(Cursor-based Pagination)替代 offset
前端不再传页码或 offset,而是传上一页最后一条记录的排序键(如 last_id=123456),后端查 WHERE id > 123456 ORDER BY id LIMIT 10。这是真正规避深度分页问题的正解。
使用场景与限制:
- 适用于无限滚动、下拉加载等“只能向前翻”的交互,不支持跳转任意页码
- 必须保证排序字段唯一且单调(推荐用自增
id或带id的复合条件,如ORDER BY create_time DESC, id DESC) - 数据实时增删会导致“漂移”:新插入的记录可能插在旧页面中间,造成重复或跳过;若业务强依赖一致性,需配合版本号或事务快照
- 首次请求仍需一个锚点,可用
SELECT MIN(id) FROM table或固定起始值
其他有效但需权衡的手段
没有银弹,有些方案见效快但有副作用,上线前务必验证:
- 加缓存:对稳定页码(如前100页)用 Redis 缓存结果,key 可设计为
page:orders:100:10;但缓存失效策略要小心,避免脏数据 - 限制最大 offset:在应用层拦截
offset > 10000的请求,返回 HTTP 400 或提示“仅支持查看前1000页”;适合后台管理类系统 - 归档冷数据:把 1 年前的订单移到
orders_archive表,主表控制在千万级以内,offset自然降下来 - 避免
SELECT *:深度分页时只查必要字段,减少回表和网络传输开销;尤其不要在LIMIT查询中JOIN大表
最易被忽略的一点:即使用了覆盖索引优化,如果 WHERE 条件本身没走索引,整个优化就失效了——先看 EXPLAIN 里的 key 和 rows,再动 LIMIT。



















