本文写给谁看:刚接触 MySQL、听过 MVCC / 事务隔离 / 幻读这些词但总觉得云里雾里的同学。 怎么读:全文按"先建立直觉 → 再看原理 → 最后实战排错"的顺序展开,每一章都可以独立看懂。遇到不懂的名词别慌,文中都会用大白话解释。 读完你能收获:① 彻底搞懂事务隔离级别;② 明白 MVCC 到底在干什么;③ 能手算"某条数据该不该被看到";④ 知道 RC 和 RR 差在哪;⑤ 理解幻读为什么会发生、怎么防;⑥ 看懂 InnoDB 的各种锁;⑦ 掌握死锁排查思路。

〇、开篇:一个让你头疼的场景

假设你开了两家银行柜台(两个事务),同时操作同一个账户:

  • 柜员 A 正在给账户改余额(写);
  • 柜员 B 只想查一下余额(读)。

最笨的办法是:A 改的时候把账户锁起来,B 必须干等。这就是"加锁"方案——简单,但慢得要命,因为读和写互相堵。

MySQL 的 InnoDB 引擎想了个聪明的办法:既然 B 只是想"看一眼",那我给它看一张"刚才的快照照片"不就行了?A 继续在原件上改,互不影响。

这个"拍快照、看历史版本"的机制,就叫 MVCC(Multi-Version Concurrency Control,多版本并发控制)

🎯 一句话记住 MVCC:它让"读不加锁、读写不冲突“成为可能,是 MySQL 高并发的功臣。

整篇文章,其实就是围绕”这张快照是怎么拍的、什么时候拍的、能看到什么“这三个问题展开的。我们一步步来。


一、前置知识:先把地基打好

在讲 MVCC 之前,有三个概念你必须先认识,否则后面会看不懂。别担心,都很简单。

1.1 什么是"事务”?

事务 (Transaction) 就是"一组要么全成功、要么全失败的操作"。最经典的例子是转账:A 扣 100 元、B 加 100 元,这两步必须一起成功,不能 A 扣了钱 B 却没收到。

BEGIN;                          -- 事务开始
UPDATE account SET money=money-100 WHERE id='A';
UPDATE account SET money=money+100 WHERE id='B';
COMMIT;                         -- 确认提交(两步一起生效)
-- 如果中途出错,就 ROLLBACK; 全部撤销,当没发生过

1.2 多人同时操作会出什么乱子?(三大并发问题)

当很多事务同时跑,会出现三种让人抓狂的情况:

问题大白话解释举例
脏读读到了别人还没确定的修改A 改了余额但还没 COMMIT,B 就读到了,结果 A 反悔撤销了,B 读到的是"假数据"
不可重复读同一件事问两遍,答案不一样B 查余额是 100,过会儿再查变成 200 了(因为 A 中间改了并提交)
幻读数个数,前后两次数量对不上B 查"余额大于 50 的有 3 个人",过会儿再查变成 4 个人了(因为 A 中间新插了一个人)

💡 记忆口诀:脏读=读到假的;不可重复读=同一行变了;幻读=行数变了(多了或少了"幻影")。

1.3 四种隔离级别:用"严格程度"换"并发速度"

为了解决 上面三个问题,SQL 标准定了四个"隔离级别",越往后越严格、越不容易出问题,但也越慢

隔离级别脏读不可重复读幻读通俗理解
读未提交 (RU)可能可能可能啥都不管,最快但最乱
读已提交 (RC)解决可能可能只读别人"已确定"的
可重复读 (RR) ⭐MySQL默认解决解决基本解决整个事务期间看到的画面"定格"
串行化 (Serializable)解决解决解决排队一个个来,最安全但最慢

重点:MySQL 默认用的是 RR(可重复读)。MVCC 主要在 RC 和 RR 这两个级别下干活。

1.4 两种"读法":当前读 vs 快照读(超级重要!)

这是理解 MVCC 的钥匙,请务必记住:

① 快照读 (Snapshot Read) —— 读"历史快照照片",不加锁,靠 MVCC:

SELECT * FROM t_user;          -- 普通的查询,就是快照读

② 当前读 (Current Read) —— 读"最新的原件",要加锁,保证读到的不会被别人偷偷改:

SELECT * FROM t_user FOR UPDATE;        -- 加排它锁的查询
SELECT * FROM t_user LOCK IN SHARE MODE; -- 加共享锁的查询
UPDATE ... ;   INSERT ... ;   DELETE ... ;  -- 所有增删改都是当前读

🔑 核心结论(先记住,后面会反复用到)

  • 快照读 ➡️ 交给 MVCC 处理(读历史、不加锁);
  • 当前读 ➡️ 交给 锁机制 处理(读最新、要加锁)。
  • 两者一文一武,配合起来才完整。

二、MVCC 的三大基石

MVCC 能工作,靠三样东西:隐藏字段Undo Log(回滚日志)Read View(读视图)。我们一个一个拆。

2.1 第一块基石:隐藏字段

你以为你建的表只有 name, age, gender 这几列?其实 InnoDB 在背后偷偷给每行加了 3 个隐藏字段

隐藏字段作用大小大白话
DB_TRX_ID记录"最后一个改这行的人是谁"(事务ID)6字节这行的"最近修改者工号"
DB_ROLL_PTR回滚指针,指向这行的"上一个版本"在哪7字节“想看旧版本?顺着这个箭头找”
DB_ROW_ID隐藏主键(你没设主键时自动生成)6字节行的"身份证号"

所以一行数据在 InnoDB 眼里其实是这样的:

nameagegenderDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
zhangsan121null1

2.2 第二块基石:Undo Log 与"版本链"

Undo Log(回滚日志) 有两个用途:

  1. 事务想反悔(ROLLBACK)时,靠它恢复原状
  2. 给 MVCC 提供历史版本,让别人能读到旧数据。

关键机制:每当有人改一行,InnoDB 不会覆盖原数据,而是把旧版本存进 Undo Log,并用 DB_ROLL_PTR 把它们串成一条链表

  • 链头 = 最新的旧版本;
  • 链尾 = 最古老的版本。

🎯 类比:就像 Word 的"修订历史"或 Git 的"提交记录"——每次改动都不覆盖,而是新增一个版本,旧版本保留在历史里。

📊 跟着数据走一遍(强烈建议看懂这个例子)

初始:事务1 插入了一行 zhangsan

[zhangsan, 12, 男, trx_id=1, roll_ptr=null]   ← 链尾(最老)

事务2 把名字改成 lisi:旧数据进 Undo Log,新行的指针指向它。

[lisi, 12, 男, trx_id=2, roll_ptr=0x123]  ──→  [zhangsan, 12, 男, trx_id=1, roll_ptr=null]
   ↑ 最新版本(链头)                                    ↑ 旧版本(链尾)

事务3 把年龄改成 21:链子再长一节。

[lisi,21,男,id=3,ptr=0x456] → [lisi,12,男,id=2,ptr=0x123] → [zhangsan,12,男,id=1,ptr=null]
   ↑ 最新                                                          ↑ 最老

现在,任何一个事务只要顺着这条链找,就能拿到任意时刻的数据样子。这就是 MVCC 的"时间机器"。

🔧 进阶小知识:后台有个叫 purge 线程 的清洁工,会定期清理"已经没人需要"的旧版本,防止链子无限变长(后面会细讲)。

2.3 第三块基石:Read View(读视图)—— 决定"你能看到什么"

光有版本链还不够,还得有个规则告诉事务:"这么多版本,你该看哪一个?" 这个规则载体就是 Read View(读视图)

Read View 是事务在做"快照读"那一刻生成的一个"视角清单",里面有 3 个关键字段(先记这 3 个就够入门):

字段含义大白话
trx_list生成视图那一刻,所有还没提交的事务 ID 列表“此刻还有哪些人在忙活、没交卷”
up_limit_id上面列表里的最小 ID“忙活的人里工号最小的”
low_limit_id生成视图时,下一个将要分配的事务 ID(≈ 最大ID+1)“比此刻所有人都大的一个新工号”

🔧 进阶补充(源码里其实还有 2 个字段,学到后面再看)

  • creator_trx_id:创建这个视图的事务自己的 ID(自己的修改自己永远看得见);
  • m_closed:这个视图是否已关闭。

有了 Read View,接下来就用一套"判断算法"决定每个版本可不可见——这就是下一节的可见性算法


三、核心中的核心:可见性判断算法

当事务要读一行时,它会拿到某个版本的 DB_TRX_ID(记作 X),然后按顺序做 3 步判断

第 1 步X < up_limit_id 吗? → 是:看得见(说明改它的人在视图生成前就交卷了)。否:进入第 2 步。

第 2 步X >= low_limit_id 吗? → 是:看不见(说明这个版本是视图生成之后才冒出来的,太新了)。否:进入第 3 步。

第 3 步Xtrx_list(活跃列表)里吗? → 在:看不见(视图生成时这人还在忙、没交卷)。不在:看得见(说明他在视图生成前就交卷了)。

如果当前版本看不见怎么办? 顺着 DB_ROLL_PTR 跳到上一个更老的版本,把这 3 步重新做一遍,直到找到一个看得见的,或者找到链尾为止。

🎯 用"区间图"一眼看懂(推荐背下来)

            能不能看见?
 ───────────┼─────────────────────────────
 X < up_limit_id              ✅ 一定看得见(视图前已提交)

 up_limit_id ≤ X < low_limit_id   ❓ 不确定,要看 trx_list:
                                  • X 在列表里 → 还活跃 → ❌ 看不见
                                  • X 不在列表 → 已提交 → ✅ 看得见

 X ≥ low_limit_id             ❌ 一定看不见(视图后才开始)

💡 怎么记:太小=老古董=看得见;太大=未来人=看不见;中间地带=看它在不在"忙活名单"里。


四、动手算一算:T2 能不能看到 T4 改的数据?

光讲规则太抽象,我们用一道"计算题"把它彻底搞懂。

4.1 题目场景

有 4 个事务依次开启,时间线如下:

时刻事务1(id=1)事务2(id=2)事务3(id=3)事务4(id=4)
t1beginbeginbeginbegin
t2做一次快照读
t3update 某行; commit
t4再做一次快照读

被改的那一行,最新版本的 DB_TRX_ID = 4问:t4 时刻 T2 能不能看到 T4 的修改?

4.2 情况一:Read View 在 T4 提交【之后】才生成

这时 T4 已经交卷了,还在忙活的只有 1、2、3。所以 Read View 是:

字段
trx_list[1, 2, 3]
up_limit_id1(列表最小值)
low_limit_id5(下一个新ID)

X = 4 代入三步算法:

  1. 4 < 1? ❌ 不成立 → 进第 2 步;
  2. 4 >= 5? ❌ 不成立 → 进第 3 步;
  3. 4[1,2,3] 里吗? ❌ 不在(T4 早就提交了)→ 看得见!

结论:这种情况能看到 T4 的修改。 这正是 RC 级别的表现——每次读都用最新视图,所以总能读到别人已提交的最新数据。

4.3 情况二:RR 级别,Read View 在 t2(T4 提交【之前】)就生成了

t2 时刻 T4 还在忙活,所以那时的视图是:trx_list=[1,2,3,4]up_limit_id=1low_limit_id=5

到了 t4,T2 再次快照读——因为是 RR 级别,它沿用 t2 的老视图,不重新生成。再用 X=4 算一遍:

  1. 4 < 1? ❌;
  2. 4 >= 5? ❌;
  3. 4[1,2,3,4] 里吗? ✅ 在!(视图生成时 T4 还没交卷)→ 看不见!

于是 T2 顺着指针回退到旧版本,读到的还是 T4 修改之前的数据。两次读结果一样——这就是"可重复读"!

4.4 两种情况对比

Read View 何时生成t4 读到的结果对应级别
情况一T4 提交后(每次都新建)能看到 21RC
情况二T4 提交前(沿用旧的)看不到,仍是旧值RR

🎉 恭喜你! 看懂这一节,你就真正理解了 MVCC 的精髓。RC 和 RR 的区别,归根结底就是这一张表的差别。


五、RC 与 RR 的本质区别(一句话总结)

RC 和 RR 最最关键的差别,就在于 Read View 的生成时机——而 Read View 是在"做快照读"时生成的:

  • RC(读已提交)每一次快照读都生成最新的 Read View → 所以总能读到最新数据
  • RR(可重复读):只在事务第一次快照读时生成 Read View,之后一直沿用 → 所以解决了不可重复读

❓ 常见疑问澄清

疑问 1:RR 下 Read View 是不是永远不更新? 答:不完全是。如果你全程只做快照读,那确实一直用旧的;但只要你做了当前读(比如 SELECT ... FOR UPDATE),就会去读最新版本。(严谨说法见下方"口径校准")

疑问 2:RR 的视图是在 BEGIN 时就生成的吗? 答:不是! 是在第一次快照读时才"懒生成"。如果你只 BEGIN 却从来不查询,就根本不会生成视图——这是个很重要的细节,关系到后面的 purge(见第十一章)。

⚠️ 口径校准(写给想抠细节的你): 教学里常说"当前读会刷新 Read View",这是简化说法。更准确的源码口径是:加锁的当前读本身并不创建/刷新 Read View,它之所以能看到最新,是因为它加了锁、直接读最新版本;而 RR 的快照视图在整个事务期内始终复用。两种说法在"现象"上一致,但"机理"不同,面试时能说清楚会非常加分。


六、幻读:RR 真的完全解决了吗?

6.1 幻读回顾

同一事务里,同样的范围查询,前后两次行数不一样,多出来的行像"幻影"。

6.2 幻读到底什么时候会发生?

记住这两句话:

  • 如果你的事务里全是快照读 → 不会幻读(因为一直用同一个视图,画面是定格的);
  • 一旦快照读和当前读混着用 → 就可能幻读

结论:纯快照读不幻读;幻读源于"快照读 + 当前读"混用。

6.3 一个直观的例子

-- 事务 A                                      -- 事务 B
BEGIN;
SELECT * FROM t_user WHERE id>10;  -- 0 行(快照读,生成视图)
                                              BEGIN;
                                              INSERT INTO t_user VALUES(11,...);
                                              COMMIT;
SELECT * FROM t_user WHERE id>10;  -- 还是 0 行 ✅(快照一致)
SELECT * FROM t_user WHERE id>10 FOR UPDATE;  -- 突然 1 行!⚠️(当前读看到最新的)

同一个事务里,快照读说"0 行",当前读说"1 行"——世界观分裂了,这就是幻读。

6.4 ⚠️ 进阶真相:RR 并非"绝对无幻读"

看这个更隐蔽的案例:

-- 事务 A
BEGIN;
SELECT * FROM t WHERE id>10;          -- 0 行
-- 事务 B 此时插入 id=11 并提交
UPDATE t SET c=99 WHERE id=11;        -- 当前读!A 亲手改了 B 插入的那行
SELECT * FROM t WHERE id>10;          -- ⚠️ 变成 1 行了!幻读出现

为什么会这样? 因为 A 执行 UPDATE 后,那行的最新版本 DB_TRX_ID 变成了 A 自己。按可见性算法第 0 条规则——"自己的修改自己永远看得见"——于是 A 的快照读也"看见"了这行,前后行数就不一致了。

💡 工程共识:不要追求"绝对无幻读"。RR 靠 next-key lock 挡住别人的插入 + MVCC 保证纯快照读自洽 已经覆盖了绝大多数场景;剩下的边角情况,靠业务幂等、唯一约束、失败重试来兜底。

6.5 那"当前读"这边靠什么防幻读?

答案是下一章的锁机制——用 Next-Key Lock(记录锁+间隙锁) 把"范围"锁住,让别人插不进来。


七、锁机制:当前读的"守护者"

MVCC 管"快照读",锁机制管"当前读"。下面我们认识 InnoDB 的各种锁。

7.1 八种锁模式速查表

锁模式含义大白话
IX意向排它锁(表级)在表上挂个牌子:“我准备锁里面的某些行”
X记录本身 + 记录前的间隙Next-Key 锁(默认形态)
S记录本身 + 记录前的间隙Next-Key 锁(共享版)
X, REC_NOT_GAP只锁记录本身精准锁定这一行
S, REC_NOT_GAP只锁记录本身精准锁定这一行(共享)
X, GAP只锁间隙,不锁记录“这片空地我先占着,谁也别往这插”
S, GAP只锁间隙,不锁记录同上(共享)
X, GAP, INSERT_INTENTION插入意向锁“我想在这片空地插一行"的申请

7.2 四种行锁形态(重点理解)

  1. Record Lock(记录锁):带 REC_NOT_GAP只锁这一行,最精准;
  2. Gap Lock(间隙锁):带 GAP只锁"空隙"不锁行,目的是阻止别人插入。⚠️ 重要特性:多个事务可以同时持有同一段间隙的 gap 锁(它们互相兼容,因为都只是"守门”,不冲突);
  3. Next-Key LockX/S 的默认形态 = 记录锁 + 间隙锁,既锁行又锁范围,是 RR 防幻读的主力;
  4. Insert Intention Lock(插入意向锁):插入前申请的"进门许可"。多个事务往同一段间隙的不同位置插,互不等待 → 提升插入并发。

🔑 兼容性要点:gap 锁之间互相兼容;但插入意向锁会和"盖住它位置的 gap/next-key 锁"冲突(守门的不让进)。

7.3 加锁三原则(案例分析必备)

  1. 锁是跟着索引扫描动态加的:扫到的每条记录加 next-key lock,遇到第一条不满足条件的改加 gap lock 然后停;
  2. 唯一索引 + 等值查询 + 命中 → 退化成记录锁;唯一索引等值没命中 → 只加 gap 锁;范围查询不退化非唯一索引永不退化
  3. 在二级索引上加锁时,对应的聚簇索引(主键)记录也会加记录锁

7.4 经典加锁案例推演

CREATE TABLE t (id INT PRIMARY KEY, c INT, d INT) ENGINE=InnoDB;
INSERT INTO t VALUES (0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
SQL(都带 FOR UPDATE)加了什么锁为什么
WHERE id=10主键:记录锁(10)唯一+等值+命中 → 退化
WHERE id=7主键:gap锁(5,10)唯一+等值+没命中 → 只锁间隙
WHERE c=10idx_c:next-key(5,10] + gap(10,15);主键:记录锁(10)c 非唯一 → 不退化
WHERE c>=10 AND c<=15idx_c:(5,10]+(10,15]+gap(15,20);主键:记录锁(10)(15)范围查询不退化
WHERE d=10(d 无索引)几乎所有记录的 next-key + supremum 间隙 ≈ 锁全表全表扫描,插入全被堵死

🩸 血泪教训:最后一个案例说明——不走索引的当前读会把整张表锁成禁区!所以"让 WHERE 条件命中索引“是减少锁冲突的第一原则。

7.5 MVCC 与锁的分工(本节小结)

  • 快照读 ➡️ MVCC(版本链 + Read View):不加锁、读历史;
  • 当前读 ➡️ Next-Key Lock 体系:加锁、读最新、锁住范围防幻读。

八、动手实验:亲手验证 RC 与 RR 的差异

光看不练假把式。打开你的 MySQL,跟着做一遍:

-- 准备一张表
CREATE TABLE t_user (id INT PRIMARY KEY, name VARCHAR(20), age INT) ENGINE=InnoDB;
INSERT INTO t_user VALUES (1, 'zhangsan', 12);

验证 RR(默认级别)—— 开两个会话窗口:

-- 【会话 A】                              -- 【会话 B】
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM t_user;   -- ① 看到 age=12(此刻生成 Read View)
                                           BEGIN;
                                           UPDATE t_user SET age=21 WHERE id=1;
                                           COMMIT;
SELECT * FROM t_user;   -- ② 快照读:还是 12 ✅(可重复读!)
SELECT * FROM t_user FOR UPDATE;  -- ③ 当前读:变成 21(读最新版本)
COMMIT;

验证 RC:把第一句换成 READ COMMITTED,重做一遍——你会发现第 ② 步直接就看到了 age=21,因为 RC 每次快照读都生成新视图。

这个小实验一次性验证了三大结论:① Read View 生成时机差异;② 快照读 vs 当前读差异;③ RR 下视图的复用。


九、Undo Log 深水区(进阶)

学完前面,你已经能应付大多数场景了。下面进入"进阶区”,挖得更深一点。

9.1 undo log 其实分两种

类型什么时候产生干嘛用什么时候能删
insert undo插入时仅供本事务回滚(回滚=把这行删掉)事务一提交就能删——别人靠 DB_TRX_ID 就知道这行不可见,不需要查它
update undo更新/删除时两用:回滚 + 给 MVCC 提供旧版本必须等 purge 线程确认没人引用了才能删

9.2 delete 的真相:先打标记,后收尸

InnoDB 的 DELETE 不是马上物理删除,而是:

  1. 给这行打个 delete_mark 标记(记进 update undo);
  2. 快照读根据可见性决定看不看得到它;
  3. 真正的物理删除由 purge 线程异步完成。

这就是为什么"刚删掉的行,老事务还能查到"。

9.3 存在哪?回滚段与 undo 表空间

  • undo log 按回滚段 (rollback segment) 组织,默认 128 个(innodb_rollback_segments);
  • 版本链里的 DB_ROLL_PTR 其实编码了"哪个回滚段 → 哪个页 → 哪个偏移";
  • 存储位置演进:5.6 挤在共享表空间 → 5.7 支持独立 undo 表空间 → 8.0 默认两个独立表空间 undo_001undo_002,还能在线清理:
ALTER UNDO TABLESPACE undo_001 SET INACTIVE;  -- 清理完自动回到 ACTIVE

9.4 purge 线程与 history list

purge 线程是个后台清洁工,周期性扫描 history list,清理两类垃圾:没人引用的 update undo、打了 delete_mark 的行。 相关参数:innodb_purge_threads(默认4)、innodb_purge_batch_sizeinnodb_max_purge_lag(history 太长时限流新事务)。

9.5 ⚠️ 长事务:MVCC 的头号公敌

只要有一个长事务不提交,它的老 Read View 就一直活着 → purge 不敢删旧版本 → 连锁灾难:

  1. undo 表空间膨胀,吃磁盘;
  2. 所有快照读都要拖着超长版本链遍历,全库查询一起变慢。

怎么监控:

SHOW ENGINE INNODB STATUS\G                    -- 看 History list length
SELECT * FROM information_schema.INNODB_TRX;   -- 揪出长事务

📢 军规:不写长事务、大事务拆小、开着事务不提交就去喝咖啡——这是严重事故。


十、Read View 深水区(进阶·源码视角)

10.1 完整字段(比入门多 2 个)

源码字段含义
m_up_limit_id活跃事务最小 ID(= 入门的 up_limit_id)
m_low_limit_id生成时的 max_trx_id(= 入门的 low_limit_id)
m_ids活跃事务列表(= 入门的 trx_list)
m_creator_trx_id创建视图的事务自己——自己的修改永远对自己可见
m_closed视图是否已关闭

10.2 可见性判断(源码逻辑翻译版)

设被判断版本的 DB_TRX_ID = X
① X == creator_trx_id  → 可见(自己改的)
② X <  up_limit_id     → 可见(视图前已提交)
③ X >= low_limit_id    → 不可见(视图后才开始)
④ X ∈  m_ids           → 不可见(视图时还活跃)
⑤ 其余                → 可见(在 [up,low) 内且已提交)

不可见就沿 DB_ROLL_PTR 取上一版本,循环以上五步。

10.3 RC / RR 生成时机的源码表述

trx_assign_read_view() 的核心逻辑:

  • RC:每次快照读都新建视图,语句结束即关闭;
  • RR第一次快照读才创建(不是 BEGIN 时!),挂在事务上复用到提交/回滚;
  • START TRANSACTION WITH CONSISTENT SNAPSHOT; 可以立刻显式创建视图;
  • Serializable:普通 SELECT 直接升级为加锁读,不走 MVCC

十一、死锁实战(进阶)

11.1 案例一:反向更新(环形等待)

-- T1                                   -- T2
UPDATE t SET c=c+1 WHERE id=5;  -- 持有5
                                         UPDATE t SET c=c+1 WHERE id=10; -- 持有10
UPDATE t SET c=c+1 WHERE id=10; -- 等T2
                                         UPDATE t SET c=c+1 WHERE id=5;  -- 等T1 → 💥 1213

11.2 案例二:gap 锁 + 插入意向锁的死亡组合

-- T1                                   -- T2
DELETE FROM t WHERE id=6;  -- 行不存在,拿 gap(5,10)
                                         DELETE FROM t WHERE id=6; -- gap锁兼容,也拿到
INSERT INTO t VALUES(6,6,6); -- 插入意向锁被T2的gap挡住,等
                                         INSERT INTO t VALUES(7,7,7); -- 被T1的gap挡住,等 → 💥 1213

这就是"gap 锁互相兼容"的副作用:各自合法持锁,一起插入时却同归于尽。

11.3 怎么检测、牺牲、分析?

  • innodb_deadlock_detect=ON(默认):每次锁等待构建等待图即时检测;发现环就回滚**代价更小(undo 量更少)**的那个事务,报 1213
  • 超高并发下可设 OFF,改用 innodb_lock_wait_timeout(默认50s)超时回滚,省检测开销;
  • innodb_print_all_deadlocks=ON 把死锁详情写进错误日志;
  • 事后分析:SHOW ENGINE INNODB STATUS\GLATEST DETECTED DEADLOCK 段,清楚展示双方"持有谁、等谁"。

11.4 预防清单

固定顺序访问数据;事务短小快提交;让 SQL 命中索引缩小锁范围;业务允许就用 RC(gap 锁显著变少);快照读别乱加锁;关键插入做幂等 + 失败重试。


十二、高频面试题速答(自查清单)

  1. MVCC 是什么?解决什么? → 多版本并发控制,读不加锁、读写不冲突。
  2. MVCC 由哪几部分组成? → 隐藏字段、Undo Log 版本链、Read View。
  3. 当前读 vs 快照读? → 当前读读最新且加锁(FOR UPDATE、DML);快照读读历史不加锁(普通 SELECT)。
  4. 可见性算法三步? → 比 up_limit_id → 比 low_limit_id → 查 trx_list。
  5. RC 和 RR 核心区别? → Read View 生成时机:RC 每次新建;RR 首次生成后沿用。
  6. RR 下 Read View 永不更新吗? → 不是;且它是在第一次快照读时懒生成,不是 BEGIN 时。
  7. RR 下纯快照读为什么不幻读? → 始终用同一个视图,画面定格。
  8. 幻读何时出现?怎么缓解? → 快照读+当前读混用时;当前读侧靠 Next-Key/Gap Lock 锁范围缓解。
  9. MVCC 解决不了什么? → 写写冲突(丢失更新),靠行锁解决。
  10. insert undo 为啥提交后即可删? → 插入前该行不存在,别人凭 trx_id 即可判不可见。
  11. delete 为啥打标记不直接删? → 老事务的快照可能还要看到它,物理删除交给 purge。
  12. RC 对 binlog 格式有啥要求? → 必须 ROW 格式,否则主从易不一致。
  13. gap 锁 vs 插入意向锁? → gap 锁守门防插入、彼此兼容;插入意向锁是进门申请,与守门锁冲突。
  14. history list length 过高说明啥? → 有长事务,purge 被阻塞,立即排查。
  15. 版本链很长时快照读会怎样? → 沿 roll_ptr 逐节遍历,链越长越慢——长事务的危害。
  16. Serializable 下还有快照读吗? → 没有,普通 select 全变加锁读。
  17. 为啥两个事务能同时持有同段 gap 锁? → gap 锁只防插入不防同类锁,否则加锁动作本身就死锁。

终章:把所有碎片拼成一张完整的图

让我们把全文串起来,看看 InnoDB 并发控制的全貌:

隐藏字段给每行盖上"身份戳" → Undo Log 串起数据的"时间线" → Read View 决定每个事务"站在哪个时间点" → 可见性算法沿版本链选出该看的版本; 与此同时,Next-Key Lock 体系为当前读守住"空间边界",purge 线程在幕后清理历史,死锁检测在危急时断臂求生。

MVCC 让"读"穿越时间,锁机制让"写"守住边界,purge 让历史得以安息——三者合力,成就了 InnoDB 的高并发世界。