查询语句eg:
SELECT * FROM sys_user WHERE id > 100000 ORDER BY id LIMIT 20;
这条 SQL 看似简单,但 MySQL 真正执行它的过程,和你写 SQL 的顺序完全不同,也和很多人想象的"先查全表再排序再截断"完全不同。
下面按 MySQL 真实执行机制 给你拆解,结合游标分页的场景讲清楚为什么它快。
你写的顺序是:
SELECT * -- ① 书写第1个
FROM sys_user -- ② 书写第2个
WHERE id > 100000 -- ③ 书写第3个
ORDER BY id -- ④ 书写第4个
LIMIT 20; -- ⑤ 书写第5个
MySQL 逻辑上的执行顺序是:
FROM → WHERE → SELECT → ORDER BY → LIMIT
但这是逻辑顺序。
在物理执行层面,如果走了索引,MySQL 会做大量优化,实际过程比这高效得多。
为了讲清楚,先明确两个前提(这也是游标分页能生效的基础):
| 条件 | 说明 |
|---|---|
id 是 主键 | InnoDB 中主键就是聚簇索引,B+ 树叶子节点存的是完整行数据 |
| 表引擎是 InnoDB | 默认引擎,数据按主键聚簇存储 |
如果 id 不是主键只是普通索引,执行过程会有所不同(需要回表),我后面会提。
1. 客户端发送 SQL 到 MySQL Server
2. 连接器:校验用户名密码、权限
3. 查询缓存:MySQL 8.0 已移除,跳过
4. 分析器:词法分析(识别 SELECT、FROM、WHERE 等关键字)→ 语法分析(生成解析树)
5. 优化器:生成执行计划(决定用哪个索引、 JOIN 顺序等)
优化器看到这条 SQL 后,会做如下判断:
WHERE id > 100000 AND ORDER BY id
id 是主键,有主键索引(聚簇索引)ORDER BY id 和 WHERE 条件用的是同一个索引字段→ 优化器决定:走主键索引,且不需要额外排序(Using index / No filesort)
可以用 EXPLAIN 验证:
EXPLAIN SELECT * FROM sys_user WHERE id > 100000 ORDER BY id LIMIT 20;
典型结果:
type: range
key: PRIMARY
rows: 20 -- 注意这里,优化器估算只扫 20 行!
Extra: Using where; Using index
⚠️
rows: 20说明优化器知道只需要扫 20 行就够了吗?不完全是,这只是估算。但LIMIT 20让优化器知道"不需要扫太多"。
这是真正干活的阶段,按执行操作的时间线来讲:
执行器调用 InnoDB 接口:找 id > 100000 的第一条记录
↓
InnoDB 从主键索引(B+ 树)的根节点开始:
- 根节点 → 非叶子节点 → ... → 定位到叶子节点中 id = 100001 的位置
- 时间复杂度:O(log N),通常 3~4 次 IO(千万级数据也只要 3~4 层 B+ 树)
B+ 树的叶子节点之间是**双向链表**连接的
↓
InnoDB 从 id = 100001 这个位置开始,沿着链表**向右顺序读**:
- 读第 1 条:id = 100001 ✓ → 返回给执行器
- 读第 2 条:id = 100002 ✓ → 返回给执行器
- ...
- 读第 20 条:id = 100020 ✓ → 返回给执行器
执行器数了数:已经拿到 20 条了
↓
LIMIT 20 条件满足 → **立即停止扫描**
↓
不需要再往下读了,直接返回结果给客户端
整个过程只读了 20 行数据,只做了 1 次 B+ 树定位 + 20 次顺序 IO。
[根节点]
/ | \
[非叶子] [非叶子] [非叶子]
| | |
v v v
┌─────────────────────────────────────────────────┐
│ 叶子节点链表(按 id 有序存储完整行数据) │
│ ... | 99999 | 100000 | 100001 | 100002 | ... │
│ ↑ │
│ 定位起点,从这里开始 │
│ 向右顺序读 20 条就停 │
└─────────────────────────────────────────────────┘
LIMIT 100000, 20 会怎样?-- 深分页写法
SELECT * FROM sys_user ORDER BY id LIMIT 100000, 20;
执行过程变成:
1. 从 B+ 树最左叶子节点开始顺序扫描
2. 读第 1 条 → 丢弃(LIMIT 说要跳过前 100000 条)
3. 读第 2 条 → 丢弃
...
100001. 读第 100001 条 → 保留(终于轮到我要的了)
...
100020. 读第 100020 条 → 保留
结果:扫描并丢弃了 100000 条,实际只返回 20 条。IO 和 CPU 白白浪费在丢弃的数据上。
| 对比项 | id > 100000 LIMIT 20 | LIMIT 100000, 20 |
|---|---|---|
| 扫描行数 | 20 行 | 100020 行 |
| B+ 树定位 | 直接定位到起点 | 从最左端开始 |
| 丢弃数据 | 0 行 | 100000 行 |
| 时间复杂度 | O(log N + 20) | O(N) |
| 深分页性能 | √ 恒定快 | × 越深越慢 |
id 不是主键,是普通索引呢?如果 id 只是普通索引(非聚簇索引),执行过程会多一步:
1. 走 id 的普通索引 B+ 树 → 找到 20 个 id 值(id > 100000 的前 20 个)
2. **回表**:用这 20 个 id 去主键索引里查完整行数据
3. 返回结果
这就是所谓的 Using index condition / Using where; Using index 和 回表 的区别。
但即使回表,也只回表 20 次,性能依然远好于 LIMIT 100000, 20(那个要回表 100020 次)。
EXPLAIN SELECT * FROM sys_user WHERE id > 100000 ORDER BY id LIMIT 20;
关键字段解读:
| 字段 | 理想值 | 含义 |
|---|---|---|
type | range | 范围扫描,比 ALL(全表)好 |
key | PRIMARY | 实际使用的索引 |
rows | 接近 20 | 估算扫描行数 |
Extra | Using where; Using index | 索引覆盖 + 条件过滤,无 filesort |
如果看到 Using filesort,说明 ORDER BY 没用上索引,需要优化(比如加联合索引)。
SELECT \* FROM sys_user WHERE id > 100000 ORDER BY id LIMIT 20的执行本质是:
- 利用主键索引 B+ 树的有序性,直接定位到
id > 100000的起点- 沿着叶子节点链表顺序读 20 行
LIMIT 20触发提前终止,不扫多余数据- 全程 O(log N + 20),与页码深浅无关
这就是为什么游标分页方案能彻底解决深分页问题——它不是"优化"了 OFFSET,而是从根本上消灭了 OFFSET。