第 23 章 索引原理与设计
索引是数据库里唯一"用空间与写放大换读性能"的结构,本章要能把它从树高算到 key_len。
学习目标
读完本章,你应当能够:
- 推导为什么 InnoDB 选 B+ 树而不是 B 树、哈希、红黑树、跳表、LSM 树,逐项给出判据而不只是背结论;
- 计算给定页大小、键长、行宽下的 B+ 树扇出、树高与可承载行数,并解释"查询稳定"的量化来源;
- 辨析聚簇索引与二级索引在页内布局上的差异,并据此解释回表、覆盖索引、主键长度对全部二级索引的连锁影响;
- 由索引的排序性质推出最左前缀原则、范围查询截断与
ORDER BY可用条件,而不是背结论; - 判断一条
WHERE是否索引失效、失效在哪一层,并给出等价改写; - 设计联合索引的列顺序、前缀索引长度与索引清理方案,并用
sys库与慢查询验证。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
页、区、段、表空间与行格式(COMPACT/DYNAMIC) | 第 20 章 20.2 节 |
| buffer pool 与索引页的载入、淘汰 | 第 20 章 20.3 节 |
EXPLAIN 各字段含义、type 等级排序、key_len 计算 | 第 25 章 25.1 节 |
| 优化器基于代价选执行计划的基本流程 | 第 20 章 20.1 节 |
| 快照读与当前读的区别 | 第 22 章 22.4 节 |
23.1 为什么是 B+ 树:五种候选结构的逐项淘汰
索引被磁盘物理约束钉死:数据以页为单位读写,一次页 I/O 是 SSD 约 100 μs、机械盘约 5~10 ms,而一次内存访问约 100 ns。 因此设计目标不是"比较次数少",而是**“访问页数少且页内顺序”**。目标一变,候选结构的胜负就完全不同。
| 结构 | 点查 | 范围查询 | 排序 | 树高(行数固定) | 被淘汰/胜出的根因 |
|---|---|---|---|---|---|
| B 树 | O(log n) | 中序遍历、页跳跃 | 不可用 | 与 B+ 树同 | 非叶也存数据 ⟹ 扇出小;数据散落各层 ⟹ 范围查询无法顺链走 |
| BST / 红黑树 | O(log n) | 中序遍历 | 可用但乱序 | 约 48 层 | 度只有 2 ⟹ 树高即 I/O 次数,数十次随机页读 |
| 哈希索引 | O(1) | 不支持 | 不支持 | 无树 | 哈希值丢失顺序;冲突退化;扩容需全量 rehash |
| B+ 树 | O(log n) | 叶链顺序扫描 | 叶层天然有序 | 3~4 层 | 非叶只存键+页号 ⟹ 扇出约 1170;数据同层 ⟹ 路径恒定 |
B 树(B-tree)为什么输。 第一,B 树每个节点都存数据,非叶节点在键与指针之外还要塞行或行指针,一次页 I/O 读到的键更少,扇出被数据挤小,树高上升。第二,数据散落各层节点,WHERE id BETWEEN 100 AND 200 无法顺一条有序链走,只能中序遍历:下降、回退、再下降,页访问顺序跳跃,预读(read-ahead)失效。 |
重要
范围查询能力的差别不在"能不能查",而在访问顺序。B+ 树把所有数据行压在叶层,叶页之间用双向链表串起来,范围扫描是一次顺序的链式前进;B 树的中序遍历则是随机的页跳跃。面试官追问"B+ 树比 B 树好在哪",只答"数据都在叶子节点"是不够的,必须接上"叶层有序链表 ⟹ 范围查询与 ORDER BY 变成顺序 I/O"。
哈希索引(hash index)为什么只做补充。 哈希把键映射到桶,等值查找均摊 O(1),但哈希值本身丢失了键的大小顺序:范围查询(> < BETWEEN)、排序(ORDER BY)、最左前缀匹配全部失效。它还有两个工程问题:哈希冲突在数据倾斜时退化成链式扫描;桶数不能动态平滑扩展,扩容要全量重哈希。InnoDB 因此不提供用户可见的哈希索引,只在内部做自适应哈希(见 23.10);Memory 引擎的 USING HASH 是显式哈希索引,代价就是上一条限制。
二叉搜索树(binary search tree, BST)与红黑树(red-black tree)为什么输在树高。 红黑树在内存里是优等生(树高约 2 × log₂N、旋转代价有界),但放到磁盘上树高就是 I/O 次数:N = 2000 万 时 log₂(2×10⁷) ≈ 24,红黑树约 48 层,每层一次页读就是数十次随机页 I/O(SSD 约 5 ms,机械盘约 240 ms)。根子在二叉树的度只有 2,而一页能容纳上千个键 —— 把度提到 1000 以上,树高从 48 掉到 3。
跳表(skip list)为什么用于内存而非磁盘。 跳表用概率性多层链表实现 O(log n) 查找,实现无锁插入很容易,所以 Redis 的 ZSet 用它。但它的节点是动态分配的离散内存块,指针指向的地址在页内/页间毫无连续性,磁盘上每次下降都可能是新的一次随机页读。跳表赢在内存随机访问便宜,输在磁盘局部性。
LSM 树(log-structured merge tree)为什么不是 InnoDB 的选择。 它把随机写变顺序写(MemTable → 不可变 MemTable → SSTable),写入吞吐远超 B+ 树,代价是读放大:点查要自新到旧逐层找,最坏扫多个 SSTable(靠 Bloom filter 兜底);合并(compaction)又反复重写数据。判据很干净:读多写少且要事务与范围查询 ⟹ B+ 树;写多读少、按 key 点查为主 ⟹ LSM。
23.2 B+ 树的布局与树高估算
B+ 树的页(page)分两类角色,两类页的内容构成决定了扇出:
| |
为什么非叶页不存数据就能把扇出做大。 非叶页里全是"键 + 子页号"对:键取子页中最小键的分隔键(separator key),页号按 6 字节计,故 16 KB 页能塞约 1170 个条目。若把整行(1 KB)也塞进非叶页,一页只剩 16 个条目,三层能放的行数从 2.1×10⁷ 跌到 16³ ≈ 4096 —— 这就是 B 树的真正代价。
注意
这是教材式最小口径,只算"键 + 页号"。真实 InnoDB 页还要扣页头页尾约 200 B、PAGE_DIRECTORY 槽位、记录头 5 B,实际扇出低于 1170;键长也不固定(BIGINT 是 8 B,CHAR(36) UUID 是 36 B)。先说教材口径再主动指出真实值更小,是加分项。
查询稳定性来自哪里。 所有数据行都在同一层(叶层),任意一行的定位路径长度都等于树高,因此单行点查的 I/O 次数恒定,不带红黑树那种"有的键在浅处、有的在深处"的抖动。量级对比:3 次页 I/O(命中 buffer pool 时是内存访问,未命中时 SSD 约 300 μs)对全表扫描的约 125 万个页读取,差距约 6 个数量级。
23.3 聚簇索引与二级索引:物理布局差异
InnoDB 的主键索引就是表的数据本身,这一点决定了二级索引的一切行为。
| |
二级索引点查两步走(回表):先在二级索引定位到 ('b', 2)(13 次页 I/O),再拿 id = 2 去聚簇索引重走一遍 3 层树(再 13 次页 I/O);1000 行命中就重复 1000 次,只有主键恰好有序时才退化为顺序扫描。
两棵树的关键差异:聚簇索引的叶页就是数据页,叶页里存的是完整行记录(含所有列,超长变长列走溢出页,见第 20 章 20.2 节);二级索引的叶页只存"索引列 + 主键值",因此命中二级索引后必须再查一次聚簇索引才能拿到其他列,这个动作叫回表。
InnoDB 必须有主键,按"显式主键 → 第一个唯一且非空的索引 → 隐藏列 DB_ROW_ID(6 字节自增)“的顺序确定聚簇索引键。 落到隐藏列最糟:所有二级索引都要拿一个看不见、不可干预的值回表;落到某个唯一索引则意味着那个索引不再是"独立索引”,它本身成了数据。
自增主键的收益是链条式的:插入总是落在 B+ 树最右侧,几乎不触发页分裂与页内数据搬移(顺序写);主键短(BIGINT 8 B)使每个非叶条目更小、扇出更大、树高更低;更关键的是每个二级索引的叶页都要存主键值,主键短 ⟹ 所有二级索引都更小。
小心
常见错误回答:“主键用不用自增无所谓,反正都能查。” 这句话会直接暴露没理解聚簇索引。主键在 InnoDB 里不是"一个约束",它是物理存储顺序,并且被复制进每一个二级索引。用长主键的代价不是"主键索引变大",而是"所有索引一起变大 + 所有写入都可能页分裂"。
23.4 为什么推荐自增主键而不是 UUID
UUID 作为主键有两个独立的缺陷,必须分开讲。
缺陷一:随机插入导致页分裂与空间浪费。 自增主键的插入点永远在最右叶页,页满了就在右侧追加新页,老页不动;UUID 随机,插入点均匀落在整个键空间,目标页大概率已满且满页中间插不进去,只能做页分裂(page split):一页拆两页并移动约一半记录,于是一次写入变成多次页写、父页要更新、页在文件中不再连续、填充率下降、随机 I/O 上升。
缺陷二:索引体积全面膨胀。 BIGINT 主键 8 B;CHAR(36) 形式的 UUID 是 36 B,加上二级索引叶页每个条目都要存主键,二级索引条目从 索引列 + 8 B 变成 索引列 + 36 B。若索引列本身只有 8 B(如 user_id BIGINT),条目从 16 B 涨到 44 B,二级索引体积接近原来的 2.75 倍 —— buffer pool 命中率、页 I/O 次数、备份体积全部受牵连。
| 主键方案 | 键长 | 插入点 | 页分裂 | 二级索引条目 | 分布式友好 |
|---|---|---|---|---|---|
AUTO_INCREMENT BIGINT | 8 B | 最右叶页(顺序) | 极少 | 索引列 + 8 B | 弱(需号段/分片步长) |
CHAR(36) UUID 文本 | 36 B | 全键空间随机 | 频繁 | 索引列 + 36 B | 强 |
BINARY(16) + UUID_TO_BIN(uuid, 1) 或有序 UUID | 16 B | 近似顺序 | 少 | 索引列 + 16 B | 强 |
两条缓解路径:一是 UUID_TO_BIN(uuid, 1) 把 UUID 转 16 字节二进制,同时把时间低位与时间高位交换,使二进制形式按时间单调,从"完全随机"变成"近似顺序";二是改用按时间递增的有序 UUID(UUIDv7 这类时间前缀方案),从根本上让键有序。 |
| |
提示
面试话术:推荐自增主键的三条理由是"顺序写不分裂、键短扇出大、二级索引只存 8 字节",比只说"自增性能好"高一个层次。若面试官追问"分库分表下自增怎么用",答"号段模式或分片步长"并引到第 26 章 26.4 节。
23.5 覆盖索引:把回表省掉
回表是二级索引的固有开销,覆盖索引(covering index) 就是让这次回表不必发生:当查询需要的所有列都能从二级索引的叶页里读到(索引列 + 主键),就无需再查聚簇索引。
触发条件:SELECT 列表与 WHERE/ORDER BY/GROUP BY 用到的列全部落在同一个索引的列集合(含隐式包含的主键)里。“同一个索引"是硬要求 —— 列横跨两个索引时一次扫描凑不齐(除非走索引合并,见第 25 章 25.4 节)。
| |
EXPLAIN 的 Extra = Using index 表示索引覆盖(注意它和 Using index condition、Using where 是三个不同东西,见 23.7)。覆盖索引的收益是实打实的 I/O 数量减半以上:无覆盖时命中 k 行的成本约 3 + k 次页访问(每次回表一次聚簇索引查找),有覆盖时约 3 + k/16 次(叶页顺序前进,每页 16 行)。
警告
陷阱一:以为"索引覆盖"和"用上索引"是一回事 —— 只有 Extra = Using index 才是覆盖,key 有值不等于不回表。陷阱二:SELECT * 把"只需索引列"变成"需要全部列”,一次性废掉所有覆盖机会。
23.6 最左前缀原则的完整推导
结论必须从 B+ 树的排序性质推出来,而不是背。
| |
据此逐条推导常见组合(索引 (a, b, c)):
| 查询条件 | 用于定位的列 | 剩余条件如何生效 | 说明 |
|---|---|---|---|
a = 1 / a = 1 AND b = 2 | a;a, b | 无 | 最左前缀逐列生效 |
a = 1 AND b = 2 AND c = 3 | a, b, c | 无 | 全列定位,key_len 覆盖三列 |
a = 1 AND c = 3 | a | c 在 5.6+ 可下推过滤 | c 不能用于定位,见 23.7 |
a = 1 AND b > 2 AND c = 3 | a | b 用于范围,c 无法定位 | 范围查询截断后续列 |
b = 2;a > 1 AND b = 2 | 无(跳过 a / a 用范围) | 只能全索引扫描或逐段判断 | 最左前缀断裂与范围截断的两种退化形态 |
| 范围查询为什么截断后续列。 下降必须在"每个上层键对应一段连续区间"下进行。a 取范围时命中多个不连续子段(a=2 一段、a=3 一段……),段内 b 有序但跨段全局无序,无法用一个连续 b 区间定位,只能扫 a 的范围再逐行过滤 b、c。 |
排序与分组能不能用索引。 条件:索引列顺序与 ORDER BY 一致或完全相反,且不混用升降序。
| |
重要
三条必背判据:等值列可以任意顺序出现在 WHERE 里(优化器会重排),范围列之后的列不能用于定位;ORDER BY 要么与索引列顺序一致或全反,要么依赖前面的等值条件把范围收窄成单段;GROUP BY 的可用条件与 ORDER BY 完全一致,因为它的实现同样依赖有序输入。EXPLAIN 里看到 Using filesort 或 Using temporary 就说明这条路径没走成。
23.7 索引下推:Using index condition
索引下推(Index Condition Pushdown, ICP)是 5.6 引入的优化,解决的是"范围截断后的列条件由谁过滤"。
没有 ICP 时引擎只按索引前缀定位候选主键,把整行回表交给 Server 层判断 c = 3,不合格即丢弃,但回表 I/O 已付掉;有 ICP 时 c = 3 被下推到引擎,在索引记录上直接判断,不合格的不回表。
| |
四个 Extra 取值必须分清:Using index = 覆盖索引、根本不回表;Using index condition = ICP,索引中的列在引擎层过滤,不合格的不回表;Using where = 引擎已把行返回给 Server 层后在 Server 层过滤;Using index; Using where = 覆盖 + 残留条件仍在 Server 层过滤。
注意
ICP 的收益取决于"被下推条件的过滤率"。若 c = 3 能滤掉 90% 的候选行,回表次数直接降一个量级;若几乎滤不掉任何行,ICP 只是多了一层判断开销。它与覆盖索引的正交关系要记住:覆盖索引省的是"不需要回表",ICP 省的是"不合格的不回表"。
23.8 索引失效情形清单
失效的判定方法统一为:看 EXPLAIN 的 key 是否为 NULL、type 是否退化为 ALL/index、rows 是否接近全表。 下面逐条给出原因与改写。
| 情形 | 典型写法 | 为什么会失效 | 改写方案 |
|---|---|---|---|
| 索引列上做函数/运算 | YEAR(create_time) = 2024、id + 1 = 100 | 索引存的是列原值,函数或运算的结果在索引中不存在,无法定位 | 改范围 create_time >= '2024-01-01' AND create_time < '2025-01-01';或 WHERE id = 99 |
| 隐式类型转换(字符串列对数字) | 列 phone VARCHAR(11),写 WHERE phone = 13800000000 | 数字与字符串比较时 MySQL 把字符串列转成数字再比,等于对列做函数 | 加引号:WHERE phone = '13800000000' |
| 隐式类型转换(数字列对字符串) | 列 id BIGINT,写 WHERE id = '100' | 字符串常量转成数字,函数落在常量上,列仍可用 | 无需改写,方向与上一条相反 |
| 前导模糊匹配 | WHERE name LIKE '%abc' | 索引有序性只对前缀有效,通配符开头无法定位起点 | LIKE 'abc%';必须前后模糊则用全文索引 |
OR 含未建索引列 | WHERE a = 1 OR d = 2(d 无索引) | 结果集必须含 d=2 的全表行 | 给 d 建索引;或改 UNION ALL 各走各的索引 |
!= / NOT IN / IS NOT NULL | WHERE status != 1 | “排除少数值"通常仍要扫大部分页(是选择性判断,不是语法禁用) | 用 IN 枚举少量目标值,或改成正向范围 |
| 联合索引未遵循最左前缀 | 索引 (a,b),写 WHERE b = 2 | b 在 a 不固定时不全局有序 | 补 a 条件,或另建 (b) / (b,a) |
ORDER BY 与索引顺序不一致 | 索引 (a,b),写 ORDER BY b | 同最左前缀 | 调整 ORDER BY 或另建索引 |
| 统计信息过期 / 表太小 / 区分度太低 | 大批量变更后仍选全表;status 只有 3 个值 | 优化器据 rows/filtered 低估收益;回表加随机 I/O 比全表顺序扫描更贵 | ANALYZE TABLE t; 刷新统计信息;不加低区分度索引 |
| |
小心
把 !=、NOT IN、IS NOT NULL 一律答成"索引失效"是常见错误。它们只是优化器可能主动放弃:当被排除的值占绝大多数、且必须回表时才会放弃。WHERE status IS NOT NULL 在 status 只有极少数 NULL 时确实走全表;而 WHERE status != 1 若 1 占全表 99%,剩下的 1% 反而会走索引。区分"语法导致不可用"与"代价导致不选"是面试的关键分水岭。
23.9 联合索引列顺序、前缀索引与索引维护
23.9.1 列顺序的设计方法论
先算选择性(selectivity),再排顺序。区分度的标准口径是 COUNT(DISTINCT col) / COUNT(*),值越接近 1 越"能筛掉人”。
| |
列顺序的判据按三类用法排优先级:
| |
(a, b) 与 (b, a) 的取舍判据:两者都能服务 WHERE a = ? AND b = ?(条件顺序由优化器重排),差别只在单独用 a 的查询多还是单独用 b 的多 —— 前者多就建 (a, b);两方向都高频时建一个复合索引加一个单列索引,而不是两个复合索引(双份写放大)。
找该加与该删的索引:该加的证据来自慢查询日志与 pt-query-digest 的 TOP 慢查询(按 Rows_examined 排序,而不是按耗时);该删的证据来自 sys.schema_unused_indexes(performance_schema 计数为 0)与 sys.schema_redundant_indexes(前缀重复)。pt-index-usage 从慢日志统计每条索引的实际使用次数,适合做双向核对。
23.9.2 前缀索引
对长字符串列(URL、邮箱、token),可以只索引前 N 个字符:KEY idx_email (email(10))。
| 项目 | 全列索引 | 前缀索引 |
|---|---|---|
| 索引体积 | 与列长成正比 | 约 N/列长,长列收益巨大 |
| 区分度 | 100% 精确 | 等于 COUNT(DISTINCT LEFT(col, N))/COUNT(*),可能误命中需回表核对 |
| 覆盖索引 | 可以 | 不能当作覆盖索引:叶页里没有完整值可返回 |
ORDER BY / GROUP BY | 可用 | 不可用:前缀有序 ≠ 全列有序,必然 filesort |
警告
前缀索引的"测量—再选长度"顺序经常被答反。正确做法是先算各长度前缀的选择性,选达到目标(例如全列选择性的 90% 以上)的最短长度,而不是先定 8 个字符再算。另外前缀索引无法覆盖、无法用于排序,在只读密集且依赖覆盖的查询上可能得不偿失。
23.9.3 索引的维护成本
索引不是免费的:每次写入都要维护该表上的每一个索引。一条 INSERT 改 1 棵聚簇索引树 + N 棵二级索引树;UPDATE 改到被索引的列就要删旧条目、插新条目,两个位置可能相距很远 —— 这就是写放大。
change buffer 缓解的正是这个成本:对非唯一二级索引的写,若目标页不在 buffer pool,InnoDB 先把变更记入 change buffer(内存 + 系统表空间持久化),等该页被读入时再合并(merge),把多次随机页读推迟并合并。两个边界:唯一索引不能用(插入前必须读页判重,页已被读入就失去延迟的意义);聚簇索引也不用(数据页就是索引页)。
| 成本项 | 表现 | 观测手段 |
|---|---|---|
| 写放大 | 每条 INSERT 维护所有索引;UPDATE 索引列 = 删 + 插 | Innodb_rows_inserted、写入延迟 |
| buffer pool 挤压 | 索引越多,总页数越多,在固定 innodb_buffer_pool_size 下命中率越低 | 命中率 = 1 − Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests |
| 冗余索引 | (a) 被 (a, b) 完全覆盖;(a, b) 与 (b, a) 各占一份 | sys.schema_redundant_indexes、sys.schema_unused_indexes |
| |
23.10 哈希索引与自适应哈希索引
InnoDB 不提供用户可见的哈希索引,唯一形式是自适应哈希索引(adaptive hash index, AHI):引擎观测到某些索引页被以相同前缀模式反复等值访问时,为这些页在 buffer pool 建哈希表,把"B+ 树下降 2~3 层"压成"一次哈希定位"。它的有效条件很窄:
| 维度 | 有效 | 无效 / 有害 |
|---|---|---|
| 访问模式与并发 | 高频重复等值查询、热点页集中,省下 B+ 树下降与 latch 争用 | 范围查询与排序为主(哈希无顺序)、低并发、访问均匀分散 |
| 失效条件 | — | 大量写入或页分裂致哈希表频繁重建,btr_search_latch 争用 |
因此命中率低或写密集时应当关闭它(innodb_adaptive_hash_index = OFF,或分区化 innodb_adaptive_hash_index_parts)。Memory 引擎的 USING HASH 是显式哈希索引,与 AHI 相反:它自知只能做等值查询,会主动拒绝范围查询的索引使用。
注意
下一层追问:“哈希等值更快,为什么 InnoDB 不做可选显式哈希索引?” 三点骨架:显式哈希无法参与范围查询与排序,会让同一条 SQL 在不同数据分布下出现"用了索引反而更慢"的选择困难;最热的主键等值点查已被聚簇索引覆盖,收益边际小;它需要独立的一致性维护(回滚、崩溃恢复都要改它),而 AHI 是可丢弃、可重建、不参与恢复的缓存层。
23.11 索引设计的完整决策流程
| |
提示
面试收尾话术:“索引设计不是选一列,而是在一组查询上做贪心集合覆盖,同时受写放大预算约束。” 这句话能把话题从"背结论"拉到"工程权衡",通常直接吃到加分。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| B+ 树胜出的根因 | 非叶只存键+页号 ⟹ 扇出约 1170 ⟹ 三层约 2100 万行、点查 3 次页 I/O、路径恒定 | 只答"数据都在叶子节点",接不上范围查询与扇出的因果 |
| 聚簇索引 | 叶页即数据页,主键即物理顺序,且被复制进每个二级索引 | 把主键当作纯逻辑约束 |
| 二级索引与回表 | 叶页只存索引列 + 主键,命中后必须回表 | 认为"用了索引就不用读表" |
| 覆盖索引 | Extra = Using index,所需列全在同一索引内 | 与 Using index condition 混为一谈 |
| 最左前缀与范围截断 | 由 B+ 树排序性质推出;范围列之后的列不能定位,只能用 ICP 过滤 | 只会背"最左",说不出范围截断的原因 |
| 索引失效的两类 | 语法导致不可用 vs 优化器代价判断后不选 | 把 !=/NOT IN 一律答成"索引失效" |
| 自增主键 | 顺序写免页分裂 + 键短扇出大 + 节省所有二级索引体积 | 只答"自增快",说不出二级索引也被拖累 |
| 写放大与 change buffer | 每写一行维护所有索引;change buffer 只对非唯一二级索引生效 | 认为唯一索引也能用 change buffer |
自测题
Q1(计算)页 16 KB,非叶条目 = 键 8 B + 页号 6 B,叶页每行 1 KB。求三层 B+ 树的扇出、可承载行数与点查页 I/O 次数;若主键改为 CHAR(36),扇出变化如何?
答案与解析
扇出 F = 16384 / 14 ≈ 1170;每叶行数 R = 16384 / 1024 ≈ 16;三层可承载 1170 × 1170 × 16 ≈ 2.1×10⁷ 行;点查页 I/O 次数 = 树高 = 3(根常驻 buffer pool 则 2)。
主键改 CHAR(36) 时分两个方向:如果它只是叶层数据列,树高不变,但叶页放不下 16 行(行变宽),叶层能存的行数下降;如果它进入非叶条目的键(例如二级索引 (name) 的条目变成 name + 36 B 主键),非叶条目从 8+6=14 涨到 36+6=42,扇出从 1170 掉到约 390,树高与叶页数量都要往上走。
错在哪:把树高当成"3 层 3 次查找、加上回表 6 次"——回表只发生在二级索引上。面试官追问:“根页为什么能常驻?” 答"根页被反复访问,LRU 长期保留它,且整棵树只有一个根页,内存可忽略"。
Q2(推导)索引 (a, b, c),查询 WHERE a = 1 AND b > 2 AND c = 3。c 能否用于索引定位?Extra 会出现什么?
答案与解析
不能用于定位。a 等值后进入 a=1 的连续段;b 是范围,命中的是 b>2 的连续子段;在这个子段内 c 有序,但优化器无法用一个 c 的连续区间去做下降定位(对 c 的约束只在"b 的每个取值内部"成立,跨 b 值不成立)。因此定位只用 a(key_len 只体现 a + 可能的部分 b),c 的条件在 5.6+ 由 ICP 下推到引擎层过滤,Extra 出现 Using index condition;若同时能覆盖则可能同时出现 Using index。
错在哪:答"能,因为 c 在索引里"——索引里有这一列 ≠ 它能用于定位。面试官追问:“那什么情况下 c 能用于定位?” 答"a = 1 AND b = 2 AND c = 3 全等值,或者 a = 1 ORDER BY b, c 这种 b 不再被范围截断的情形"。
Q3(设计)t_order(user_id BIGINT, status TINYINT, created_at DATETIME, amount DECIMAL),高频查询是 WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT 20,另有一条 WHERE user_id = ? ORDER BY created_at DESC LIMIT 20。给出索引设计并说明理由。
答案与解析
首选 KEY idx_u_s_c (user_id, status, created_at):user_id、status 都是等值列,放最左;created_at 是排序列,且在两个等值列固定后其子段内有序,ORDER BY created_at DESC 可反向扫描该子段,消除 filesort,LIMIT 20 直接截断。
第二条 WHERE user_id = ? ORDER BY created_at DESC 只有 user_id 等值,同一 user 下 status 可取多个值,而在 (user_id, status, created_at) 的序里 created_at 只在每个 status 段内有序、跨 status 无序,所以不能免排序:可另加 KEY idx_u_c (user_id, created_at),或接受它走 idx_u_s_c 定位 + ICP 过滤 + filesort(带 LIMIT 20 且 user 下数据有限,常可接受)。
错在哪:把 status 放最左(因为它选择性低),或把 created_at 放在范围类列之后导致它失去排序能力。面试官追问:“新增 (user_id, created_at) 会不会造成写放大?” 要能答"会,每多一个索引就多一次随机写与一份 buffer pool 占用,要用 sys.schema_unused_indexes 复核后再决定"。
Q4(判断)列 phone VARCHAR(11) 且已建索引。WHERE phone = 13800000000 与 WHERE phone = '13800000000' 哪个走索引?列 uid BIGINT 有索引,WHERE uid = '100' 呢?
答案与解析
phone 是字符串列,与数字比较时 MySQL 按规则把字符串列转换为数字再比较,等价于在列上做函数运算,索引失效(key = NULL)。加引号后常量是字符串,直接按索引键比较,走索引。
uid 是数字列,'100' 是字符串常量,比较时把常量转换为数字,函数作用在常量上,索引正常可用。
错在哪:把两条规则记成一句"隐式转换会导致索引失效"。正确表述是:"转换发生在索引列上才失效,发生在常量上不影响。"面试官追问:“怎么快速验证?” 答"EXPLAIN 看 key,或 FORCE INDEX 后仍回退全表说明是不想用而非不能用"。
Q5(设计)t_user(email VARCHAR(100)) 上要支持 WHERE email = ? 与 ORDER BY email。数据量 500 万,全列选择性为 1.0。给出前缀索引方案,并说明它在哪两个场景下不可用。
答案与解析
先测量:依次算 COUNT(DISTINCT LEFT(email, N))/COUNT(*),取达到目标(如全列选择性的 95% 以上)的最小 N,例如 N=12 时选择性 0.999。然后建 KEY idx_email (email(12)),索引体积约为全列的 12/100。
不可用的两个场景:① 覆盖索引——叶页里只有前缀,无法返回完整 email,SELECT email FROM t_user WHERE email = ? 仍需回表取完整值;② ORDER BY / GROUP BY——前缀有序不等于全列有序,ORDER BY email 无法由该索引提供有序输入,会出现 filesort。
错在哪:只说"前缀索引省空间",说不出它牺牲了覆盖与排序。面试官追问:“前缀索引下 email = 'a@b.com' 精确查会错吗?” 答"不会错,只是命中的候选(前缀相同的)需要回表核对完整值,可能多读几行"。
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 为什么 MySQL 的索引用 B+ 树而不是 B 树? | 是否理解磁盘页约束下的数据结构取舍 | 错答:“因为 B+ 树查询更快。” 说不出 B 树数据散落各层导致范围查询要中序遍历、非叶存数据导致扇出变小 | 23.1、23.2 |
| 三层 B+ 树大概能存多少行?为什么查询是稳定的? | 能否把"稳定性"量化成树高与 I/O 次数 | 错答:“大概几百万。” 给不出 扇出 × 扇出 × 每页行数 的估算口径,也说不清"所有数据同层 ⟹ 路径恒定" | 23.2 |
什么是回表?为什么 SELECT * 会影响性能? | 聚簇索引与二级索引的物理差异 | 错答:“回表就是再查一次。” 说不出二级索引叶页只存索引列 + 主键,也想不到 SELECT * 会破坏覆盖索引 | 23.3、23.5 |
| 为什么推荐自增主键而不是 UUID? | 能否把页分裂与索引体积两条账都算出来 | 错答:“UUID 太长占空间。” 漏掉随机插入导致页分裂与填充率下降,也漏掉二级索引要复制主键 | 23.4 |
| 最左前缀原则是怎么来的? | 是否从排序性质推导而不是背结论 | 错答:“MySQL 规定要按顺序。” 说不出"a 全局有序、b 只在 a 固定时有序",因此接不上范围截断 | 23.6 |
WHERE a = 1 AND b > 2 AND c = 3 用得上 (a,b,c) 索引吗? | 范围查询截断与 ICP | 错答:“三个条件都用上了。” 说不出只有 a 参与定位、c 靠 ICP 过滤 | 23.6、23.7 |
Using index 和 Using index condition 有什么区别? | 覆盖索引与 ICP 的正交性 | 错答:“都是用了索引,差不多。” 说不出前者不回表、后者减少回表 | 23.5、23.7 |
为什么在索引列上用函数或用 LIKE '%x' 会导致索引失效? | 是否理解索引是"原值的有序副本" | 错答:“MySQL 优化器不够智能。” 说不出本质是"函数结果在索引中不存在"、“前导通配符无法定位起点” | 23.8 |
只要出现 != 或 NOT IN 索引就失效吗? | 能否区分"语法不可用"与"代价不选" | 错答:“是,都不走索引。” 说不出这是优化器基于选择性的代价判断,反例是 != 排除少量行时仍会走索引 | 23.8 |
| 联合索引的列顺序怎么定? | 选择性、等值/范围/排序三类用法的权衡 | 错答:“区分度高的放前面。” 漏掉范围列必须靠后、排序列可免排序、低区分度等值列反而应靠后这几个例外 | 23.1、23.9 |
| 既然哈希索引等值查找是 O(1),为什么 InnoDB 不做显式哈希索引? | 索引结构取舍与恢复一致性 | 错答:“哈希索引有冲突所以不好。” 说不出它不支持范围与排序、且需独立持久化与恢复维护,而 AHI 是可丢弃的缓存 | 23.10 |
延伸阅读
- MySQL 8.0 Reference Manual, “Optimization: How MySQL Uses Indexes”、“InnoDB Index Types”、“Index Condition Pushdown Optimization”、“Adaptive Hash Index”
- MySQL 8.0 Reference Manual,
EXPLAINOutput Format 与sys.schema_redundant_indexes/sys.schema_unused_indexes视图说明 - 《高性能 MySQL》(第 4 版)第 6 章「查询性能优化(索引)」、第 8 章「优化服务器设置」
- 《数据库系统概念》第 14 章「索引与散列」,B+ 树与 LSM 树的对比
- 第 20 章 20.2 节(页与行格式)、第 22 章 22.4 节(快照读)、第 24 章 24.3 节(加锁规则)、第 25 章 25.1 节(
EXPLAIN与key_len)、第 26 章 26.4 节(分库分表下的主键与全局 ID)