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

为什么 MVCC 能让读不阻塞写、写不阻塞读?

Why does MVCC allow reads to not block writes, and writes to not block reads?

答案语言

考察要点

这道题旨在考察你对 InnoDB 存储引擎核心并发控制机制的理解深度。面试官想看你是否真正掌握:

  1. MVCC 的核心思想:即“多版本”与“并发控制”如何结合,实现读写不阻塞。
  2. InnoDB 中 MVCC 的实现细节:包括隐藏列、undo log 的多重作用以及 Read View 的工作原理。
  3. 不同读操作的本质:你是否能清晰地区分快照读(Snapshot Read)和当前读(Current Read),并理解它们分别如何与 MVCC 和锁协同工作。

核心概念

MVCC (Multi-Version Concurrency Control),即多版本并发控制。它的核心思想是为每一行数据保留多个版本,使得在处理读写请求时,读操作可以读取一个旧版本的数据(快照),而写操作可以同时创建一个新版本的数据,从而避免了读写之间的相互阻塞。

InnoDB 的 MVCC 实现主要依赖于以下三大组件:

1. 隐藏列与 Undo Log 版本链

InnoDB 会在每行记录后面添加三个隐藏字段:

  • DB_TRX_ID (6字节): 记录最后一次修改(插入或更新)该行记录的事务 ID。
  • DB_ROLL_PTR (7字节): 回滚指针,指向该行记录的上一个版本,存储在 undo log 中。
  • DB_ROW_ID (6字节): 隐藏的行 ID,当表没有主键或非 NULL 唯一索引时,InnoDB 会用它来生成聚簇索引。

当一个事务更新一行数据时,并不会直接覆盖旧数据,而是:

  1. 将旧数据记录到 undo log 中。
  2. 在当前数据行上写入新数据,并更新 DB_TRX_ID 为当前事务的 ID。
  3. 通过 DB_ROLL_PTR 将当前数据行与 undo log 中的旧版本记录串联起来,形成一个版本链。

图示:版本链 假设有一行数据,初始 trx_id 为 80。

text
1(Data Row in Table)
2+-------------------------------------------------+
3| id=1, name='A', balance=1000, DB_TRX_ID=80, ... |
4+-------------------------------------------------+

事务 90 将 balance 更新为 800:

text
1(Undo Log) (Data Row in Table)
2+-------------------------------------+ +-------------------------------------------------+
3| trx_id=80, name='A', balance=1000 |<-+ | id=1, name='A', balance=800, DB_TRX_ID=90, ... |
4+-------------------------------------+ | +-------------------------------------------------+
5 ^ |
6 | DB_ROLL_PTR points here |
7 +---------------------------+

事务 101 将 balance 更新为 600:

text
1(Undo Log - Version Chain)
2+-------------------------------------+ +-------------------------------------+ +-------------------------------------------------+
3| trx_id=80, name='A', balance=1000 |<-+ | trx_id=90, name='A', balance=800 |<-+ | id=1, name='A', balance=600, DB_TRX_ID=101, ...|
4+-------------------------------------+ | +-------------------------------------+ | +-------------------------------------------------+
5 | ^ |
6 | | DB_ROLL_PTR |
7 +-----------+ |
8 ^ |
9 | DB_ROLL_PTR |
10 +---------------------------+

2. Read View (读视图)

Read View 是事务在进行快照读(如普通 SELECT)时,动态生成的“数据库状态快照”。它决定了事务能“看到”哪个版本的数据。Read View 包含以下关键信息:

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

3. 可见性判断算法

当一个事务(假设 trx_id 为 creator_trx_id)要读取某行数据时,它会根据自己的 Read View 对该行记录的 DB_TRX_ID(我们称之为 row_trx_id)进行如下判断:

  1. 如果 row_trx_id == creator_trx_id:说明是本事务自己修改的,可见。
  2. 如果 row_trx_id < min_trx_id:说明修改该行的事务在当前事务开启前就已经提交,可见。
  3. 如果 row_trx_id >= max_trx_id:说明修改该行的事务在当前事务创建 Read View 之后才开启,不可见。
  4. 如果 min_trx_id <= row_trx_id < max_trx_id:需要检查 row_trx_id 是否在 m_ids 列表中。
    • 在:说明修改该行的事务在创建 Read View 时仍处于活跃状态(未提交),不可见。
    • 不在:说明修改该行的事务在创建 Read View 前已经提交,可见。

如果当前版本不可见,则通过 DB_ROLL_PTR 顺着 undo log 版本链寻找上一个版本,然后重复上述可见性判断,直到找到第一个可见的旧版本或链表末尾。

核心问题解答:读写如何不阻塞?

  • 读不阻塞写: 当事务 A(快照读)在读取数据时,它根据 Read View 规则去查找可见的版本。此时,如果事务 B(写操作)正在修改这行数据,它会生成一个新版本并加锁,但事务 A 并不需要等待事务 B 释放锁。事务 A 会沿着 undo log 链找到一个符合其 Read View 规则的旧版本数据并返回。因此,读操作不会被写操作阻塞。

  • 写不阻塞读: 当事务 B(写操作)在修改数据时,它会先将原数据拷贝到 undo log,然后修改聚簇索引中的数据行,并对该行记录加排他锁。这个过程非常快。对于正在进行快照读的事务 A,它根本不关心这行数据上的锁,因为它访问的是旧版本数据。因此,写操作不会被(快照)读操作阻塞。

注意:上述讨论的“读”特指快照读 (SELECT)。对于当前读 (SELECT ... FOR UPDATE, UPDATE, DELETE),它需要读取最新版本的记录并加锁,此时如果遇到其他事务的写锁,仍然是会发生阻塞的。

隔离级别与 Read View

  • Read Committed (RC): 每次执行快照读时,都会生成一个新的 Read View。这导致在同一事务内,多次读取同一行数据可能会得到不同的结果(不可重复读)。
  • Repeatable Read (RR) (MySQL 默认): 仅在事务中第一次执行快照读时生成一个 Read View,后续的所有快照读都复用这个 Read View。这保证了在同一事务内多次读取同一行数据结果总是一致的(可重复读)。

常见低分回答

  • 混淆 MVCC 与锁:“MVCC 就是读用共享锁,写用排他锁,所以读写不冲突”。这是错误的。MVCC 的核心是版本,快照读根本不加锁。这是对 MVCC 原理的根本性误解。
  • 只提 undo log,不提 Read View:“写的时候把旧数据放 undo log,读的时候去读 undo log 就行了”。这个回答过于笼统,没有解释清楚“读哪个版本”以及“如何判断版本可见”,Read View 才是可见性判断的核心。
  • 无法区分快照读和当前读:认为任何 SELECT 语句都能享受 MVCC 带来的无锁好处,忽略了 SELECT ... FOR UPDATE 这类当前读会加锁并可能被阻塞的场景。
  • 对版本链理解不清:认为更新是“原地更新”,无法解释多个版本的来源和组织方式。

工程实践与优化

  • 警惕长事务:一个长时间运行的事务会导致其 Read View 所需的旧版本 undo log 无法被清理(purge)。这会造成 undo log 表空间持续膨胀,不仅占用大量磁盘空间,还会降低新事务的查询性能(因为版本链可能变得很长)。
  • 监控长事务:可以通过 information_schema.innodb_trx 表来监控长事务,并及时处理。
  • 选择合适的隔离级别:虽然 MySQL 默认是 RR,但对于大部分互联网业务场景,RC 已经足够,且能避免 RR 级别下因 Read View 持久而导致的 undo log 积压问题,并发性能也可能更好。
  • 分析 Undo 开销:对于更新频繁的表,大量的 undo log 生成和版本链遍历会成为性能瓶颈。可以通过 pt-query-digest 等工具分析查询,或通过 Performance Schema 监控 undo 相关的开销。
  • 执行计划分析:虽然 EXPLAIN 不直接显示 MVCC 的开销,但如果一个查询很慢,且表中更新频繁,应考虑到可能是版本链过长导致。

常见追问与延伸题目

  1. 追问:既然 RR 级别下 MVCC 可以防止幻读,为什么还说 MySQL 的 RR 没能完全解决幻读?

    • 回答要点:MVCC 确实能在快照读的场景下,通过复用 Read View 来避免幻读。但如果在事务中混合使用了当前读(如 SELECT ... FOR UPDATE),幻读问题依然可能出现。例如,一个事务先进行了一次快照读,之后另一个事务插入了新数据并提交,接着第一个事务执行了当前读或更新操作,这时它就能“看到”新插入的数据,仿佛产生了幻觉。MySQL 为了在 RR 级别下彻底解决幻读,引入了 Next-Key Lock(记录锁 + 间隙锁),它在执行当前读时会锁定一个范围,防止其他事务在这个范围内插入新数据。
  2. 追问:Read Committed 和 Repeatable Read 的 Read View 生成时机有什么不同?这带来了什么影响?

    • 回答要点:
      • RC:每次 SELECT 都生成新的 Read View。
      • RR:事务中第一次 SELECT 时生成 Read View,之后复用。
      • 影响:RC 级别下,事务可以看到其他已提交事务的最新修改,导致不可重复读,但能读到较新的数据。RR 级别下,事务生命周期内看到的数据版本是一致的,保证了可重复读,但可能读到“旧”数据,且 Read View 的存在会阻止旧版本 undo log 的回收。
  3. 追问:Undo log 会无限增长吗?数据库是如何回收它的?

    • 回答要点:
      • 在异常情况下(如持续的长事务)undo log 可能会增长得非常大,但正常情况下有回收机制。
      • 回收机制:InnoDB 有一个后台的 purge 线程负责回收。
      • 回收条件:一个 undo log 版本记录可以被回收,必须同时满足两个条件:1) 创建该版本的事务已经提交。2) 没有任何一个活跃的 Read View 还需要这个版本来维持数据可见性(即该版本的 trx_id 小于所有现存 Read View 的 min_trx_id)。
      • 长事务的影响:长事务的存在,会使其 Read View (或 min_trx_id) 长期保持一个较小的值,导致其后产生的大量 undo log 都无法被 purge 线程回收,从而造成 undo log 积压。