【计算基础|MySQL13】【工程实战】事务隔离与 MVCC 现象复现

【计算基础|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 件事

  1. 三类并发问题盯准点:脏读=读未提交、不可重复读=同行被改、幻读=同范围多了行。
  2. MVCC 靠 ReadView 决定能看到哪个版本;RC 每次 SELECT 建一个 ReadView,RR 事务内第一次 SELECT 建一次、后面复用------这是两种隔离级别的本质区别。
  3. InnoDB 的 RR 不是万能的:快照读(普通 SELECT)无幻读,当前读(FOR UPDATE/UPDATE/DELETE)的幻读要靠间隙锁挡。
  4. FOR UPDATE 是当前读,会读最新并加锁;普通 SELECT 是快照读,不加锁读历史版本------同一个事务里这俩读出不一样的行数是正常现象,不是 bug。
  5. 间隙锁把索引记录之间的空隙锁死,别的事务插不进去,所以会"阻塞"。RC 没有间隙锁,并发高但当前读可能幻读。
  6. 排查事务/锁问题: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 锁(上):种类与加锁规则》:把这篇反复提到的"行锁、间隙锁、Next-Key Lock、意向锁"一次性讲透------锁到底分哪几类、WHERE 条件怎么决定加什么锁、为什么没索引就锁全表、MDL 为什么能把库锁死。

相关推荐
吹什么轩3 小时前
MySQL:数据类型
android·adb
深度学习040712 小时前
MySQL InnoDB Cluster 部署完整文档
android·mysql·adb
安替-AnTi14 小时前
不用 root:我用 Tailscale 打通了云手机的 ADB 通道
adb·测试·调试·云手机·claude code
张小姐的猫16 小时前
【MySQL】表的约束
android·linux·服务器·数据库·c++·mysql·adb
花间相见1 天前
【计算基础|MySQL11】事务与隔离级别
sql·mysql·adb·架构
张小姐的猫2 天前
【MySQL】数据类型
android·linux·数据库·c++·mysql·adb·php
花间相见2 天前
【计算基础|MySQL10】【工程实战】explain 与慢查询优化
adb
2501_915909063 天前
抓包工具断点拦截是什么?如何半路拦截修改 HTTP 请求
网络协议·计算机网络·网络安全·adb·https·udp