本文写给谁看:刚接触 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 眼里其实是这样的:
| name | age | gender | DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID |
|---|---|---|---|---|---|
| zhangsan | 12 | 男 | 1 | null | 1 |
2.2 第二块基石:Undo Log 与"版本链"
Undo Log(回滚日志) 有两个用途:
- 事务想反悔(ROLLBACK)时,靠它恢复原状;
- 给 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 步:
X在trx_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) |
|---|---|---|---|---|
| t1 | begin | begin | begin | begin |
| t2 | 做一次快照读 | |||
| t3 | update 某行; 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_id | 1(列表最小值) |
| low_limit_id | 5(下一个新ID) |
把 X = 4 代入三步算法:
4 < 1? ❌ 不成立 → 进第 2 步;4 >= 5? ❌ 不成立 → 进第 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=1,low_limit_id=5。
到了 t4,T2 再次快照读——因为是 RR 级别,它沿用 t2 的老视图,不重新生成。再用 X=4 算一遍:
4 < 1? ❌;4 >= 5? ❌;4在[1,2,3,4]里吗? ✅ 在!(视图生成时 T4 还没交卷)→ 看不见! ❌
于是 T2 顺着指针回退到旧版本,读到的还是 T4 修改之前的数据。两次读结果一样——这就是"可重复读"!
4.4 两种情况对比
| Read View 何时生成 | t4 读到的结果 | 对应级别 | |
|---|---|---|---|
| 情况一 | T4 提交后(每次都新建) | 能看到 21 | RC |
| 情况二 | 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 四种行锁形态(重点理解)
- Record Lock(记录锁):带
REC_NOT_GAP,只锁这一行,最精准; - Gap Lock(间隙锁):带
GAP,只锁"空隙"不锁行,目的是阻止别人插入。⚠️ 重要特性:多个事务可以同时持有同一段间隙的 gap 锁(它们互相兼容,因为都只是"守门”,不冲突); - Next-Key Lock:
X/S的默认形态 = 记录锁 + 间隙锁,既锁行又锁范围,是 RR 防幻读的主力; - Insert Intention Lock(插入意向锁):插入前申请的"进门许可"。多个事务往同一段间隙的不同位置插,互不等待 → 提升插入并发。
🔑 兼容性要点:gap 锁之间互相兼容;但插入意向锁会和"盖住它位置的 gap/next-key 锁"冲突(守门的不让进)。
7.3 加锁三原则(案例分析必备)
- 锁是跟着索引扫描动态加的:扫到的每条记录加 next-key lock,遇到第一条不满足条件的改加 gap lock 然后停;
- 唯一索引 + 等值查询 + 命中 → 退化成记录锁;唯一索引等值没命中 → 只加 gap 锁;范围查询不退化;非唯一索引永不退化;
- 在二级索引上加锁时,对应的聚簇索引(主键)记录也会加记录锁。
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=10 | idx_c:next-key(5,10] + gap(10,15);主键:记录锁(10) | c 非唯一 → 不退化 |
WHERE c>=10 AND c<=15 | idx_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 不是马上物理删除,而是:
- 给这行打个 delete_mark 标记(记进 update undo);
- 快照读根据可见性决定看不看得到它;
- 真正的物理删除由 purge 线程异步完成。
这就是为什么"刚删掉的行,老事务还能查到"。
9.3 存在哪?回滚段与 undo 表空间
- undo log 按回滚段 (rollback segment) 组织,默认 128 个(
innodb_rollback_segments); - 版本链里的
DB_ROLL_PTR其实编码了"哪个回滚段 → 哪个页 → 哪个偏移"; - 存储位置演进:5.6 挤在共享表空间 → 5.7 支持独立 undo 表空间 → 8.0 默认两个独立表空间
undo_001、undo_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_size、innodb_max_purge_lag(history 太长时限流新事务)。
9.5 ⚠️ 长事务:MVCC 的头号公敌
只要有一个长事务不提交,它的老 Read View 就一直活着 → purge 不敢删旧版本 → 连锁灾难:
- undo 表空间膨胀,吃磁盘;
- 所有快照读都要拖着超长版本链遍历,全库查询一起变慢。
怎么监控:
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\G→ LATEST DETECTED DEADLOCK 段,清楚展示双方"持有谁、等谁"。
11.4 预防清单
固定顺序访问数据;事务短小快提交;让 SQL 命中索引缩小锁范围;业务允许就用 RC(gap 锁显著变少);快照读别乱加锁;关键插入做幂等 + 失败重试。
十二、高频面试题速答(自查清单)
- MVCC 是什么?解决什么? → 多版本并发控制,读不加锁、读写不冲突。
- MVCC 由哪几部分组成? → 隐藏字段、Undo Log 版本链、Read View。
- 当前读 vs 快照读? → 当前读读最新且加锁(FOR UPDATE、DML);快照读读历史不加锁(普通 SELECT)。
- 可见性算法三步? → 比 up_limit_id → 比 low_limit_id → 查 trx_list。
- RC 和 RR 核心区别? → Read View 生成时机:RC 每次新建;RR 首次生成后沿用。
- RR 下 Read View 永不更新吗? → 不是;且它是在第一次快照读时懒生成,不是 BEGIN 时。
- RR 下纯快照读为什么不幻读? → 始终用同一个视图,画面定格。
- 幻读何时出现?怎么缓解? → 快照读+当前读混用时;当前读侧靠 Next-Key/Gap Lock 锁范围缓解。
- MVCC 解决不了什么? → 写写冲突(丢失更新),靠行锁解决。
- insert undo 为啥提交后即可删? → 插入前该行不存在,别人凭 trx_id 即可判不可见。
- delete 为啥打标记不直接删? → 老事务的快照可能还要看到它,物理删除交给 purge。
- RC 对 binlog 格式有啥要求? → 必须 ROW 格式,否则主从易不一致。
- gap 锁 vs 插入意向锁? → gap 锁守门防插入、彼此兼容;插入意向锁是进门申请,与守门锁冲突。
- history list length 过高说明啥? → 有长事务,purge 被阻塞,立即排查。
- 版本链很长时快照读会怎样? → 沿 roll_ptr 逐节遍历,链越长越慢——长事务的危害。
- Serializable 下还有快照读吗? → 没有,普通 select 全变加锁读。
- 为啥两个事务能同时持有同段 gap 锁? → gap 锁只防插入不防同类锁,否则加锁动作本身就死锁。
终章:把所有碎片拼成一张完整的图
让我们把全文串起来,看看 InnoDB 并发控制的全貌:
隐藏字段给每行盖上"身份戳" → Undo Log 串起数据的"时间线" → Read View 决定每个事务"站在哪个时间点" → 可见性算法沿版本链选出该看的版本; 与此同时,Next-Key Lock 体系为当前读守住"空间边界",purge 线程在幕后清理历史,死锁检测在危急时断臂求生。
MVCC 让"读"穿越时间,锁机制让"写"守住边界,purge 让历史得以安息——三者合力,成就了 InnoDB 的高并发世界。