1. MVCC是为了解决什么问题?

MVCC(Multi-Version Concurrency Control,多版本并发控制)主要是为了解决数据库读写操作之间的并发冲突,从而在保证数据一致性的前提下,大幅提高数据库的并发性能

在没有MVCC的时代,数据库通常通过锁机制来保证并发事务的隔离性。但纯粹的锁机制会遇到两个主要问题:

  1. 读写阻塞:当一个事务正在写数据(加锁)时,其他事务无论是读还是写,都必须等待锁释放。这会导致性能瓶颈,特别是在读多写少的场景下。
  2. 锁的粒度和管理开销:使用锁就需要考虑锁的粒度(行锁、表锁)、类型(共享锁、排他锁)以及可能带来的死锁问题,管理复杂,开销大。

MVCC的设计目标就是:让读操作不再阻塞写操作,让写操作不再阻塞读操作。

它通过为每一行数据维护多个版本来实现这个目标。读操作可以去读一个历史快照版本,而写操作可以同时去创建新的版本,两者互不干扰。


2. MVCC的核心机制简介(InnoDB引擎)

在深入示例之前,先简单了解MVCC在InnoDB中的实现依赖:

  1. 隐藏字段:InnoDB每行记录都有两个(或三个)隐藏字段:
  • DB_TRX_ID (6字节):记录最后一次插入更新该行数据的事务ID。
  • DB_ROLL_PTR (7字节):回滚指针,指向该行数据在Undo Log中的上一个历史版本。
  • DB_ROW_ID (6字节):行标识(如果表没有主键,InnoDB会自动生成一个隐藏主键)。
  1. Undo Log(回滚日志):存储数据被修改前的旧版本。当一行数据被更新时,旧版本的数据会被拷贝到Undo Log中,并用DB_ROLL_PTR指向它。这些Undo Log通过回滚指针串联成一个版本链
  2. Read View(读视图):事务在执行快照读(普通的SELECT语句)时,会生成一个当前系统的快照,即Read View。它主要用于判断当前事务能够看到哪个版本的数据。Read View主要包含:
  • 当前系统中所有活跃(未提交)事务的ID列表
  • 当前系统中最小的事务ID
  • 下一个将要被分配的事务ID(即最大事务ID+1)。

判断可见性的规则:事务根据Read View和版本链中每个版本的DB_TRX_ID来判断数据是否可见。一个数据版本对当前事务可见,当且仅当:

  • 该版本的DB_TRX_ID小于Read View中的最小活跃事务ID(说明该版本在当前事务开始前已提交)。
  • 或者,该版本的DB_TRX_ID是当前事务自己的ID(说明是自己修改的)。

3. SQL示例详解

我们通过一个经典的“不可重复读”场景来演示MVCC如何工作。

假设我们有一张表 account

id

name

balance

1

Alice

1000

事务的隔离级别为 REPEATABLE-READ(可重复读)

时间线如下:

时间点

事务A (Transaction ID: 100)

事务B (Transaction ID: 200)

T1

START TRANSACTION;

START TRANSACTION;

T2

SELECT balance FROM account WHERE id=1;

(输出 1000)

T3

UPDATE account SET balance = 1500 WHERE id=1;

(提交)

T4

COMMIT;

T5

START TRANSACTION;

(新事务,ID: 300)

T6

SELECT balance FROM account WHERE id=1;

(输出 ?)

在T3时刻,事务B更新数据时发生了什么?
  1. 数据库会为这行记录(id=1)在Undo Log中生成一个旧版本的拷贝,内容是 balance = 1000
  2. 然后更新当前行的数据,设置 balance = 1500,并将 DB_TRX_ID 字段设置为事务B的ID 200,将 DB_ROLL_PTR 指向刚刚在Undo Log中创建的旧版本记录。
  3. 此时,这行数据的版本链如下:
  • 当前最新版本balance=1500, DB_TRX_ID=200, DB_ROLL_PTR -> v1
  • v1 (历史版本)balance=1000, DB_TRX_ID=100 (假设最初是事务100插入的)
在T4时刻,事务A再次查询(T2之后,T4提交之前)

事务A在T2时刻执行第一个SELECT时,已经生成了一个Read View(假设此时没有其他活跃事务,所以Read View中记录的最小事务ID是100,最大是200+1=201,活跃列表为[100, 200]? 这里注意,事务A的ID是100,事务B的ID是200,在T2时刻事务B还未提交)。

当事务A在T4前再次执行SELECT balance FROM account WHERE id=1;时:

  1. 它找到这行数据的最新版本(balance=1500, TRX_ID=200)。
  2. 它用自己的Read View判断这个版本是否可见。TRX_ID=200 在它的Read View的活跃事务列表中(因为事务B还未提交),所以该版本对事务A不可见
  3. 于是顺着回滚指针 DB_ROLL_PTR 找到上一个版本 v1 (balance=1000, TRX_ID=100)。
  4. 判断 TRX_ID=100:它小于Read View中的最小事务ID吗?不,它等于当前事务A自己的ID(100),所以这个版本对事务A是可见的
  5. 因此,事务A读到的值仍然是 1000,尽管事务B已经修改了数据。这就实现了可重复读,避免了不可重复读的问题。
在T6时刻,新事务300查询

事务300在事务B提交之后才开始,它执行SELECT时会生成一个新的Read View。此时系统中事务B(200)已经提交,不再活跃。

  1. 它找到最新版本(balance=1500, TRX_ID=200)。
  2. 用它的Read View判断:TRX_ID=200 不在其Read View的活跃事务列表中,且小于下一个事务ID(301),所以该版本对事务300可见
  3. 因此,事务300读到的值是 1500。它读到了已提交的最新数据。

总结

特性/场景

没有MVCC(通过锁实现)

有MVCC

解决的问题

事务A长时间读

阻塞事务B的更新操作,导致事务B超时或等待。

事务B正常更新,创建新版本。事务A读取Undo Log中的历史快照版本。

读写不阻塞

事务B更新未提交

事务A的读操作会被阻塞,等待事务B释放锁。

事务A的读操作不受影响,读取的是更新前的快照。

读不阻塞写

可重复读

需要在整个事务期间对读取的数据加锁,锁管理复杂,并发度低。

通过Read View和版本链,天然在事务内提供一致性快照,无需加读锁。

高并发下的数据一致性

MVCC的精妙之处在于:它通过“空间换时间”的策略,将数据的历史版本保存在Undo Log中,为每个事务提供了一份“快照”。读写操作不再需要竞争同一份数据资源,从而极大地提升了数据库在并发场景下的性能和吞吐量。它主要是为了实现 READ COMMITTEDREPEATABLE READ 这两种隔离级别而设计的。