事务隔离级别与 MVCC
同一个事务里两次查询结果不一致,或者"查不到却插不进去"——这些现象背后是隔离级别与 MVCC 在起作用。很多文章只给出"RR 解决了不可重复读"的结论,但说不清为什么 RR 下幻读只是"部分解决"。这篇文章从 undo 版本链和 ReadView 的源码级结构讲起,最后用两个会话把四种隔离级别的行为逐一复现。基准 MySQL 8.0 + InnoDB,5.7 的差异单独标注。
一、四种隔离级别与三种异常
SQL 标准定义四种隔离级别,MySQL 全部支持:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现 |
| --- | --- | --- | --- | --- |
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新版本 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 每次快照读生成 ReadView |
| REPEATABLE READ(默认) | 不可能 | 不可能 | 可能(快照读下不可能) | 首次快照读生成 ReadView |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 快照读加共享 next-key lock |
三个概念要分清:
- 脏读:读到别的事务未提交的数据。
- 不可重复读:同一事务内两次读同一行,值变了(别人 UPDATE 并提交)。
- 幻读:同一事务内两次按同一条件查,行数变了(别人 INSERT 并提交)。
注意:MySQL 8.0 与 5.7 的默认隔离级别都是 REPEATABLE READ,这一点没有变化。常被混淆的是 8.0 默认字符集从
latin1变成utf8mb4、默认认证插件从mysql_native_password变成caching_sha2_password。
二、当前读与快照读
InnoDB 的读分两种,这是理解 MVCC 的前提:
- 快照读(一致性非锁定读):普通
SELECT,读 undo 版本链上的某个历史版本,不加锁。 - 当前读(锁定读):
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT,读最新已提交版本并加锁。
MVCC 只作用于快照读。当前读永远读最新数据,这也是"RR 下幻读只被部分解决"的根源。
三、undo log 版本链
每行有三个隐藏列:
DB_TRX_ID:最后修改该行的事务 ID。DB_ROLL_PTR:指向 undo log 中该行的上一个版本。DB_ROW_ID:无主键时生成的行 ID。
每次 UPDATE 都会把旧值写进 undo log,并用 DB_ROLL_PTR 串成链表。假设一行被事务 100、200、300 依次修改,版本链如下:
当前行 (DB_TRX_ID=300)
↓ roll_ptr
undo: 版本 200 (值 B)
↓
undo: 版本 100 (值 A)
↓
undo: 插入前的初始版本
快照读要读哪个版本,由 ReadView 决定。purge 线程负责删除"没有 ReadView 需要"的旧版本,长事务会阻止 purge。
四、ReadView 的结构
ReadView 是事务在做快照读时生成的一个可见性判断依据,核心字段四个:
m_ids:生成 ReadView 时,当前活跃(未提交)的事务 ID 列表。min_trx_id:m_ids中的最小值。max_trx_id:生成 ReadView 时系统应分配的下一个事务 ID(即当前最大事务 ID + 1)。creator_trx_id:创建该 ReadView 的事务 ID。
判断某版本是否可见,按顺序比较:
if trx_id == creator_trx_id: 可见(自己的修改)
elif trx_id < min_trx_id: 可见(提交早于所有活跃事务)
elif trx_id >= max_trx_id: 不可见(ReadView 之后才开启的事务)
elif trx_id in m_ids: 不可见(生成 ReadView 时还活跃)
else: 可见(已提交)
不可见就顺着 DB_ROLL_PTR 找上一个版本,直到找到可见版本或链表结束。
RC 与 RR 的唯一差别
两者判断逻辑完全一样,差别只在生成 ReadView 的时机:
- RC:每次快照读都生成新的 ReadView,所以能看到别人新提交的数据。
- RR:只在事务的第一次快照读时生成,之后复用,所以整个事务看到的是同一份快照。
这也解释了一个细节:RR 下如果事务先执行 UPDATE(当前读)再 SELECT,ReadView 是在 UPDATE 之后才生成的,此时能看到自己和他人的最新提交。
五、可复现实验:隔离级别差异
准备数据:
CREATE DATABASE IF NOT EXISTS mvcc_demo DEFAULT CHARACTER SET utf8mb4;
USE mvcc_demo;
CREATE TABLE account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(32) NOT NULL,
balance INT NOT NULL,
PRIMARY KEY (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
INSERT INTO account (name, balance) VALUES ('alice', 1000), ('bob', 1000);
-- 确认隔离级别
SELECT @@transaction_isolation; -- 8.0 与 5.7.20+ 均为 transaction_isolation
注意:5.7.20 之前变量名是
tx_isolation,8.0 已移除旧名,脚本里混用会报错。
实验一:RC 下的不可重复读
会话 A:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1; -- 1000
会话 B:
START TRANSACTION;
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;
会话 A 再查:
SELECT balance FROM account WHERE id = 1; -- RC: 900,出现不可重复读
COMMIT;
实验二:RR 下同一场景
把会话 A 的隔离级别换成 REPEATABLE READ 重做,第二次查询仍是 1000。原因是 ReadView 在第一次 SELECT 时生成,事务 B 的 trx_id 大于等于 max_trx_id,落在"不可见"分支,于是沿版本链读到了旧值。
实验三:RR 下的幻读
会话 A:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE id > 100; -- 0
会话 B:
INSERT INTO account (name, balance) VALUES ('carol', 500);
COMMIT;
会话 A 再查,仍是 0(快照读)。但此时执行:
SELECT COUNT(*) FROM account WHERE id > 100 FOR UPDATE; -- 1,当前读看到了新行
这就是"RR 部分解决幻读":快照读不幻读,当前读仍会幻读。要彻底避免,需要在事务开始时用 SELECT ... FOR UPDATE 或 LOCK IN SHARE MODE 对范围加 next-key lock,或者升级到 SERIALIZABLE。
六、RR 为什么"看起来"解决了幻读
关键在于间隙锁。在 RR 下,UPDATE、DELETE 以及加锁读会对扫描到的索引区间加 next-key lock(记录锁 + 间隙锁),阻止其他事务在区间内插入。所以"先查后插"的业务在 RR 下用 SELECT ... FOR UPDATE 是安全的。
但普通快照读不加锁,别人仍然可以插入,只是你的快照读看不到。如果后续基于快照做了 UPDATE,就会出问题:
-- 会话 A(RR)
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE id > 100; -- 0,认为没有
UPDATE account SET balance = balance + 1 WHERE id > 100; -- 影响 1 行!
UPDATE 是当前读,看到了别人插入的行。这是"快照读 + 当前读混用"导致的经典不一致。
七、当前读的加锁验证
-- 会话 A
START TRANSACTION;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 会话 B,会阻塞
UPDATE account SET balance = 0 WHERE id = 1;
在会话 B 中执行 SHOW ENGINE INNODB STATUS\G,TRANSACTIONS 段能看到 lock_mode X locks rec but not gap 和等待信息。这是验证加锁行为最直接的手段。
八、常见坑 / 最佳实践
- 不要为了"性能"把隔离级别改成 RC 却不评估业务。RC 下没有间隙锁,死锁更少、并发更高,但会出现不可重复读和幻读。如果业务依赖"事务内多次查询结果一致",必须留在 RR。
- 事务里不要混用快照读和当前读做判断。先用普通
SELECT判断存在性、再UPDATE,中间可能被别人插入。正确做法是直接用SELECT ... FOR UPDATE或依赖唯一索引 +INSERT ... ON DUPLICATE KEY UPDATE。 - 事务尽量短。RR 的 ReadView 从第一次快照读一直持有到事务结束,长事务会让 purge 无法清理,undo 表空间膨胀,同时
history list length持续上升。 - 监控
innodb_trx的trx_isolation_level。连接池、中间件、ORM 都可能改掉会话隔离级别,排查时先确认实际值,别假设是默认 RR。 SELECT ... FOR UPDATE必须命中索引。没命中索引会锁全表所有记录加间隙,等于把并发打回串行,还可能引发大面积死锁。
九、小结
- MVCC 通过 undo 版本链 + ReadView 实现快照读,读操作不加锁。
- ReadView 四个字段:
m_ids、min_trx_id、max_trx_id、creator_trx_id,按事务 ID 区间判断可见性。 - RC 与 RR 的判断逻辑相同,唯一差别是 ReadView 生成时机:每次读 vs 首次读。
- RR 下快照读不会幻读,但当前读仍会;间隙锁只对加锁读和写操作生效。
- 长事务是 MVCC 最大的敌人,直接表现为 undo 膨胀与 purge 滞后。
- 点赞
- 收藏
- 关注作者
评论(0)