摘要:从面试官的经典问题出发,深度剖析索引的分类、聚簇索引与非聚簇索引的区别、联合索引的最左前缀原则、B+树的特性、以及索引优化的实战技巧。通过真实案例、图解和性能测试,系统性回答"为什么主键用自增ID"、"什么字段适合做主键"、"B+树的叶子节点是单向还是双向"等高频问题,给出面试标准答案。
💥 面试现场
哈吉米接到了心仪公司的面试通知,二面是技术深度面。
面试官:"聊聊MySQL索引吧,先说说索引是什么?有什么好处?"
哈吉米:"索引是帮助MySQL快速查找数据的数据结构,就像书的目录……"
面试官:"嗯,那索引有哪些分类?"
哈吉米:"呃……主键索引、唯一索引、普通索引……"
面试官:"聚簇索引和非聚簇索引的区别知道吗?"
哈吉米:"……"(冷汗)
面试官:"那B+树的叶子节点是单向链表还是双向链表?"
哈吉米:"……"(崩溃)
晚上,哈吉米找南北绿豆和阿西噶阿西复盘。
哈吉米:"我被问蒙了,索引的问题这么多!"
南北绿豆:"来,我把面试高频问题都给你梳理一遍!"
🎯 问题1:索引的分类是什么?
面试标准答案:
索引可以从多个维度分类:
按数据结构分类
| 类型 | 说明 | 引擎 |
|---|---|---|
| B+树索引 | 最常用,InnoDB默认 | InnoDB、MyISAM |
| 哈希索引 | 等值查询快,不支持范围查询 | Memory |
| 全文索引 | 支持全文搜索 | InnoDB(5.6+)、MyISAM |
| 空间索引 | GIS地理位置 | MyISAM |
按物理存储分类
| 类型 | 说明 |
|---|---|
| 聚簇索引(Clustered Index) | 数据和索引存在一起,叶子节点就是数据 |
| 非聚簇索引(Secondary Index) | 索引和数据分开,叶子节点存主键值 |
重点:InnoDB的主键索引是聚簇索引,其他索引都是非聚簇索引。
按列数分类
| 类型 | 说明 | 示例 |
|---|---|---|
| 单列索引 | 只包含一个列 | INDEX idx_name(name) |
| 联合索引(复合索引) | 包含多个列 | INDEX idx_name_age(name, age) |
按功能分类
| 类型 | 说明 | 特点 |
|---|---|---|
| 主键索引 | PRIMARY KEY | 唯一、非NULL、聚簇索引 |
| 唯一索引 | UNIQUE | 唯一、可NULL |
| 普通索引 | INDEX | 可重复 |
| 全文索引 | FULLTEXT | 支持全文搜索 |
🎯 问题2:聚簇索引和非聚簇索引的区别?
面试标准答案:
聚簇索引(主键索引)
定义:索引和数据存储在一起,叶子节点就是完整的数据行。
结构:
主键索引(聚簇索引):
[50]
/ \
[20,35] [65,80]
/ | \ / | \
↓ ↓ ↓ ↓ ↓ ↓
叶子节点(存储完整数据行):
[10, alice, 25, ...]
[25, bob, 30, ...]
[40, charlie, 28, ...]
...
特点:
- ✅ 查询快(一次查询就能拿到完整数据)
- ✅ 范围查询快(叶子节点是链表,顺序扫描)
- ❌ 一张表只能有一个聚簇索引(主键)
非聚簇索引(二级索引)
定义:索引和数据分开,叶子节点存储的是主键值。
结构:
二级索引 idx_name(非聚簇索引):
[lucy]
/ \
[alice] [tom]
/ | \ / | \
↓ ↓ ↓ ↓ ↓ ↓
叶子节点(只存主键值):
[alice, 10] ← 主键值
[bob, 25]
[charlie, 40]
...
查询流程:
1. 在二级索引找到主键值(如10)
2. 拿着主键值回到主键索引查询(回表)
3. 拿到完整数据
特点:
- ✅ 可以有多个二级索引
- ❌ 需要回表(两次查询)
回表的代价
-- 查询SQL
SELECT * FROM user WHERE name = 'alice';
-- 执行流程
1. 在idx_name索引找到name='alice'的主键值:id=10
2. 拿着id=10回到主键索引查询完整数据
3. 返回结果
总IO次数:2次(索引查询 + 回表)
时序图:
sequenceDiagram
participant Query as 查询
participant SecondaryIndex as 二级索引(idx_name)
participant PrimaryIndex as 主键索引
Query->>SecondaryIndex: 1. 查找name='alice'
SecondaryIndex->>SecondaryIndex: 找到主键值=10
SecondaryIndex->>PrimaryIndex: 2. 回表查询id=10
PrimaryIndex->>PrimaryIndex: 读取完整数据
PrimaryIndex->>Query: 3. 返回结果
Note over Query,PrimaryIndex: 总共2次IO
南北绿豆:"这就是为什么覆盖索引性能更好——不需要回表!"
🎯 问题3:什么字段适合做主键?
面试标准答案:
推荐:自增ID
CREATE TABLE `order` (
id BIGINT PRIMARY KEY AUTO_INCREMENT, ← 推荐
order_no VARCHAR(32),
user_id BIGINT
);
优点:
- ✅ 趋势递增,顺序插入,不会页分裂
- ✅ 占用空间小(8字节)
- ✅ 查询性能好
- ✅ 所有二级索引占用空间小
不推荐:业务字段(如订单号)
-- ❌ 不推荐
CREATE TABLE `order` (
order_no VARCHAR(32) PRIMARY KEY, ← 不推荐
user_id BIGINT
);
缺点:
- ❌ 字符串主键,占用空间大(32字节)
- ❌ 可能无序,导致页分裂
- ❌ 所有二级索引都要存32字节的order_no
不推荐:UUID
-- ❌ 不推荐
CREATE TABLE `order` (
id VARCHAR(36) PRIMARY KEY, ← UUID
order_no VARCHAR(32)
);
缺点(详见上一篇文章):
- ❌ 无序,频繁页分裂
- ❌ 占用空间大(36字节)
- ❌ 索引碎片率高
推荐:自增ID + 唯一索引
-- ✅ 推荐
CREATE TABLE `order` (
id BIGINT PRIMARY KEY AUTO_INCREMENT, ← 自增ID做主键
order_no VARCHAR(32) UNIQUE, ← 业务字段建唯一索引
user_id BIGINT
);
优点:
- ✅ 主键性能好(自增ID)
- ✅ 业务唯一性保证(唯一索引)
- ✅ 两全其美
🎯 问题4:为什么主键推荐用自增ID而不是UUID?
面试标准答案(详见第8篇文章):
UUID的4个问题:
- 无序插入 → 频繁页分裂 → 性能下降90%
- 索引碎片率高 → 范围查询慢10倍
- 占用空间大 → 所有索引体积增加4.5倍
- 回表代价高 → 随机IO,性能差5倍
自增ID的优点:
- 顺序插入 → 无页分裂 → 性能好
- 占用空间小 → 8字节
- 查询性能好 → 物理连续
分布式场景:用雪花算法(趋势递增 + 全局唯一)
🎯 问题5:B+树的叶子节点是单向还是双向?
面试标准答案:
双向链表!
结构:
非叶子节点(索引层):
[50]
/ \
[20,35] [65,80]
叶子节点(数据层,双向链表):
NULL ← [10] ↔ [25] ↔ [40] ↔ [55] ↔ [70] ↔ [90] → NULL
为什么是双向?
阿西噶阿西:"因为要支持ORDER BY DESC(倒序查询)!"
-- 正序查询(从左往右)
SELECT * FROM user WHERE id > 10 ORDER BY id ASC LIMIT 10;
-- 倒序查询(从右往左)
SELECT * FROM user WHERE id > 10 ORDER BY id DESC LIMIT 10;
如果是单向链表:倒序查询需要先找到最右边,再往回找(效率低)。
双向链表:可以双向遍历,支持正序和倒序。
🎯 问题6:联合索引ABC,现在有个查询条件A = XXX and C < XXX,索引怎么走?
面试标准答案:
只能用到A列,C列用不上。
原因:违反最左前缀原则(跳过了B列)。
-- 联合索引
CREATE INDEX idx_abc ON table(a, b, c);
-- 查询
WHERE a = 1 AND c < 100
-- 索引使用情况:
- a = 1:✅ 走索引
- b:❌ 跳过了
- c < 100:❌ 因为跳过了b,c也用不上
EXPLAIN验证:
EXPLAIN SELECT * FROM table WHERE a = 1 AND c < 100\G
key: idx_abc
key_len: 4 ← 只用了a列(假设INT,4字节)
Extra: Using index condition
正确写法:
-- 如果经常查a和c,建新索引
CREATE INDEX idx_a_c ON table(a, c);
-- 或者调整查询,把b也加上
WHERE a = 1 AND b IS NOT NULL AND c < 100;
🎯 问题7:MySQL主键是聚簇索引吗?
面试标准答案:
InnoDB:是,主键就是聚簇索引
MyISAM:不是,主键也是非聚簇索引
InnoDB的存储结构
主键索引(聚簇索引):
叶子节点存储完整数据
二级索引(非聚簇索引):
叶子节点存储主键值
MyISAM的存储结构
主键索引(非聚簇索引):
叶子节点存储数据的物理地址
数据文件(.MYD):
独立存储,按插入顺序
查询流程:
1. 在主键索引找到物理地址
2. 根据物理地址读取数据文件
对比:
| 引擎 | 主键索引类型 | 数据存储位置 |
|---|---|---|
| InnoDB | 聚簇索引 | 和主键索引在一起 |
| MyISAM | 非聚簇索引 | 独立的.MYD文件 |
🎯 问题8:表中1个字段,你主键用自增ID还是UUID,为什么?
面试标准答案:
单机场景:自增ID
原因:
- 顺序插入,无页分裂,性能好
- 占用空间小(8字节 vs 36字节)
- 二级索引占用空间小
- 查询性能好
分布式场景:雪花算法
原因:
- 全局唯一
- 趋势递增(避免页分裂)
- 占用空间小(8字节)
- 本地生成,不依赖数据库
不推荐UUID:
- 无序,频繁页分裂
- 占用空间大
- 性能差(详见第8篇文章)
🎯 问题9:如果聚簇索引的数据更新,它的存储要不要变化?
面试标准答案:
分情况讨论:
情况1:更新非主键列(不变)
-- 更新非主键列
UPDATE user SET name = 'bob' WHERE id = 10;
-- 存储位置不变
-- 只是在原位置修改数据
原因:主键没变,在B+树中的位置没变。
情况2:更新主键列(要变)
-- 更新主键
UPDATE user SET id = 100 WHERE id = 10;
-- 存储位置变化:
1. 删除id=10的数据(从原位置删除)
2. 插入id=100的数据(插入到新位置)
3. 可能触发页分裂/页合并
原因:主键变了,在B+树中的位置必须变。
时序图:
sequenceDiagram
participant Client
participant BPlusTree as B+树
participant OldPos as 原位置(id=10)
participant NewPos as 新位置(id=100)
Client->>BPlusTree: UPDATE user SET id=100 WHERE id=10
BPlusTree->>OldPos: 1. 找到id=10的数据
OldPos->>OldPos: 2. 标记删除
BPlusTree->>NewPos: 3. 在id=100的位置插入数据
Note over NewPos: 可能触发页分裂
BPlusTree->>Client: 4. 返回成功
阿西噶阿西:"所以不要更新主键!性能很差,而且可能触发页分裂。"
🎯 问题10:Mysql中的索引是怎么实现的?
面试标准答案:
InnoDB用B+树实现索引。
B+树的特性:
1. 所有数据都在叶子节点
2. 非叶子节点只存索引键(不存数据)
3. 叶子节点是双向链表(支持范围查询)
4. 树的高度通常是3-4层
一棵3层B+树能存多少数据?
假设:
- 数据页大小:16KB
- 主键BIGINT:8字节
- 指针:6字节
- 每行数据:1KB
非叶子节点:
每页能存:16KB / (8B + 6B) ≈ 1170个索引键
叶子节点:
每页能存:16KB / 1KB = 16行数据
3层B+树:
第1层(根):1页
第2层:1170页
第3层(叶子):1170 * 1170 = 1,368,900页
总数据量:1,368,900 * 16 ≈ 2100万行
结论:3层B+树能存约2100万数据
哈吉米:"所以为什么用B+树而不是哈希、红黑树?"
南北绿豆:"三个原因:"
| 数据结构 | 优点 | 缺点 | 为什么不用 |
|---|---|---|---|
| 哈希 | 等值查询O(1) | 不支持范围查询 | MySQL大量范围查询(>、<、BETWEEN) |
| 红黑树 | 平衡,查询O(log n) | 树太高,IO次数多 | 1000万数据,树高23层(23次IO) |
| B+树 | 树矮胖,IO少,支持范围查询 | 插入可能分裂 | ✅ 最优选择 |
B+树优势:
- ✅ 3-4层就能存储千万级数据
- ✅ 叶子节点链表,范围查询快
- ✅ 非叶子节点不存数据,每页能存更多索引键
🎯 问题11:查询数据时,到了B+树的叶子节点,之后的查找数据是如何做?
面试标准答案:
聚簇索引(主键索引)
1. 根据B+树找到叶子节点
2. 叶子节点就是完整数据,直接返回
示例:
SELECT * FROM user WHERE id = 10;
流程:
根节点 → 中间节点 → 叶子节点[10, alice, 25, ...] → 返回数据
总IO次数:3次(假设3层树)
非聚簇索引(二级索引)
1. 根据B+树找到叶子节点
2. 叶子节点存的是主键值,读取主键值
3. 拿着主键值回到主键索引(回表)
4. 在主键索引的叶子节点找到完整数据
示例:
SELECT * FROM user WHERE name = 'alice';
流程:
二级索引根节点 → 中间节点 → 叶子节点[alice, 10] → 读取主键10
→ 主键索引根节点 → 中间节点 → 叶子节点[10, alice, 25, ...] → 返回数据
总IO次数:6次(两棵树各3层)
时序图:
graph TD
A[SELECT * FROM user WHERE name='alice'] --> B{走哪个索引?}
B -->|主键索引| C[在主键B+树中查找]
C --> D[找到叶子节点]
D --> E[直接返回数据 总IO:3次]
B -->|二级索引| F[在二级索引B+树中查找]
F --> G[找到叶子节点]
G --> H[读取主键值=10]
H --> I[回表:在主键B+树中查找id=10]
I --> J[找到叶子节点]
J --> K[返回数据 总IO:6次]
style E fill:#90EE90
style K fill:#FFE4B5
阿西噶阿西:"这就是为什么覆盖索引重要——避免回表,减少IO!"
🎯 问题12:B+树的好处是什么?
面试标准答案:
3大核心优势:
优势1:树矮胖,IO次数少
对比:
红黑树(1000万数据):
树高:log₂(1000万) ≈ 23层
查询IO次数:23次
B+树(1000万数据):
树高:3-4层
查询IO次数:3-4次
性能差距:6倍IO
优势2:非叶子节点不存数据,扇出大
如果非叶子节点存数据:
每页只能存:16KB / (8B主键 + 1KB数据) ≈ 16个
如果非叶子节点不存数据(B+树):
每页能存:16KB / (8B主键 + 6B指针) ≈ 1170个
扇出:1170 / 16 = 73倍
树高更低,IO更少
优势3:叶子节点是链表,范围查询快
-- 范围查询
SELECT * FROM user WHERE id >= 10 AND id <= 100;
-- B+树执行流程:
1. 定位到id=10的叶子节点
2. 沿着链表顺序扫描到id=100
3. 只需要顺序IO
-- 如果是红黑树:
1. 查找id=10
2. 查找id=11
3. 查找id=12
...
需要91次树遍历,91次随机IO
🎯 问题13:说说B+树和B树的区别
面试标准答案:
| 特性 | B树 | B+树 |
|---|---|---|
| 数据存储 | 所有节点都存数据 | 只有叶子节点存数据 |
| 叶子节点 | 不连接 | 双向链表连接 |
| 非叶子节点 | 存数据 + 索引 | 只存索引 |
| 范围查询 | 需要中序遍历 | 叶子节点顺序扫描 |
| 查询稳定性 | 不稳定(可能在非叶子节点找到) | 稳定(都在叶子节点) |
为什么MySQL用B+树?
- 叶子节点链表 → 范围查询快
- 非叶子节点不存数据 → 扇出大,树更矮
- 所有数据在叶子节点 → 查询性能稳定
🎯 问题14:B+树的叶子节点都可以存哪些东西?
面试标准答案:
聚簇索引(主键索引)
叶子节点存储:完整的数据行
叶子节点内容:
[主键值, 列1, 列2, 列3, ..., 所有列的数据]
示例:
[10, 'alice', 25, '13800138000', '2024-10-07 10:00:00']
非聚簇索引(二级索引)
叶子节点存储:索引列的值 + 主键值
叶子节点内容:
[索引列的值, 主键值]
示例(索引是name):
['alice', 10]
['bob', 25]
['charlie', 40]
对比:
| 索引类型 | 叶子节点内容 | 是否需要回表 |
|---|---|---|
| 聚簇索引 | 完整数据行 | ❌ 不需要 |
| 非聚簇索引 | 索引列值 + 主键值 | ✅ 需要回表 |
🎯 问题15:为什么MySQL不用跳表?
面试标准答案:
跳表的问题:
- 不够稳定:跳表是概率数据结构,查询性能不稳定
- 不适合磁盘存储:跳表适合内存,不适合磁盘IO
- 范围查询不如B+树:B+树的叶子节点是链表,更适合范围扫描
B+树的优势:
- 确定性算法(性能稳定)
- 适合磁盘存储(减少IO次数)
- 范围查询优秀
跳表的应用:Redis的有序集合(ZSet)用的是跳表(因为Redis是内存数据库)。
🎯 问题16:如果聚簇索引的数据更新,它的存储要不要变化?
面试标准答案(详见问题4):
分情况:
- 更新非主键列:存储位置不变
- 更新主键列:存储位置变化(删除+插入)
建议:不要更新主键(性能差,可能页分裂)
🎯 问题17:什么字段适合加索引?为啥?
面试标准答案:
适合加索引的字段:
-
WHERE条件中的列
SELECT * FROM user WHERE status = 1; -- status适合加索引 -
JOIN条件中的列
SELECT * FROM order o JOIN user u ON o.user_id = u.id; -- user_id适合加索引 -
ORDER BY的列
SELECT * FROM order ORDER BY create_time DESC; -- create_time适合加索引 -
选择性高的列(不重复值多)
-- 手机号(选择性99%)✅ 适合 -- 性别(选择性0.02%)❌ 不适合单独建索引 -
频繁查询的列
不适合加索引的字段:
| 场景 | 原因 |
|---|---|
| 更新频繁的列 | 每次更新都要更新索引,开销大 |
| 选择性低的列(性别、状态) | 回表代价大于全表扫描 |
| TEXT/BLOB大字段 | 索引占用空间太大 |
| 查询很少的列 | 索引浪费空间 |
🎉 结束语
晚上10点,哈吉米把面试高频题都复习了一遍。
哈吉米:"原来索引的问题这么多!聚簇索引、非聚簇索引、B+树、驱动表……"
南北绿豆:"对,索引是MySQL的核心,面试必问。"
阿西噶阿西:"记住几个关键点:主键用自增ID、被驱动表必须有索引、B+树叶子节点是双向链表、不要更新主键。"
哈吉米:"明天二面,我有信心了!"
南北绿豆:"加油!把这些原理讲清楚,面试官肯定满意!"
记忆口诀:
聚簇索引数据存,非聚簇引存主键
B+树叶子是双向,范围查询顺序扫
主键推荐自增ID,UUID无序页分裂
小表驱动大表快,被驱动表索引建
子查询改JOIN写,性能提升几十倍
希望这篇文章能帮你搞定索引相关的面试题!把这些原理讲清楚,offer就是你的了!💪