limit 100000,10 需扫描 100010 行,因 mysql 从头逐行计数至 offset+size 后丢弃前 offset 行;即使命中索引,回表次数仍达 100010 次,且子查询等写法若未消除 offset 扫描则无效。

为什么 LIMIT 100000,10 要扫描 100010 行
MySQL 的 LIMIT offset, size 不是“跳到第 offset 行再取 size 行”,而是“从头开始,逐行计数,直到累计读够 offset + size 行,再丢弃前 offset 行”。
这意味着:
-
LIMIT 0,10:读 10 行,返回全部 -
LIMIT 100000,10:必须先读 100010 行,再扔掉前 100000 行,只留最后 10 行 - 扫描行数 =
offset + size,和是否命中索引无关
尤其当查询带 WHERE 条件时,这个扫描发生在回表阶段:
先用二级索引(如 idx_updated_at)快速定位满足条件的主键 id,再拿着这些 id 回到主键索引一棵棵查完整行——每回一次表,就算一次磁盘 I/O。offset 越大,需要回表的 id 就越多,哪怕最终只返回 10 行。
回表次数怎么被 offset 拉高的
回表不是按“页”发生的,而是按“命中的索引记录条数”发生的。假设你查:
SELECT * FROM user WHERE age > 25 ORDER BY id LIMIT 100000, 10;
执行流程其实是:
- 用
age索引扫出所有age > 25的id(可能几万条) - 按
id排序后,取前 100010 个id - 对这 100010 个
id全部回表查完整行 - 丢弃前 100000 行,返回后 10 行
所以真正拖慢查询的,不只是“扫描多”,更是“回表多”。
哪怕加了覆盖索引(比如只查 id, age),只要 SELECT 列没被索引完全覆盖,就逃不掉回表。
哪些写法看似优化,其实没绕开 offset 扫描
很多开发者尝试用子查询或 JOIN “提前截断”,但若没切断扫描起点,依然无效:
-
❌ 错误示范(仍扫描 100010 行):
SELECT * FROM user WHERE id IN (SELECT id FROM user WHERE age > 25 LIMIT 100000, 10);
→ 子查询里LIMIT 100000,10本身就要扫 100010 行,只是把回表推迟到了外层 ✅ 正确前提:子查询必须能走覆盖索引,且只返回
id
例如:SELECT id FROM user WHERE age > 25 ORDER BY id LIMIT 100000, 10如果age和id在同一个联合索引里(KEY idx_age_id (age, id)),就能避免回表,此时子查询才真正轻量⚠️ 更隐蔽的坑:
ORDER BY字段没索引,或用了函数/表达式,会导致排序无法利用索引,进而让整个扫描变成 filesort + 全字段扫描,offset 影响被进一步放大
真正避开 offset 的关键:把“第 N 页”转成“从某值之后取 N 条”
核心思路是放弃“我要第几页”,改问“我上次看到的最后一条,它的排序字段值是多少?”
适用前提:
- 排序列(如
updated_at或id)有索引 - 该列值在分页范围内严格单调或基本不重复(
id最稳,updated_at需配合id作第二排序)
典型写法:
SELECT * FROM user WHERE updated_at <p>这样数据库直接定位到索引中 <code>updated_at</code> 小于该时间的位置,往后连续取 10 条,全程不依赖 offset,也不扫描无关行。</p><p>最容易被忽略的一点:<br> 这个方案要求<strong>前端或服务端必须可靠保存上一页最后一条的排序字段值</strong>,不能靠“当前页第一条的值减一”这种估算——时间精度、并发更新、时区、索引顺序都可能导致漏数据或重复。</p>











