MySQL MVCC 与事务隔离级别:从一条 update 看版本链

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。

可见性判断的简化逻辑是:

  1. 若版本的 DB_TRX_ID == m_creator_trx_id,说明是本事务自己改的,可见。
  2. 若 DB_TRX_ID < m_up_limit_id,说明该版本在 Read View 创建前就已提交,可见。
  3. 若 DB_TRX_ID >= m_low_limit_id,说明是 Read View 创建后才开启的事务改的,不可见。
  4. 若 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 更好",而是权衡:把并发控制的责任从数据库部分转移到应用层。

五、什么时候别用 / 别踩的坑

  1. 别在 RR 下指望 SELECT 和 UPDATE 看到同一份数据 。前者是快照读,后者是当前读,两者本就不保证一致。要一致,就得用 SELECT ... FOR UPDATE 把快照读也变成当前读。
  2. 别用长事务 。长事务会让 Read View 一直存活,undo 无法 purge,undo 表空间持续膨胀,purge 线程落后,最终拖慢整个实例。监控 information_schema.innodb_trx 里的 trx_started 是基本操作。
  3. 别以为 RC 就没有锁 。RC 下 UPDATE/DELETE 依然加行锁,只是不加间隙锁。并发更新同一行照样阻塞、照样可能死锁。
  4. 别把 SELECT ... FOR UPDATE 当万能药。它会把快照读升级为当前读并加锁,在 RR 下可能引入间隙锁,扩大锁范围,反而更容易死锁。
  5. 别忽略唯一索引等值查询的优化 。当 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 到底锁了哪些记录。感兴趣的话点个关注,我们下篇见。

参考来源:

相关推荐
草莓熊Lotso1 小时前
【C++ 基于微服务的即时通讯系统】etcd + brpc 从安装到服务注册发现全链路实战
网络·数据库·c++·redis·微服务·etcd
猿究院_夏燊5 小时前
Spring Bean 生命周期
java·后端·spring
Wang's Blog6 小时前
Java 项目实战: 交友项目:登录链路回顾与今日任务地图
java·开发语言·状态模式·交友
信誓旦旦的程序猿6 小时前
【Python量化实战宝典 #08】可转债与债券接口手册:转股价·溢价率·纯债价值(Python 可跑)
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
Lethehong6 小时前
Oracle 加个字段,目标库就报错?KFS 用“先拆后组“打通异构 DDL
数据库·oracle
砚底藏山河6 小时前
python量化:行情数据回溯
java·开发语言·python·金融·maven
Jlzn88887 小时前
华东电芯分选通道数怎么配才不堵线
java·人工智能·架构·自动化·制造
Web极客码7 小时前
锁定真正的磁盘卡顿:我用 OpenCode 配合 eBPF 诊断 NVMe 队列深度延迟与 I/O 写放大
数据库·oracle
香吧香7 小时前
GRPC连接机制---长连接分析
java·网络与传输协议
我是人✓7 小时前
SpringBoot项目的五种创建方式
java·spring boot·后端