为什么 MVCC 能让读不阻塞写、写不阻塞读?
Why does MVCC allow reads to not block writes, and writes to not block reads?
考察要点
这道题旨在考察你对 InnoDB 存储引擎核心并发控制机制的理解深度。面试官想看你是否真正掌握:
MVCC的核心思想:即“多版本”与“并发控制”如何结合,实现读写不阻塞。InnoDB中MVCC的实现细节:包括隐藏列、undo log的多重作用以及Read View的工作原理。- 不同读操作的本质:你是否能清晰地区分快照读(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会用它来生成聚簇索引。
当一个事务更新一行数据时,并不会直接覆盖旧数据,而是:
- 将旧数据记录到
undo log中。 - 在当前数据行上写入新数据,并更新
DB_TRX_ID为当前事务的 ID。 - 通过
DB_ROLL_PTR将当前数据行与undo log中的旧版本记录串联起来,形成一个版本链。
图示:版本链
假设有一行数据,初始 trx_id 为 80。
1(Data Row in Table)2+-------------------------------------------------+3| id=1, name='A', balance=1000, DB_TRX_ID=80, ... |4+-------------------------------------------------+
事务 90 将 balance 更新为 800:
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:
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)进行如下判断:
- 如果
row_trx_id==creator_trx_id:说明是本事务自己修改的,可见。 - 如果
row_trx_id<min_trx_id:说明修改该行的事务在当前事务开启前就已经提交,可见。 - 如果
row_trx_id>=max_trx_id:说明修改该行的事务在当前事务创建Read View之后才开启,不可见。 - 如果
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的开销,但如果一个查询很慢,且表中更新频繁,应考虑到可能是版本链过长导致。
常见追问与延伸题目
-
追问:既然 RR 级别下
MVCC可以防止幻读,为什么还说 MySQL 的 RR 没能完全解决幻读?- 回答要点:
MVCC确实能在快照读的场景下,通过复用Read View来避免幻读。但如果在事务中混合使用了当前读(如SELECT ... FOR UPDATE),幻读问题依然可能出现。例如,一个事务先进行了一次快照读,之后另一个事务插入了新数据并提交,接着第一个事务执行了当前读或更新操作,这时它就能“看到”新插入的数据,仿佛产生了幻觉。MySQL 为了在 RR 级别下彻底解决幻读,引入了Next-Key Lock(记录锁 + 间隙锁),它在执行当前读时会锁定一个范围,防止其他事务在这个范围内插入新数据。
- 回答要点:
-
追问:
Read Committed和Repeatable Read的 Read View 生成时机有什么不同?这带来了什么影响?- 回答要点:
- RC:每次
SELECT都生成新的Read View。 - RR:事务中第一次
SELECT时生成Read View,之后复用。 - 影响:RC 级别下,事务可以看到其他已提交事务的最新修改,导致不可重复读,但能读到较新的数据。RR 级别下,事务生命周期内看到的数据版本是一致的,保证了可重复读,但可能读到“旧”数据,且
Read View的存在会阻止旧版本undo log的回收。
- RC:每次
- 回答要点:
-
追问: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积压。
- 在异常情况下(如持续的长事务)
- 回答要点: