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 到底锁了哪些记录。感兴趣的话点个关注,我们下篇见。

参考来源:

相关推荐
Memory_荒年1 小时前
订单超时未支付?从“定时扫库”一步步到最终形态
java·后端
RuoyiOffice1 小时前
SpringBoot3 企业薪酬核算:考勤、绩效与薪资规则如何算出一张可解释的工资单
spring boot·vue3·规则引擎·hrm·ruoyi office·薪酬核算·薪资试算
夜雪一千1 小时前
MySQL 默认值使用方法
数据库·mysql
SelectDB1 小时前
1TB/天 × 30 天日志成本怎么估:核对命令、降冷配置与踩坑记录
大数据·数据库·数据分析
SelectDB1 小时前
日志选型别只比 Loki 和 ELK:三条路径、一份可复制的落地清单
大数据·数据库·数据分析
bksczm1 小时前
MySQL进阶篇之范式及E-R图
数据库·sql·mysql
SelectDB1 小时前
ES 写入拒绝与磁盘暴涨:排查命令、参数调整与改用 Doris 的落地步骤
大数据·数据库·数据分析
此时不提桶,更待何时2 小时前
05-04-B-HBase与时序库面试与生产事故实战
数据库·面试·hbase
hai_android2 小时前
LruCache 图片浏览器内存缓存
android·java·kotlin