MySQL 可重复读到底防不防幻读?2 个事务同屏跑给你看
同一个可重复读事务,普通 SELECT 连续两次都读到 3 行。
换成 SELECT ... FOR UPDATE,同一时刻读到 4 行。
事务 B 再插第 5 行,被卡住了。
这不是页面算错。
MySQL 的 RR 有两套读法:快照读和当前读。
我把两套读法做成一个单文件网页,左右两个事务同屏操作。
14 条自检全过,18 组场景和一份独立 Python 状态机逐项对上。

一、为什么值得看:RR 防的是“快照里的幻读”
SQL 标准里,RR 只要求挡住脏读和不可重复读,幻读是可选。
MySQL 的 InnoDB 默认用 RR,又往前做了一步。
普通 SELECT 走快照读,整个事务看同一份快照。
别人后来插入的行,快照里没有,所以你连续查两次都是 3 行。
SELECT ... FOR UPDATE 走当前读,读最新已提交版本。
这时它能看见新插入的第 4 行。
看见还不算完,它还会加记录锁和间隙锁。
间隙锁的作用,是让别的事务无法在这个区间里插入第 5 行。
所以“RR 防不防幻读”不能一句话回答。
问的是普通 SELECT,答案是不出现幻读。
问的是当前读,答案是能看见新行,但并发插入会被挡住。
| 读法 | 读什么 | 新插入的行 | 并发插入 |
|---|---|---|---|
| 普通 SELECT,快照读 | 事务开始时的快照 | 看不到 | 不拦 |
| SELECT ... FOR UPDATE,当前读 | 最新已提交加本事务修改 | 看得到 | 间隙锁拦住 |
二、这个页面能做什么
页面顶部是四种隔离级别。
中间左右两栏是事务 A 和事务 B。
每栏都能 BEGIN、SELECT、UPDATE、INSERT、SELECT ... FOR UPDATE、COMMIT、ROLLBACK。
下面三块分别是 users 表、行版本号、锁状态和事件时间线。
右上角有四个一键场景,分别演示脏读、不可重复读、RC 幻读、RR 快照读与当前读。
还有一条“运行自检”,逐条跑 14 个断言。
整个页面一个 HTML 文件,不引 CDN,不联网。
三、四个场景,数字怎么走
先看脏读。
隔离级别切到 RU。
A 把 id=1 的 age 从 30 改成 99,还没提交。
B 执行 SELECT,读到 99。
表里这一行标着“未提交(A)”,可 B 的快照里其实还是 30。
这就是脏读:读到了别人没提交的值。

RC 下,脏读没了。
A 先读 id=1,得到 30。
B 改成 50 并提交。
A 再读,得到 50。
同一个事务,两次读不一样,这是不可重复读。

RC 下的范围查询也会变。
A 第一次 SELECT *,3 行。
B 插入 Dave,id=4,提交。
A 再 SELECT *,4 行。
这是幻读。
RR 下,普通 SELECT 不再变。
A 第一次 SELECT *,3 行。
B 插入 Dave 并提交。
A 再 SELECT *,还是 3 行。
因为 A 一直读 BEGIN 时的快照 v1。
接着 A 执行 SELECT ... FOR UPDATE,范围 [1,10]。
这次读到 4 行,Dave 出现了。
同时锁状态里多了 4 个记录锁和一个间隙锁 [1,10]。
if (iso === 'RU') rows = readRU(t, id);
else if (iso === 'RC' || iso === 'SER') rows = readRC(t, id);
else rows = readSnap(t, id);
![图 4:RR 快照读与当前读对比。事务 A 的快照版本 v1 只有 3 行,普通 SELECT 两次都读到 3 行;执行 SELECT FOR UPDATE 后当前读到 4 行,Dave 出现在结果里;锁状态显示记录锁 id=1 到 id=4,以及间隙锁 [1,10]](https://opc-open-images.obs.cn-north-4.myhuaweicloud.com/codearts-mysql-isolation-lab/04-rr-current-read.png)
B 再 BEGIN,插入 Eve。
页面算出新 id 是 5,正好落在 [1,10] 里。
INSERT 被挡住,日志给出中文提示:
“插入位置 id=5 落在事务 A 的间隙锁 [1,10] 内,INSERT 阻塞等待锁释放”。
表里没有第 5 行,B 的状态还是“进行中”。
const gap = findGapLock(other, newId);
if (gap) return finish(t, blocked(`插入位置 id=${newId} 落在事务 ${other} 的间隙锁 [${gap.low},${gap.high}] 内,INSERT 阻塞等待锁释放`), sql);
![图 5:间隙锁失败路径。事务 B 插入 id=5 的 Eve,日志里先显示 INSERT 语句,再给出红色提示“插入位置 id=5 落在事务 A 的间隙锁 [1,10] 内,INSERT 阻塞等待锁释放”;users 表仍只有 4 行,事务 A 的间隙锁 [1,10] 还在](https://opc-open-images.obs.cn-north-4.myhuaweicloud.com/codearts-mysql-isolation-lab/05-gap-lock-blocked.png)
四、验证与踩坑
我在本地起了静态服务,把 5 个场景逐个点了一遍。
python -m http.server 8080
页面自检 14/14 通过。
这 14 条是页面自己写的,不能自己证明自己。
我另写了一份 Python 状态机,按同一套规则重算 18 组场景。
18/18 和页面一致。
对比项包括读到的 age、行 id、版本号、是否阻塞、是否报错。
| 场景 | 页面读到 | Python 读到 |
|---|---|---|
| RU 脏读 | 99 | 99 |
| RC 不可重复读 | 30 然后 50 | 30 然后 50 |
| RR 可重复读 | 30 然后 30 | 30 然后 30 |
| RC 幻读 | 3 行然后 4 行 | 3 行然后 4 行 |
| RR 快照读 | 3 行然后 3 行 | 3 行然后 3 行 |
| RR 当前读 | 4 行 | 4 行 |
| SER 当前读 | 50 | 50 |
这一步抓出了一个真问题。
SERIALIZABLE 下,普通 SELECT 最初复用了 RR 的快照读。
A 先 BEGIN,B 改成 50 并提交,A 再 SELECT,页面还显示 30。
按 InnoDB 的规则,串行化下普通 SELECT 会转成加共享锁的当前读,应该看到 50。
我把 SER 分支改成 readRC,并补了两条自检。
一条验证 SER 当前读能看到新值,一条验证共享锁能挡住 B 的 UPDATE。
自检从 12 条变成 14 条。
第二个小问题是被阻塞的 INSERT 只显示原因,不显示 SQL。
补上 SQL 后,截图 5 才能一眼看出是哪条语句被挡。
失败路径也试了。
没 BEGIN 就 COMMIT,提示“事务未开始,无法 COMMIT”。
没 BEGIN 就 SELECT,提示“事务未开始,请先执行 BEGIN”。
事务进行中重复 BEGIN,提示“事务已在进行中,请先 COMMIT 或 ROLLBACK”。
五、结果与边界
这个页面能回答三件事。
普通 SELECT 和当前读分别看到什么。
RC 和 RR 的差别到底落在哪一行。
间隙锁为什么会挡住 INSERT。
它不能替代真库。
页面没有连 MySQL,跑的是按 InnoDB 规则写的状态机。
undo log 清理、锁等待超时、死锁检测、二级索引上的 next-key 锁,这些都没建模。
页面里的间隙锁是一个连续区间 [1,10]。
真实 InnoDB 的 next-key 锁是按索引记录一段一段加的,间隙不一定连续。
自动递增 id 也做了简化,按当前最大 id 加一。
这些边界不影响“快照读对当前读”的结论,但拿它当线上诊断工具会不够。
六、使用码道体会
码道有三种使用方式:WebUI、TUI 和桌面 IDE。
这次用的是 WebUI。
我把状态模型、四种隔离级别、四个场景、锁状态、自检都写进需求。
它生成了一个 571 行的单文件 index.html。
结构基本对,省事的是表数据、版本号、锁状态、时间线都做进了同一屏。
但 AI 写的自检会跟着 AI 自己的错误走。
SER 分支就是例子:页面能跑,14 条里最初也没有一条能抓住它。
所以我现在的做法是,需求里要求页面自检,交付后再写一份独立实现复算。
页面自检负责回归,独立复算负责抓第一版的错。
七、准备环境:打开码道 Web
浏览器打开码道 Web 版: https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwlb&sourcead=dmzntgwlb1&invitecode=INV-IXhxk5bwWx_qKSP1FcgSuiOEf6ruDXtjvLFiCH9nMs5dZGLapaSSylwt-cKRj5LoT3MlxikWxy8VhqplfvtQjefQOq31Tbp3R3wYP3K535Cn4XTaUzqqExKPYTWvvw4LijV14IS1torXdTGp7oyXb6SR5VxgYTg50jM ,登录后就能在对话窗口输入需求,不需要装软件。
八、总结
RR 下普通 SELECT 连续两次读到 3 行,不是幻读被彻底消灭。
它读的是事务开始时的快照。
当前读能看见别人后来提交的第 4 行。
间隙锁再把第 5 行挡在门外。
三件事分开看,RR 的“防幻读”就清楚了。
页面和源码在 Demo Park:https://atomgit.com/deli007/demo_park/tree/main/codearts-mysql-isolation-lab
浙公网安备 33010602011771号