简单来说,MVCC 是一种让数据库在同一时间支持多人读写的技术。它就像给数据库里的每一份数据都保留了多个历史版本,这样,当有人正在修改数据时,其他人仍然可以看到修改前的数据版本,互不干扰。
1. 为什么我们需要 MVCC?
想象一下,你和你的朋友同时在编辑一份共享文档。
没有 MVCC 的世界(悲观锁的世界):当你的朋友打开文档编辑时,文档就会被“锁”起来,你必须等他编辑完并保存后,你才能打开编辑。如果你在等待过程中,你的朋友又去吃了个饭,你就得一直等着。这叫做“悲观锁”,因为它总是假设会发生冲突,所以提前把资源锁住,效率很低。
有 MVCC 的世界(乐观锁的思想):你们俩可以同时打开文档编辑。你看到的是文档当前的最新版本,他看到的是他打开时的那个版本。当你们都修改完保存时,系统会协调处理,比如看看有没有冲突(如果修改了同一个地方),如果没有冲突就各自保存,如果有冲突就提示解决。数据库里的 MVCC 更智能一些,它通过保存多个版本,让读操作几乎不会被写操作阻塞。
核心痛点: 在高并发的数据库系统中,如果读操作和写操作互相等待,效率会非常低下。例如,一个耗时很长的报表查询(读操作)可能会阻塞住一个重要的交易更新(写操作),反之亦然。我们希望读不阻塞写,写不阻塞读。MVCC 就是解决这个问题的关键技术。
2. MVCC 的核心思想:版本化数据
MVCC 的全称是 Multi-Version Concurrency Control
,即多版本并发控制。顾名思义,它的核心在于“多版本”。
数据库中的每一行数据,当它被修改时,并不是直接在原地修改,而是会创建一个新的版本。旧的版本会保留下来,而不是立即删除。
你可以把数据库想象成一个时间机器,每一行数据在不同的时间点,都有一个对应的“快照”。
思考:
- 为什么要保留旧版本?
- 这些版本是如何区分的?
- 什么时候旧版本会被清理掉?
3. MVCC 的实现原理:幕后的英雄们
MVCC 并不是一个单一的“功能”,它是一套协同工作的机制。在 MySQL 的 InnoDB
存储引擎中,MVCC 主要依赖于以下几个“幕后英雄”:
- 隐藏列(Hidden Columns)
- Undo Log(回滚日志)
- Read View(读视图)
我们来逐一了解它们扮演的角色。
3.1 隐藏列:行记录的“身份证”和“保质期”
InnoDB
表的每一行记录,除了你定义的那些列(比如 name
, age
),还会默默地增加几个隐藏列。这些隐藏列就像是给每一行数据附加的“元数据”,记录着它的生命周期信息。
主要的隐藏列有:
DB_TRX_ID
(Transaction ID):事务 ID。记录了最近一次修改(插入或更新)本行记录的事务 ID。这个 ID 是一个递增的数字,每个新事务都会获得一个更大的 ID。DB_ROLL_PTR
(Roll Pointer):回滚指针。指向Undo Log
中的一条记录。通过这个指针,可以找到该行数据上一个版本的记录(也就是当前版本修改前的样子)。多个版本通过这个指针连接起来,形成一个版本链。DB_ROW_ID
(Row ID):行 ID。这个是隐式主键,如果表没有定义主键,InnoDB
会自动生成一个。DB_TRX_ID
就像是数据被“出生”或“改造”时的“时间戳”或“事件编号”。DB_ROLL_PTR
就像是通往“前世”的传送门,通过它你可以追溯到数据的所有历史版本。
可视化模拟:数据行的演变
假设我们有一个 users
表:
id | name | age | (隐藏)DB_TRX_ID | (隐藏)DB_ROLL_PTR |
---|---|---|---|---|
Step 1: 插入一条记录
事务 T1 插入一条记录 (1, 'Alice', 25)
。
id | name | age | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
1 | Alice | 25 | T1 | NULL |
DB_TRX_ID
是 T1 的事务 ID。DB_ROLL_PTR
为 NULL,因为它没有上一个版本。
Step 2: 事务 T2 更新记录
事务 T2 将 Alice
的 age
更新为 26
。
关键点: InnoDB
不会在原地修改!它会:
- 在
Undo Log
中,把当前(1, 'Alice', 25)
这条记录(即旧版本)记录下来。 - 在原记录行上,修改
age
为26
,并更新DB_TRX_ID
和DB_ROLL_PTR
。
id | name | age | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
1 | Alice | 26 | T2 | 指向 T1 版本的 Undo Log |
现在,看起来只有一条记录。但实际上,通过 DB_ROLL_PTR
,我们可以回溯到 Undo Log
中 T1 插入的那个版本。
Step 3: 事务 T3 再次更新记录
事务 T3 将 Alice
的 name
更新为 Ali
。
- 在
Undo Log
中,把当前(1, 'Alice', 26)
这个版本记录下来。 - 在原记录行上,修改
name
为Ali
,并更新DB_TRX_ID
和DB_ROLL_PTR
。
id | name | age | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
1 | Ali | 26 | T3 | 指向 T2 版本的 Undo Log |
版本链形成:
从当前最新版本开始,通过 DB_ROLL_PTR
就能像链条一样,一直回溯到数据的最初版本。这就是 MVCC 的版本链(Version Chain)。
3.2 Undo Log:记录历史的时光机
Undo Log
,顾名思义,是用来回滚(undo)操作的日志。但它在 MVCC 中扮演的角色远不止于此。它实际上是存储旧版本数据的地方。
- 回滚用途: 当事务需要回滚时,
InnoDB
可以根据Undo Log
中的记录,将数据恢复到事务开始前的状态。 - MVCC 用途: 当一个读事务需要读取某个数据行的历史版本时,它会沿着
DB_ROLL_PTR
指向的Undo Log
链条,找到符合其Read View
条件的旧版本数据。
每次对数据进行 INSERT
, UPDATE
, DELETE
操作时,都会生成对应的 Undo Log
:
INSERT
对应的Undo Log
:记录新插入行的主键信息,用于回滚时删除该行。UPDATE
对应的Undo Log
:记录被修改行的旧值,以及旧版本的DB_TRX_ID
和DB_ROLL_PTR
。这正是构建版本链的关键。DELETE
对应的Undo Log
:记录被删除行的所有信息,用于回滚时恢复该行。
思考:
Undo Log
会一直增长吗?- 旧版本的
Undo Log
什么时候会被清理?
当所有的活跃事务都不再需要某个旧版本的数据时,这些旧版本的 Undo Log
就会被 Purge
线程清理掉。这个清理过程是自动进行的,确保 Undo Log
文件不会无限膨胀。
3.3 Read View:定义事务的“时间维度”
Read View
(读视图) 是 MVCC 最精髓的部分。它决定了一个事务在某一时刻能够“看到”哪些数据版本。
当一个事务启动时,它会生成一个 Read View
。这个 Read View
就像是给事务拍了一张“快照”,记录了当前活跃的事务 ID 列表。
Read View
主要包含以下几个关键信息:
m_ids
:当前活跃的事务 ID 列表。这个列表记录了在生成Read View
时,所有正在进行但还未提交的事务 ID。min_trx_id
(或up_limit_id
):m_ids
中最小的事务 ID。比这个 ID 小的事务,都已经被提交了。max_trx_id
(或low_limit_id
):InnoDB
系统中,下一个将被分配的事务 ID(即当前最大的事务 ID 加 1)。比这个 ID 大的事务,都是在Read View
之后才启动的。creator_trx_id
:创建这个Read View
的事务本身的 ID。
判断规则:事务 T 尝试读取一行记录 R(其 DB_TRX_ID
为 trx_id_R
)时,根据事务 T 的 Read View
,会进行如下判断:
trx_id_R < min_trx_id
:- 表示修改该行记录的事务
trx_id_R
在当前事务的Read View
建立之前就已经提交了。 - 结论: 记录 R 是可见的。
- 表示修改该行记录的事务
trx_id_R >= max_trx_id
:- 表示修改该行记录的事务
trx_id_R
在当前事务的Read View
建立之后才启动或提交。 - 结论: 记录 R 是不可见的(因为是未来的修改)。
- 表示修改该行记录的事务
min_trx_id <= trx_id_R < max_trx_id
:- 表示修改该行记录的事务
trx_id_R
在当前事务的Read View
建立时是活跃的(或者在Read View
建立后,但在max_trx_id
之前提交的)。 - 此时需要进一步判断
trx_id_R
是否在m_ids
列表中:- 如果
trx_id_R
在m_ids
列表中:说明trx_id_R
对应的事务在Read View
生成时是活跃的(或尚未提交),因此不可见。需要沿着DB_ROLL_PTR
找上一个版本,继续判断。 - 如果
trx_id_R
不在m_ids
列表中:说明trx_id_R
对应的事务在Read View
生成时已经提交。- 如果
trx_id_R
等于creator_trx_id
(即当前事务自己修改的),则可见。 - 否则,该版本是可见的。
- 如果
- 如果
- 表示修改该行记录的事务
总结可见性判断流程:
深入思考:
为什么需要 min_trx_id
和 max_trx_id
?它们可以快速排除掉大部分情况。m_ids
是最核心的判断,它精准地描绘了“快照”那一刻的活跃事务。
4. MVCC 与隔离级别
MVCC 并不是适用于所有的事务隔离级别。它主要服务于读已提交 (Read Committed) 和 可重复读 (Repeatable Read) 这两个隔离级别。
读未提交 (Read Uncommitted):直接读取最新版本,不使用 MVCC。可能读到脏数据。
读已提交 (Read Committed, RC):每次
SELECT
语句执行时,都会重新生成一个Read View
。这意味着在一个事务中,两次相同的SELECT
查询可能会读到不同的数据(如果其他事务在这两次查询之间提交了)。它能避免脏读,但可能出现不可重复读。可重复读 (Repeatable Read, RR):在一个事务的整个生命周期内,只在事务开始时生成一次
Read View
。此后的所有SELECT
查询都使用这个固定的Read View
。这保证了在同一个事务中,无论查询多少次,看到的数据都是一致的,从而避免了脏读和不可重复读。注意: 在
RR
隔离级别下,MVCC 结合间隙锁 (Gap Lock) 才能彻底解决幻读。MVCC 本身只能解决快照读(Snapshot Read)下的幻读,无法解决当前读(Current Read,如SELECT ... FOR UPDATE
)下的幻读。串行化 (Serializable):强制事务串行执行,读写都会加锁,不使用 MVCC。
总结 MVCC 在不同隔离级别中的表现:
隔离级别 | Read View 生成时机 |
特点 |
---|---|---|
Read Committed | 每条 SELECT 语句开始时 |
只能看到已提交的最新版本,可能出现不可重复读。 |
Repeatable Read | 事务开始时(第一次读操作时) | 事务内看到的都是固定快照,不会出现不可重复读。 |
5. MVCC 的操作流程模拟(以 RR 隔离级别为例)
让我们通过一个具体的例子,模拟 MVCC 在 Repeatable Read
隔离级别下的工作流程。
假设:
- 表
accounts
,初始数据:id=1, balance=100
- 事务 T1, T2, T3
- 事务 ID 递增,T1 < T2 < T3
初始状态:
accounts
表中只有一条记录,其 DB_TRX_ID
为 T0(假设是初始化事务 ID)。
id | balance | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 100 | T0 | NULL |
场景模拟:
时间点 A:
事务 T1 启动。
SELECT * FROM accounts WHERE id = 1;
(这是 T1 的第一次读操作,生成 Read View
)
T1 的
Read View
建立:m_ids
:[]
(此时没有其他活跃事务)min_trx_id
: 假设为100
(如果 T0 是 99)max_trx_id
:101
(下一个事务 ID)creator_trx_id
:T1
(假设 T1 的 ID 是 100)
T1 读判断:
- 读取行
(id=1, balance=100, DB_TRX_ID=T0)
T0 < min_trx_id
(T0 < 100) -> 可见。
- 读取行
结果: T1 读到
(id=1, balance=100)
时间点 B:
事务 T2 启动。
UPDATE accounts SET balance = 150 WHERE id = 1;
T2 提交。
T2 执行过程:
- 在
Undo Log
中保存当前行(id=1, balance=100, DB_TRX_ID=T0)
。 - 更新当前行:
id=1, balance=150, DB_TRX_ID=T2, DB_ROLL_PTR
指向Undo Log
中的 T0 版本。
当前数据行状态:
id | balance | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 150 | T2 | 指向 T0 版本的 Undo Log |
版本链:
当前行(T2: balance=150)
--> Undo Log(T0: balance=100)
时间点 C:
事务 T3 启动。
UPDATE accounts SET balance = 200 WHERE id = 1;
T3 提交。
T3 执行过程:
- 在
Undo Log
中保存当前行(id=1, balance=150, DB_TRX_ID=T2)
。 - 更新当前行:
id=1, balance=200, DB_TRX_ID=T3, DB_ROLL_PTR
指向Undo Log
中的 T2 版本。
当前数据行状态:
id | balance | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 200 | T3 | 指向 T2 版本的 Undo Log |
版本链:
当前行(T3: balance=200)
--> Undo Log(T2: balance=150)
--> Undo Log(T0: balance=100)
时间点 D:
事务 T1 再次执行读操作。
SELECT * FROM accounts WHERE id = 1;
T1 使用其初始的
Read View
:m_ids
:[]
(这个Read View
是在时间点 A 生成的,当时 T2, T3 还没启动)min_trx_id
:100
(T1 自己的 ID)max_trx_id
:101
(下一个事务 ID)creator_trx_id
:T1
(100
)
T1 读判断:
读取当前行:
(id=1, balance=200, DB_TRX_ID=T3)
trx_id_R = T3
min_trx_id = 100
,max_trx_id = 101
T3 >= max_trx_id
(T3 肯定比 T1 的max_trx_id
大,因为 T3 是后来启动的事务) -> 不可见。- 沿着
DB_ROLL_PTR
找下一个版本。
查找
Undo Log
中的 T2 版本:(id=1, balance=150, DB_TRX_ID=T2)
trx_id_R = T2
min_trx_id = 100
,max_trx_id = 101
T2 >= max_trx_id
-> 不可见。- 沿着
DB_ROLL_PTR
找下一个版本。
查找
Undo Log
中的 T0 版本:(id=1, balance=100, DB_TRX_ID=T0)
trx_id_R = T0
min_trx_id = 100
T0 < min_trx_id
-> 可见。
结果: T1 再次读到
(id=1, balance=100)
。
整个过程可视化:
通过这个模拟,我们可以清楚地看到,即使其他事务在 T1 期间修改并提交了数据,T1 也能通过其固定的 Read View
和版本链,找到它“应该看到”的那个旧版本数据,从而实现了可重复读。
6. MVCC 的优点和缺点
优点:
- 提高并发性能: 读操作不再需要等待写操作释放锁,写操作也不需要等待读操作。实现了读写并行,大大提高了数据库的并发处理能力。
- 避免脏读和不可重复读: 通过
Read View
和版本链,每个事务都能读到一致的数据快照。 - 减轻锁的开销: 很多读操作(快照读)不再需要加锁,减少了锁竞争,降低了死锁的风险。
简单来说,MVCC 是一种让数据库在同一时间支持多人读写的技术。它就像给数据库里的每一份数据都保留了多个历史版本,这样,当有人正在修改数据时,其他人仍然可以看到修改前的数据版本,互不干扰。
让我们从最最基础的概念开始,一步步揭开 MVCC 的神秘面纱。
1. 为什么我们需要 MVCC?
想象一下,你和你的朋友同时在编辑一份共享文档。
没有 MVCC 的世界(悲观锁的世界):当你的朋友打开文档编辑时,文档就会被“锁”起来,你必须等他编辑完并保存后,你才能打开编辑。如果你在等待过程中,你的朋友又去吃了个饭,你就得一直等着。这叫做“悲观锁”,因为它总是假设会发生冲突,所以提前把资源锁住,效率很低。
有 MVCC 的世界(乐观锁的思想):你们俩可以同时打开文档编辑。你看到的是文档当前的最新版本,他看到的是他打开时的那个版本。当你们都修改完保存时,系统会协调处理,比如看看有没有冲突(如果修改了同一个地方),如果没有冲突就各自保存,如果有冲突就提示解决。数据库里的 MVCC 更智能一些,它通过保存多个版本,让读操作几乎不会被写操作阻塞。
核心痛点: 在高并发的数据库系统中,如果读操作和写操作互相等待,效率会非常低下。例如,一个耗时很长的报表查询(读操作)可能会阻塞住一个重要的交易更新(写操作),反之亦然。我们希望读不阻塞写,写不阻塞读。MVCC 就是解决这个问题的关键技术。
2. MVCC 的核心思想:版本化数据
MVCC 的全称是 Multi-Version Concurrency Control
,即多版本并发控制。顾名思义,它的核心在于“多版本”。
数据库中的每一行数据,当它被修改时,并不是直接在原地修改,而是会创建一个新的版本。旧的版本会保留下来,而不是立即删除。
你可以把数据库想象成一个时间机器,每一行数据在不同的时间点,都有一个对应的“快照”。
思考:
- 为什么要保留旧版本?
- 这些版本是如何区分的?
- 什么时候旧版本会被清理掉?
别急,我们一步步来。
3. MVCC 的实现原理:幕后的英雄们
MVCC 并不是一个单一的“功能”,它是一套协同工作的机制。在 MySQL 的 InnoDB
存储引擎中,MVCC 主要依赖于以下几个“幕后英雄”:
- 隐藏列(Hidden Columns)
- Undo Log(回滚日志)
- Read View(读视图)
我们来逐一了解它们扮演的角色。
3.1 隐藏列:行记录的“身份证”和“保质期”
InnoDB
表的每一行记录,除了你定义的那些列(比如 name
, age
),还会默默地增加几个隐藏列。这些隐藏列就像是给每一行数据附加的“元数据”,记录着它的生命周期信息。
主要的隐藏列有:
DB_TRX_ID
(Transaction ID):事务 ID。记录了最近一次修改(插入或更新)本行记录的事务 ID。这个 ID 是一个递增的数字,每个新事务都会获得一个更大的 ID。DB_ROLL_PTR
(Roll Pointer):回滚指针。指向Undo Log
中的一条记录。通过这个指针,可以找到该行数据上一个版本的记录(也就是当前版本修改前的样子)。多个版本通过这个指针连接起来,形成一个版本链。DB_ROW_ID
(Row ID):行 ID。这个是隐式主键,如果表没有定义主键,InnoDB
会自动生成一个。
联想:
DB_TRX_ID
就像是数据被“出生”或“改造”时的“时间戳”或“事件编号”。DB_ROLL_PTR
就像是通往“前世”的传送门,通过它你可以追溯到数据的所有历史版本。
可视化模拟:数据行的演变
假设我们有一个 users
表:
id | name | age | (隐藏)DB_TRX_ID | (隐藏)DB_ROLL_PTR |
---|---|---|---|---|
Step 1: 插入一条记录
事务 T1 插入一条记录 (1, 'Alice', 25)
。
id | name | age | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
1 | Alice | 25 | T1 | NULL |
DB_TRX_ID
是 T1 的事务 ID。DB_ROLL_PTR
为 NULL,因为它没有上一个版本。
Step 2: 事务 T2 更新记录
事务 T2 将 Alice
的 age
更新为 26
。
关键点: InnoDB
不会在原地修改!它会:
- 在
Undo Log
中,把当前(1, 'Alice', 25)
这条记录(即旧版本)记录下来。 - 在原记录行上,修改
age
为26
,并更新DB_TRX_ID
和DB_ROLL_PTR
。
id | name | age | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
1 | Alice | 26 | T2 | 指向 T1 版本的 Undo Log |
现在,看起来只有一条记录。但实际上,通过 DB_ROLL_PTR
,我们可以回溯到 Undo Log
中 T1 插入的那个版本。
Step 3: 事务 T3 再次更新记录
事务 T3 将 Alice
的 name
更新为 Ali
。
- 在
Undo Log
中,把当前(1, 'Alice', 26)
这个版本记录下来。 - 在原记录行上,修改
name
为Ali
,并更新DB_TRX_ID
和DB_ROLL_PTR
。
id | name | age | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
1 | Ali | 26 | T3 | 指向 T2 版本的 Undo Log |
版本链形成:
从当前最新版本开始,通过 DB_ROLL_PTR
就能像链条一样,一直回溯到数据的最初版本。这就是 MVCC 的版本链(Version Chain)。
3.2 Undo Log:记录历史的时光机
Undo Log
,顾名思义,是用来回滚(undo)操作的日志。但它在 MVCC 中扮演的角色远不止于此。它实际上是存储旧版本数据的地方。
- 回滚用途: 当事务需要回滚时,
InnoDB
可以根据Undo Log
中的记录,将数据恢复到事务开始前的状态。 - MVCC 用途: 当一个读事务需要读取某个数据行的历史版本时,它会沿着
DB_ROLL_PTR
指向的Undo Log
链条,找到符合其Read View
条件的旧版本数据。
每次对数据进行 INSERT
, UPDATE
, DELETE
操作时,都会生成对应的 Undo Log
:
INSERT
对应的Undo Log
:记录新插入行的主键信息,用于回滚时删除该行。UPDATE
对应的Undo Log
:记录被修改行的旧值,以及旧版本的DB_TRX_ID
和DB_ROLL_PTR
。这正是构建版本链的关键。DELETE
对应的Undo Log
:记录被删除行的所有信息,用于回滚时恢复该行。
思考:
Undo Log
会一直增长吗?- 旧版本的
Undo Log
什么时候会被清理?
当所有的活跃事务都不再需要某个旧版本的数据时,这些旧版本的 Undo Log
就会被 Purge
线程清理掉。这个清理过程是自动进行的,确保 Undo Log
文件不会无限膨胀。
3.3 Read View:定义事务的“时间维度”
Read View
(读视图) 是 MVCC 最精髓的部分。它决定了一个事务在某一时刻能够“看到”哪些数据版本。
当一个事务启动时,它会生成一个 Read View
。这个 Read View
就像是给事务拍了一张“快照”,记录了当前活跃的事务 ID 列表。
Read View
主要包含以下几个关键信息:
m_ids
:当前活跃的事务 ID 列表。这个列表记录了在生成Read View
时,所有正在进行但还未提交的事务 ID。min_trx_id
(或up_limit_id
):m_ids
中最小的事务 ID。比这个 ID 小的事务,都已经被提交了。max_trx_id
(或low_limit_id
):InnoDB
系统中,下一个将被分配的事务 ID(即当前最大的事务 ID 加 1)。比这个 ID 大的事务,都是在Read View
之后才启动的。creator_trx_id
:创建这个Read View
的事务本身的 ID。
判断规则:事务 T 尝试读取一行记录 R(其 DB_TRX_ID
为 trx_id_R
)时,根据事务 T 的 Read View
,会进行如下判断:
trx_id_R < min_trx_id
:- 表示修改该行记录的事务
trx_id_R
在当前事务的Read View
建立之前就已经提交了。 - 结论: 记录 R 是可见的。
- 表示修改该行记录的事务
trx_id_R >= max_trx_id
:- 表示修改该行记录的事务
trx_id_R
在当前事务的Read View
建立之后才启动或提交。 - 结论: 记录 R 是不可见的(因为是未来的修改)。
- 表示修改该行记录的事务
min_trx_id <= trx_id_R < max_trx_id
:- 表示修改该行记录的事务
trx_id_R
在当前事务的Read View
建立时是活跃的(或者在Read View
建立后,但在max_trx_id
之前提交的)。 - 此时需要进一步判断
trx_id_R
是否在m_ids
列表中:- 如果
trx_id_R
在m_ids
列表中:说明trx_id_R
对应的事务在Read View
生成时是活跃的(或尚未提交),因此不可见。需要沿着DB_ROLL_PTR
找上一个版本,继续判断。 - 如果
trx_id_R
不在m_ids
列表中:说明trx_id_R
对应的事务在Read View
生成时已经提交。- 如果
trx_id_R
等于creator_trx_id
(即当前事务自己修改的),则可见。 - 否则,该版本是可见的。
- 如果
- 如果
- 表示修改该行记录的事务
总结可见性判断流程:
深入思考:
为什么需要 min_trx_id
和 max_trx_id
?它们可以快速排除掉大部分情况。m_ids
是最核心的判断,它精准地描绘了“快照”那一刻的活跃事务。
4. MVCC 与隔离级别
MVCC 并不是适用于所有的事务隔离级别。它主要服务于读已提交 (Read Committed) 和 可重复读 (Repeatable Read) 这两个隔离级别。
读未提交 (Read Uncommitted):直接读取最新版本,不使用 MVCC。可能读到脏数据。
读已提交 (Read Committed, RC):每次
SELECT
语句执行时,都会重新生成一个Read View
。这意味着在一个事务中,两次相同的SELECT
查询可能会读到不同的数据(如果其他事务在这两次查询之间提交了)。它能避免脏读,但可能出现不可重复读。可重复读 (Repeatable Read, RR):在一个事务的整个生命周期内,只在事务开始时生成一次
Read View
。此后的所有SELECT
查询都使用这个固定的Read View
。这保证了在同一个事务中,无论查询多少次,看到的数据都是一致的,从而避免了脏读和不可重复读。注意: 在
RR
隔离级别下,MVCC 结合间隙锁 (Gap Lock) 才能彻底解决幻读。MVCC 本身只能解决快照读(Snapshot Read)下的幻读,无法解决当前读(Current Read,如SELECT ... FOR UPDATE
)下的幻读。串行化 (Serializable):强制事务串行执行,读写都会加锁,不使用 MVCC。
总结 MVCC 在不同隔离级别中的表现:
隔离级别 | Read View 生成时机 |
特点 |
---|---|---|
Read Committed | 每条 SELECT 语句开始时 |
只能看到已提交的最新版本,可能出现不可重复读。 |
Repeatable Read | 事务开始时(第一次读操作时) | 事务内看到的都是固定快照,不会出现不可重复读。 |
5. MVCC 的操作流程模拟(以 RR 隔离级别为例)
让我们通过一个具体的例子,模拟 MVCC 在 Repeatable Read
隔离级别下的工作流程。
假设:
- 表
accounts
,初始数据:id=1, balance=100
- 事务 T1, T2, T3
- 事务 ID 递增,T1 < T2 < T3
初始状态:
accounts
表中只有一条记录,其 DB_TRX_ID
为 T0(假设是初始化事务 ID)。
id | balance | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 100 | T0 | NULL |
场景模拟:
时间点 A:
事务 T1 启动。
SELECT * FROM accounts WHERE id = 1;
(这是 T1 的第一次读操作,生成 Read View
)
T1 的
Read View
建立:m_ids
:[]
(此时没有其他活跃事务)min_trx_id
: 假设为100
(如果 T0 是 99)max_trx_id
:101
(下一个事务 ID)creator_trx_id
:T1
(假设 T1 的 ID 是 100)
T1 读判断:
- 读取行
(id=1, balance=100, DB_TRX_ID=T0)
T0 < min_trx_id
(T0 < 100) -> 可见。
- 读取行
结果: T1 读到
(id=1, balance=100)
时间点 B:
事务 T2 启动。
UPDATE accounts SET balance = 150 WHERE id = 1;
T2 提交。
T2 执行过程:
- 在
Undo Log
中保存当前行(id=1, balance=100, DB_TRX_ID=T0)
。 - 更新当前行:
id=1, balance=150, DB_TRX_ID=T2, DB_ROLL_PTR
指向Undo Log
中的 T0 版本。
当前数据行状态:
id | balance | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 150 | T2 | 指向 T0 版本的 Undo Log |
版本链:
当前行(T2: balance=150)
--> Undo Log(T0: balance=100)
时间点 C:
事务 T3 启动。
UPDATE accounts SET balance = 200 WHERE id = 1;
T3 提交。
T3 执行过程:
- 在
Undo Log
中保存当前行(id=1, balance=150, DB_TRX_ID=T2)
。 - 更新当前行:
id=1, balance=200, DB_TRX_ID=T3, DB_ROLL_PTR
指向Undo Log
中的 T2 版本。
当前数据行状态:
id | balance | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 200 | T3 | 指向 T2 版本的 Undo Log |
版本链:
当前行(T3: balance=200)
--> Undo Log(T2: balance=150)
--> Undo Log(T0: balance=100)
时间点 D:
事务 T1 再次执行读操作。
SELECT * FROM accounts WHERE id = 1;
T1 使用其初始的
Read View
:m_ids
:[]
(这个Read View
是在时间点 A 生成的,当时 T2, T3 还没启动)min_trx_id
:100
(T1 自己的 ID)max_trx_id
:101
(下一个事务 ID)creator_trx_id
:T1
(100
)
T1 读判断:
读取当前行:
(id=1, balance=200, DB_TRX_ID=T3)
trx_id_R = T3
min_trx_id = 100
,max_trx_id = 101
T3 >= max_trx_id
(T3 肯定比 T1 的max_trx_id
大,因为 T3 是后来启动的事务) -> 不可见。- 沿着
DB_ROLL_PTR
找下一个版本。
查找
Undo Log
中的 T2 版本:(id=1, balance=150, DB_TRX_ID=T2)
trx_id_R = T2
min_trx_id = 100
,max_trx_id = 101
T2 >= max_trx_id
-> 不可见。- 沿着
DB_ROLL_PTR
找下一个版本。
查找
Undo Log
中的 T0 版本:(id=1, balance=100, DB_TRX_ID=T0)
trx_id_R = T0
min_trx_id = 100
T0 < min_trx_id
-> 可见。
结果: T1 再次读到
(id=1, balance=100)
。
整个过程可视化:
通过这个模拟,我们可以清楚地看到,即使其他事务在 T1 期间修改并提交了数据,T1 也能通过其固定的 Read View
和版本链,找到它“应该看到”的那个旧版本数据,从而实现了可重复读。
6. MVCC 的优点和缺点
优点:
- 提高并发性能: 读操作不再需要等待写操作释放锁,写操作也不需要等待读操作。实现了读写并行,大大提高了数据库的并发处理能力。
- 避免脏读和不可重复读: 通过
Read View
和版本链,每个事务都能读到一致的数据快照。 - 减轻锁的开销: 很多读操作(快照读)不再需要加锁,减少了锁竞争,降低了死锁的风险。
缺点:
- 空间开销: 需要存储多个旧版本的数据(在
Undo Log
中),会占用额外的存储空间。 - 清理成本: 需要后台线程(Purge 线程)定期清理不再需要的旧版本数据,这会带来一定的系统开销。
- 实现复杂性: MVCC 的实现机制相对复杂,需要考虑版本链的管理、
Read View
的生成和判断逻辑、以及与锁机制的协同。
MVCC 是现代关系型数据库(特别是面向 OLTP 场景的数据库)并发控制的核心基石。它巧妙地利用了时间维度(事务 ID)和空间维度(版本链在 Undo Log
中的存储),实现了读写分离的并发控制,极大地提升了数据库的吞吐量和用户体验。
理解 MVCC,不仅是理解一个数据库特性,更是理解一种乐观并发控制的思想:宁愿多存储一些历史数据,也不愿让用户互相等待。这种思想在分布式系统、版本控制系统(如 Git)中也有类似的体现。
最终,MySQL 中的 MVCC,就是通过隐藏列、Undo Log 和 Read View 这三者精妙的配合,为每个事务提供了一个特定时间点的数据快照,从而实现了“读不阻塞写,写不阻塞读”的强大并发能力。
缺点:
- 空间开销: 需要存储多个旧版本的数据(在
Undo Log
中),会占用额外的存储空间。 - 清理成本: 需要后台线程(Purge 线程)定期清理不再需要的旧版本数据,这会带来一定的系统开销。
- 实现复杂性: MVCC 的实现机制相对复杂,需要考虑版本链的管理、
Read View
的生成和判断逻辑、以及与锁机制的协同。
MVCC 是现代关系型数据库(特别是面向 OLTP 场景的数据库)并发控制的核心基石。它巧妙地利用了时间维度(事务 ID)和空间维度(版本链在 Undo Log
中的存储),实现了读写分离的并发控制,极大地提升了数据库的吞吐量和用户体验。
理解 MVCC,不仅是理解一个数据库特性,更是理解一种乐观并发控制的思想:宁愿多存储一些历史数据,也不愿让用户互相等待。这种思想在分布式系统、版本控制系统(如 Git)中也有类似的体现。
最终,MySQL 中的 MVCC,就是通过隐藏列、Undo Log 和 Read View 这三者精妙的配合,为每个事务提供了一个特定时间点的数据快照,从而实现了“读不阻塞写,写不阻塞读”的强大并发能力。