MySQL InnoDB 索引底层:B+树深度解析
索引是数据库性能的核心。本文从磁盘存储原理出发,深入剖析 InnoDB 存储引擎中 B+树索引的数据结构设计、B+树 vs B树、聚簇索引与二级索引的区别、联合索引与最左前缀原则,以及索引优化的实战技巧。
一、磁盘存储原理
1.1 为什么理解磁盘 IO 对索引至关重要
理解索引必须先理解磁盘 IO,因为数据库的数据最终存储在磁盘上:
现代寻址
LBA 逻辑块寻址
32位 LBA: 2TB
48位 LBA: 128PB
寻址方式
CHS 寻址
Cylinder 柱面
Head 磁头
Sector 扇区
磁盘结构
磁盘驱动器
磁盘片 Platter
主轴 Spindle
磁臂 Actuator Arm
磁头 Head
1.2 磁盘 IO 的代价
磁盘IO成本
CPU与磁盘速度对比
0 纳秒
1000倍
4倍
20倍
100倍
1000倍
10倍
寻道时间: 3-15 ms
~0.5 ns
L1 Cache
L2 Cache
L3 Cache
内存访问
SSD 访问
机械磁盘
一次磁盘 IO
旋转延迟: 0-8 ms
传输时间: 微秒级
总计: 3-15 ms
1.3 页面与预读
InnoDB 以页面(Page) 为单位管理数据:
预读机制
线性预读
顺序访问触发
读取多个连续页面
随机预读
同一extent的页面被频繁访问
读取整个extent
页面规格
页面大小: 16KB (可配置)
Page Header: 38 B
Page Trailer: 8 B
用户数据: ~16KB
InnoDB页面结构
Page Header
Page Body (用户数据)
Page Trailer
1.4 局部性原理
对数据库的影响
数据按页存储
读取一页包含多行
索引设计应考虑局部性
空间局部性
访问某个数据,其附近的数据可能也会被访问
按块/页面读取
一次 IO 获取多个数据
时间局部性
最近访问的数据可能再次访问
热点数据应保留在内存
二、B+树数据结构
2.1 B树 vs B+树
磁盘友好性
非叶子节点不存储数据
每个节点可容纳更多索引
树高更小
磁盘 IO 更少
B+树特征
只有叶子节点存储数据
非叶子节点只存储索引
叶子节点链表连接
查找稳定
范围查询高效
B树特征
所有节点都存储数据
非叶子节点也存储数据
每个节点有多个子节点
查找不稳定
范围查询效率低
2.2 B+树结构详解
叶子节点
B+树示例
链表
链表
链表
叶子节点链表
根节点
20, 40, 60
非叶子节点 1
20, 40
非叶子节点 2
40, 60
非叶子节点 3
60
叶子节点 1
叶子节点 2
叶子节点 3
叶子节点 4
1, data
5, data
9, data
15, data
18, data
22, data
35, data
42, data
55, data
65, data
78, data
90, data
2.3 B+树性质
B+树必须满足以下性质(阶数为 m):
1. 每个节点最多有 m 个子节点
2. 每个非叶子节点(除根)至少有 m/2 个子节点
3. 根节点至少有两个子节点(除非是叶子节点)
4. 所有叶子节点在同一层,并且包含所有数据
5. 有 k 个子节点的非叶子节点包含 k-1 个键
2.4 B+树查询流程
否
是
否
是
是
否
查询 key=35
从根节点开始
35 < 20?
35 < 40?
沿左子树查找
沿右子树查找
沿中间子树查找
比较节点 1
定位到节点
找到?
返回数据
继续在链表遍历
链表遍历到目标
2.5 B+树时间复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查找 | O(log n) | 树高决定 |
| 范围查找 | O(log n + m) | log n 定位起始,m 为结果数 |
| 插入 | O(log n) | 可能触发分裂 |
| 删除 | O(log n) | 可能触发合并 |
InnoDB B+树高度计算:
假设:
- 每行数据约 1KB
- 每页 16KB
- 每个索引键 + 指针约 16B
每页可存储:16KB / 16B = 1024 个索引项
三层 B+树容量:
- 第一层:1 个根节点
- 第二层:1024 个节点
- 第三层:1024 * 1024 = 1,048,576 个叶子节点
- 总数据:1,048,576 * 16KB = 16GB
即:三层 B+树可存储约 16GB 数据
三、InnoDB 页面结构
3.1 InnoDB 页面布局
InnoDB Page (16KB)
File Header (38B)
页号、前后指针、校验和
Page Header (56B)
页类型、行数、free空间起点
Infimum + Supremum
虚拟行记录
User Records
用户数据行
Free Space
空闲空间
Page Directory
页目录
File Trailer (8B)
校验和
3.2 行记录格式
c
// InnoDB 行格式 Compact
typedef struct {
// 变长字段长度列表(逆序)
unsigned char *null_bitmap;
// 记录头信息
unsigned char *record_header;
// 列数据
unsigned char *columns[];
} row_record_t;
3.3 记录头信息
记录头 (5 bytes)
1B: 记录状态
1B: 下一条记录偏移
2B: 记录长度
3B: 主键值
2B: 事务ID
6B: 回滚指针
四、聚簇索引
4.1 聚簇索引原理
聚簇索引 (Clustered Index)是一种数据存储方式,而非索引类型:
B+树结构
物理存储
聚簇索引核心特征
表数据按主键顺序存储
B+树的叶子节点存储完整行数据
每张表只能有一个聚簇索引
数据页 1
数据页 2
数据页 3
数据页 N
B+树根节点
叶子节点: 数据页 1-100
叶子节点: 数据页 101-200
叶子节点: 数据页 201-300
4.2 聚簇索引 vs 二级索引
二级索引
非主键列作为索引键
叶子节点存储主键值
需要回表获取完整数据
可能产生回表 IO
聚簇索引
主键作为索引键
叶子节点存储完整行数据
直接获取数据,无需回表
主键查询效率最高
4.3 主键选择建议
折中方案
Snowflake ID
趋势递增
分布式唯一
推荐使用
不推荐方案
UUID 作为主键
无序插入,页分裂频繁
索引大,树高大
性能差 10-50 倍
推荐方案
自增 BIGINT 主键
顺序插入,无页分裂
索引紧凑,树高小
推荐 BIGINT UNSIGNED AUTO_INCREMENT
4.4 主键对性能的影响
UUID主键
数据随机存储
插入: O(log n) + 页分裂
频繁页分裂
索引膨胀
查询: O(log n) + 回表多
自增主键
数据按顺序存储
插入: O(1)
无页分裂
索引紧凑
查询: O(log n) 最优
五、二级索引(辅助索引)
5.1 二级索引原理
性能影响
回表次数 = 查询结果数
结果多时,IO 开销大
解决方案:覆盖索引
回表查询
SELECT * FROM t WHERE name='张三'
命中 name 索引
获取主键 id=5
回表查询聚簇索引
获取完整行数据
二级索引结构
索引列作为键
B+树非叶子节点存储索引值
B+树叶子节点存储主键值
5.2 覆盖索引
覆盖索引是一种优化手段,索引中包含查询需要的所有字段:
覆盖索引
SELECT name, age FROM users WHERE name='张三'
索引 (name, age) 已包含所需字段
无需回表
查询在索引中完成
非覆盖索引
SELECT * FROM users WHERE name='张三'
name 索引找到主键
回表获取其他字段
1 次额外 IO
5.3 联合索引结构
联合索引是多列索引 ,遵循最左前缀原则:
排序规则
先按 a 排序
a 相同时按 b 排序
a、b 都相同时按 c 排序
联合索引 (a, b, c)
a=1, b=1, c=1
a=1, b=1, c=2
a=1, b=2, c=1
a=1, b=2, c=3
a=2, b=1, c=1
a=2, b=2, c=1
5.4 最左前缀原则
索引 (name, age, city)
完全匹配: WHERE name='张三' AND age=25 AND city='北京'
✅ 使用索引
匹配前缀: WHERE name='张三' AND age=25
✅ 使用索引
只匹配 name: WHERE name='张三'
✅ 使用索引
跳过 name: WHERE age=25
❌ 不使用索引
范围在中间: WHERE name='张三' AND age>25
⚠️ 部分使用索引
范围在后面: WHERE name='张三' AND city='北京'
❌ 不使用 city
5.5 最左前缀原理
查询分析
查 a=1: 可以定位起点
查 a=1 AND b=1: 可定位
查 b=1: 无法定位起点
数据组织
索引按 (a,b,c) 排序
a 是主要排序键
a 有序后,b 才有序
a、b 有序后,c 才有序
六、索引失效场景
6.1 导致索引失效的情况
索引失效场景
使用 OR 连接非索引列
例: WHERE name='张三' OR age=25
在索引列上使用函数
例: WHERE YEAR(create_time)=2024
隐式类型转换
例: phone 是 VARCHAR,WHERE phone=13800138
LIKE 开头是通配符
例: WHERE name LIKE '%张%'
NOT NULL / IS NOT NULL
部分情况不使用索引
WHERE 子句中有运算
例: WHERE price * 1.2 > 100
6.2 函数导致索引失效
sql
-- 索引失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
SELECT * FROM users WHERE SUBSTRING(name, 1, 2) = '张';
-- 优化方案:改写为范围查询
SELECT * FROM orders WHERE create_time >= '2024-01-01'
AND create_time < '2025-01-01';
-- 使用函数索引(MySQL 8.0+)
CREATE INDEX idx_create_time ON orders (create_time);
CREATE INDEX idx_year ON orders ((YEAR(create_time)));
6.3 隐式类型转换
sql
-- phone 字段是 VARCHAR(20)
-- 查询时传入整数,MySQL 会将字符串转为整数
-- 导致索引失效
-- 错误写法
SELECT * FROM users WHERE phone = 13800138000;
-- 正确写法
SELECT * FROM users WHERE phone = '13800138000';
七、索引优化实战
7.1 EXPLAIN 深度解析
sql
EXPLAIN SELECT * FROM users WHERE name = '张三';
EXPLAIN 输出
id: 查询序号
select_type: 查询类型
table: 表名
type: 访问类型
possible_keys: 可用索引
key: 实际使用索引
key_len: 索引长度
rows: 扫描行数
Extra: 额外信息
7.2 type 访问类型(从好到差)
说明
system: 表只有一行
const: 主键/唯一索引等值查询
eq_ref: 关联时使用主键或唯一索引
ref: 非唯一索引等值查询
range: 索引范围扫描
index: 全索引扫描
ALL: 全表扫描(最差)
性能从好到差
system
const
eq_ref
ref
range
index
ALL
7.3 索引设计原则
长度
索引长度越小,一个页能存越多索引
减少 IO 次数
前缀索引优化长字段
覆盖度
查询字段尽量在索引中
减少回表次数
覆盖索引优化
选择性
选择性 = 不同值数量 / 总行数
选择性越高,索引效果越好
性别字段选择性低,不适合建索引
7.4 前缀索引
sql
-- 对长字符串字段创建前缀索引
ALTER TABLE orders ADD INDEX idx_order_no (order_no(10));
-- 前缀长度选择
SELECT COUNT(DISTINCT LEFT(order_no, 5)) / COUNT(*) AS sel5,
COUNT(DISTINCT LEFT(order_no, 10)) / COUNT(*) AS sel10,
COUNT(DISTINCT LEFT(order_no, 15)) / COUNT(*) AS sel15
FROM orders;
-- 选择 sel 接近完整列选择性的最小长度
7.5 复合索引设计
sql
-- 查询条件
SELECT * FROM orders
WHERE order_status = 1 AND create_time > '2024-01-01';
-- 最佳索引顺序
-- 如果查询频率相同:
-- 原则:区分度高的列放前面
-- order_status 区分度高 -> 放前面
CREATE INDEX idx_status_time ON orders (order_status, create_time);
-- 如果查询只有 create_time 条件
-- 需要额外创建索引
CREATE INDEX idx_create_time ON orders (create_time);
八、InnoDB Page 底层结构(源码级)
8.1 InnoDB Page 16KB 完整二进制布局
InnoDB 的数据页是 16KB,每个字节都有明确的用途:
File Header (38B) - 页的控制信息
FIL_PAGE_SPACE_OR_CHKSUM (4B)
校验和,Page Hash 用
FIL_PAGE_OFFSET (4B)
页号,表空间中唯一
FIL_PAGE_PREV (4B)
上一页页号
FIL_PAGE_NEXT (4B)
下一页页号
FIL_PAGE_LSN (8B)
页面最新修改的 LSN
FIL_PAGE_TYPE (2B)
页类型:0x45BF=索引页
FIL_PAGE_FILE_FLUSH_LSN (8B)
仅在系统表空间页使用
FIL_PAGE_ARCH_LOG_NO (4B)
归档日志序号
Page Header (56B) - 页面使用信息
PAGE_N_DIR_SLOTS (2B)
Page Directory 中的槽数
PAGE_HEAP_TOP (2B)
堆顶偏移,指向空闲空间起始
PAGE_N_HEAP (2B)
堆中的记录数(含伪记录)
PAGE_FREE (2B)
删除链表的第一个节点的偏移
PAGE_GARBAGE (2B)
删除链表的记录总字节数
PAGE_LAST_INSERT (2B)
最后插入记录偏移
PAGE_DIRECTION (2B)
插入方向:PAGE_LEFT/RIGHT/SAME
PAGE_N_DIRECTION (2B)
同方向连续插入次数
PAGE_N_RECS (2B)
当前页记录数(不含伪记录)
PAGE_MAX_TRX_ID (8B)
修改该页的最大事务ID
PAGE_LEVEL (2B)
B+树层级:0=叶子节点
PAGE_INDEX_ID (8B)
属于哪个索引
8.2 行记录在页内的存储结构
File Trailer (8B)
最后 8B: 前 4B 是页内容的校验和
后 4B 与 File Header 的 LSN 相同
Page Directory - 页目录(二分查找)
Page Directory
逆向存放记录指针
槽1: 指向一组记录中最后一条
槽2: 指向一组记录中最后一条
槽N: 指向一组记录中最后一条
User Record - 用户记录
User Records
从 Free Space 中分配
记录1: next_offset 指向下一条
记录2: next_offset 指向下一条
记录3: next_offset = 0
Infimum + Supremum (26B) - 伪记录
INFIMUM (13B)
比页内任何记录都小的虚拟记录
下一条指向第一条用户记录
SUPREMUM (13B)
比页内任何记录都大的虚拟记录
标记页内记录链表的结束
8.3 记录头(Record Header)5 字节详解
NULL 标志位
NULL_BITMAP
第 1B: 列 1-8 是否为 NULL
第 2B: 列 9-16 是否为 NULL
变长字段长度列表
变长列长度列表(逆序存储)
如 VARCHAR(100), VARCHAR(200), INT
存储: 2B(V200长度), 1B(V100长度)
5 字节记录头结构
Record Header (5B)
1B: 记录状态
heap_no < 256 时用 2B
0x00=普通记录 0x20=伪记录
2B: next_record 偏移
正值,指向下一条记录
所有记录通过它形成链表
2B: 记录长度
包含记录头 + 数据
3B: 主键值
1B: record_type (0=普通,1=B+树节点,2=伪Infimum,3=伪Supremum)
2B: null_bitmap 长度
8.4 Page Directory 的二分查找原理
二分查找过程
- 从 Page Directory 中间槽开始
- 比较该槽指向的记录
- 根据 next_record 移动
- O(log n) 定位记录
Page Directory 结构
Page Directory
槽数: 4 ~ 8 个
(每组 4~8 条记录)
每个槽指向:
该组最后一条记录
记录按主键顺序:
用 next_record 链表连接
为什么需要 Page Directory?
线性遍历太慢!
页内有 100+ 条记录时
O(n) 查找不可接受
8.5 B+树非叶子节点(内部节点)的特殊结构
内部节点记录结构
Index Record
子页指针 (4B 或 6B)
指向下一层 Page
索引列值 (变长)
具体数据或前缀
B+树内部节点 vs 叶子节点
内部节点(非叶子)
记录数 = 子节点指针数 - 1
存储: 索引键值 + 子页指针
不存储行数据,只存储导航信息
叶子节点
记录数 = 子节点指针数
存储: 索引键值 + 行数据(或主键)
叶子节点通过双向链表连接
九、B+树分裂与合并(源码级)
9.1 页面分裂的 6 种场景
场景6: 大数据量更新
UPDATE 导致记录膨胀
页面需要分裂
场景5: 非唯一索引插入
唯一性约束检查
可能导致额外 IO
场景4: 中间位置插入
当前页: [10, 20, 30]
插入: 15
分裂后分布不均
插入页 : 新页 ≈ 1:2
场景3: 插入值大于最大记录
当前页: [10, 20, 30]
插入: 40
追加到页末尾
可能触发分裂
场景2: 插入值小于最小记录
当前页: [10, 20, 30]
插入: 5
插入到页开头
可能触发分裂
场景1: 记录按主键顺序插入
当前页: [1, 3, 5]
插入: 4
定位到 3 和 5 之间
直接插入
9.2 页面分裂详细流程
分裂触发条件
页已满 (PAGE_HEAP_TOP = PAGE_END)
需要为新记录腾出空间
- 创建新页面
- 计算分裂点
通常是页中间记录
3. 将 [分裂点, 结束] 记录移动到新页
4. 在原页保留 [开始, 分裂点) 记录
5. 新插入记录根据值决定放入哪页
6. 更新父节点,添加指向新页的指针
7. 如父节点也满了,递归向上分裂
9.3 分裂后的数据分布
B+树向上生长
分裂递归条件:
父节点无足够空间
创建新的根节点
原根节点分裂
新根节点存储分裂点键值
树高度 +1
3 层 → 4 层 → 5 层...
不规则分裂
原页: [1,2,100,101,102]
新页: [3,4,5,6,7]
插入 50 → 原页(不满)
插入 100 → 新页(已满)
原页利用率低
造成空间浪费
页填充因子 (Page Fill Factor)
通常 15/16 ≈ 93.75%
标准分裂 (50/50)
原页: [1,2,3,4,5,6]
新页: [7,8,9,10,11,12]
插入 5.5 → 原页
插入 7 → 新页
9.4 页面合并(Delete Merge)
不合并的情况
-
相邻页之一已半满
-
相邻页是相邻节点
-
删除后页 >= 2 条记录
合并流程 -
检查相邻页
(上一页或下一页)
2. 计算合并后大小
是否 < 页面大小
3. 如可合并,将记录移动
4. 删除空页面
5. 更新父节点
移除指向空页的指针
6. 可能触发父节点合并
合并触发条件
页填充度 < 50%
且相邻页可合并
FILL_FACTOR 默认 50%
删除后页空间不足一半
9.5 频繁分裂的危害
优化方案
Bulk Insert 批量插入
减少单条插入触发分裂
OPTIMIZE TABLE 重建表
整理碎片
选择合适的主键类型
如 BIGINT 自增
控制页填充因子
innodb_page_size × fill_factor
分裂导致的性能问题
页碎片化
记录分布零散
随机 IO 增加
每次分裂可能触发磁盘 IO
空间利用率下降
页可能只填充 50%
B+树深度增加
更多磁盘 IO
写入放大
大量修改日志
十、Buffer Pool 缓冲池(源码级)
10.1 Buffer Pool 整体架构
子缓冲池 (innodb_buffer_pool_instances)
Instance 1
32-128MB
Instance 2
32-128MB
Instance N
32-128MB
管理结构
Mutli-List 管理
FREE List: 空闲页
FLUSH List: 需刷盘的脏页
LRU List: 最近使用页
Buffer Pool (默认 128MB,可配置更大)
Buffer Pool
数据页缓存
控制块数组
管理每个缓存页的元信息
缓存页数组
实际存储数据页 (16KB)
10.2 LRU 链表深度解析
参数配置
innodb_old_blocks_pct = 37%
OLD 区占整个 LRU 的比例
innodb_old_blocks_time = 1000ms
页进入 OLD 后多久可移到 YOUNG
分页原因
为什么要分区?
全表扫描
一次性读入大量冷页
如果不分区:
热点页被挤出 Buffer Pool
分页后:
全表扫描优先淘汰 OLD 区
LRU 链表结构
LRU (Least Recently Used)
HEAD
热点数据区
(YOUNG area)
OLD (TAIL)
冷数据区
TAIL
最久未使用
优先淘汰
10.3 页面访问的 LRU 流程
是
YOUNG 区 (前 1/3)
OLD 区 (后 2/3)
否
是
否
访问某个页面
该页是否在 Buffer Pool?
命中 Buffer Pool
页在哪个区域?
移动到 LRU HEAD
(但距离 HEAD 太近则不动)
移动到 OLD 区头部
并记录访问时间
返回数据给用户
缓存未命中
FREE List 有空闲页?
从 FREE 申请空闲页
加载数据到缓存页
淘汰 LRU TAIL 页面
插入 LRU OLD 头部
10.4 脏页刷新(Checkpoint)
Checkpoint 类型
Sharp Checkpoint
完全检查点
数据库关闭时
所有脏页刷盘
Fuzzy Checkpoint
模糊检查点
Master Thread
每秒/每 10 秒刷新
FLUSH List 刷新
按 oldest_modification LSN
Page Cleaner Thread
后台异步刷新
为什么需要刷新脏页?
数据在内存修改后未刷盘
系统崩溃会丢失数据
但不能每次修改都刷盘
IO 开销太大
Checkpoint 策略:
平衡性能与数据安全
10.5 Change Buffer(写缓冲)
Merge 过程
- 读取目标页到 Buffer Pool
- 与 Change Buffer 合并
- 应用所有修改
- 标记 Change Buffer 记录为已删除
- 写 Redo Log 记录合并操作
Change Buffer 结构
Change Buffer
最大占 Buffer Pool 的 50%
IBUF_ENTRY_ROOT (root page)
Change Buffer B+树的根页
存储内容:
索引键值 + 操作类型 + 主键
为什么需要 Change Buffer?
二级索引的插入/更新
需要检查唯一性
唯一性检查必须读取磁盘
每次插入都读磁盘 IO 太大
解决: 写入内存缓冲区
批量合并到磁盘
10.6 刷新邻接页(InnoDB Page Cleaner)
参数配置
innodb_flush_neighbors = 1
刷本页时同时刷新相邻脏页
= 0: 仅刷新本页
= 1: 刷相邻(同 extent)
= 2: 刷更多相邻页
刷新邻接页策略
为什么要刷邻接页?
顺序写入 vs 随机写入
物理磁盘顺序写入更快
刷新一个脏页时
同时刷新它相邻的脏页
尽可能将随机 IO 转为顺序 IO
十一、索引与 Buffer Pool 交互
11.1 索引页在 Buffer Pool 中的管理
索引页加载流程
是
否
查询请求: WHERE id = 100
计算 hash = hash(id)
定位到 Buffer Pool Instance
hash 表中查找
命中?
更新 LRU,访问数据
从磁盘加载页
放入 LRU OLD 区
返回数据
11.2 索引页淘汰与内存压力
应对策略
预读 (Read-Ahead)
线性预读:
顺序访问触发
读取多个后续页面
随机预读:
同一 extent 的多个页被访问
读取整个 extent
减少磁盘 IO 次数
提高缓存命中率
内存压力场景
Buffer Pool 内存不足
LRU TAIL 淘汰冷页
如果淘汰的是索引页
下次访问需要重新加载
产生磁盘 IO
11.3 索引统计信息
统计信息更新时机
- ANALYZE TABLE 命令
- 表大小变化 > 1/16 或 2B 行
- InnoDB 重启
- 统计信息采样
(SHOW INDEX FROM t)
索引统计信息内容
Index Statistics
Cardinality (基数)
索引列不同值的数量
Selectivity (选择性)
Cardinality / 总行数
索引深度
B+树层数
叶子页数量
索引总大小
八、面试高频问题
8.1 为什么 MySQL 选择 B+树而不是 B树?
1. 磁盘 IO 友好
- B+树非叶子节点不存储数据,只存索引
- 同样的数据量,B+树高度更低
- 查询时磁盘 IO 更少
2. 范围查询友好
- B+树叶子节点是链表连接
- 范围查询只需定位起点,顺序遍历链表
- B树需要中序遍历,效率低
3. 查询稳定
- B+树所有查询都需要到叶子节点
- B树可能在非叶子节点找到,结果不稳定
8.2 聚簇索引和非聚簇索引的区别?
┌─────────────────────────────────────────────────────────────┐
│ 聚簇索引 │
├─────────────────────────────────────────────────────────────┤
│ - 数据按主键顺序存储在 B+树叶子节点 │
│ - 叶子节点包含完整行数据 │
│ - 每张表只能有一个聚簇索引 │
│ - 主键查询效率最高 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 非聚簇索引(二级索引) │
├─────────────────────────────────────────────────────────────┤
│ - 叶子节点存储索引列 + 主键值 │
│ - 需要回表获取完整数据 │
│ - 可以有多个非聚簇索引 │
│ - 主键查询会先查非聚簇索引,再回表 │
└─────────────────────────────────────────────────────────────┘
8.3 什么是回表?如何减少回表?
回表:使用二级索引查询时,先获取主键值,再去聚簇索引查找完整数据
减少回表的方法:
1. 覆盖索引:SELECT 的字段都在索引中,无需回表
2. 主键查询:直接走聚簇索引,无需回表
3. 减少查询结果:使用 LIMIT 限制返回数量
8.4 最左前缀原则的原理?
联合索引 (a, b, c) 的数据排序逻辑:
1. 先按 a 排序
2. a 相同时按 b 排序
3. a、b 都相同时按 c 排序
因此:
- WHERE a = 1: 可以使用索引(定位起点)
- WHERE a = 1 AND b = 1: 可以使用索引
- WHERE a = 1 AND b = 1 AND c = 1: 完全使用索引
- WHERE b = 1: 无法使用索引(a 无序,无法定位)
- WHERE a > 1: 可以使用索引,但后续列无法利用索引排序
8.5 为什么建议使用自增主键?
1. 顺序插入,数据紧凑
- 新数据追加到页尾
- 无需页分裂
- 索引紧凑,树高小
2. 随机主键的问题
- UUID 无序,插入位置随机
- 可能触发页分裂
- 造成数据页稀疏
- 索引膨胀,性能下降
性能对比:
- 自增主键插入:O(1)
- UUID 插入:O(log n) + 页分裂 + 随机 IO
九、总结
9.1 核心要点
二级索引
叶子节点存主键
需要回表
覆盖索引优化
最左前缀原则
聚簇索引
数据按主键存储
叶子节点是完整数据
每表一个
主键查询最快
B+树特性
非叶子节点只存储索引
叶子节点链表连接
O(log n) 查询
范围查询高效
9.2 索引设计检查清单
✅ 索引设计检查清单:
1. 选择性
- 选择性 > 0.1 的字段适合建索引
- 性别、状态等低选择性字段不适合单独建索引
2. 最左前缀
- 复合索引考虑查询条件的顺序
- 区分度高的列放前面
3. 覆盖索引
- 查询字段尽量在索引中
- 减少回表次数
4. 长度控制
- 长字段使用前缀索引
- 减少索引占用空间
5. 主键选择
- 推荐使用自增 BIGINT 主键
- 避免 UUID 作为主键
9.3 常见错误
❌ 常见索引设计错误:
1. 在低选择性字段上建索引
- 性别字段:只有 2 个值
- 索引效果差
2. 复合索引顺序不当
- 查询条件与索引顺序不匹配
- 无法有效利用索引
3. 忽视回表开销
- SELECT * 导致大量回表
- 应该使用覆盖索引
4. 忽视最左前缀
- 跳过索引的前导列
- 导致索引失效
5. 在索引列上使用函数
- YEAR(create_time) 导致索引失效
- 应该改为范围查询