本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
事务与并发(ACID / 隔离级别 / 锁 / MVCC) · MVCC 多版本并发控制

什么是 MVCC?InnoDB 是如何用 undo log 实现 MVCC 的?

What is MVCC? How does InnoDB implement MVCC using the undo log?

答案语言

考察要点

这道题面试官想看你是否深刻理解数据库并发控制的核心机制。主要考察点包括:

  1. MVCC 的核心思想:是否理解其“多版本”的本质,以及它相对于传统锁机制“读写不阻塞”的优势。
  2. InnoDB 的实现细节:是否清楚构成 MVCC 的三大基石——隐藏列、undo log 版本链、Read View,以及它们如何协同工作。
  3. 隔离级别的关联:是否明白 MVCC 是如何支撑“读已提交”(RC)和“可重复读”(RR)这两种隔离级别的,并能说出二者的关键差异(Read View 的创建时机)。

核心概念

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制方法,它通过保存数据在某个时间点的快照来实现。这意味着每个读操作都能看到一个一致的数据版本,而不会被其他事务的写操作阻塞,从而实现了“读写不阻塞”的高并发性能。

InnoDB 的 MVCC 是通过以下几个核心组件协同实现的:

1. 隐藏列

InnoDB 表的每一行记录,除了我们定义的列外,还包含三个隐藏列:

  • DB_TRX_ID (6 bytes):记录最后一次修改(INSERT 或 UPDATE)该行的事务 ID。
  • DB_ROLL_PTR (7 bytes):回滚指针,指向该行上一个版本的 undo log 记录。
  • DB_ROW_ID (6 bytes):隐藏的行 ID,当表没有主键或唯一非空索引时,InnoDB 会用它来生成聚簇索引。

2. Undo Log 与版本链

Undo Log 在 MVCC 中扮演着至关重要的双重角色:

  1. 事务回滚:当事务需要回滚时,可以通过 undo log 恢复到修改前的数据。
  2. 版本提供:为其他事务的快照读(Snapshot Read)提供历史版本的数据。

当一个事务修改数据时,它会执行以下操作:

  1. 用排他锁锁定该行。
  2. 将该行修改前的数据记录到 undo log 中。
  3. 修改行记录,并将 DB_TRX_ID 更新为当前事务 ID,DB_ROLL_PTR 指向刚刚生成的 undo log 记录。

这样,一行记录的多个历史版本就通过 DB_ROLL_PTR 串联成一个链表,我们称之为版本链。

示例:版本链的形成 假设有一个事务 10(trx_id=10)将 name 从 'A' 改为 'B',之后事务 20(trx_id=20)又将其从 'B' 改为 'C'。

mermaid
1graph LR
2 subgraph "数据页 (Data Page)"
3 A1["行记录<br/>name='C'<br/>DB_TRX_ID=20<br/>DB_ROLL_PTR=ptr_to_B"]
4 end
5 subgraph "Undo Log"
6 B1["undo log 记录<br/>name='B'<br/>DB_TRX_ID=10<br/>DB_ROLL_PTR=ptr_to_A"]
7 C1["undo log 记录<br/>name='A'<br/>..."]
8 end
9 A1 --> B1
10 B1 --> C1

当前数据页中存储的是最新版本(name='C'),并通过回滚指针 DB_ROLL_PTR 指向 undo log 中的上一个版本(name='B'),以此类推,构成一个版本链。

3. Read View(读视图)

Read View 是事务在执行快照读时,用于判断哪个版本的数据对它可见的“标尺”。它在事务首次执行 SELECT 时创建(RC 级别下每次 SELECT 都创建),主要包含以下四个关键属性:

  • m_ids: 创建 Read View 时,所有活跃(未提交)事务的 ID 列表。
  • min_trx_id: m_ids 列表中的最小事务 ID。
  • max_trx_id: 创建 Read View 时,系统下一个将要分配的事务 ID(即当前最大事务 ID + 1)。
  • creator_trx_id: 创建该 Read View 的事务的 ID。

4. 可见性判断算法

当一个事务(假设其 Read View 已创建)去读取某一行记录时,它会沿着版本链,从最新版本开始,逐个应用以下规则,直到找到第一个可见的版本:

  1. 判断 row_trx_id == creator_trx_id?
    • 是:可见。这表示是当前事务自己修改的记录。
  2. 判断 row_trx_id < min_trx_id?
    • 是:可见。这表示在当前事务开启前,修改该行的事务就已经提交了。
  3. 判断 row_trx_id >= max_trx_id?
    • 是:不可见。这表示该版本是由一个在当前事务的 Read View 创建之后才开启的事务所修改的。
  4. 判断 row_trx_id 是否在 m_ids 列表中?
    • 是:不可见。这表示在创建 Read View 时,修改该行的事务仍然活跃(未提交)。
    • 否:可见。这表示修改该行的事务在当前事务开启后、但在 Read View 创建前就已经提交了。

如果当前版本不可见,事务就会通过 DB_ROLL_PTR 去版本链中查找上一个版本,并重复此过程。

5. RC 与 RR 隔离级别的差异

MVCC 如何支撑 RC 和 RR?关键就在于 Read View 的创建时机:

  • Read Committed (RC):每次执行 SELECT 语句时,都会重新创建一个新的 Read View。这导致在同一事务内,后续的 SELECT 可以看到其他已提交事务所做的修改(不可重复读)。
  • Repeatable Read (RR):仅在事务中的第一次 SELECT 语句执行时创建 Read View,后续的所有 SELECT 都复用这个 Read View。这保证了在整个事务期间,看到的数据版本都是一致的,从而避免了不可重复读和幻读(仅针对快照读)。

常见低分回答

  1. “MVCC 就是无锁化,完全不用锁”:这是最常见的误解。MVCC 解决的是“快照读”(SELECT)的并发问题,但对于“当前读”(SELECT ... FOR UPDATE, SELECT ... LOCK IN SHARE MODE, UPDATE, DELETE, INSERT),InnoDB 仍然需要使用 Next-Key Lock(行锁 + 间隙锁)来保证数据一致性,防止写冲突。
  2. 对 undo log 作用的理解片面:只说 undo log 是用来做事务回滚的,完全没提它在 MVCC 中为快照读提供历史版本数据的核心作用。
  3. 混淆 RC 和 RR 的实现:说不清楚 RC 和 RR 的区别,或者错误地解释 Read View 的创建时机。例如,说“RR 级别下事务开始就创建 Read View”,这是不准确的,准确的说是“第一次 SELECT 时创建”。
  4. 解释过于笼统,缺乏细节:只说“MVCC 就是保存了多个版本”,但说不出版本存在哪里(undo log)、如何串联(版本链)、如何判断可见性(Read View),这表明理解浮于表面。

工程实践与优化

  • 警惕长事务:一个长时间运行的事务会持有一个非常旧的 Read View。这会导致 purge 线程无法清理该 Read View 所需的、非常古老的 undo log 版本。结果是 undo log 文件持续膨胀,占用大量磁盘空间,并可能拖慢整体性能。
    • 监控:通过 information_schema.innodb_trx 表可以查到长时间运行的事务。SHOW ENGINE INNODB STATUS 中的 History list length 也反映了未被清理的 undo log 数量,这个值持续增长是个危险信号。
  • Undo Log 配置与管理:
    • MySQL 5.7 之前,undo log 存储在系统表空间(ibdata1)中,难以收缩。
    • MySQL 5.7+ 允许将 undo log 放在独立的 undo 表空间。MySQL 8.0+ 默认开启并支持在线收缩 undo 表空间(innodb_undo_log_truncate),大大改善了空间管理。合理配置 innodb_undo_tablespaces 数量可以分散 I/O 压力。
  • 避免大事务:对于需要修改大量数据的操作(如数据清理、批量更新),应拆分成多个小事务分批执行。这可以防止产生过大的 undo log,减少锁定的时间和范围,并避免长事务问题。
    sql
    1-- 反例:一个大事务处理100万行
    2START TRANSACTION;
    3DELETE FROM logs WHERE create_time < '2023-01-01';
    4COMMIT;
    5
    6-- 正例:分批处理
    7-- while (true) {
    8-- START TRANSACTION;
    9-- DELETE FROM logs WHERE create_time < '2023-01-01' LIMIT 1000;
    10-- COMMIT;
    11-- if (rows_affected == 0) break;
    12-- sleep(0.1); // 避免过度占用CPU和IO
    13-- }

常见追问与延伸题目

  1. 追问:MVCC 彻底解决幻读问题了吗?

    • 回答要点:不完全是。在 MySQL 的 RR 隔离级别下,MVCC 机制本身可以防止快照读时出现幻读。因为 Read View 是固定的,事务期间新插入的、且已提交的行,其 trx_id 会大于 Read View 的 max_trx_id,因此对当前事务不可见。
    • 但是,如果事务中混合了当前读(如 SELECT ... FOR UPDATE),幻读依然可能发生。例如,事务 A 先 SELECT 了一次,然后事务 B 插入新数据并提交,事务 A 再执行 SELECT ... FOR UPDATE,此时就会读到事务 B 新插入的“幻影”行。为了解决这个问题,InnoDB 引入了间隙锁(Gap Lock),它在当前读时会锁定一个范围,防止其他事务在这个范围内插入数据,从而彻底杜绝了幻读。所以,MySQL 的 RR 级别是通过 MVCC + Next-Key Lock(行锁+间隙锁) 共同解决幻读问题的。
  2. 追问:如果一个长事务导致 undo log 暴增,除了杀掉事务,有什么办法预防和优化?

    • 回答要点:
      • 应用层优化:这是根本。避免在应用代码中开启一个事务后,执行 RPC 调用、等待用户输入等耗时操作。将大批量的数据操作拆分为小批次,分批提交。
      • 数据库监控:建立完善的监控告警体系,对 information_schema.innodb_trx 中运行时间过长的事务进行告警,及时通知业务方处理。
      • 参数配置:在 MySQL 8.0+ 中,确保 innodb_undo_log_truncate=ON,并合理配置 innodb_max_undo_log_size,使得 undo 表空间在达到阈值后可以自动收缩,虽然不能解决根本问题,但可以缓解空间压力。
      • 只读事务优化:对于明确只有读操作的事务,在 MySQL 8.0+ 中可以使用 START TRANSACTION READ ONLY;,这可以进行一些优化,例如不分配事务 ID,减少开销。
  3. 追问:PostgreSQL 的 MVCC 实现和 MySQL 有什么不同?

    • 回答要点:两者核心思想类似,但物理实现截然不同。
      • MySQL (InnoDB):采用页外(off-page)存储旧版本。最新版本的数据行在数据页中,而旧版本存储在独立的 undo log 空间。UPDATE 是原地更新(in-place update),但需要将旧版本拷贝到 undo log。清理工作由后台的 purge 线程负责。
      • PostgreSQL:采用**页内(in-page)**存储多版本。UPDATE 操作实际上是一个 DELETE + INSERT,它会将新版本的数据行作为一条新记录插入到同一个表中,并标记旧版本行为“失效”(dead tuple),但并不立即物理删除。旧版本和新版本可能在同一个数据页里。清理工作由 VACUUM 进程负责回收这些失效的行所占用的空间。
      • 优劣对比:
        • MySQL:UPDATE 性能较高(原地更新),但读取旧版本可能需要多次 I/O(遍历版本链)。
        • PostgreSQL:UPDATE 开销较大(需要插入新行),容易导致表膨胀(table bloat),需要依赖 VACUUM 机制。但事务回滚非常快,只需将新插入的行标记为失效即可。