文章目录
- 面试官:你说说 MVCC 怎么防幻读?我:它根本没防
-
- 一、隔离性到底在隔离什么?
- 二、不可重复读 vs 幻读,到底差在哪
-
- 按操作分(根因)
- 按作用对象分(表现)
- 三、MySQL 解决并发问题的四层机制
- 四、行锁不保护读——这是故意的
- 五、MVCC 到底怎么判断可见性
- 六、RR 和 RC 的根区别就两条
- 七、间隙锁干了什么
- 八、RR 为什么还是可能幻读
- 九、长事务为什么是慢性毒药
-
- 1. 锁传导放大
- 2. undo log 膨胀
- 3. 主从延迟
- 4. 回滚代价大
- 常见坑
- 总结
面试官:你说说 MVCC 怎么防幻读?我:它根本没防
你以为 RR 级别能防幻读?快照读确实让你"看不到"幻行,但幻行已经插进去了——一趟当前读就全露馅。今天把事务隔离性从根上拆清楚。
一、隔离性到底在隔离什么?
教科书说"一个用户购买商品不影响另一个用户"。不准确。
库存剩 1 件,两个用户同时下单:
用户A:BEGIN → 读库存(1件) → 减1 → COMMIT
用户B:BEGIN → 读库存(1件) → 减1 → COMMIT
都以为买到了 → 超卖
隔离性的正确定义:并发事务的结果,等价于某种串行执行顺序。如果 A 和 B 串行(A 先 B 后),B 读到库存 0,不会下单。冲突场景下,隔离性恰恰是要互相制约。
默认 RR 和 RC 都防不住超卖,因为快照读不阻塞快照读。得加锁:
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
二、不可重复读 vs 幻读,到底差在哪
按操作分(根因)
不可重复读 → UPDATE 造成的(同一行,值变了)
幻读 → INSERT/DELETE 造成的(同一个条件,行数变了)
按作用对象分(表现)
不可重复读:行还在,内容变了
第一次查 id=1 → age=25
第二次查 id=1 → age=30
幻读:行本身多了或少了
第一次查 age>20 → 3 条
第二次查 age>20 → 4 条
两套说法不冲突,切的角度不同。
三、MySQL 解决并发问题的四层机制
从上到下是一个漏斗:
用户请求
↓
隔离级别(决定你能看到什么数据)
↓
MVCC(读不阻塞写,写不阻塞读)
↓
锁(写写互斥 + 间隙锁防幻)
↓
undo/redo log + Buffer Pool(持久化 + 缓存)
MVCC 解决读写并发,锁解决写写并发,隔离级别控制 MVCC 的严格程度,undo/redo log 兜底。
四、行锁不保护读——这是故意的
MVCC 和锁是两套互不干扰的机制:
MVCC 快照读(无锁) 行锁(有锁)
───────────────── ──────────
SELECT(纯读) UPDATE
SELECT(纯读) DELETE
SELECT … FOR UPDATE
两条线完全独立!读走左边,写走右边。
所以 RC 下有行锁,仍然发生不可重复读——读根本就没走锁那条路。
事务A:SELECT age → 快照读 age=25(左边,无锁)
事务B:UPDATE age=30; COMMIT(右边,拿行锁写完提交)
事务A:SELECT age → RC 建新快照 → age=30(不可重复读)
行锁管的是"两个写别打架",不管"读看到什么"。
五、MVCC 到底怎么判断可见性
每行数据藏着三个列:
| DB_TRX_ID | 最近修改这一行的事务 ID |
| DB_ROLL_PTR | 回滚指针,指向 undo log 旧版本 |
| DB_ROW_ID | 没主键时自动生成的行 ID |
同一行数据的多个历史版本通过 DB_ROLL_PTR 串成一条版本链。ReadView 创建时拍下快照:
min_trx_id = 当前活跃事务中最小的 ID
max_trx_id = 下一个还没分配的事务 ID
m_ids = 当前活跃(还没提交)的事务 ID 列表
判断规则(RR 和 RC 共用):
trx_id < min_trx_id → 已提交 → 可见 ✓
trx_id >= max_trx_id → 未启动 → 不可见 ✗
min ≤ trx_id < max → 在 m_ids 里 → 未提交 → 不可见 ✗
不在 m_ids 里 → 已提交 → 可见 ✓
不可见不是返回 NULL,而是顺着 DB_ROLL_PTR 链往前翻,直到找到一个能看的旧版本。
六、RR 和 RC 的根区别就两条
| 快照创建时机 | 事务第一次 SELECT | 每次 SELECT |
| 快照创建次数 | 1 次 | N 次 |
| 间隙锁 | 有 | 无 |
| 不可重复读 | 不会 | 会 |
| 幻读 | 大部分不会 | 会 |
判断规则完全一样,唯一的变量是 ReadView 什么时候重建。同一个规则,输入参数不同,输出就不同。
RR:ReadView 不变 → m_ids 不变 → 每次检查结果一样 → 可重复读
RC:每次 SELECT 重建 → m_ids 越来越小 → 可能读到新提交的数据 → 不可重复读
RC 不用间隙锁不是为了省事——高并发下间隙锁是性能杀手:
RC:SELECT FOR UPDATE → 只锁存在的行,INSERT 不阻塞
RR:SELECT FOR UPDATE → 行 + 间隙全锁,INSERT 也被堵
秒杀场景 100 人排队,RC 只让同行的 UPDATE 排队,RR 连 INSERT 也一起排队。并发吞吐差好几个量级。
七、间隙锁干了什么
行锁只能锁存在的行,管不了新插入的行。间隙锁锁的是索引记录之间的空隙。
索引 age:15 → [间隙] → 22 → [间隙] → 25 → [间隙] → 30 → [间隙] → 40
事务A:SELECT * WHERE age BETWEEN 20 AND 30 FOR UPDATE;
→ 加 Next-Key Lock:(15,22] + (22,25] + (25,30] + (30,40)
→ 任何想插在 15~40 之间的 INSERT 全部阻塞
间隙锁只在 RR 下生效,RC 不用它——牺牲幻读防护,换并发性能。
八、RR 为什么还是可能幻读
快照读 + 当前读混用,间隙锁没提前打开:
— 事务A(RR)
BEGIN;
— 1. 快照读,不加锁,间隙敞开的
SELECT * FROM user WHERE age > 20; → 3 条
— 2. 事务B INSERT age=25; COMMIT;
— 间隙锁没开 → 插入成功
— 3. 当前读
UPDATE user SET name = 'xxx' WHERE age > 20;
— ↑ 走当前读!读到 4 条,包括 B 刚插的幻行
— ↑ 幻行被打上事务A的 trx_id
— 4. 快照读
SELECT * FROM user WHERE age > 20; → 4 条
— ↑ 自己的修改优先于快照 → 幻行可见了
两条规则的冲突:
规则 1:快照读忽略快照之后提交的事务 → 防幻读靠这个
规则 2:自己的修改永远可见 → SELECT 必须看到自己的 trx_id
幻行一旦被你的 UPDATE 打上标记,规则 2 优先级更高,快照防不住了。
防法:事务一开头就 SELECT … FOR UPDATE,间隙锁提前锁死,幻行根本进不来。
九、长事务为什么是慢性毒药
1. 锁传导放大
事务A(10 分钟)锁一行 → 事务B 等 → 事务C 等 B → 事务D 等 C → …
一排车堵在单行道上,头车不动,后面全卡
2. undo log 膨胀
长事务持有旧 ReadView,undo log 的旧版本不能回收——那个长事务的快照还需要它们。拖慢所有访问同一行的其他事务。
3. 主从延迟
主库必须等事务执行完才写 binlog,10 分钟的事务 = 从库至少延迟 10 分钟。
4. 回滚代价大
事务里 SQL 越多,回滚时要逆向执行的 undo 操作越多。
常见坑
❌ 事务里调外部 API(发短信、调支付)
❌ 事务里做批量处理(一万条循环完才提交)
❌ 事务忘了提交/回滚,连接挂起
总结
如果觉得有用,点赞收藏,面试前翻出来看一遍。




