【计算基础|MySQL13】【工程实战】事务隔离与 MVCC 现象复现
MVCC、ReadView、脏读、不可重复读、幻读------这些词面试背得滚瓜烂熟,真到生产上出了"同一个事务里两次 SELECT 结果不一样"、"UPDATE 把会话 hang 死",还是两眼一抹黑。原因很简单:隔离级别不是背出来的,是两个会话对着跑出来的。
这篇不堆理论,直接在 Docker MySQL 8 上开两个会话 A/B,把脏读、不可重复读、可重复读、快照读 vs 当前读、间隙锁挡插入、ReadView 创建时机六个现象挨个复现一遍。每条 SQL 后面贴真实输出,你照着敲一遍,比看十篇原理文章都管用。
环境:Docker 起 MySQL 8.0.46,默认存储引擎 InnoDB,默认隔离级别 REPEATABLE-READ。两个
mysql客户端会话(本文用 PyMySQL 模拟,和手动开两个终端效果完全一致),一张测试表t(id INT PK, val INT),初始数据(1,10),(5,50)。
一、先把名词钉死(30 秒过一遍)
结论先给:四个隔离级别解决三类问题,InnoDB 在 RR 下已经把这三类问题基本都摁住了。
| 现象 | 一句话定义 | 举个例子 |
|---|---|---|
| 🔴 脏读 | 读到了别人还没提交的数据 | A 改了没提交,B 就读到新值了,A 一 ROLLBACK,B 读了个寂寞 |
| 🟡 不可重复读 | 同一事务内,同一行两次读不一样 | A 第一次读 val=10,中途 B 把它改成 20 并提交,A 再读变成 20 |
| 🟡 幻读 | 同一事务内,同一个范围两次读的行数不一样 | A 第一次 WHERE id>2 返回 2 行,中途 B 插了一行并提交,A 再读返回 3 行 |
三者的区别盯准一点:脏读读的是"未提交";不可重复读是"已提交的行修改";幻读是"已提交的行增删"。
InnoDB 靠什么做到"读不加锁、还能可重复读"?两个字:MVCC 。读的时候不碰真实数据,而是读一条历史版本链 ;这条历史版本链长什么样,由 ReadView(读视图)决定。RC 和 RR 的本质区别就一句话:
- RC:每执行一次 SELECT,就生成一个新的 ReadView。
- RR:事务内第一次 SELECT 生成 ReadView,整个事务复用它。
这一节后面实验 6 会用输出把这句话钉死。
二、实验环境准备
sql
-- 所有会话先切到 demo13 库
CREATE DATABASE IF NOT EXISTS demo13;
USE demo13;
DROP TABLE IF EXISTS t;
CREATE TABLE t (id INT PRIMARY KEY, val INT) ENGINE=InnoDB;
INSERT INTO t VALUES (1,10),(5,50);
确认初始数据:
text
mysql> SELECT * FROM t;
+----+------+
| id | val |
+----+------+
| 1 | 10 |
| 5 | 50 |
+----+------+
后面每个实验都从这张干净的表开始。本文的"会话 A/B"就是两个独立连接,你自己操作时开两个终端窗口、分别 mysql -h127.0.0.1 -uroot -proot123 demo13 即可。
三、实验 1:RU 脏读复现
目标:在 READ UNCOMMITTED 下,B 能读到 A 还没提交的修改。
时间线(左 A 右 B):
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; |
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; |
| 2 | BEGIN; |
|
| 3 | UPDATE t SET val=99 WHERE id=1;(不提交) |
|
| 4 | SELECT val FROM t WHERE id=1; |
|
| 5 | ROLLBACK; |
|
| 6 | SELECT val FROM t WHERE id=1; |
真实输出:
text
[A] SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
-> OK
[B] SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
-> OK
[A] BEGIN
-> OK
[A] UPDATE t SET val=99 WHERE id=1
-> OK (rowcount=1)
[B] SELECT val FROM t WHERE id=1
-> val
-> 99 <-- 🔴 A 还没 COMMIT,B 就读到 99 了,脏读!
[A] ROLLBACK
-> OK
[B] SELECT val FROM t WHERE id=1
-> val
-> 10 <-- A 一 ROLLBACK,B 再读又变回 10
🔴 重点 :B 在第 4 步读到的 99 是一个"海市蜃楼"------A 根本没提交,这条修改随时可能回滚。脏读的危害不用多讲:你拿一个可能不存在的中间值做了业务判断(比如按 99 这个库存扣了单),A 一回滚,账就对不上了。生产环境几乎没人用 RU。
四、实验 2:RC 不可重复读复现
目标:READ COMMITTED 下,A 事务内两次读同一行,结果可以不同。
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; |
同左 |
| 2 | BEGIN; |
|
| 3 | SELECT val FROM t WHERE id=1; |
|
| 4 | BEGIN; UPDATE t SET val=20 WHERE id=1; COMMIT; |
|
| 5 | SELECT val FROM t WHERE id=1; |
|
| 6 | COMMIT; |
真实输出:
text
[A] BEGIN
[A] SELECT val FROM t WHERE id=1
-> val
-> 10
[B] BEGIN
[B] UPDATE t SET val=20 WHERE id=1
[B] COMMIT
[A] SELECT val FROM t WHERE id=1
-> val
-> 20 <-- 🟡 同一个事务里,第一次 10,第二次 20
[A] COMMIT
解释 :RC 下没有脏读了(B 读到的都是已提交的数据),但代价是"不可重复读"。原因就是第一节说的------RC 每次 SELECT 都生成新 ReadView,B 一提交,A 下次 SELECT 的 ReadView 就能看到它。
⚠️ 不可重复读是 RC 的设计行为,不是 bug。很多互联网公司故意用 RC,因为没有间隙锁、并发更高(后面锁的两篇会细讲)。
五、实验 3:RR 可重复读验证
目标:REPEATABLE READ(InnoDB 默认)下,A 事务内两次读同一行,结果一致。
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; |
同左 |
| 2 | BEGIN; |
|
| 3 | SELECT val FROM t WHERE id=1; |
|
| 4 | BEGIN; UPDATE t SET val=30 WHERE id=1; COMMIT; |
|
| 5 | SELECT val FROM t WHERE id=1; |
|
| 6 | COMMIT; |
|
| 7 | SELECT val FROM t WHERE id=1;(事务外) |
真实输出:
text
[A] BEGIN
[A] SELECT val FROM t WHERE id=1
-> val
-> 20 <-- 第一次 SELECT,创建 ReadView(快照=20)
[B] BEGIN
[B] UPDATE t SET val=30 WHERE id=1
[B] COMMIT
[A] SELECT val FROM t WHERE id=1
-> val
-> 20 <-- ✅ 还是 20!复用了第一次的 ReadView
[A] COMMIT
[A] SELECT val FROM t WHERE id=1
-> val
-> 30 <-- 事务提交后,新的 SELECT 才能看到 30
🔴 重点:第 5 步 A 读的还是 20,不是 B 刚提交的 30。这就是"可重复读"------ReadView 在第一次 SELECT 时拍了快照,整个事务都活在这个快照里。第 7 步 A 自己都 COMMIT 了,再读才是 30。
六、实验 4:RR 快照读无幻读 vs 当前读幻读
这是 RR 最容易被误解的地方。先记住两个词:
- 快照读 :普通
SELECT,走 MVCC 读历史版本,不加锁。 - 当前读 :
SELECT ... FOR UPDATE/LOCK IN SHARE MODE/UPDATE/DELETE,读最新已提交版本并加锁。
准备数据:DELETE FROM t; INSERT INTO t VALUES (1,10),(2,20),(3,30),(4,40),(5,50);
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT * FROM t WHERE id>2;(快照读) |
|
| 3 | BEGIN; INSERT INTO t VALUES (6,60); COMMIT; |
|
| 4 | SELECT * FROM t WHERE id>2;(快照读) |
|
| 5 | SELECT * FROM t WHERE id>2 FOR UPDATE;(当前读) |
|
| 6 | COMMIT; |
真实输出:
text
[A] BEGIN
[A] SELECT * FROM t WHERE id>2 -- 快照读
-> id | val
-> 3 | 30
-> 4 | 40
-> 5 | 50 (3 行)
[B] BEGIN
[B] INSERT INTO t VALUES (6,60)
[B] COMMIT
[A] SELECT * FROM t WHERE id>2 -- 还是快照读
-> id | val
-> 3 | 30
-> 4 | 40
-> 5 | 50 (✅ 还是 3 行,无幻读)
[A] SELECT * FROM t WHERE id>2 FOR UPDATE -- 当前读!
-> id | val
-> 3 | 30
-> 4 | 40
-> 5 | 50
-> 6 | 60 (🔴 变成 4 行,幻读出现了!)
[A] COMMIT
为什么会这样?
- 第 4 步是快照读,复用第 2 步的 ReadView,B 刚插的 id=6 不在快照里,所以看不见。
- 第 5 步是
FOR UPDATE当前读,它不看 ReadView,直接读最新已提交版本并加锁,于是看到了 B 提交的 id=6。
🔴 面试高频结论 :InnoDB 的 RR 靠 MVCC 解决了快照读的幻读 ;但当前读的幻读要靠下一篇要讲的间隙锁(Next-Key Lock)来防。光说"RR 解决了幻读"是不严谨的------准确说法是"RR 下快照读无幻读,当前读通过间隙锁防幻读"。
七、实验 5:间隙锁挡幻读
实验 4 第 5 步是当前读,它不光读最新,还顺手把范围上的间隙锁了。这就引出实验 5。
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT * FROM t WHERE id>2 FOR UPDATE;(当前读) |
|
| 3 | INSERT INTO t VALUES (7,70);(会怎样?) |
|
| 4 | COMMIT; |
真实输出(B 的 INSERT 我们用 5 秒超时探测):
text
[A] BEGIN
[A] SELECT * FROM t WHERE id>2 FOR UPDATE
-> id | val
-> 3 | 30
-> 4 | 40
-> 5 | 50
-> 6 | 60
[B] INSERT INTO t VALUES (7,70);
-> [B] INSERT 仍在阻塞中(被间隙挡住),5 秒超时到达 <-- 🔴 卡住了
[A] COMMIT
-> [B] INSERT (7,70) 成功 <-- A 一提交,B 立刻插入成功
解释 :A 的 WHERE id>2 FOR UPDATE 是范围当前读,InnoDB 给它加的不是单条记录锁,而是 Next-Key Lock(记录锁 + 间隙锁) ,把 (2, +∞) 整个左开右闭区间都锁住了。B 想在 id=7 这个"间隙"插数据,撞上间隙锁,只能阻塞排队;等 A 一 COMMIT 释放锁,B 的 INSERT 立刻成功。
这就是间隙锁防当前读幻读的原理:你不是想插吗?我把你能插的缝隙全锁死,看你怎么插。RC 隔离级别下没有间隙锁,所以 RC 的当前读是会幻读的------这也是为什么很多"RC + 高并发"业务要小心范围查询。
八、实验 6:RC vs RR 的 ReadView 创建时机
前面反复说"RC 每次 SELECT 新建 ReadView,RR 事务内第一次 SELECT 建一次"。这一节用对照实验把它钉死。
8.1 RC 下:两次 SELECT 之间 B 提交,A 能读到新值
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; |
同左 |
| 2 | BEGIN; |
|
| 3 | SELECT val FROM t WHERE id=1; |
|
| 4 | BEGIN; UPDATE t SET val=100 WHERE id=1; COMMIT; |
|
| 5 | SELECT val FROM t WHERE id=1; |
|
| 6 | COMMIT; |
真实输出:
text
[A] BEGIN
[A] SELECT val FROM t WHERE id=1
-> val
-> 10
[B] UPDATE t SET val=100 WHERE id=1
[B] COMMIT
[A] SELECT val FROM t WHERE id=1
-> val
-> 100 <-- 🟡 RC:第二次 SELECT 新建了 ReadView,看到 B 提交的 100
[A] COMMIT
8.2 RR 下:同样操作,A 读不到新值
| 步骤 | 会话 A | 会话 B |
|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; |
同左 |
| 2 | BEGIN; |
|
| 3 | SELECT val FROM t WHERE id=1; |
|
| 4 | BEGIN; UPDATE t SET val=200 WHERE id=1; COMMIT; |
|
| 5 | SELECT val FROM t WHERE id=1; |
|
| 6 | COMMIT; |
|
| 7 | SELECT val FROM t WHERE id=1;(事务外) |
真实输出:
text
[A] BEGIN
[A] SELECT val FROM t WHERE id=1
-> val
-> 100 <-- 注意这里是 100(上一个实验 B 改的)
[B] UPDATE t SET val=200 WHERE id=1
[B] COMMIT
[A] SELECT val FROM t WHERE id=1
-> val
-> 100 <-- ✅ RR:ReadView 复用,还是 100
[A] COMMIT
[A] SELECT val FROM t WHERE id=1
-> val
-> 200 <-- 提交后新 SELECT,才看到 200
🔴 对照结论:
| 隔离级别 | ReadView 创建时机 | 同事务内两次 SELECT |
|---|---|---|
| RC | 每次 SELECT 都新建 | 能看到中途别人提交的新值(不可重复读) |
| RR | 第一次 SELECT 时创建,事务内复用 | 看不到中途别人提交的新值(可重复读) |
九、隔离级别现象对照表(背这张就够)
| 现象 | RU 读未提交 | RC 读已提交 | RR 可重复读(InnoDB 默认) | Serializable 串行化 |
|---|---|---|---|---|
| 脏读 | ✅ 会 | ❌ 不会 | ❌ 不会 | ❌ 不会 |
| 不可重复读 | ✅ 会 | ✅ 会 | ❌ 不会(快照读) | ❌ 不会 |
| 幻读 | ✅ 会 | ✅ 会 | ❌ 不会(快照读无 + 间隙锁挡当前读) | ❌ 不会 |
| 整体并发度 | 最高 | 高 | 中等 | 最低 |
| 典型用途 | 几乎不用 | 互联网公司常用 | InnoDB 默认 | 极少用 |
十、怎么自己观察这些事(排查命令)
光复现还不够,线上出了问题你得能查。MySQL 8 里这几张表/命令是必备工具:
sql
-- 1. 看当前正在跑的事务(trx_id/trx_state/trx_started/trx_query)
SELECT trx_id, trx_state, trx_started, trx_query
FROM information_schema.innodb_trx\G
-- 2. 看锁等待关系(谁在等谁的锁)------ MySQL 8 用 performance_schema
SELECT * FROM performance_schema.data_lock_waits\G
-- 3. 看到底加了哪些锁(LOCK_TYPE/LOCK_MODE/LOCK_DATA)
SELECT * FROM performance_schema.data_locks\G
-- 4. 看 InnoDB 状态,重点看 TRANSACTIONS 段和死锁段
SHOW ENGINE INNODB STATUS\G
-- 5. 看当前会话卡在哪(State=Locked 的就是在等锁)
SHOW PROCESSLIST;
innodb_trx 里关键字段:trx_id(事务ID)、trx_state(RUNNING/LOCK WAIT)、trx_started(开始时间,算长事务)、trx_query(正在执行的 SQL)。线上发现"会话 hang 住",先 SHOW PROCESSLIST 找 State 是 Locked 的连接,再用 data_lock_waits 顺着等待链找到源头事务。
总结:你真正需要记住的 6 件事
- 三类并发问题盯准点:脏读=读未提交、不可重复读=同行被改、幻读=同范围多了行。
- MVCC 靠 ReadView 决定能看到哪个版本;RC 每次 SELECT 建一个 ReadView,RR 事务内第一次 SELECT 建一次、后面复用------这是两种隔离级别的本质区别。
- InnoDB 的 RR 不是万能的:快照读(普通 SELECT)无幻读,当前读(FOR UPDATE/UPDATE/DELETE)的幻读要靠间隙锁挡。
FOR UPDATE是当前读,会读最新并加锁;普通 SELECT 是快照读,不加锁读历史版本------同一个事务里这俩读出不一样的行数是正常现象,不是 bug。- 间隙锁把索引记录之间的空隙锁死,别的事务插不进去,所以会"阻塞"。RC 没有间隙锁,并发高但当前读可能幻读。
- 排查事务/锁问题:
innodb_trx看事务、data_locks看加了什么锁、data_lock_waits看谁等谁、SHOW ENGINE INNODB STATUS看死锁。
验证清单
- 能在两个会话里复现 RU 脏读:B 读到 A 未提交的 99,A 回滚后 B 又读回 10
- 能复现 RC 不可重复读:同事务内两次 SELECT 同一行结果从 10 变成 20
- 能复现 RR 可重复读:B 提交后 A 的普通 SELECT 还是旧值,A 提交后才读到新值
- 能复现"快照读无幻读、当前读有幻读":
SELECT两次都是 3 行,FOR UPDATE变成 4 行 - 能复现间隙锁挡插入:A
FOR UPDATE范围查询后,B 的 INSERT 阻塞,A COMMIT 后才成功 - 能用一句话说清 RC 和 RR 在 ReadView 创建时机上的区别
- 会用
innodb_trx/data_locks/data_lock_waits三张表排查锁问题
参考资源
- MySQL 8.0 官方文档:InnoDB 事务隔离级别 https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
- MySQL 8.0:InnoDB 锁与事务模型 https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
- MySQL 8.0:不可提交读 / 可重复读 / 可串行化 https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation.html
- MySQL 8.0:information_schema.innodb_trx https://dev.mysql.com/doc/refman/8.0/en/information-schema-innodb-trx-table.html
- MySQL 8.0:performance_schema data_locks / data_lock_waits https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html
下一篇《MySQL 锁(上):种类与加锁规则》:把这篇反复提到的"行锁、间隙锁、Next-Key Lock、意向锁"一次性讲透------锁到底分哪几类、WHERE 条件怎么决定加什么锁、为什么没索引就锁全表、MDL 为什么能把库锁死。