mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4583 字
13 分钟
MySQL 深入:InnoDB 架构与实现细节
2024-07-03

存储引擎中,理解了 B 树与 LSM 树的设计取舍;在索引原理中,我们剖析了 B+ 树如何加速查询;在事务与并发控制中,我们建立了 MVCC 与锁的理论框架。现在,是时候把这些概念落地到 MySQL InnoDB 的具体实现中了。

InnoDB 是 MySQL 默认的事务型存储引擎,也是生产环境中最广泛使用的引擎。它将 B+ 树索引与数据融为一体(聚簇索引),用 Undo Log 实现多版本并发控制,用 Gap Lock 与 Next-Key Lock 解决幻读问题,用 Change Buffer 优化二级索引的写入性能,用 Doublewrite 防止页撕裂。本章将逐一拆解这些机制,让你不仅知道 InnoDB “做了什么”,更理解”为什么这样做”。

前置知识#

一、InnoDB 架构概述#

1.1 整体架构#

InnoDB 的架构分为内存结构磁盘结构两大部分,中间通过后台线程进行数据搬移与日志刷写。

graph TB subgraph MEMORY["内存结构"] BP["Buffer Pool<br/>数据页缓存/LRU"] CB["Change Buffer<br/>二级索引变更缓冲"] AHI["Adaptive Hash Index<br/>自适应哈希索引"] LB["Log Buffer<br/>Redo Log 缓冲"] end subgraph DISK["磁盘结构"] subgraph TABLESPACE["表空间"] SST["System Tablespace<br/>数据字典/双写/Change Buffer"] IBD["File-Per-Table Tablespace<br/>每表独立 .ibd 文件"] GT["General Tablespace<br/>共享表空间"] UT["Undo Tablespace<br/>Undo Log 存储"] TT["Temporary Tablespace<br/>临时表数据"] end RL["Redo Log<br/>ib_logfile0/1"] UL["Undo Log<br/>回滚段/版本链"] DW["Doublewrite Buffer<br/>页撕裂防护"] end BP <-->|"页读取/刷回"| TABLESPACE CB -->|"异步合并"| IBD LB -->|"刷盘"| RL BP -->|"AHI 加速"| AHI style MEMORY fill:#e3f2fd,stroke:#1565c0 style DISK fill:#fff3e0,stroke:#e65100 style TABLESPACE fill:#e8f5e9,stroke:#2e7d32

1.2 内存结构详解#

Buffer Pool#

Buffer Pool 是 InnoDB 中最大的内存消费者,默认占物理内存的 80%。它以(默认 16KB)为单位缓存磁盘数据,避免每次读写都访问磁盘。

InnoDB 的 LRU 并非朴素的 LRU,而是改进的 LRU:将链表分为 young 区(热数据,约 5/8)和 old 区(冷数据,约 3/8)。新读入的页先放入 old 区头部,只有被再次访问且超过 innodb_old_blocks_time(默认 1 秒)后才移入 young 区。这避免了全表扫描等一次性操作将热数据挤出缓存。

// Buffer Pool 的核心数据结构(简化)
typedef struct buf_pool_t {
lru_list_t lru_list; // 改进 LRU:young 区 + old 区
flush_list_t flush_list; // 脏页链表(已修改但未刷盘)
page_hash_t page_hash; // 页哈希表(快速查找)
size_t old_ratio; // old 区占比,默认 3/8
} buf_pool_t;

Log Buffer#

Log Buffer 是 Redo Log 的内存缓冲区,事务提交时根据 innodb_flush_log_at_trx_commit 参数决定刷盘策略:

参数值行为安全性性能
0每秒刷盘一次崩溃可能丢失 1 秒数据最高
1每次提交都 fsync不丢数据最低
2每次提交写入 OS 缓存,每秒 fsyncOS 崩溃可能丢数据中等
Warning

生产环境中 innodb_flush_log_at_trx_commit 必须设为 1,否则崩溃可能丢失已提交事务的数据。这是 InnoDB 持久性保证的基石。

Change Buffer#

Change Buffer 是二级索引页的变更缓冲区。当二级索引页不在 Buffer Pool 中时,InnoDB 不急于从磁盘读取该页,而是将变更记录在 Change Buffer 中,等后续页面被读取时再合并。这在第六节中详细分析。

Adaptive Hash Index#

AHI 是 InnoDB 的自优化机制:监控到某些 B+ 树页面被频繁访问时,自动建立哈希索引,将 O(log n) 查找降为 O(1)。高并发写入场景下 AHI 的锁竞争可能成为瓶颈,可通过 SET GLOBAL innodb_adaptive_hash_index = OFF 关闭。

1.3 磁盘结构详解#

表空间体系#

InnoDB 通过**表空间(Tablespace)**管理磁盘存储。不同类型的表空间承担不同职责:

表空间类型文件内容说明
System Tablespaceibdata1数据字典、Doublewrite、Change Buffer共享,不建议存用户数据
File-Per-Table*.ibd单表的数据与索引innodb_file_per_table=ON(默认)
General Tablespace自定义多表共享灵活但少用
Undo Tablespaceundo_001/002Undo Log独立于系统表空间
Temporary Tablespaceibtmp1临时表数据重启自动重建

Redo Log#

Redo Log 是 InnoDB 的 WAL 实现,采用固定大小、循环写入的方式。由 ib_logfile0ib_logfile1 两个文件组成,写满后从头覆盖。

Redo Log 的核心是 LSN(Log Sequence Number)——单调递增的序号贯穿整个 InnoDB:每次修改数据页 LSN 递增,每个数据页头部记录 page_lsn,检查点之前的脏页可以安全覆盖 Redo Log。

Undo Log#

Undo Log 记录数据修改前的旧值,用于事务回滚和 MVCC。Undo Log 存储在回滚段(Rollback Segment)中,以版本链的形式组织,这是 InnoDB MVCC 的基础。详细机制在第四节中展开。

二、行格式与页结构#

2.1 行格式#

InnoDB 支持四种行格式,其中 CompactDynamic 是最常用的:

graph LR RF["行格式"] --> R["Redundant<br/>MySQL 5.0 之前"] RF --> C["Compact<br/>MySQL 5.0+"] RF --> D["Dynamic<br/>MySQL 5.7+ 默认"] RF --> DC["Compressed<br/>压缩存储"] style D fill:#4CAF50,color:#fff

Compact 行格式#

Compact 格式将一行数据分为记录头记录体两部分:

// Compact 行格式(简化)
typedef struct compact_record_t {
// ---- 记录头(Record Header)----
uint8_t n_owned; // 页目录槽位数
uint16_t heap_no; // 堆编号(0=infimum, 1=supremum)
uint8_t record_type; // 0=普通, 1=B+树指针, 2=infimum, 3=supremum
uint16_t next_record; // 下一条记录偏移量
uint16_t info_flags; // 标志位(deleted_mask 等)
// ---- 记录体(Record Body)----
uint8_t var_len_list[]; // 变长字段长度列表(逆序,每列1~2字节)
uint8_t null_bitmap; // NULL 标志位(每列1 bit)
trx_id_t trx_id; // 事务 ID(6 字节)
roll_ptr_t roll_ptr; // 回滚指针(7 字节)→ Undo Log
row_id_t row_id; // 行 ID(6 字节,无主键时自动生成)
byte col_data[]; // 实际列数据
} compact_record_t;
Note

每行数据都包含 trx_idroll_ptrrow_id 三个隐藏列。trx_id 记录最后一次修改该行的事务 ID,roll_ptr 指向 Undo Log 中的上一版本——这就是 InnoDB MVCC 版本链的物理基础。详见第四节

Dynamic 行格式#

Dynamic 是 Compact 的改进版,核心区别在于溢出页处理:

特性CompactDynamic
长列存储前 768 字节在页内,其余在溢出页仅存 20 字节指针,全部在溢出页
页内空间利用率较低(长列占大量空间)较高(页内只存指针)
适用场景旧版本兼容MySQL 5.7+ 默认,推荐使用
-- 查看表的行格式
SELECT TABLE_NAME, ROW_FORMAT
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'testdb';
-- 修改行格式
ALTER TABLE users ROW_FORMAT = DYNAMIC;

2.2 页结构#

InnoDB 的页是磁盘 I/O 的最小单位,默认大小 16KB。每个页由以下部分组成:

// InnoDB 16KB 页结构(简化)
typedef struct page_t {
// ---- File Header(38 字节)----
uint32_t space_id; // 表空间 ID
uint32_t page_no; // 页编号
lsn_t lsn; // 最后修改的 LSN
page_type_t type; // 页类型
uint32_t prev_page; // B+ 树前一页
uint32_t next_page; // B+ 树后一页
// ---- Page Header(56 字节)----
uint16_t n_dir_slots; // 页目录槽数
uint16_t heap_top; // 堆顶指针
uint16_t n_heap; // 堆中记录数
uint16_t first_garbage; // 第一条已删除记录
uint16_t garbage_bytes; // 已删除记录总字节数
uint16_t last_insert; // 最后插入位置
// ---- Infimum + Supremum(边界哨兵)----
record_t infimum; // "比任何记录都小"
record_t supremum; // "比任何记录都大"
// ---- User Records(单向链表,按主键排序)----
record_t records[];
// ---- Free Space ----
// ---- Page Directory(稀疏索引)----
uint16_t dir_slots[]; // 二分查找→定位4~8条记录组→线性遍历
// ---- File Trailer(8 字节)----
uint32_t checksum; // 页面校验和
lsn_t lsn; // 与 File Header LSN 对照
} page_t;

页内记录的查找过程:

flowchart LR A["通过 B+ 树<br/>定位到页"] --> B["二分查找<br/>Page Directory"] B --> C["定位到<br/>4~8 条记录的组"] C --> D["线性遍历<br/>组内记录"] D --> E["找到目标记录"] style A fill:#e3f2fd,stroke:#1565c0 style B fill:#e8f5e9,stroke:#2e7d32 style C fill:#fff3e0,stroke:#e65100 style D fill:#fce4ec,stroke:#c62828 style E fill:#e1bee7,stroke:#6a1b9a

三、聚簇索引与二级索引#

3.1 聚簇索引#

InnoDB 是索引组织表(Index-Organized Table, IOT)——数据按主键的 B+ 树物理存储。聚簇索引的叶子节点直接包含完整的行数据,而非指向数据的指针。

graph TB subgraph CLUSTERED["聚簇索引(主键索引)"] R1["根节点<br/>key: 50"] R1 --> I1["内部节点<br/>key: 25"] R1 --> I2["内部节点<br/>key: 75"] I1 --> L1["叶节点<br/>[1,10,20]<br/>完整行数据"] I1 --> L2["叶节点<br/>[25,30,40]<br/>完整行数据"] I2 --> L3["叶节点<br/>[50,60,70]<br/>完整行数据"] I2 --> L4["叶节点<br/>[75,80,90]<br/>完整行数据"] L1 <-->|"双向链表"| L2 L2 <-->|"双向链表"| L3 L3 <-->|"双向链表"| L4 end style CLUSTERED fill:#e8f5e9,stroke:#2e7d32

聚簇索引的关键特性:

  • 一张表只有一个聚簇索引——数据只能按一种顺序物理存储
  • 主键即聚簇索引——如果没有定义主键,InnoDB 选择第一个唯一非空索引;如果也没有,InnoDB 自动生成 6 字节的隐藏 row_id
  • 范围查询高效——叶子节点通过双向链表连接,范围扫描只需顺序遍历

3.2 二级索引#

二级索引的叶子节点不存储完整行数据,而是存储主键值。通过二级索引查找数据需要回表——先在二级索引中找到主键值,再到聚簇索引中查找完整行。

graph TB subgraph SECONDARY["二级索引(idx_name)"] SR["根节点<br/>name: M"] SR --> SL1["叶节点<br/>[Alice→1, Bob→5]<br/>name → 主键值"] SR --> SL2["叶节点<br/>[Mike→3, Zoe→8]<br/>name → 主键值"] end subgraph CLUSTERED2["聚簇索引(回表)"] CR["主键 B+ 树"] CR --> CL1["叶节点<br/>id=1: Alice, ...<br/>id=5: Bob, ..."] CR --> CL2["叶节点<br/>id=3: Mike, ...<br/>id=8: Zoe, ..."] end SL1 -->|"回表查询"| CR SL2 -->|"回表查询"| CR style SECONDARY fill:#e3f2fd,stroke:#1565c0 style CLUSTERED2 fill:#e8f5e9,stroke:#2e7d32
-- 回表过程演示
SELECT * FROM users WHERE name = 'Mike';
-- 执行步骤:
-- 1. 在二级索引 idx_name 中查找 'Mike' → 得到主键 id=3
-- 2. 在聚簇索引中查找 id=3 → 得到完整行数据
-- 3. 返回结果
-- 覆盖索引:避免回表
SELECT id, name FROM users WHERE name = 'Mike';
-- 二级索引已包含 id 和 name,无需回表!
Tip

覆盖索引是避免回表的关键优化。如果查询所需的列全部包含在二级索引中,InnoDB 直接从索引返回数据,无需访问聚簇索引。关于覆盖索引的更多细节,参见索引原理中的覆盖索引章节。

3.3 联合索引#

联合索引是对多个列建立的 B+ 树索引,遵循最左前缀原则——索引项按定义顺序排列,先按第一列排序,第一列相同再按第二列排序,以此类推。

-- 创建联合索引
CREATE INDEX idx_city_age_name ON users(city, age, name);
-- 能使用索引的查询
SELECT * FROM users WHERE city = 'Beijing'; -- 最左前缀
SELECT * FROM users WHERE city = 'Beijing' AND age = 25; -- 最左两列
SELECT * FROM users WHERE city = 'Beijing' AND age = 25 AND name = 'Alice'; -- 全部列
-- 不能使用索引的查询(缺少最左列)
SELECT * FROM users WHERE age = 25; -- 跳过了 city
SELECT * FROM users WHERE name = 'Alice'; -- 跳过了 city 和 age
-- 部分使用索引(最左前缀匹配到 city)
SELECT * FROM users WHERE city = 'Beijing' AND name = 'Alice'; -- 只用到 city 列

3.4 索引组织表 vs 堆表#

InnoDB 的索引组织表与 PostgreSQL 的堆表是两种根本不同的存储模型:

维度索引组织表(InnoDB)堆表(PostgreSQL)
数据存储位置聚簇索引的叶子节点独立的堆文件
二级索引存储主键值行标识符(CTID)
二级索引更新需更新主键值引用只需更新 CTID 指针
主键更新代价极高(所有二级索引需更新)较低(CTID 不变)
范围查询高效(物理有序)需额外排序
空间占用二级索引较大(存主键值)二级索引较小(存 CTID)
典型代表InnoDB、OraclePostgreSQL、SQL Server
Note

在索引组织表中,主键的选择至关重要——短且不变的主键(如自增整数)能最小化二级索引的空间开销并避免主键更新级联。关于两种模型的深入对比,参见第七节PostgreSQL深入

四、InnoDB MVCC 实现#

事务与并发控制中,我们建立了 MVCC 的通用理论框架。现在来看 InnoDB 是如何具体实现的。

4.1 Undo Log 版本链#

每行数据的 roll_ptr 指向 Undo Log 中的旧版本,形成一条版本链:

flowchart LR subgraph CURRENT["当前版本"] V3["id=1, name='Charlie'<br/>trx_id=103, roll_ptr→"] end subgraph UNDO1["Undo Log 版本1"] V2["id=1, name='Bob'<br/>trx_id=102, roll_ptr→"] end subgraph UNDO2["Undo Log 版本2"] V1["id=1, name='Alice'<br/>trx_id=101, roll_ptr=NULL"] end V3 --> V2 --> V1 style CURRENT fill:#e8f5e9,stroke:#2e7d32 style UNDO1 fill:#fff3e0,stroke:#e65100 style UNDO2 fill:#fce4ec,stroke:#c62828
-- 版本链的形成过程
-- 事务 101:INSERT
INSERT INTO users (id, name) VALUES (1, 'Alice');
-- 当前版本:name='Alice', trx_id=101
-- 事务 102:UPDATE
UPDATE users SET name = 'Bob' WHERE id = 1;
-- 当前版本:name='Bob', trx_id=102, roll_ptr → Undo(name='Alice', trx_id=101)
-- 事务 103:UPDATE
UPDATE users SET name = 'Charlie' WHERE id = 1;
-- 当前版本:name='Charlie', trx_id=103, roll_ptr → Undo(name='Bob', trx_id=102)
-- → Undo(name='Alice', trx_id=101)

4.2 ReadView#

ReadView 是事务在进行快照读时创建的”可见性快照”,包含四个关键字段:

// ReadView 结构(简化)
typedef struct read_view_t {
trx_id_t creator_trx_id; // 创建该 ReadView 的事务 ID
trx_id_t up_limit_id; // 最小活跃事务 ID(m_ids 最小值)
trx_id_t low_limit_id; // 下一个待分配的事务 ID(max(m_ids)+1)
trx_id_t m_ids[]; // 创建时刻所有活跃事务 ID 列表
} read_view_t;

4.3 可见性判断规则#

给定一个版本的 trx_id,判断它对当前 ReadView 是否可见:

def is_visible(trx_id, read_view):
# 1. trx_id < up_limit_id:事务已提交,可见
if trx_id < read_view.up_limit_id:
return True
# 2. trx_id >= low_limit_id:事务在 ReadView 创建后才开始,不可见
if trx_id >= read_view.low_limit_id:
return False
# 3. up_limit_id <= trx_id < low_limit_id:检查是否在活跃列表中
if trx_id in read_view.m_ids:
return False # 事务尚未提交,不可见
else:
return True # 事务已提交,可见

如果当前版本不可见,则沿 roll_ptr 访问上一个版本,重复判断,直到找到可见版本或到达版本链末尾。

4.4 RR vs RC 的 ReadView 差异#

这是 InnoDB 中 REPEATABLE READ 和 READ COMMITTED 的本质区别——不在于锁,而在于 ReadView 的创建时机:

隔离级别ReadView 创建时机效果
READ COMMITTED每次执行 SELECT 都创建新的 ReadView每次查询都能看到最新已提交数据
REPEATABLE READ事务中第一次 SELECT 时创建 ReadView,后续复用事务内多次读取结果一致
-- RC vs RR 的行为差异
-- 事务 A -- 事务 B
BEGIN; BEGIN;
SELECT name FROM users WHERE id = 1;
-- 结果:'Alice'(ReadView-1)
UPDATE users SET name = 'Bob'
WHERE id = 1;
COMMIT;
SELECT name FROM users WHERE id = 1;
-- RC:'Bob'(新 ReadView,看到已提交的修改)
-- RR:'Alice'(复用 ReadView-1,看不到事务 B 的修改)
Important

InnoDB 的 REPEATABLE READ 不仅防止不可重复读,还通过 Gap Lock 和 Next-Key Lock 防止幻读——这超出了 SQL 标准对 RR 隔离级别的要求。而 READ COMMITTED 下 Gap Lock 不生效,这也是为什么 RC 模式下并发度更高但隔离性更弱的原因。锁机制的细节在下一节展开。

五、锁机制深入#

5.1 InnoDB 锁类型#

InnoDB 的锁体系从粒度到类型层层递进:

graph TB LOCK["InnoDB 锁"] LOCK --> GLOBAL["全局锁<br/>FTWRL<br/>备份用"] LOCK --> TABLE["表级锁"] LOCK --> ROW["行级锁"] TABLE --> TABLE_META["元数据锁 MDL"] TABLE --> TABLE_INTENTION["意向锁 IS/IX"] TABLE --> TABLE_AUTO["AUTO_INC 锁"] ROW --> RECORD["Record Lock<br/>锁定索引记录"] ROW --> GAP["Gap Lock<br/>锁定记录前的间隙"] ROW --> NEXTKEY["Next-Key Lock<br/>Record + Gap"] ROW --> INSERT_INT["Insert Intention Lock<br/>插入意向锁"] style LOCK fill:#e3f2fd,stroke:#1565c0 style ROW fill:#fff3e0,stroke:#e65100 style RECORD fill:#e8f5e9,stroke:#2e7d32 style GAP fill:#fce4ec,stroke:#c62828 style NEXTKEY fill:#f3e5f5,stroke:#6a1b9a

Record Lock#

Record Lock 锁定索引记录本身,而非数据行。如果表没有索引,InnoDB 会使用隐藏的聚簇索引进行锁定。

-- Record Lock 示例
-- 表:users(id INT PRIMARY KEY, name VARCHAR(50), age INT, INDEX idx_age(age))
BEGIN;
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 锁定:id=5 的 Record Lock(在聚簇索引上)
SELECT * FROM users WHERE age = 25 FOR UPDATE;
-- 锁定:age=25 的 Record Lock(在 idx_age 索引上)
-- + 对应主键的 Record Lock(回表后在聚簇索引上)

Gap Lock#

Gap Lock 锁定索引记录之间的间隙,防止其他事务在间隙中插入新记录。Gap Lock 是 InnoDB 在 REPEATABLE READ 下防止幻读的关键机制。

-- Gap Lock 示例
-- 假设 users 表中 age 列有值:10, 20, 30, 40
BEGIN;
SELECT * FROM users WHERE age = 20 FOR UPDATE;
-- 在 RR 隔离级别下,不仅锁定 age=20 的记录
-- 还锁定 (10, 20) 和 (20, 30) 两个间隙
-- 即 Next-Key Lock:(-∞, 10], (10, 20], (20, 30]
-- 另一个事务尝试插入
INSERT INTO users (name, age) VALUES ('New', 25);
-- 阻塞!因为 25 落在 (20, 30) 间隙内

Next-Key Lock#

Next-Key Lock = Record Lock + Gap Lock,锁定一条记录及其前面的间隙。这是 InnoDB 在 RR 隔离级别下的默认加锁方式。

-- Next-Key Lock 的范围表示
-- 假设索引值为:5, 10, 15, 20
-- Next-Key Lock 的可能范围:
-- (-∞, 5] → 间隙(-∞,5) + 记录5
-- (5, 10] → 间隙(5,10) + 记录10
-- (10, 15] → 间隙(10,15) + 记录15
-- (15, 20] → 间隙(15,20) + 记录20
-- (20, +∞) → 间隙(20,+∞)

Insert Intention Lock#

Insert Intention Lock 是 Gap Lock 的特殊形式,表示意图在间隙中插入。多个事务在同一间隙中插入不同位置时不会互相阻塞——事务 A 持有 (10, 20) 的 Gap Lock 时,事务 B(插入 age=15)和事务 C(插入 age=12)都阻塞;A 释放后,B 和 C 并行插入。

5.2 加锁规则#

InnoDB 的加锁遵循以下规则(适用于 RR 隔离级别):

  1. 加锁的基本单位是 Next-Key Lock
  2. 查找过程中访问到的对象才会加锁
  3. 等值查询:唯一索引命中记录 → 退化为 Record Lock;未命中 → 退化为 Gap Lock
  4. 等值查询:普通索引命中记录 → 向右遍历到不满足条件的第一条记录,该记录的 Gap Lock 不包含
  5. 范围查询:对扫描到的每个索引记录加 Next-Key Lock
-- 加锁规则实战分析
-- 表:t(id INT PRIMARY KEY, c INT, INDEX idx_c(c))
-- 数据:id=5,c=5 | id=10,c=10 | id=15,c=15 | id=20,c=20
-- 场景1:等值查询,唯一索引命中
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 加锁:id=10 的 Record Lock(退化)
-- 场景2:等值查询,唯一索引未命中
SELECT * FROM t WHERE id = 12 FOR UPDATE;
-- 加锁:(10, 15) 的 Gap Lock(退化)
-- 场景3:等值查询,普通索引命中
SELECT * FROM t WHERE c = 10 FOR UPDATE;
-- 加锁:idx_c 上 (5, 10] 的 Next-Key Lock
-- idx_c 上 (10, 15) 的 Gap Lock(向右到不满足条件的第一条)
-- 聚簇索引上 id=10 的 Record Lock(回表)
-- 场景4:范围查询
SELECT * FROM t WHERE c >= 10 AND c < 15 FOR UPDATE;
-- 加锁:idx_c 上 (5, 10] 的 Next-Key Lock
-- idx_c 上 (10, 15] 的 Next-Key Lock
-- 聚簇索引上 id=10, id=15 的 Record Lock

5.3 死锁案例#

死锁是并发事务循环等待对方持有的锁导致的僵局。以下是一个经典的死锁场景:

-- 死锁案例:交叉更新
-- 事务 A -- 事务 B
BEGIN; BEGIN;
UPDATE t SET c=100
WHERE id=5;
-- 持有:id=5 的 Record Lock
UPDATE t SET c=200
WHERE id=10;
-- 持有:id=10 的 Record Lock
UPDATE t SET c=100
WHERE id=10;
-- 等待:id=10 的 Record Lock(被 B 持有)
UPDATE t SET c=200
WHERE id=5;
-- 等待:id=5 的 Record Lock(被 A 持有)
-- 死锁!
-- 查看最近的死锁信息
SHOW ENGINE INNODB STATUS\G -- 关注 "LATEST DETECTED DEADLOCK" 段
-- innodb_deadlock_detect = ON(默认开启) innodb_lock_wait_timeout = 50(默认50秒)
Warning

死锁并非总是坏事——InnoDB 会自动检测死锁并回滚其中一个事务。但频繁死锁意味着业务逻辑存在锁竞争热点,应通过以下方式优化:

  • 按固定顺序访问表和行

  • 保持事务短小

  • 使用低隔离级别(如 RC)减少 Gap Lock

  • 添加合适的索引避免锁升级

六、Change Buffer 与 Doublewrite#

6.1 Change Buffer#

Change Buffer 是 InnoDB 对二级索引 DML 操作的优化。核心思想:二级索引页不在 Buffer Pool 中时,不立即读入,而是将变更缓存在 Change Buffer 中

sequenceDiagram participant T as 事务 participant CB as Change Buffer participant BP as Buffer Pool participant D as 磁盘 T->>BP: INSERT/UPDATE 二级索引 alt 索引页在 Buffer Pool 中 BP->>BP: 直接修改索引页 else 索引页不在 Buffer Pool 中 T->>CB: 将变更记录到 Change Buffer Note over CB: 避免一次随机读 I/O end Note over CB,D: 后台合并(merge) CB->>BP: 索引页被读入 Buffer Pool BP->>BP: 合并 Change Buffer 中的变更 BP->>D: 刷回磁盘
-- Change Buffer 相关参数
SET GLOBAL innodb_change_buffer_max_size = 25; -- 占 Buffer Pool 比例,默认 25%
SET GLOBAL innodb_change_buffering = all; -- all/inserts/deletes/changes/none

Change Buffer 的适用场景与限制:

场景Change Buffer 效果原因
大量写入、少量读取显著提升减少随机读 I/O
写入后立即读取反而降低读取触发 merge,增加开销
唯一索引不适用插入时必须检查唯一性,必须读入索引页
SSD 存储效果有限随机读 I/O 本身很快

6.2 Doublewrite#

Doublewrite 是 InnoDB 防止**页撕裂(Partial Page Write)**的机制。页撕裂发生在写入 16KB 数据页时系统崩溃——可能只有部分页面被写入磁盘,导致数据页损坏。

flowchart LR subgraph WRITE["写入流程"] A["修改数据页"] --> B["写入 Doublewrite Buffer<br/>(2MB 连续空间)"] B --> C["fsync 确保 Doublewrite 落盘"] C --> D["写入实际表空间文件"] D --> E["fsync 确保数据页落盘"] end subgraph RECOVER["崩溃恢复"] F["检查数据页校验和"] --> G{"校验和是否一致?"} G -->|"一致"| H["页面完好,无需修复"] G -->|"不一致"| I["从 Doublewrite Buffer<br/>复制完整页面"] I --> J["用 Redo Log 重做<br/>该页面的修改"] end style WRITE fill:#e3f2fd,stroke:#1565c0 style RECOVER fill:#fff3e0,stroke:#e65100

Doublewrite Buffer 位于 System Tablespace 中的 2MB 连续空间,分为 2 个段,每个段 1MB = 64 个 16KB 页。写入数据页前先将完整页面写入 Doublewrite Buffer 并 fsync,确保即使写入过程中崩溃也能从 Doublewrite 恢复完整页面。

Note

有人可能会问:Redo Log 不是能恢复数据吗?为什么还需要 Doublewrite?答案是:Redo Log 记录的是”对页面的修改操作”(物理逻辑日志),它假设页面本身是完整的。如果页面已经撕裂,Redo Log 无法正确重放。Doublewrite 保证了页面的物理完整性,Redo Log 保证了页面的逻辑正确性——两者。

6.3 Redo Log 与 LSN#

Redo Log 是 InnoDB 崩溃恢复的核心。LSN(Log Sequence Number)是贯穿整个 InnoDB 的递增序号:

LSN 在 InnoDB 中的流转:修改数据页时 ;写入 Redo Log 时 ;刷盘时 更新;检查点时 更新。

崩溃恢复流程:从 开始扫描 Redo Log,对每个 Redo 记录,若 则跳过(页面已是最新),否则重做(应用修改)。

-- 查看 LSN 和检查点信息
SHOW ENGINE INNODB STATUS\G
-- 关注 "LOG" 段:Log sequence number / Log flushed up to / Last checkpoint at
-- Redo Log 相关参数
SHOW VARIABLES LIKE 'innodb_log%';

七、InnoDB vs PostgreSQL 对比#

InnoDB 和 PostgreSQL 代表了关系型数据库两种截然不同的设计哲学。理解它们的差异,是做出正确技术选型的基础。

7.1 索引组织表 vs 堆表#

graph TB subgraph IOT["InnoDB:索引组织表"] direction TB IP["聚簇索引 B+ 树"] --> IL["叶子节点 = 完整行数据"] IS["二级索引"] -->|"存主键值"| IP end subgraph HEAP["PostgreSQL:堆表"] direction TB HP["堆文件<br/>无序存储行数据"] PI["主键索引 B+ 树"] -->|"存 CTID"| HP SI["二级索引 B+ 树"] -->|"存 CTID"| HP end style IOT fill:#e8f5e9,stroke:#2e7d32 style HEAP fill:#e3f2fd,stroke:#1565c0
对比维度InnoDB(索引组织表)PostgreSQL(堆表)
数据物理顺序按主键有序无序(堆文件)
二级索引引用主键值(可能很大)CTID(6 字节)
主键更新级联更新所有二级索引不影响二级索引
范围查询天然有序,高效需排序或索引扫描
二次索引查找需回表(B+ 树再查一次)直接通过 CTID 访问堆
空间效率二级索引较大二级索引紧凑

7.2 Undo Log vs Append-Only MVCC#

这是两种数据库最根本的架构差异——如何实现多版本并发控制:

对比维度InnoDB(Undo Log)PostgreSQL(Append-Only)
旧版本存储Undo Log(独立区域)堆表中(新版本插入新行)
版本链方向当前版本 → 旧版本旧版本 → 当前版本
读旧版本沿 roll_ptr 访问 Undo Log通过 xmin/xmax 判断可见性
清理机制Undo Log PurgeVACUUM
更新操作原地更新 + 写 Undo Log插入新行 + 标记旧行
表膨胀无(旧版本在 Undo Log 中)严重(需 VACUUM 回收)
索引更新二级索引可能需回表判断可见性HOT 更新避免索引变更
-- InnoDB:原地更新 + Undo Log
UPDATE users SET name = 'Bob' WHERE id = 1;
-- 1. 旧值写入 Undo Log 2. 原地修改数据页 3. roll_ptr 指向旧版本
-- PostgreSQL:Append-Only
UPDATE users SET name = 'Bob' WHERE id = 1;
-- 1. 旧行标记 xmax 2. 插入新行设 xmin 3. VACUUM 回收旧行
Note

两种 MVCC 实现各有优劣。InnoDB 的 Undo Log 方式不会导致表膨胀,但 Undo Log 空间有限,长事务可能导致 Undo Log 暴涨。PostgreSQL 的 Append-Only 方式实现简洁,但表膨胀是运维的核心痛点——VACUUM 的调优直接影响生产稳定性。更多细节参见PostgreSQL深入

7.3 锁与并发控制#

对比维度InnoDBPostgreSQL
行锁实现锁定索引记录锁定行标识符
幻读防护Gap Lock / Next-Key LockSerializable Snapshot Isolation
死锁检测自动检测 + 回滚自动检测 + 回滚
锁升级无(始终行级锁)无(始终行级锁)
加锁对象索引记录(非数据行)元组(Tuple)
RC 隔离级别Gap Lock 不生效无 Gap Lock 概念

八、总结#

本章深入剖析了 InnoDB 存储引擎的内部实现,核心要点如下:

  1. 架构:InnoDB 分为内存结构(Buffer Pool、Log Buffer、Change Buffer、AHI)和磁盘结构(表空间、Redo Log、Undo Log、Doublewrite),后台线程负责数据搬移与日志刷写。

  2. 行格式与页结构:Dynamic 行格式将长列存储在溢出页中,提高页内空间利用率。16KB 页通过 Page Directory 实现页内快速查找,File Trailer 保证页面完整性。

  3. 聚簇索引:InnoDB 是索引组织表,数据按主键物理存储。二级索引存储主键值,查询需回表。主键应选择短且不变的列(如自增整数)。

  4. MVCC:通过 Undo Log 版本链 + ReadView 实现快照读。RC 每次查询创建新 ReadView,RR 复用首次查询的 ReadView。

  5. 锁机制:Next-Key Lock = Record Lock + Gap Lock,是 RR 隔离级别下防止幻读的关键。等值查询在唯一索引上退化为 Record Lock。死锁通过自动检测与回滚处理。

  6. Change Buffer:优化二级索引写入,避免随机读 I/O。适用于写多读少场景,不适用于唯一索引。

  7. Doublewrite:防止页撕裂,保证数据页物理完整性。与 Redo Log 互补——Doublewrite 保物理完整,Redo Log 保逻辑正确。

  8. InnoDB vs PostgreSQL:索引组织表 vs 堆表、Undo Log vs Append-Only 是两种数据库最根本的架构差异,决定了各自的性能特征和运维痛点。

理解 InnoDB 的内部实现,不仅有助于排查 MySQL 的性能问题和死锁,更能帮助你在技术选型时做出明智的决策——知道何时选择 InnoDB 的索引组织表,何时选择 PostgreSQL 的堆表。下一章深入 PostgreSQL 的内部实现,对比两种数据库的设计取舍。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MySQL 深入:InnoDB 架构与实现细节
https://blog.souloss.com/posts/database/mysql-deep-dive/
作者
Souloss
发布于
2024-07-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时