MySQL InnoDB 存储引擎详解
InnoDB 是 MySQL 8 默认的存储引擎,也是绝大多数业务系统使用的核心存储引擎。
前面介绍索引、事务、锁和死锁时,很多机制实际上都建立在 InnoDB 的内部结构之上。
例如:
为什么 InnoDB 的索引查询通常很快?
为什么主键设计会影响二级索引大小?
为什么更新数据不一定立即写入磁盘?
Buffer Pool 到底有什么作用?
Redo Log 为什么能够提高写入性能?
InnoDB 为什么能够支持事务和崩溃恢复?
要理解这些问题,需要从 InnoDB 的存储结构开始。
1. InnoDB 是什么
MySQL 本身并不负责所有数据存储工作。
可以简单理解为:
MySQL Server
↓
SQL 解析 / 优化 / 执行
↓
Storage Engine
↓
InnoDB
↓
数据文件 / 日志文件
存储引擎负责真正的数据存储和访问。
早期 MySQL 中存在多种存储引擎,例如:
InnoDB
MyISAM
Memory
Archive
现代 OLTP 系统中,InnoDB 是最重要的选择。
InnoDB 提供了关系型业务系统通常需要的核心能力:
事务
行级锁
MVCC
崩溃恢复
外键
聚簇索引
Redo Log
Undo Log
Buffer Pool
因此,对于订单、支付、账户、库存、用户等需要事务一致性的业务,InnoDB 通常是默认选择。
2. InnoDB 的数据是怎么存储的
理解 InnoDB,首先要理解一个非常重要的概念:
InnoDB 不是直接以“行”为单位操作磁盘,而是以 Page(页)作为基本的 I/O 和存储管理单位。
InnoDB 默认数据页大小通常为:
16 KB
可以粗略理解为:
Table
↓
Index
↓
Page
↓
Row
例如:
users
├── Page 1
│ ├── Row 1
│ ├── Row 2
│ └── Row 3
│
├── Page 2
│ ├── Row 4
│ ├── Row 5
│ └── Row 6
│
└── Page 3
├── Row 7
└── Row 8
数据库读取数据时,通常不是从磁盘读取某一行,而是把包含目标记录的页加载到内存中。
这也是 Buffer Pool 存在的基础。
为什么使用 Page
磁盘和 SSD 的访问成本远高于内存。
如果每读取一条数据都直接访问磁盘:
查询 10000 行
↓
10000 次磁盘访问
性能会非常差。
如果按照 Page 加载:
读取 Page
↓
加载到 Buffer Pool
↓
在内存中访问多条记录
就可以大幅减少磁盘 I/O。
因此可以把 InnoDB 的数据访问简单理解为:
磁盘
↓
Page
↓
Buffer Pool
↓
SQL 执行
3. 聚簇索引:InnoDB 最重要的设计之一
InnoDB 最大的特点之一,就是表数据本身按照聚簇索引组织。
对于普通 InnoDB 表,如果存在主键:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
age INT
);
可以粗略理解为:
Primary Key Index
↓
┌──────────────────┐
│ id=1 + 完整行数据 │
├──────────────────┤
│ id=2 + 完整行数据 │
├──────────────────┤
│ id=3 + 完整行数据 │
└──────────────────┘
也就是说:
InnoDB 的聚簇索引叶子节点保存完整的行数据。
因此:
SELECT *
FROM users
WHERE id = 100;
通过主键找到对应索引记录后,就已经能够得到完整数据。
不需要再通过其他地址寻找数据行。
如果没有主键呢?
InnoDB 会按照一定规则选择聚簇索引:
优先使用主键;
如果没有主键,则选择第一个满足条件的
UNIQUE NOT NULL索引;如果仍然没有合适索引,则生成隐藏的聚簇索引。
因此,在业务表设计中,通常应该明确设计主键,而不是完全依赖 InnoDB 自动处理。
4. 二级索引为什么会回表
假设:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
age INT,
INDEX idx_name(name)
);
这里有两个重要结构:
聚簇索引
PRIMARY KEY
↓
id + 完整行数据
二级索引
idx_name
↓
name + 主键值
例如:
idx_name
"Jack" → 100
"Tom" → 101
"Lucy" → 102
这里的 100、101 就是对应的主键值。
当执行:
SELECT *
FROM users
WHERE name = 'Jack';
执行过程可以粗略理解为:
idx_name
↓
找到 name = Jack
↓
得到主键 id = 100
↓
根据 id=100 查询聚簇索引
↓
得到完整数据
第二次根据主键去聚簇索引获取完整数据,这就是通常所说的:
回表
因此二级索引并不是简单地保存:
name → 数据地址
而是保存:
name → 主键值
这也是为什么 InnoDB 的主键设计会影响二级索引。
例如主键使用:
BIGINT
二级索引只需要保存较小的主键值。
如果主键是非常大的字符串,所有二级索引都会携带更大的主键值,可能导致:
索引占用空间增加;
索引页能容纳的记录减少;
B+Tree 层级可能增加;
I/O 和缓存压力增加。
因此主键设计不仅仅是业务字段设计问题,也是存储结构问题。
5. Buffer Pool:InnoDB 的核心内存区域
如果说 Page 是 InnoDB 的基本存储单位,那么:
Buffer Pool 是 InnoDB 最重要的内存区域之一。
它的主要作用是缓存数据页和索引页。
例如:
磁盘
│
├── Page 1
├── Page 2
├── Page 3
└── Page 4
↓
Buffer Pool
↓
┌────────────────┐
│ Page 1 │
│ Page 2 │
│ Page 4 │
└────────────────┘
查询:
SELECT *
FROM users
WHERE id = 100;
如果相关页已经存在于 Buffer Pool:
SQL
↓
Buffer Pool
↓
找到 Page
↓
返回数据
就可以避免一次磁盘读取。
如果不在:
SQL
↓
Buffer Pool 未命中
↓
从磁盘读取 Page
↓
加载到 Buffer Pool
↓
执行查询
因此:
数据库性能很大程度上取决于数据页是否能够有效地留在内存中。
Buffer Pool 不只是缓存查询
它还保存被修改但尚未立即刷盘的数据页。
这类页通常称为:
Dirty Page
例如:
UPDATE users
SET age = 30
WHERE id = 100;
数据页可能先在内存中被修改:
磁盘 Page
↓
Buffer Pool
↓
修改
↓
Dirty Page
之后由 InnoDB 根据刷盘机制将其写回磁盘。
这就是 InnoDB 能够利用内存和异步 I/O 提高性能的重要原因之一。
6. InnoDB 为什么需要 Redo Log 和 Undo Log
InnoDB 的事务能力离不开日志系统。
其中两个非常重要的日志是:
Redo Log
Undo Log
二者解决的问题完全不同。
Redo Log
Redo Log 主要用于:
保证已经提交的修改能够在崩溃后恢复。
例如:
UPDATE
↓
修改 Buffer Pool
↓
产生 Redo Log
↓
事务提交
如果此时数据库突然宕机:
Buffer Pool 中的数据丢失
重新启动后,可以利用 Redo Log 恢复已经持久化到日志中的修改。
因此可以粗略理解:
Redo Log
=
“这次修改已经发生过,需要在恢复时重新做一遍”
Undo Log
Undo Log 主要用于:
事务回滚;
MVCC 一致性读。
例如:
START TRANSACTION;
UPDATE users
SET age = 30
WHERE id = 100;
ROLLBACK;
数据库需要知道:
原来 age = ?
Undo Log 保存了恢复旧版本所需要的信息。
因此:
Redo Log
→ 崩溃恢复 / 重做
Undo Log
→ 回滚 / MVCC
两者虽然都是日志,但职责完全不同。
7. InnoDB 的事务与数据修改过程
把前面的内容结合起来,可以看看一次简单的更新大概发生了什么。
执行:
START TRANSACTION;
UPDATE users
SET age = 30
WHERE id = 100;
COMMIT;
可以粗略理解为:
① 定位索引记录
↓
② 读取相关 Page
↓
③ Page 加载到 Buffer Pool
↓
④ 获取必要的锁
↓
⑤ 修改 Buffer Pool 中的数据
↓
⑥ 产生 Undo Log
↓
⑦ 产生 Redo Log
↓
⑧ COMMIT
↓
⑨ 按持久化策略处理 Redo Log
↓
⑩ Dirty Page 后续刷盘
这里有一个非常重要的认识:
事务提交并不意味着每一个被修改的数据页都必须在这一瞬间全部写回数据文件。
InnoDB 可以先保证必要的日志持久化,再由后台机制处理 Dirty Page。
这就是:
日志先行
+
异步刷脏页
带来的性能优势。
当然,具体行为还受到:
innodb_flush_log_at_trx_commit
以及操作系统和存储设备等因素影响。
因此,不能简单理解成:
COMMIT= 所有数据立即写入数据库数据文件。
8. InnoDB 如何利用 B+Tree 组织索引
InnoDB 的索引主要使用 B+Tree。
例如:
Root
│
┌───────┴───────┐
↓ ↓
Branch Branch
/ \ / \
↓ ↓ ↓ ↓
Leaf Leaf Leaf Leaf
叶子节点按照索引键组织。
对于聚簇索引:
Leaf
↓
完整行数据
对于二级索引:
Leaf
↓
索引列 + 主键值
B+Tree 的优势在于:
树高度相对较低;
节点可以保存大量索引记录;
适合磁盘和页式存储;
支持等值查询;
支持范围查询;
叶子节点有序。
例如:
SELECT *
FROM users
WHERE id BETWEEN 1000 AND 2000;
数据库可以定位到:
id = 1000
然后沿着叶子节点的顺序继续访问后续记录。
因此 B+Tree 不仅适合:
=
也非常适合:
>
<
>=
<=
BETWEEN
ORDER BY
等操作。
9. InnoDB 性能优化应该关注什么
理解 InnoDB 后,很多 MySQL 优化问题就可以串起来了。
9.1 合理设计主键
主键会直接参与聚簇索引,并被二级索引引用。
一般业务系统中常见:
BIGINT
或者其他适合业务的紧凑型主键。
重点不是盲目选择某一种 ID,而是考虑:
唯一性;
长度;
索引空间;
写入特征;
是否容易产生大量随机页访问;
是否适合业务查询。
9.2 控制二级索引数量
索引并不是越多越好。
每增加一个索引:
查询可能更快
↓
但 INSERT / UPDATE / DELETE
↓
需要维护更多索引
因此应该根据真实 SQL 设计索引。
9.3 尽量提高 Buffer Pool 命中率
如果数据库频繁出现:
Buffer Pool Miss
就可能产生更多磁盘 I/O。
需要结合:
innodb_buffer_pool_size
以及:
数据集大小;
并发量;
查询模式;
内存容量;
热点数据分布;
综合调整。
9.4 减少无意义的数据访问
例如:
SELECT *
FROM orders
WHERE user_id = 100;
如果实际只需要:
id
status
created_at
可以明确查询:
SELECT id, status, created_at
FROM orders
WHERE user_id = 100;
在合适的索引设计下,还有机会形成覆盖索引,从而减少回表。
9.5 控制事务大小
事务过大可能导致:
锁持有时间增加
Undo 增加
Redo 增加
Dirty Page 增加
并发能力下降
因此批量数据处理通常应该合理拆分。
10. 总结
理解 InnoDB,可以建立下面这张整体关系图:
MySQL
│
InnoDB
│
┌───────────┼───────────┐
↓ ↓ ↓
B+Tree Page Transaction
│ │ │
↓ ↓ ↓
Index Buffer Pool Lock/MVCC
│ │ │
┌────┴────┐ ↓ │
↓ ↓ Dirty Page │
主键索引 二级索引 │
│ │ │
↓ ↓ ↓
完整数据 主键值 Undo Log
│
↓
Redo Log
│
↓
Crash Recovery
其中几个概念尤其重要:
第一,InnoDB 以 Page 为基本的数据管理和 I/O 单位。
不是直接按照一行一行访问磁盘。
第二,InnoDB 的聚簇索引保存完整行数据。
主键不仅仅是一个业务字段,它直接影响表的数据组织方式。
第三,二级索引保存索引列和主键值。
通过二级索引找到主键后,再访问聚簇索引,就是常说的回表。
第四,Buffer Pool 是 InnoDB 性能的核心组成部分。
大量数据访问首先希望在内存中完成,减少磁盘 I/O。
第五,Redo Log 和 Undo Log 职责不同。
Redo Log → 崩溃恢复、保证已提交修改能够恢复
Undo Log → 事务回滚、MVCC
第六,InnoDB 的事务、锁、索引、日志和 Buffer Pool 并不是孤立的。
一次简单的 UPDATE,背后实际上涉及:
索引定位
→ Page
→ Buffer Pool
→ Lock
→ Undo
→ Redo
→ Commit
→ Dirty Page
→ 刷盘
理解这条链路之后,再去学习 MySQL 的事务、锁、死锁、性能优化,就不再只是记忆一些零散的知识点。
日志系统文章MySQL Redo Log、Undo Log 与 Binlog,可以进一步理解:
Redo Log
Undo Log
Binlog
三者分别解决什么问题,以及 MySQL 为什么需要它们共同保证事务和数据一致性。