
MySQL MVCC 与事务隔离级别:从一条 update 看版本链
关键词标签:MVCC、Read View、undo log、InnoDB 隔离级别、间隙锁
一个反直觉的起点
很多人对 MVCC 的第一印象是"读不加锁、读写不阻塞",于是顺理成章地认为:UPDATE 也不该阻塞快照读。但真实情况恰恰相反------在 InnoDB 里,快照读(普通 SELECT)走的是 MVCC 版本链,而 UPDATE 走的是当前读(current read),它必须去读最新已提交版本,并且要加锁 。也就是说,同一条记录,SELECT 和 UPDATE 在同一个事务里看到的可能不是"同一个版本"。这个分裂,正是理解隔离级别、版本链、Read View 三者关系的钥匙。
本系列前面已经讲过 AQS、线程池、CompletableFuture 这些并发原语。数据库事务的并发控制和它们同源------都是"多版本 + 可见性判定"的思路。这里我们换个战场,从 InnoDB 的存储引擎内部看一遍。
一、隐藏列与版本链:一行数据到底存了什么
1.1 三个隐藏字段
InnoDB 的聚簇索引叶子节点上,每一行记录除了用户定义的列,还有几个隐藏列:
DB_TRX_ID(6 字节):最近一次插入或修改该行的事务 ID。DB_ROLL_PTR(7 字节):回滚指针,指向 undo log 中该行的上一个版本。DB_ROW_ID(6 字节):当表没有主键、也没有唯一非空索引时,InnoDB 自动生成的隐含主键。
DB_ROLL_PTR 把一行行的历史版本串成了一条链表,这就是版本链。链头是最新版本(在聚簇索引里),链尾是最老的 undo 版本。
1.2 undo log 的两种类型
undo log 在 InnoDB 里分两类,理解它们的区别对理解 MVCC 很关键:
- insert undo:只服务于事务回滚,事务提交后即可清理,因为插入的记录对别的事务本来就不可见。
- update undo :既服务于回滚,也服务于 MVCC 快照读,所以不能在事务提交后立刻删除,必须等没有更老的 Read View 需要它时,才由 purge 线程回收。
这解释了一个常见现象:长事务会导致 undo 表空间膨胀、purge 落后。因为只要还有一个活跃的 Read View 需要读老版本,undo 就不能被清。
二、Read View:可见性判定的四个字段
当一个事务执行快照读时,InnoDB 会为它生成一个 Read View。核心字段(ReadView 类,见 storage/innobase/include/read0types.h)大致是:
m_ids:生成 Read View 时,系统中活跃但未提交的事务 ID 集合。m_low_limit_id:下一个将要分配的事务 ID,即m_ids中最大值 + 1(早期版本叫max_trx_id)。m_up_limit_id:m_ids中的最小值。m_creator_trx_id:创建这个 Read View 的事务自身 ID。
可见性判断的简化逻辑是:
- 若版本的
DB_TRX_ID == m_creator_trx_id,说明是本事务自己改的,可见。 - 若
DB_TRX_ID < m_up_limit_id,说明该版本在 Read View 创建前就已提交,可见。 - 若
DB_TRX_ID >= m_low_limit_id,说明是 Read View 创建后才开启的事务改的,不可见。 - 若
m_up_limit_id <= DB_TRX_ID < m_low_limit_id,再看它是否在m_ids里:在,说明当时还活跃,不可见;不在,说明已提交,可见。
不可见时,就顺着 DB_ROLL_PTR 往版本链的老版本走,直到找到一个可见版本,或者链走完(返回空)。
2.1 RC 与 RR 的真正差别
这是最容易被背错的地方。RC 和 RR 的差别不在可见性算法,而在 Read View 的生成时机:
- READ COMMITTED :每次快照读都重新生成一个 Read View。所以同一事务里两次
SELECT能看到别人新提交的数据。 - REPEATABLE READ :只在事务中第一次快照读时生成 Read View,之后复用。所以整个事务里看到的是同一个快照。
注意:RR 下"第一次快照读"才创建 Read View,而不是 BEGIN 时。如果事务里先做了一次 UPDATE(当前读),再 SELECT,Read View 是在 SELECT 那一刻才建的。
三、从一条 UPDATE 看穿当前读
3.1 UPDATE 不走版本链
假设 order-service 里有这样一张表(示例,非真实系统):
CREATE TABLE `t_order` (
`id` BIGINT NOT NULL PRIMARY KEY,
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0,
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB;
考虑两个事务:
-- 事务 A
BEGIN;
UPDATE t_order SET amount = amount + 100 WHERE id = 1;
-- 此时未提交
-- 事务 B
BEGIN;
UPDATE t_order SET amount = amount + 1 WHERE id = 1;
-- 会阻塞,等待 A 释放行锁
事务 B 的 UPDATE 是当前读,它需要读最新版本并加排他锁(X 锁)。由于 A 已经持有该行的 X 锁且未提交,B 只能等。这跟"MVCC 读不加锁"并不矛盾------MVCC 只服务于快照读。
3.2 版本链是怎么长出来的
假设 id = 1 初始 amount = 100,DB_TRX_ID = 50。事务 A(trx_id = 100)把它改成 200:
- 聚簇索引里的记录被更新为
amount = 200, DB_TRX_ID = 100,DB_ROLL_PTR指向 undo 里那条amount = 100, DB_TRX_ID = 50的旧版本。 - 旧的
amount = 100那条被写进 update undo log,成为版本链的上一环。
如果 A 回滚,InnoDB 就顺着 DB_ROLL_PTR 把数据还原。如果 A 提交,这条 undo 也不能马上删------只要还有更老的 Read View 需要看到 amount = 100,它就得留着。
3.3 一个容易踩的误区
有人以为"RR 下 UPDATE 会看到快照里的旧值"。不会。UPDATE ... WHERE 的匹配是基于当前读 的:它读最新已提交版本,并加锁。因此在 RR 下,如果事务 A 已经提交了 amount = 200,事务 B 的 UPDATE 会基于 200 去算,而不是基于 B 自己快照里的旧值。这就是"当前读"和"快照读"在同一个事务里可能不一致的根源。
四、隔离级别与幻读:间隙锁补的那一刀
4.1 四种隔离级别的行为
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | InnoDB 下基本避免(快照读) |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
4.2 RR 下幻读真的没了吗
标准 SQL 里 RR 允许幻读,但 InnoDB 在 RR 下通过间隙锁(gap lock)+ Next-Key Lock 在很大程度上避免了当前读的幻读。注意限定词:当前读。快照读本来就不会幻读,因为它读的是固定快照。
假设:
-- 事务 A
BEGIN;
SELECT * FROM t_order WHERE user_id = 7 FOR UPDATE;
-- 对 user_id = 7 的区间加 Next-Key Lock
-- 事务 B
BEGIN;
INSERT INTO t_order (id, user_id, amount) VALUES (999, 7, 1);
-- 会被间隙锁阻塞
间隙锁锁的是"区间",不是某一行。它防的是别人往这个区间里插新行。代价是并发插入能力下降,也更容易死锁。
4.3 为什么很多公司把隔离级别设成 RC
RC 不加间隙锁,锁范围小、并发高,配合 binlog 的 ROW 格式也能保证主从一致。代价是可能出现不可重复读和幻读,需要业务侧用乐观锁或 SELECT ... FOR UPDATE 自行处理。这不是"RC 更好",而是权衡:把并发控制的责任从数据库部分转移到应用层。
五、什么时候别用 / 别踩的坑
- 别在 RR 下指望
SELECT和UPDATE看到同一份数据 。前者是快照读,后者是当前读,两者本就不保证一致。要一致,就得用SELECT ... FOR UPDATE把快照读也变成当前读。 - 别用长事务 。长事务会让 Read View 一直存活,undo 无法 purge,undo 表空间持续膨胀,purge 线程落后,最终拖慢整个实例。监控
information_schema.innodb_trx里的trx_started是基本操作。 - 别以为 RC 就没有锁 。RC 下
UPDATE/DELETE依然加行锁,只是不加间隙锁。并发更新同一行照样阻塞、照样可能死锁。 - 别把
SELECT ... FOR UPDATE当万能药。它会把快照读升级为当前读并加锁,在 RR 下可能引入间隙锁,扩大锁范围,反而更容易死锁。 - 别忽略唯一索引等值查询的优化 。当
WHERE命中唯一索引且是等值查询、记录存在时,InnoDB 会退化为记录锁(record lock),不加间隙锁。这个边界情况在排查锁等待时很重要。
小结
把这条链路串起来:DB_TRX_ID + DB_ROLL_PTR 构成版本链 → Read View 决定哪个版本可见 → RC/RR 的差别只在 Read View 生成时机 → UPDATE 走当前读并加锁 → RR 用间隙锁补幻读。理解了这套机制,再去看"为什么长事务危险""为什么 RC 并发更高""为什么 UPDATE 会阻塞",就不再是背结论,而是能顺着源码推出来。
下一篇我们聊聊 InnoDB 的锁等待与死锁检测:从 SHOW ENGINE INNODB STATUS 的输出,反推一条 SQL 到底锁了哪些记录。感兴趣的话点个关注,我们下篇见。
参考来源:
- MySQL 官方文档:InnoDB 多版本控制 https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html
- MySQL 官方文档:InnoDB 锁 https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html