欢迎光临
我们一直在努力

面试官:你说说 MVCC 怎么防幻读?我:它根本没防

文章目录

  • 面试官:你说说 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 的根区别就两条

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(发短信、调支付)
❌ 事务里做批量处理(一万条循环完才提交)
❌ 事务忘了提交/回滚,连接挂起


总结

  • 隔离性 = 并发结果等价串行执行,不是"互不影响"
  • MVCC + 锁 = InnoDB 的两条腿:MVCC 管读写并发,锁管写写并发,各走各的路
  • RR 和 RC 的判断规则完全一样,区别只在 ReadView 创建时机和间隙锁有无
  • 快照读防"看到",间隙锁防"插进去"——幻读要防住得提前开间隙锁,混用快照读 + 当前读会露馅
  • 长事务 = 锁阻塞链 + undo log 膨胀 + 主从延迟,事务只包必须原子执行的操作,调 API、批处理分批提交
  • 如果觉得有用,点赞收藏,面试前翻出来看一遍。


    赞(0)
    未经允许不得转载:171主机测评 » 面试官:你说说 MVCC 怎么防幻读?我:它根本没防
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址