mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4550 字
13 分钟
MySQL InnoDB 架构与实现
2024-07-03

索引原理与失效分析中,我们剖析了 B+ 树如何加速查询。这篇把这些概念放到 MySQL InnoDB 的具体实现里看。

InnoDB 是 MySQL 默认的事务型存储引擎,也是生产环境中最广泛使用的引擎。它将 B+ 树索引与数据融为一体(聚簇索引),用 Undo Log 实现多版本并发控制,用 Change Buffer 优化二级索引的写入性能,用 Doublewrite 防止页撕裂。本文逐一拆解这些机制,重点落在架构组件、行格式与页结构、聚簇索引与二级索引、Change Buffer 与 Doublewrite。锁机制(Record/Gap/Next-Key Lock)涉及并发控制与幻读防护,篇幅独立且与隔离级别强相关,留到锁篇单独展开。

前置知识#

Important
  • 了解 B+ 树索引的基本原理,便于理解聚簇索引与二级索引的回表机制,索引失效分析的细节参见索引原理与失效分析

  • 基本的操作系统概念:页、内存缓存、fsync、崩溃恢复

一、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 中最大的内存消费者,默认大小为 128MB(134217728 字节)。它以(默认 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 参数决定刷盘策略:

0Optionalinteger

每秒刷盘一次。崩溃可能丢失 1 秒数据,性能最高。

1Requiredinteger

每次提交都 fsync。不丢数据,性能最低。

2Optionalinteger

每次提交写入 OS 缓存,每秒 fsync。OS 崩溃可能丢数据,性能中等。
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 两个文件组成,写满后从头覆盖。

Note

版本变化ib_logfile0/1 是 MySQL 8.0.30 之前的实现。8.0.30 起 Redo Log 改为 #innodb_redo 目录下的多个 #ib_redo* 文件,容量由 innodb_redo_log_capacity 统一管理(旧参数 innodb_log_file_size / innodb_log_files_in_group 仍保留兼容但被新参数覆盖)。循环写入与 LSN 机制不变。

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;

页内记录的查找过程:

:::step{通过 B+ 树定位到页}

:::step{定位到 4~8 条记录的组} :::

:::

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

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

在索引组织表中,主键的选择直接影响所有二级索引的空间开销和更新代价,短且不变的主键(如自增整数)能最小化二级索引的空间开销并避免主键更新级联。关于两种模型的深入对比,参见第六节

四、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;
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 # 事务已提交,可见

4.3 可见性判断规则#

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

如果当前版本不可见,则沿 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 模式下并发度更高但隔离性更弱的原因。锁机制的细节(Record/Gap/Next-Key Lock 的加锁规则、死锁分析)留到锁篇单独展开。

五、Change Buffer 与 Doublewrite#

5.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 本身很快

5.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

版本变化:上述”System Tablespace 内 2MB 连续空间”是 MySQL 8.0.20 之前的实现。8.0.20 起 Doublewrite 改为独立文件(#ib_16384_0.dblwr 存元数据,#ib_16384_1.dblwr 存实际页副本,容量不再是固定 2MB),由 innodb_doublewrite 等参数控制。防页撕裂的原理不变,只是存储位置和形式变了。

Note

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

5.3 Redo Log 与 LSN#

Redo Log 是 InnoDB 崩溃恢复的核心。LSN(Log Sequence Number)是贯穿整个 InnoDB 的单调递增序号,每次修改数据页时递增。

LSN 在 InnoDB 中的流转涉及几个关键节点:数据页修改时产生新的 LSN 并记入 Redo Log Buffer;Redo Log 刷盘时更新 Log flushed up to;数据页刷盘时更新页头的 page_lsn;检查点推进时更新 Last checkpoint at。检查点之前的脏页已安全落盘,对应区间的 Redo Log 可以安全覆盖。

崩溃恢复流程:从最近的检查点 LSN 开始扫描 Redo Log,对每条 Redo 记录,若记录的 LSN 小于等于数据页的 page_lsn 则跳过(页面已是最新),否则重做(应用修改)。

-- 查看 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 代表了关系型数据库两种截然不同的设计哲学。理解它们的差异,是做出正确技术选型的基础。

6.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 访问堆
空间效率二级索引较大二级索引紧凑

6.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 更新避免索引变更
-- 原地更新 + Undo Log
UPDATE users SET name = 'Bob' WHERE id = 1;
-- 1. 旧值写入 Undo Log 2. 原地修改数据页 3. roll_ptr 指向旧版本
-- 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 的调优直接影响生产稳定性。

6.3 锁与并发控制#

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

七、踩坑与运维#

7.1 Change Buffer 在唯一索引与写密集场景下的陷阱#

Change Buffer 的设计初衷是减少二级索引的随机读 I/O,但它在唯一索引上不生效。插入唯一索引时,InnoDB 必须读取索引页检查唯一性约束,无法绕过这次磁盘读,Change Buffer 就失去了意义。这意味着:如果你建了唯一索引,Insert 性能会明显低于普通二级索引。

写密集场景下还有一个隐患:大量变更堆积在 Change Buffer 中迟迟没有合并,一旦触发批量 merge(例如全表扫描或备份),会瞬间产生大量随机 I/O,导致响应时间尖峰。这在生产环境中表现为”平时写入很快,某次查询或备份时数据库突然变慢”。

Warning

写密集表如果同时有唯一索引,Change Buffer 无法优化这部分写入。评估唯一索引的必要性:业务上确实需要唯一性约束时才建,不要为了”查询快一点”就加唯一索引,普通二级索引配合 Change Buffer 通常吞吐更高。

待补充真实案例:某业务表加唯一索引后写入 QPS 下降的具体数据。

7.2 Doublewrite 的空间与 I/O 开销#

Doublewrite Buffer 占用 System Tablespace 中固定 2MB 的连续空间,且每次刷脏页都要先写一遍 Doublewrite 再写实际表空间,相当于每次刷盘多写一份数据。在写密集场景下,这部分额外 I/O 不可忽视。

MySQL 8.0.20 起,Doublewrite 可以配置为独立文件或关闭(innodb_doublewrite = OFF),关闭后刷盘 I/O 减半,但一旦发生部分写(partial page write),数据页将无法用 Redo Log 恢复,只能靠备份恢复。生产环境一般不建议关闭。

八、运维参数速查#

InnoDB 的关键行为可通过参数调优,以下是生产环境最常关注的几项:

参数默认值作用调优建议
innodb_buffer_pool_size128MBBuffer Pool 大小,决定数据页和索引页的内存缓存容量专用 DB 服务器设为物理内存的 60%~80%,过小会导致频繁磁盘读
innodb_flush_log_at_trx_commit1事务提交时 Redo Log 的刷盘策略生产环境必须设为 1 保证持久性,设为 0 或 2 崩溃会丢数据
innodb_doublewriteON是否开启 Doublewrite Buffer生产环境保持 ON,写密集且可接受数据丢失风险时才考虑关闭
innodb_change_buffer_max_size25Change Buffer 占 Buffer Pool 的最大比例(百分比)写多读少的二级索引场景可调高到 50,读多写少或大量唯一索引时调低
# 查看当前 InnoDB 关键参数
mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size'; \
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; \
SHOW VARIABLES LIKE 'innodb_doublewrite'; \
SHOW VARIABLES LIKE 'innodb_change_buffer_max_size';"
# 在线调整 Change Buffer 上限(动态参数)
mysql -e "SET GLOBAL innodb_change_buffer_max_size = 30;"

支持与分享

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

MySQL InnoDB 架构与实现
https://blog.souloss.cn/posts/middleware/db/mysql-innodb-architecture/
作者
Souloss
发布于
2024-07-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时