快速吃透 MySQL 索引:B+Tree、聚簇索引、最左前缀、覆盖索引、索引失效
面试问 MySQL,十有八九会落到索引上。但很多人对索引的理解是碎片化的:知道"建索引能提速""最左前缀""不要前模糊 LIKE",却说不清索引为什么快、B+Tree 好在哪里、二级索引为什么要回表、EXPLAIN 里的 Using index / Using index condition 到底差在哪。
这篇文章把 MySQL 索引从头到尾串一遍:从数据结构出发,到物理存储,再到联合索引与优化器行为。看完能自己判断"这个 SQL 该建什么索引、会不会命中"。
1. 索引是什么:从"全表扫描"说起
在没有索引的 MySQL 表上查一行数据,InnoDB 只能从第一个数据页开始,把整张表的所有页依次读进内存,逐行比对条件。这就是全表扫描(full scan)——复杂度 O(n),行数一多就慢。
索引做的事情,本质上和书的目录、字典的拼音检字表一样:额外维护一份"有序的、缩小范围的查找路径",用少量 IO 定位到目标,而不是翻遍全书。
索引能提速的本质是把"线性查找"变成"树形查找":读多少次磁盘,取决于树的高度,而不是表的行数。所以下文的一切,都围绕"把树做矮、把路径做窄"展开。
2. 先把数据结构和盘托出:B 树、B+Tree 到底是什么
很多教程一上来就甩"B+Tree 多叉平衡树",对基础薄弱的人等于没讲。我们从最简单的二叉查找树开始,一步一步把它"改造"成 B+Tree——每走一步,都是为了解决上一步留下的问题。
2.1 起点:二叉查找树(BST)——每个节点只有两个分叉
二叉查找树(Binary Search Tree)的规则只有一条:每个节点存一个键,左子树的所有键都比它小,右子树的所有键都比它大。查找时比较一次,就能排除一整半子树:
它有两个毛病:
- 会退化:如果数据恰好按顺序插入,BST 会歪成一条链表,查找又回到 O(n);
- 就算不退化:自平衡的 AVL / 红黑树能把高度压到 O(log n),但每个节点仍然只有 2 个分叉。两千万行数据 → 高度约
log2(2000万) ≈ 24层。
2.2 磁盘的脾气:树有几层,就要读几次盘
关键约束在这里:数据库的数据在磁盘上,而磁盘 IO 是整块整块读的——InnoDB 一次最少读一个"页"(默认 16KB),不能只读一个字节。索引也是按页存的,B+Tree 里的每个"节点"恰好就是一个磁盘页。
所以查一个键的耗时 ≈ 树的高度 × 读一页的 IO 时间。上面那棵 24 层的平衡二叉树,查一次就是 24 次磁盘 IO——每次 IO 是毫秒级,攒在一起就是肉眼可见的慢。
于是目标只有一个:
把树做矮。 一层能分出的叉越多(扇出 fanout 越大),同样的数据树就越矮、IO 次数就越少。
怎么让一个节点多分几个叉?答案简单粗暴——一个节点别只存一个键,多存几个键,键之间多放几个指向子树的指针。这就是 B 树。
2.3 B 树:一个节点装一堆键的多叉平衡树
B 树(B-tree,这里的 B 一般指 Balanced / Broad,即"平衡、多叉")。它里面的一个节点长这样——键从小到大排列,键与键之间夹着一个孩子指针:
它有三条硬规则:
- 键有序:
P0 的子树 < K1 < P1 的子树 < K2 < P2 的子树 < ...,保证查找时可以二分; - 永远平衡:所有叶子都在同一层——插入 / 删除后节点会通过分裂 / 合并自我维持,不像 BST 会退化;
- 内部节点也存数据:查到任何一个节点,都可能直接拿到记录,不用非得走到叶子。
一棵真实的 B 树长这样(每个节点存多个键、都有多个孩子):
比如查 42:根 [33],42 > 33 → 走右子树 → [48, 57],42 < 48 → 走左孩子 → [42, 44] 命中。只有 3 层,3 次 IO。同样的数据量,二叉树早就 20+ 层了。
B 树已经把树高压得很低了,但数据库最终用的还不是它,而是升级版 B+Tree——因为对数据库来说,B 树有两个致命缺点。
2.4 B+Tree:数据全压到叶子,叶子串成链表
B+Tree 在 B 树基础上改了两处,每一处都直指数据库的诉求:
改动 1:内部节点只存键,不存数据。 数据(索引列值 + 主键)全部挪到叶子节点。内部节点在同样 16KB 里能塞更多键 → 扇出更大 → 树更矮。为了"更矮",它连"中途命中数据"这个能力都主动放弃了。
改动 2:叶子节点按键的顺序,用指针串成双向链表。 整棵树的"有序数据列表"就平铺在叶子层。
(内层节点的键只是"路标",真正存放数据的都在叶子,叶子之间还有双向链表。)
单点查找:和 B 树一样从根一路走到底。例如找 14 → [13],14 > 13 走右 → [17, 23],14 < 17 走左 → 叶子 [14, 15] 命中。因为数据全在叶子,查什么都要走到底——但这无所谓,走到底也就 3~4 层。
范围查询:这是 B+Tree 吊打 B 树的地方。比如"查所有键在 [8, 20] 的记录"——先在叶子 [7, 8] 找到起点 8,然后顺着叶子链表一路向右:[7,8] → [12,13] → [14,15] → [19,20],一路收数据,全程不回溯上层。而 B 树的数据散在每一层,范围查询要不停地在父子节点之间跳来跳去,才能凑出有序结果。
2.5 B 树 vs B+Tree:一张表看清
| B 树 | B+Tree(MySQL 在用) | |
|---|---|---|
| 内部节点 | 存键 也存数据 | 只存键(扇出更大 → 树更矮) |
| 数据在哪 | 散在各层 | 全部在叶子 |
| 叶子 | 无顺序链 | 按序串成双向链表 |
| 单点查找 | 中途可能命中 | 一定走到底(但也就 3~4 层) |
| 范围查询 | 需回溯、跳层 | 沿叶子链表顺序扫,几乎免费 |
| 排序 / GROUP BY | 需额外处理 | 叶子天然有序,顺序读即可 |
结论一句话:数据库要的恰恰是"范围查询和排序快"——业务 SQL 里 BETWEEN、>、<、ORDER BY、LIMIT 比比皆是。为了这个,B+Tree 宁愿把内部节点全部腾出来放键(换来更矮),把数据全压到叶子再用链表串起来(换来范围查询免费)。这就是"腾出空间换分叉,再用链表换范围查询"。
2.6 回到 MySQL:一条索引 = 一棵 B+Tree,一个节点 = 一个 16KB 页
把上面的图拼回 InnoDB 的世界观:每建一条索引,MySQL 就在磁盘上维护一棵 B+Tree,树里的每个节点正好就是一个 16KB 的页。于是"查一个键几次 IO"直接等于"树有几层",可以算笔账:
默认页 16KB,一个索引项按 key 8B + 指针 6B ≈ 14B 估算:
- 每个内部节点(页)能装:
16KB / 14B ≈ 1170个键 → 扇出约 1170; - 叶子页按每页装 16 行(约 1KB/行)算:
- 第 1 层(根):1 页,1170 个分叉
- 第 2 层:1170 个页,1170 × 1170 ≈ 137 万个分叉
- 第 3 层(叶子):137 万页 × 16 行 ≈ 2190 万行
也就是说:两千多万行的表,按索引查一条,只需要 3 次磁盘 IO。对比全表扫描逐页翻两千多万行,差距是数量级的。
3. 聚簇索引 vs 二级索引:回表从哪来
索引不能凭空存在,它得占空间、和数据组织在一起。InnoDB 的表数据本身就是一棵 B+Tree(按主键组织的),这就是聚簇索引(clustered index):
3.1 聚簇索引 = 主键
- InnoDB 表必须有主键(没有会隐式生成
rowid); - 聚簇索引的叶子节点 = 整行数据;
- 所以"表数据"和"主键索引"是一体的,按主键查是最直接的,一次定位直接拿到行。
3.2 二级索引(secondary index):叶子存主键
给普通列建的索引(唯一索引、联合索引、普通索引)都是二级索引。它的叶子节点不存整行,只存"该列的键值 + 主键值":
这个"先在二级索引找到主键,再按主键回聚簇索引取整行"的动作,叫回表(bookmark lookup)。回表 = 一次额外的随机 IO。
优化的两条路(后文展开):
- 覆盖索引:让查询需要的列全在二级索引里 → 不用回表;
- MRR(Multi-Range Read):先把命中的主键按物理顺序排好再批量回表,把随机读变顺序读(MySQL 5.6+,多数情况自动开启)。
3.3 主键怎么选:自增还是 UUID
聚簇索引的插入顺序直接影响页分裂:
| 主键类型 | 插入行为 | 后果 |
|---|---|---|
| 自增 BIGINT | 新主键总比之前大 → 往 B+Tree 尾部追加 | 叶子页写满才分裂,页重用率高,碎片少 ✅ |
| UUID / 随机字符串 | 插入位置随机 | 频繁触发页分裂、页重排,产生碎片,写放大明显 ❌ |
所以 InnoDB 下强烈推荐自增主键(或单调递增的键),不要用业务字段(身份证号、UUID)当主键——除非有极强理由。这也是为什么"无业务主键的表也要加个自增 id"。
4. 索引类型一览
| 索引类型 | 特点 | 典型用途 |
|---|---|---|
| 主键索引 | 唯一 + 聚簇,不能为 NULL | 行身份标识 |
| 唯一索引 | 值唯一,叶子存主键 | 手机号、订单号等业务唯一约束 |
| 普通索引 | 仅加速查询 | 高频 where 列 |
| 联合索引(复合索引) | 多列组成一棵 B+Tree | 多条件查询、覆盖索引、排序优化 |
| 全文索引 | 分词匹配,不走 B+Tree | LIKE 全文搜索 |
| 前缀索引 | 只索引字符串前 N 个字符 | 长文本列省空间 |
一句话:B+Tree 的叶子链表有序性,让"范围、排序、分组"也能吃上索引——所以联合索引不止用来"过滤",还常用来"省排序",见下一节。
5. 联合索引与最左前缀:面试重灾区
联合索引 (a, b, c) 不是三棵树,而是一棵按 (a, b, c) 字典序排序的 B+Tree:先按 a 排,a 相同再按 b 排,b 相同再按 c 排。
所以它只对"从最左列开始、连续"的查询有效——这就是最左前缀原则:
5.1 命中判定
以索引 (a, b, c) 为例:
| WHERE 条件 | 命中 | 说明 |
|---|---|---|
a = 1 | ✅ 全用 | |
a = 1 AND b = 2 | ✅ 全用 | |
a = 1 AND b = 2 AND c = 3 | ✅ 全用 | 最优,精确命中一条路径 |
a = 1 AND c = 3 | ⚠️ 只用到 a | b 被跳过,c 用不上(中间断列) |
b = 2 | ❌ 失效 | 没从 a 开始 |
c = 3 | ❌ 失效 | |
b = 2 AND a = 1 | ✅ 全用 | 优化器会重排条件,等价于 a=1 AND b=2 |
a=1 AND c=3 为什么 c 用不上? 因为 B+Tree 先按 a 排、再按 b 排——知道 a=1 后,b 没有限定,c 在这个范围内是乱序的,无法二分,只能把 a=1 的所有子树遍历完再过滤 c。
5.2 范围查询:范围右侧停止
条件里有范围(> < BETWEEN LIKE 'x%')时,范围列之后的列都用不上索引:
| WHERE 条件 | 用到的列 |
|---|---|
a = 1 AND b > 5 | a + b(b 做范围) |
a = 1 AND b > 5 AND c = 3 | a + b 范围,c 失效(c 在 b 范围内乱序) |
所以设计联合索引时,等值条件放前面,范围条件放最后。
5.3 ORDER BY / GROUP BY 也能省
索引本身就是有序的,ORDER BY 满足最左前缀时能免去 filesort:
-- 有索引 (a, b, c)
SELECT * FROM t WHERE a = 1 ORDER BY b, c; -- ✅ 索引顺序即排序,免 filesort
SELECT * FROM t WHERE a = 1 ORDER BY c; -- ❌ 跳列,需 filesort
SELECT * FROM t WHERE b = 1 ORDER BY a; -- ❌ 违反最左前缀
MySQL 8.0 还支持倒序索引(INDEX (a DESC)),能直接优化 ORDER BY a DESC,不再反向扫描。
6. 覆盖索引 & 索引下推(ICP):两个"省回表"的利器
6.1 覆盖索引(Using index)
二级索引的叶子存了"索引列 + 主键"。如果查询只需要这两部分的数据,就不需要回表——直接在二级索引上就能答完。
-- 索引 (a, b)
SELECT a, b FROM t WHERE a = 1; -- ✅ Extra: Using index(不回表)
SELECT * FROM t WHERE a = 1; -- ❌ 还要回表取其他列
"用 SELECT * 却想覆盖索引"是常见误区——覆盖索引要求查询列 ⊆ 索引列。这也是"别滥用 SELECT *"的一个实际理由:多取一个非索引列,可能就让查询从"免回表"掉回"回表"。
6.2 索引下推(Index Condition Pushdown,MySQL 5.6+)
没有 ICP 时,二级索引过滤出主键→回表,Server 层再对整行做其余条件过滤。有了 ICP,引擎层在读取索引记录时就用索引里的列先过滤掉一部分,减少回表次数:
-- 联合索引 (name, age)
SELECT * FROM t WHERE name LIKE '张%' AND age = 20;
没有 ICP:先把所有 张% 的主键拿出来回表,再逐行判断 age=20。
有 ICP:在索引扫描时就用 age=20 把明显不符的 张% 先剔除,少回表。
EXPLAIN 里 Extra: Using index condition 就代表走 ICP 了。这是免费优化(5.6+ 默认开启),尤其适合"联合索引的非最左列做二次过滤"。
7. EXPLAIN 怎么看:别停留在"有没有用索引"
EXPLAIN SELECT ... 的输出里,几个字段最重要:
7.1 type:访问方式,从好到差
system > const > eq_ref > ref > range > index > ALL
| type | 含义 | 评价 |
|---|---|---|
const | 主键/唯一索引等值查,最多 1 行 | 🟢 最优 |
eq_ref | 联表时被驱动表用主键/唯一索引关联 | 🟢 |
ref | 普通索引等值查 | 🟢 好 |
range | 索引范围查(> < BETWEEN LIKE 'x%') | 🟡 可接受 |
index | 遍历整棵索引树(没有过滤条件) | 🟠 比 ALL 略好(索引比表小) |
ALL | 全表扫描 | 🔴 重点排查 |
7.2 key / rows / Extra
key:实际用到的索引(possible_keys是"可能",要以key为准);rows:预估扫描行数,越小越好(是预估,不是实际);Extra常见值:
| Extra | 含义 |
|---|---|
Using index | 覆盖索引,免回表 ✅ |
Using index condition | 走 ICP,索引下推 ✅ |
Using where | 引擎返回后 Server 层再过滤(可能过滤不多,一般没问题) |
Using filesort | 额外排序,说明 ORDER BY 没用上索引 ⚠️ |
Using temporary | 用了临时表(常见于 GROUP BY / DISTINCT)⚠️ |
判断一个慢查询的通用顺序:看 type 是不是 ALL/index → 看 key 用没用上 → 看 Extra 有没有 filesort/temporary → 看 rows 的量级。
8. 索引失效的 8 个场景
注意: "索引失效"指的是"该列没用上索引/只能全扫",用 EXPLAIN 看 key 和 rows 最靠谱。以下是最常见的 8 类:
① 违反最左前缀(联合索引)
-- 索引 (a, b, c),下面这条只用得到 a,甚至全失效
WHERE b = 2
② 函数 / 表达式作用在索引列上
WHERE LENGTH(name) = 5 -- ❌ 函数套在索引列上
WHERE YEAR(create_time) = 2024 -- ❌ 等价写法:create_time BETWEEN '2024-01-01' AND '2024-12-31'
MySQL 8.0 支持函数索引(表达式索引):
INDEX ((YEAR(create_time))),此时上面的写法可以命中。
③ 隐式类型转换(字符串列被转成数字)
-- phone 是 VARCHAR 索引列
WHERE phone = 12345678901 -- ❌ 数字优先,索引列 phone 被隐式 CAST,函数作用在索引列上
反过来,数字索引列 vs 字符串参数通常不失效(字符串被转数字,索引列不转换)。
④ 前模糊 LIKE
WHERE name LIKE '%abc' -- ❌ 前缀不固定,B+Tree 无从二分
WHERE name LIKE 'abc%' -- ✅ 范围查询,可走索引
⑤ OR 连接了非索引列
WHERE id = 1 OR phone = '13800000000' -- 两边都是索引列 → 可能走 index_merge
WHERE id = 1 OR status = 'CLOSED' -- ❌ status 无索引 → 整条退化为全表扫描
⑥ 负向查询
WHERE status != 'CLOSED' -- ❌ != / <> / NOT IN / NOT LIKE 大多全表
WHERE status NOT IN ('A','B')
⑦ 空值判断(要看方向)
WHERE col IS NULL→ 能走索引(InnoDB 二级索引会存 NULL);WHERE col IS NOT NULL→ 大表上通常退化为全表(能用的场景有限)。
⑧ 优化器自己放弃
- 区分度太低:
WHERE sex = '男'(整表一半数据,全扫比走索引还快); - 结果集占比大(一般超过表行数 ~20%);
- 表数据量太小(< 几千行,全扫的 IO 反而更少)。
正确姿势:不要背口诀,遇到慢查询就 EXPLAIN——看 key 是不是空、type 是不是 ALL、rows 是不是整表量级,这三个信号直接告诉你"失没失效、严不严重"。
9. 实战优化建议
① 用区分度高的列建索引:越能"一刀切"的列越好。性别、状态这种枚举值区分度趋近 1,单独建索引意义不大,适合放联合索引的末尾做二次过滤(配合 ICP)。
② 选合适的类型,能短就短:主键用 BIGINT 别用字符串;长文本列可考虑前缀索引(INDEX (name(10)))省空间,代价是损失部分区分度。
③ 按最左前缀原则"一树多用":与其建 (a)、(a,b)、(a,b,c) 三棵树,不如只建 (a,b,c) 一棵——它同时覆盖三种查询,减少索引维护开销和磁盘占用。
④ 别为排序裸奔:高频 ORDER BY/GROUP BY 的列尽量并进联合索引,把 filesort/temporary 消掉。
⑤ 写多读少的表慎建索引:每条索引都是"读加速、写减速"——写入要维护索引、还可能在页分裂时放大写放大。所以写密集表宁可少建。
⑥ 定期清理冗余/无用索引:key 里几乎见不到的索引、与别的索引重复前缀的索引,都是白付的写代价。用 sys.schema_unused_indexes 可以查出没被用过的索引。
⑦ 小技巧:MySQL 8.0.13+ 支持索引跳跃扫描(Index Skip Scan)——最左列区分度低时可以"跳过"它直接用到第二列,但限制多、不稳定,别当成主路径。
10. 实战案例:一个订单表从设计到优化的完整过程
前面讲了一堆原理,落地到一张真实表上怎么用?我们用最经典的电商订单表走一遍完整流程:表结构设计 → 索引设计 → 三个真实查询场景逐个分析。
10.1 表结构设计
CREATE TABLE `orders` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号(业务唯一)',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0待支付 1已支付 2已发货 3已完成 4已取消',
`channel` TINYINT NOT NULL DEFAULT 0 COMMENT '渠道: 1App 2Web 3小程序',
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额',
`created_at` DATETIME NOT NULL COMMENT '下单时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), -- ① 自增主键
UNIQUE KEY `uk_order_no` (`order_no`), -- ② 订单号唯一索引
KEY `idx_user_created` (`user_id`, `created_at`), -- ③ 用户 + 时间联合索引
KEY `idx_status_created` (`status`, `created_at`) -- ④ 状态 + 时间联合索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
四条索引分别对应前文的四个原则:
| 索引 | 设计理由 | 对应原理 |
|---|---|---|
| ① 自增主键 | 顺序插入 → 聚簇索引尾部追加,避免页分裂 | §3.3 主键怎么选 |
② uk_order_no | 订单号是高频点查键,且必须唯一 | §4 唯一索引 |
③ (user_id, created_at) | "用户"等值 + "时间"范围/排序 | §5 最左前缀 + §5.3 免 filesort |
④ (status, created_at) | status 区分度低,不单独建,配时间做扫描 | §9 ① 区分度 |
10.2 索引设计:从查询反推
不要"先建表再拍脑袋加索引",而是先把核心业务查询列出来,倒推每条查询需要什么索引:
| 业务查询 | SQL 形态 | 用到的索引 |
|---|---|---|
| 我的订单列表(按时间倒序分页) | WHERE user_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 20 | ③ idx_user_created |
| 订单详情 / 支付回调(按订单号) | WHERE order_no = ? | ② uk_order_no |
| 超时未支付订单扫描(定时任务) | WHERE status = 0 AND created_at < ? | ④ idx_status_created |
下面逐个场景看它怎么走索引、能到多好、还有哪些坑。
10.3 场景 1:用户订单列表(联合索引 + 深分页)
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10086
AND created_at < '2025-07-01'
ORDER BY created_at DESC
LIMIT 20;
走索引:idx_user_created (user_id, created_at) —— user_id 等值定位到该用户的所有订单,created_at < ? 在区间内二分,且 ORDER BY created_at 正好和索引序一致,直接倒着扫叶子链表即可,免去 filesort。
EXPLAIN 预期:type=range,key=idx_user_created,Extra 里没有 Using filesort。
坑 ①:回表。查询要的 order_no/amount/status 不在索引里,每行都要按主键回表。列表查询常是"宁可用窄索引 + 回表",也别为了覆盖索引把一堆列塞进联合索引(索引会变胖、写放大)。
坑 ②:深分页。LIMIT 100000, 20 时 MySQL 要先把前 10 万行扫出来再丢掉,越翻越慢。改用游标分页——记住上一页最后一条的 (created_at, id),下一页带着它继续:
-- 上一页最后一条是 created_at='2025-06-28 10:00:00', id=888888
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10086
AND (created_at, id) < ('2025-06-28 10:00:00', 888888) -- 元组比较,天然走索引
ORDER BY created_at DESC
LIMIT 20;
10.4 场景 2:订单号点查(唯一索引)
SELECT * FROM orders WHERE order_no = '20250712001';
走索引:uk_order_no 唯一索引 → type=const,B+Tree 一次定位到叶子,拿到主键回表取整行,总共 2~3 次 IO。这是索引最理想的使用姿势。
坑:绝对不要写成 WHERE order_no LIKE '%0712%'——前模糊让索引失效(§8 ④),等于全表扫。业务上要"按订单号前缀模糊搜",那是搜索需求,应该走全文索引或搜索引擎,而不是硬凹 B+Tree。
10.5 场景 3:超时未支付订单扫描(低区分度列怎么设计)
定时任务每分钟扫一次"下单超过 30 分钟还没支付"的订单:
SELECT id FROM orders
WHERE status = 0
AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 100;
如果只建单列 KEY(status)——这是个经典反例:status=0 可能命中半张表,优化器算算成本觉得"全表扫比走索引还快",直接 ALL。这就是 §9 ① 说的"区分度趋近 1 的列单独建索引意义不大"。
正确做法是 idx_status_created (status, created_at):
status = 0等值精确命中,再靠created_at < ?在区间内收窄——把"先筛状态、再筛时间"两个条件都压进一棵索引;- 而且查询只取
id,主键就在二级索引叶子节点里 → 覆盖索引,连回表都省了,Extra: Using index。
一条订单表,四个查询需求,三条索引 + 一个自增主键就全覆盖了——这就是"从查询反推索引"的价值:不是索引越多越好,而是每条索引都有对应的真实查询在用它。
11. 一句话总结
MySQL 索引能提速,本质是 InnoDB 用极矮的 B+Tree(非叶子只存键 + 叶子有序链表)把"线性扫描"变成"几次 IO 的树形查找"。InnoDB 的表数据本身就是主键聚簇索引,二级索引只存"列值+主键"、查全行要回表,所以有了覆盖索引(免回表)和索引下推(少回表)两个优化点。联合索引的最左前缀决定了它能覆盖哪些查询,范围条件会切断右侧的列。遇到慢查询别背口诀,
EXPLAIN看 type / key / Extra / rows 四件套:目标是type别到ALL、key别为空、Extra别出现filesort和temporary。
参考
- MySQL 官方文档:InnoDB Index Structures
- MySQL 官方文档:EXPLAIN Output Format
- MySQL 官方文档:Index Condition Pushdown Optimization
- MySQL 官方文档:Descending Indexes / Skip Scan
