硬核图解:MySQL 是如何利用 MVCC + 锁实现"可重复读"的?

在数据库面试或架构设计中,我们经常会被问到一个经典问题:"MySQL 的 RR(Repeatable Read)隔离级别是如何解决不可重复读的?"
很多同学脱口而出:"MVCC!"
这个答案对,但也不全对。MVCC(多版本并发控制)确实是核心,但它解决的是读写并发 时的视图一致性问题。而在真实的业务场景中,数据库必须同时处理并发读 和并发写 。仅仅依靠 MVCC 是不够的,必须配合锁机制才能构建起完整的隔离性防线。
今天,我们就通过一个真实的"银行转账 + 财务审计"场景,像剥洋葱一样拆解 MySQL 底层是如何通过 MVCC 和锁的协同,来保证数据绝对安全的。
一、 案发现场:转账与审计的并发博弈
为了还原真实场景,我们构建一个简单的账户表 accounts,包含 ID、姓名和余额。
初始数据如下(总余额 3500.00):
| id | name | balance |
|---|---|---|
| 1 | Alice | 1000.00 |
| 2 | Bob | 500.00 |
| 3 | Charlie | 2000.00 |
现在,系统中有两个并发事务正在执行:
- 事务 T1 (TRX_ID=100):转账业务。Alice 向 Bob 转账 100 元。
- 事务 T2 (TRX_ID=200):审计业务。查询所有账户的总余额。
我们的目标是观察:在 T1 修改数据但未提交、以及提交后的各个阶段,T2 到底看到了什么?
二、 时序拆解:上帝视角看数据流转
为了讲清楚 MVCC 的工作原理,我们将时间轴拉长,一步步看 MySQL 内部发生了什么。
T1 & T2 事务开启
sql
-- 事务 T1 (ID=100)
START TRANSACTION;
-- 事务 T2 (ID=200)
START TRANSACTION;
此时,两个事务都领取到了自己的事务 ID。数据库中的数据尚未变动。
T2 第一次查询:快照生成(Read View)
事务 T2 发起第一次总额查询:
sql
SELECT SUM(balance) FROM accounts;
底层发生了什么? 在 RR 隔离级别下,这是 T2 的第一条快照读(Snapshot Read)。MySQL 会在此时此刻为 T2 生成一个 Read View(读视图)。这个视图包含了当前系统中所有"活跃"(未提交)的事务 ID。
- T2 的 Read View 核心参数 :
m_ids:[100, 200](当前活跃的事务是 T1 和 T2)min_trx_id:100(最小活跃 ID)max_trx_id:201(下一个将要分配的 ID)
结果 :T2 看到的数据是初始状态,总额 3500.00。
T3 事务 T1 执行转账:版本链诞生
事务 T1 开始干活了,执行转账 SQL:
sql
UPDATE accounts SET balance = 900.00 WHERE id = 1; -- Alice -100
UPDATE accounts SET balance = 600.00 WHERE id = 2; -- Bob +100
底层发生了什么? 这里涉及到了 MVCC 的核心------Undo Log 版本链。 MySQL 不会直接覆盖旧数据,而是生成新记录,并用回滚指针(Roll_Ptr)指向旧记录。
此时 id=1 (Alice) 的行结构变成了这样:
- 最新版本 :Balance=900,
DB_TRX_ID=100(T1的ID) - Undo Log 旧版本 :Balance=1000,
DB_TRX_ID=50(上次修改的事务ID)
同时,T1 会对 id=1 和 id=2 这两行数据加上排他锁(X锁),防止其他事务(如 T3)同时修改。
T4 事务 T2 第二次查询:复用快照
在 T1 修改完但未提交时,T2 再次查询总额:
sql
SELECT SUM(balance) FROM accounts;
结果预测 : 如果发生脏读,T2 会算成 3500(900+600+2000)。但在 RR 级别下,结果依然是 3500.00(1000+500+2000)。
为什么?可见性判断算法生效 T2 复用 了 T2 时刻生成的 Read View。当扫描到 Alice 的行(最新 balance=900)时,MySQL 拿着这行的事务 ID (TRX_ID=100) 去问 Read View:
"事务 100 修改的数据,我能看吗?"
Read View 答:
"
100在我的活跃列表[100, 200]里。这意味着我创建快照时,它还没提交呢。不可见!"
于是,MySQL 顺着 Undo Log 链条往下找,找到了旧版本(balance=1000, TRX_ID=50)。TRX_ID=50 小于最小活跃 ID,说明早就提交了,可见!
同理,Bob 的账户也读取了旧版本。
T5 & T6 T1 提交与 T2 第三次查询
sql
-- T1 提交
COMMIT;
-- T2 第三次查询
SELECT SUM(balance) FROM accounts;
最关键的时刻来了:T1 已经提交了,数据在物理上已经变成了 900 和 600。T2 此时再查,能看到变化吗?
结果 :依然是 3500.00(1000+500+2000)。
这就是 "可重复读"(Repeatable Read) 的含义。 在 RR 级别下,事务只在第一次 SELECT 时生成 Read View,后续所有的查询都复用这个 Read View。 虽然 T1 提交了,但在 T2 的 Read View 眼里,T1 依然属于"创建快照时还未提交的家伙",所以它的修改依然不可见。
三、 技术解密:MVCC 与锁的完美协同
通过上面的流程,我们可以总结出 MySQL 处理并发的核心逻辑:读写分离。
这里的"读写分离"不是指主从架构,而是指读操作和写操作使用不同的并发控制机制。
1. MVCC:守护"一致性读"
T2 在整个过程中,无论 T1 怎么折腾,无论 T1 是否提交,T2 就像在一个独立的平行宇宙中,看到的数据始终一致。
- 机制:Read View + Undo Log。
- 防御:脏读(Dirty Read)、不可重复读(Non-repeatable Read)。
2. 锁:守护"并发写"
在 T1 更新数据的同时,如果有一个事务 T3 也想修改 Alice 的余额:
sql
UPDATE accounts SET balance = 800 WHERE id = 1;
T3 会被阻塞。因为 T1 持有了行级锁(Record Lock)。只有 T1 提交释放锁后,T3 才能执行。
- 机制:行锁(Row Lock)、间隙锁(Gap Lock)。
- 防御:更新丢失(Lost Update)、写冲突。
3. 为什么是最佳搭档?
如果不使用这套组合拳,我们会陷入两个极端:
- 没有 MVCC:T2 为了读取数据一致性,必须加读锁(S锁)。这会导致 T1 想转账时被阻塞,读写串行化,性能极差。
- 没有锁:T1 和 T3 同时修改,发生"更新丢失",钱算错了,这是金融系统的灾难。
MVCC 让读不阻塞写,锁让写不覆盖写。
四、 总结
我们常说的"MySQL 隔离性",在底层其实是由两套机制共同支撑的:
-
Read View 可见性规则:
pythonif trx_id == creator_id: return "可见 (自己改的)" if trx_id < min_trx_id: return "可见 (早就是历史了)" if trx_id in active_ids: return "不可见 (还在跑着呢)" -
锁机制:确保同一时刻只有一个事务能修改同一行数据,维护数据的物理完整性。
下次再遇到"转账并发"的问题,不要只盯着代码层面的 synchronized 或分布式锁,别忘了数据库底层已经为你筑起了最坚固的防线。
思考题:如果将隔离级别改为 RC(读已提交),T6 时刻 T2 查询的结果会变成多少?为什么?欢迎在评论区讨论。