在存储引擎中,理解了 B 树与 LSM 树的设计取舍;在索引原理中,我们剖析了 B+ 树如何加速查询;在事务与并发控制中,我们建立了 MVCC 与锁的理论框架。现在,是时候把这些概念落地到 MySQL InnoDB 的具体实现中了。
InnoDB 是 MySQL 默认的事务型存储引擎,也是生产环境中最广泛使用的引擎。它将 B+ 树索引与数据融为一体(聚簇索引),用 Undo Log 实现多版本并发控制,用 Gap Lock 与 Next-Key Lock 解决幻读问题,用 Change Buffer 优化二级索引的写入性能,用 Doublewrite 防止页撕裂。本章将逐一拆解这些机制,让你不仅知道 InnoDB “做了什么”,更理解”为什么这样做”。
前置知识
- Ch02 存储引擎:B 树与 LSM 树的设计取舍是理解 InnoDB 的前提
- Ch03 索引原理:B+ 树索引原理、覆盖索引、索引下推
- Ch04 事务与并发控制:MVCC、隔离级别、两阶段锁
一、InnoDB 架构概述
1.1 整体架构
InnoDB 的架构分为内存结构和磁盘结构两大部分,中间通过后台线程进行数据搬移与日志刷写。
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 缓存,每秒 fsync | OS 崩溃可能丢数据 | 中等 |
生产环境中 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 Tablespace | ibdata1 | 数据字典、Doublewrite、Change Buffer | 共享,不建议存用户数据 |
| File-Per-Table | *.ibd | 单表的数据与索引 | innodb_file_per_table=ON(默认) |
| General Tablespace | 自定义 | 多表共享 | 灵活但少用 |
| Undo Tablespace | undo_001/002 | Undo Log | 独立于系统表空间 |
| Temporary Tablespace | ibtmp1 | 临时表数据 | 重启自动重建 |
Redo Log
Redo Log 是 InnoDB 的 WAL 实现,采用固定大小、循环写入的方式。由 ib_logfile0 和 ib_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 支持四种行格式,其中 Compact 和 Dynamic 是最常用的:
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;每行数据都包含 trx_id、roll_ptr 和 row_id 三个隐藏列。trx_id 记录最后一次修改该行的事务 ID,roll_ptr 指向 Undo Log 中的上一版本——这就是 InnoDB MVCC 版本链的物理基础。详见第四节。
Dynamic 行格式
Dynamic 是 Compact 的改进版,核心区别在于溢出页处理:
| 特性 | Compact | Dynamic |
|---|---|---|
| 长列存储 | 前 768 字节在页内,其余在溢出页 | 仅存 20 字节指针,全部在溢出页 |
| 页内空间利用率 | 较低(长列占大量空间) | 较高(页内只存指针) |
| 适用场景 | 旧版本兼容 | MySQL 5.7+ 默认,推荐使用 |
-- 查看表的行格式SELECT TABLE_NAME, ROW_FORMATFROM INFORMATION_SCHEMA.TABLESWHERE 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;页内记录的查找过程:
三、聚簇索引与二级索引
3.1 聚簇索引
InnoDB 是索引组织表(Index-Organized Table, IOT)——数据按主键的 B+ 树物理存储。聚簇索引的叶子节点直接包含完整的行数据,而非指向数据的指针。
聚簇索引的关键特性:
- 一张表只有一个聚簇索引——数据只能按一种顺序物理存储
- 主键即聚簇索引——如果没有定义主键,InnoDB 选择第一个唯一非空索引;如果也没有,InnoDB 自动生成 6 字节的隐藏
row_id - 范围查询高效——叶子节点通过双向链表连接,范围扫描只需顺序遍历
3.2 二级索引
二级索引的叶子节点不存储完整行数据,而是存储主键值。通过二级索引查找数据需要回表——先在二级索引中找到主键值,再到聚簇索引中查找完整行。
-- 回表过程演示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,无需回表!覆盖索引是避免回表的关键优化。如果查询所需的列全部包含在二级索引中,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; -- 跳过了 citySELECT * 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、Oracle | PostgreSQL、SQL Server |
在索引组织表中,主键的选择至关重要——短且不变的主键(如自增整数)能最小化二级索引的空间开销并避免主键更新级联。关于两种模型的深入对比,参见第七节和PostgreSQL深入。
四、InnoDB MVCC 实现
在事务与并发控制中,我们建立了 MVCC 的通用理论框架。现在来看 InnoDB 是如何具体实现的。
4.1 Undo Log 版本链
每行数据的 roll_ptr 指向 Undo Log 中的旧版本,形成一条版本链:
-- 版本链的形成过程-- 事务 101:INSERTINSERT INTO users (id, name) VALUES (1, 'Alice');-- 当前版本:name='Alice', trx_id=101
-- 事务 102:UPDATEUPDATE users SET name = 'Bob' WHERE id = 1;-- 当前版本:name='Bob', trx_id=102, roll_ptr → Undo(name='Alice', trx_id=101)
-- 事务 103:UPDATEUPDATE 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 -- 事务 BBEGIN; 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 的修改)InnoDB 的 REPEATABLE READ 不仅防止不可重复读,还通过 Gap Lock 和 Next-Key Lock 防止幻读——这超出了 SQL 标准对 RR 隔离级别的要求。而 READ COMMITTED 下 Gap Lock 不生效,这也是为什么 RC 模式下并发度更高但隔离性更弱的原因。锁机制的细节在下一节展开。
五、锁机制深入
5.1 InnoDB 锁类型
InnoDB 的锁体系从粒度到类型层层递进:
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, 40BEGIN;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 隔离级别):
- 加锁的基本单位是 Next-Key Lock
- 查找过程中访问到的对象才会加锁
- 等值查询:唯一索引命中记录 → 退化为 Record Lock;未命中 → 退化为 Gap Lock
- 等值查询:普通索引命中记录 → 向右遍历到不满足条件的第一条记录,该记录的 Gap Lock 不包含
- 范围查询:对扫描到的每个索引记录加 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 Lock5.3 死锁案例
死锁是并发事务循环等待对方持有的锁导致的僵局。以下是一个经典的死锁场景:
-- 死锁案例:交叉更新-- 事务 A -- 事务 BBEGIN; BEGIN;UPDATE t SET c=100WHERE id=5;-- 持有:id=5 的 Record Lock
UPDATE t SET c=200 WHERE id=10; -- 持有:id=10 的 Record Lock
UPDATE t SET c=100WHERE 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秒)死锁并非总是坏事——InnoDB 会自动检测死锁并回滚其中一个事务。但频繁死锁意味着业务逻辑存在锁竞争热点,应通过以下方式优化:
按固定顺序访问表和行
保持事务短小
使用低隔离级别(如 RC)减少 Gap Lock
添加合适的索引避免锁升级
六、Change Buffer 与 Doublewrite
6.1 Change Buffer
Change Buffer 是 InnoDB 对二级索引 DML 操作的优化。核心思想:二级索引页不在 Buffer Pool 中时,不立即读入,而是将变更缓存在 Change Buffer 中。
-- Change Buffer 相关参数SET GLOBAL innodb_change_buffer_max_size = 25; -- 占 Buffer Pool 比例,默认 25%SET GLOBAL innodb_change_buffering = all; -- all/inserts/deletes/changes/noneChange Buffer 的适用场景与限制:
| 场景 | Change Buffer 效果 | 原因 |
|---|---|---|
| 大量写入、少量读取 | 显著提升 | 减少随机读 I/O |
| 写入后立即读取 | 反而降低 | 读取触发 merge,增加开销 |
| 唯一索引 | 不适用 | 插入时必须检查唯一性,必须读入索引页 |
| SSD 存储 | 效果有限 | 随机读 I/O 本身很快 |
6.2 Doublewrite
Doublewrite 是 InnoDB 防止**页撕裂(Partial Page Write)**的机制。页撕裂发生在写入 16KB 数据页时系统崩溃——可能只有部分页面被写入磁盘,导致数据页损坏。
Doublewrite Buffer 位于 System Tablespace 中的 2MB 连续空间,分为 2 个段,每个段 1MB = 64 个 16KB 页。写入数据页前先将完整页面写入 Doublewrite Buffer 并 fsync,确保即使写入过程中崩溃也能从 Doublewrite 恢复完整页面。
有人可能会问: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 堆表
| 对比维度 | 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 Purge | VACUUM |
| 更新操作 | 原地更新 + 写 Undo Log | 插入新行 + 标记旧行 |
| 表膨胀 | 无(旧版本在 Undo Log 中) | 严重(需 VACUUM 回收) |
| 索引更新 | 二级索引可能需回表判断可见性 | HOT 更新避免索引变更 |
-- InnoDB:原地更新 + Undo LogUPDATE users SET name = 'Bob' WHERE id = 1;-- 1. 旧值写入 Undo Log 2. 原地修改数据页 3. roll_ptr 指向旧版本
-- PostgreSQL:Append-OnlyUPDATE users SET name = 'Bob' WHERE id = 1;-- 1. 旧行标记 xmax 2. 插入新行设 xmin 3. VACUUM 回收旧行两种 MVCC 实现各有优劣。InnoDB 的 Undo Log 方式不会导致表膨胀,但 Undo Log 空间有限,长事务可能导致 Undo Log 暴涨。PostgreSQL 的 Append-Only 方式实现简洁,但表膨胀是运维的核心痛点——VACUUM 的调优直接影响生产稳定性。更多细节参见PostgreSQL深入。
7.3 锁与并发控制
| 对比维度 | InnoDB | PostgreSQL |
|---|---|---|
| 行锁实现 | 锁定索引记录 | 锁定行标识符 |
| 幻读防护 | Gap Lock / Next-Key Lock | Serializable Snapshot Isolation |
| 死锁检测 | 自动检测 + 回滚 | 自动检测 + 回滚 |
| 锁升级 | 无(始终行级锁) | 无(始终行级锁) |
| 加锁对象 | 索引记录(非数据行) | 元组(Tuple) |
| RC 隔离级别 | Gap Lock 不生效 | 无 Gap Lock 概念 |
八、总结
本章深入剖析了 InnoDB 存储引擎的内部实现,核心要点如下:
-
架构:InnoDB 分为内存结构(Buffer Pool、Log Buffer、Change Buffer、AHI)和磁盘结构(表空间、Redo Log、Undo Log、Doublewrite),后台线程负责数据搬移与日志刷写。
-
行格式与页结构:Dynamic 行格式将长列存储在溢出页中,提高页内空间利用率。16KB 页通过 Page Directory 实现页内快速查找,File Trailer 保证页面完整性。
-
聚簇索引:InnoDB 是索引组织表,数据按主键物理存储。二级索引存储主键值,查询需回表。主键应选择短且不变的列(如自增整数)。
-
MVCC:通过 Undo Log 版本链 + ReadView 实现快照读。RC 每次查询创建新 ReadView,RR 复用首次查询的 ReadView。
-
锁机制:Next-Key Lock = Record Lock + Gap Lock,是 RR 隔离级别下防止幻读的关键。等值查询在唯一索引上退化为 Record Lock。死锁通过自动检测与回滚处理。
-
Change Buffer:优化二级索引写入,避免随机读 I/O。适用于写多读少场景,不适用于唯一索引。
-
Doublewrite:防止页撕裂,保证数据页物理完整性。与 Redo Log 互补——Doublewrite 保物理完整,Redo Log 保逻辑正确。
-
InnoDB vs PostgreSQL:索引组织表 vs 堆表、Undo Log vs Append-Only 是两种数据库最根本的架构差异,决定了各自的性能特征和运维痛点。
理解 InnoDB 的内部实现,不仅有助于排查 MySQL 的性能问题和死锁,更能帮助你在技术选型时做出明智的决策——知道何时选择 InnoDB 的索引组织表,何时选择 PostgreSQL 的堆表。下一章深入 PostgreSQL 的内部实现,对比两种数据库的设计取舍。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






