主题 12MySQL 事务与 MVCC
上一章解决「快不快」,这一章解决「对不对」。 面试官问完索引,下一句几乎必然是「那你说说事务」——因为索引决定性能,事务决定钱会不会算错。 这一章的所有结论都来自本机 MySQL 8.0.44 上两个连接对着跑的真实输出: 可见性、间隙锁、锁等待超时报错、一次完整的死锁日志,都能你自己复现。
12.1为什么索引之后必问事务
索引和事务是两个方向的问题。索引问的是「这条查询要多久」,事务问的是「两个人在同一秒改同一行,最后谁赢、中间人看见什么」。 前者答错顶多慢,后者答错是账目错误——所以面试官对事务的追问往往更狠。
这一章要解决的问题长这样:
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「事务的隔离级别有几种?」 | 四种级别与三个现象的对应 | 只会背名字,说不出「RR 到底解决了什么」 |
| 「MVCC 是怎么实现的?」 | 隐藏列 + undo 版本链 + ReadView | 答「多版本并发控制」六个字 → 追问一句就空 |
| 「为什么我读不到别人已经提交的数据?」 | 快照读的 ReadView 时机 | 说不出「快照是什么时候建的」 |
| 「RR 能防住幻读吗?」 | 快照读 / 当前读的区别 | 答「能」或「不能」都是错的,这题是分水岭 |
| 「线上死锁了怎么查?」 | 锁等待视图 + 死锁日志 | 只会说「重启一下」→ 直接出局 |
### 段落都是两个真实连接交替执行的结果
(A 与 B,Python + pymysql,脚本 mvcc_lab.py,库名 mvcc_lab)。
环境:MySQL 8.0.44 / InnoDB / 默认隔离级别 REPEATABLE-READ / 页 16KB。
这一章不能只读——开两个命令行窗口,一行一行敲,你才会真正记住「A 看到的是旧值」那种反直觉的感觉。
12.2三个现象,四种级别:先把术语钉死
所有讲不清事务的人,都卡在第一步:三个现象的定义混在一起。用「读到了什么」来区分,一次就能分清:
| 现象 | 一句话定义 | 关键在「谁变了」 |
|---|---|---|
| 脏读 | 读到了别人还没提交的修改 | 对方可能回滚,你读到的是从未存在过的数据 |
| 不可重复读 | 同一事务内两次读同一行,值不一样 | 别人改了已有的行并提交了 |
| 幻读 | 同一事务内两次读同一范围,行数不一样 | 别人插入/删除了行,不是改 |
记忆钩子:不可重复读 = 值变了,幻读 = 行数变了。前者锁住那一行就能解决,后者锁住行没用——因为新插入的行原来根本不存在,你锁不到一个不存在的东西。这就是为什么幻读需要间隙锁,也是这一章后半段的重点。
四种隔离级别就是拿这三个现象做取舍:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现手段 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 什么都不做,直接读最新版本 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 每条语句新建一个快照 |
| REPEATABLE READ(默认) | 不可能 | 不可能 | 基本不可能(见 12.6) | 整个事务共用一个快照 + 间隙锁 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 读也加锁,退化成串行 |
注意第三个格子里我写的是「基本不可能」而不是「不可能」。标准 SQL 规范里 RR 是不防幻读的,只有 SERIALIZABLE 防; MySQL 的 InnoDB 用间隙锁把 RR 做到了「绝大多数情况下防住」,但不是所有情况——12.6 会用实测数据把这件事讲死。 这一格是面试的分水岭,答「能防」和答「不能防」的人都拿不到分,答「看是快照读还是当前读」才有分。
REPEATABLE-READ,
但很多公司会主动改成 READ-COMMITTED(比如阿里系不少团队)。
原因不是 RC 更安全,而是 RC 没有间隙锁,锁的范围更小、死锁更少。
代价是主从复制需要 row 格式的 binlog。
面试被问「你们用什么隔离级别、为什么」,能答出这层取舍的人不多。
12.3MVCC 的三块积木
MVCC 的全称是 Multi-Version Concurrency Control,多版本并发控制。这个名字最大的问题是: 它听起来像个「机制」,其实是三个东西拼起来的。答不出这三个,就等于没答。
积木一:每行数据的两个隐藏列
你建表时只写了 id / balance / name,但 InnoDB 在磁盘上实际存的每行都有额外两列:
| 隐藏列 | 长度 | 作用 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最后一次修改这行的事务 ID。每次 update 都会刷新它 |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 里的上一个版本 |
DB_ROW_ID | 6 字节 | 没有主键时自动生成的隐藏主键(有主键就不需要) |
关键是第二个:DB_ROLL_PTR 是一个指针。它把这一行的所有历史版本串成了一条链。
积木二:undo 版本链
假设 account 表里 id=1 这行被三个事务依次改过。在 InnoDB 里它不是「一行」,而是一条链:
当前行(最新版本)
┌──────────────────────────────┐
│ id=1 balance=210 │
│ DB_TRX_ID = 103 │
│ DB_ROLL_PTR ────────┐ │
└─────────────────────┼────────┘
↓
undo log 里的旧版本
┌──────────────────────────┐
│ id=1 balance=200 │
│ DB_TRX_ID = 102 │
│ DB_ROLL_PTR ────────┐ │
└─────────────────────┼────┘
↓
┌──────────────────────┐
│ id=1 balance=100 │
│ DB_TRX_ID = 101 │
│ DB_ROLL_PTR = null │
└──────────────────────┘
看清这条链的形状:它就是一个单链表,从最新的版本往旧的走,DB_ROLL_PTR 就是 next 指针。
你在链表专题里练的「顺着 next 遍历到某个节点」,在这里就是「从一个版本往回找到我该看的那个版本」。
区别只有一个:链表的 next 往前走,版本链的 roll_ptr 往后走。
这条链的存在,是「一个人改了三次,三个人同时读能看到三个不同值」的物理基础—— 不是三个人各存一份数据,而是一份数据挂了三个历史版本。
积木三:ReadView(快照)
版本链有了,但还缺一个东西:我怎么知道该停在哪一个版本? ReadView 就是答案。它不是一个「数据副本」,而是四个数字,相当于给版本链画了一条线:
| 字段 | 含义 |
|---|---|
m_ids | 生成快照那一刻,所有还活着(未提交)的事务 ID 列表 |
min_trx_id | m_ids 里最小的那个 |
max_trx_id | 生成快照时,系统下一个要分配的事务 ID(不是 m_ids 的最大值) |
creator_trx_id | 创建这个 ReadView 的事务自己的 ID |
max_trx_id 不是 m_ids 的最大值,而是「下一个将分配的值」——所以它本身不在 m_ids 里;
② ReadView 里存的是事务 ID,不是行数据,所以它非常轻;
③ 「快照」这个说法容易让人以为复制了一份数据,实际上一行数据都没复制,只是记了几个数字。
面试官听到你说「快照就是复制当前数据」,基本就可以结束这个话题了。
12.4ReadView 怎么判断「这行我能不能看」
拿到 ReadView 之后,InnoDB 对版本链从头(最新)往后(更旧)走,对每一个版本用它的 DB_TRX_ID 做判断。
四步,顺序不能乱:
| 步骤 | 条件 | 结论 | 为什么 |
|---|---|---|---|
| 1 | trx_id == creator_trx_id | 可见 | 是我自己改的,当然要能看见 |
| 2 | trx_id < min_trx_id | 可见 | 比我快照里最老的事务还老,说明早就提交了 |
| 3 | trx_id >= max_trx_id | 不可见 | 它是我建快照之后才开启的事务,未来人 |
| 4 | min_trx_id <= trx_id < max_trx_id | 看下两行 | 这个区间里的事务,有些提交了有些还在跑 |
| 4a | 在 m_ids 里 | 不可见 | 它还活着,它的修改我不能看 |
| 4b | 不在 m_ids 里 | 可见 | 它已经提交了(否则会在 m_ids 里) |
遇到「不可见」怎么办?顺着 DB_ROLL_PTR 往下一个旧版本走,重新判断,直到找到一个可见的版本,或者走到链尾(说明这行在我快照之前还不存在)。
把它画成流程图更清楚:
取版本链上最当前的版本
│
▼
trx_id == 我自己的 ID ? ──是──→ 【可见,就用它】
│否
▼
trx_id < min_trx_id ? ────是──→ 【可见,就用它】
│否
▼
trx_id >= max_trx_id ? ───是──→ 【不可见,往旧版本走】
│否
▼
trx_id 在 m_ids 里 ? ─────是──→ 【不可见,往旧版本走】
│否
▼
【可见,就用它】
(走到链尾都没有可见版本 → 这行对我等于不存在)
RC 和 RR 的差别,只有一个字的差别
很多人背了「RR 可重复读,RC 读已提交」,但说不上来底层到底差在哪。答案短得惊人:
| 隔离级别 | ReadView 什么时候创建 | 结果 |
|---|---|---|
| READ COMMITTED | 每一条 select 语句都新建一个 | 每条语句都看到最新已提交数据 → 不可重复读 |
| REPEATABLE READ | 事务里第一次快照读时建一个,之后复用 | 整个事务看同一份快照 → 可重复读 |
就这一条。不是「RR 加锁更多」,也不是「RR 有个缓存」,而是快照的创建频率不同。
实测对比(同一个脚本,只改隔离级别):
### REPEATABLE READ(默认)
A: begin; select balance from account where id=1; → 100
B: update account set balance=200 where id=1; commit;
A: select balance from account where id=1; → 100 ← 仍是旧值
A: commit;
A: select balance from account where id=1; → 200
### READ COMMITTED
A: begin; select balance from account where id=10; → 100
B: update account set balance=300 where id=10; commit;
A: select balance from account where id=10; → 300 ← 事务内就变了
同一个事务,同样的两条 select,RR 给 100,RC 给 300。差别就在第一个 100 之后,A 有没有「继续用那个 ReadView」。
12.5快照读与当前读:一条 update 打破了所有直觉
上面那套 MVCC 只对快照读生效。所谓快照读就是普通的 select。
但有一类语句不走 MVCC,而是直接读最新已提交的数据,并且顺便加锁,这叫当前读:
| 语句 | 类型 | 为什么必须读最新 |
|---|---|---|
select ... | 快照读 | 不需要最新,一致就行 |
select ... for update | 当前读 | 你要基于它做修改,读旧的会算错 |
select ... lock in share mode | 当前读 | 同上,只是加 S 锁 |
update ... / delete ... | 当前读 | 改之前必须先拿到最新值 |
insert ... | 当前读 | 要检查唯一键冲突 |
「update 是当前读」这句话,在书上看一百遍不如自己跑一次。看实测:
### 实验:同一事务里,快照读和 update 看到的值不一样
A: update account set balance=100 where id=5; ← 重置基准
A: begin;
A: select balance from account where id=5; → 100
B: update account set balance=200 where id=5; commit;
A: select balance from account where id=5; → 100 ← 快照读,旧值
A: update account set balance=balance+10 where id=5;
(影响行数 1)
A: commit;
A: select balance from account where id=5; → 210 ← 不是 110!
最后那个 210 是这一章最重要的一个数字。
如果 update 也走快照,它会读到 100,加 10 变成 110,把 B 的 200 直接覆盖掉——这叫丢失更新。
实际结果是 210,说明 update 读到的是 200,也就是 B 提交后的最新值。
它在快照读还给 100 的同一个事务里,读到了 200。
select 走 MVCC 看历史,update / delete / for update 走当前读看现在。
所以「事务里查到的值」和「事务里 update 用的值」可能不是同一个,这不是 bug,是设计。
也正因为如此,update t set v = v + 1 这种写法是安全的,而「先 select 出来在 Java 里算完再 set 回去」是不安全的——
后者用的是快照值,会把别人的修改覆盖掉。
12.6幻读:快照读不幻,当前读会幻
先看实测,再讲原因。表 t_gap 里有 id = 5、10、15 三行:
### 实验:RR 隔离级别下,同一事务里的两种读
A: begin;
A: select count(*) from t_gap where id between 1 and 10; → 2
B: insert into t_gap values (7,0); commit;
A: select count(*) from t_gap where id between 1 and 10; → 2 ← 没变
A: select count(*) from t_gap where id between 1 and 10 for update; → 3 ← 变了!
同一个事务、同一个范围、同一行 SQL 只差一个 for update,
结果一个 2、一个 3。这就是「RR 到底防不防幻读」这个问题的真正答案:
| 读的方式 | 会不会幻读 | 原因 |
|---|---|---|
快照读(select) | 不会 | ReadView 里 B 的事务还不存在,它插的行对整个事务是隐形的 |
当前读(for update) | 会 | 读最新数据,B 已经提交了,那行就冒出来了 |
所以标准答案应该这么组织:「MySQL 的 RR 在快照读下靠 MVCC 避免幻读,在当前读下靠间隙锁阻止别人插入来避免幻读; 但如果同一事务里先快照读、后当前读,就仍然会看到新行,这是 MVCC 和锁两套机制衔接处的缝。」 这段话说出来,比背「RR 能防幻读」高一个档次。
间隙锁:锁的不是行,是「行与行之间的空」
为什么当前读需要间隙锁?因为你要防的是「别人插入一行新的」,而新行在加锁那一刻还不存在,你没法给它加行锁。 InnoDB 的办法是:锁住它可能出现的位置,也就是两条记录之间的间隙。
### 实验:RR 下 A 锁定 id between 5 and 10,B 想插 id=7
A: begin;
A: select * from t_gap where id between 5 and 10 for update;
(此刻查 performance_schema.data_locks,A 实际加到的锁)
t_gap PRIMARY RECORD X,REC_NOT_GAP 5
t_gap PRIMARY RECORD X 10
B: insert into t_gap values (7,0);
→ 阻塞 3.1 秒后报错 (1205, 'Lock wait timeout exceeded; try restarting transaction')
读这个结果要懂两种锁的记号:
| data_locks 里的 LOCK_MODE | 含义 | 覆盖范围 |
|---|---|---|
X,REC_NOT_GAP | 只有记录锁 | 只锁 id=5 这一行,不含间隙 |
X(不带 REC_NOT_GAP) | Next-Key Lock | 锁住 id=10 这行 + 它前面的间隙 (5,10) |
X,GAP | 纯间隙锁 | 只锁间隙,不锁记录本身 |
X + supremum pseudo-record | 锁到无穷远 | 最后一条记录之后的所有空间,等于锁了未来所有插入 |
所以上面那次加锁的真实含义是:id=5 这行被行锁锁住,间隙 (5,10) 被 id=10 的 next-key lock 覆盖,10 本身也被锁。 id=7 落在 (5,10) 里,所以插不进去。
查一个不存在的 id,反而锁得更宽
这个实验最能说明「间隙锁锁的是空」:
### 实验:id=8 不存在,RR 下 for update 它
A: begin;
A: select * from t_gap where id=8 for update;
t_gap PRIMARY RECORD X,GAP 10 ← 一条纯间隙锁,锁的是 (5,10)
B: insert into t_gap values (7,0); → 阻塞 3.0s 后 1205
B: insert into t_gap values (9,0); → 阻塞 3.0s 后 1205
B: insert into t_gap values (10,0); → 立刻报 (1062, "Duplicate entry '10' for key 't_gap.PRIMARY'")
注意第三个:id=10 并没有被阻塞,而是直接报主键冲突。因为 10 这行已经存在,唯一键检查在加锁之前就失败了, 根本不需要等。这个细节很多人会误读成「10 没被锁」,其实是「轮不到锁就出局了」。
select ... where id = 8 for update,id=8 不存在,会不会加锁?」
正确答案是「会,而且加的是间隙锁,锁的是一个区间」。
很多人以为「查不到就不加锁」,这是把行锁的思路套到间隙上。
实测它锁住了 (5,10) 整段,7 和 9 都插不进去。
RC 下没有间隙锁:同一个实验,结果完全不同
### 实验:把 A 的隔离级别切成 READ COMMITTED,重复上面第一个实验
A: set session transaction isolation level read committed;
A: begin;
A: select * from t_gap where id between 5 and 10 for update;
t_gap PRIMARY RECORD X,REC_NOT_GAP 5
t_gap PRIMARY RECORD X,REC_NOT_GAP 10 ← 两条都退化成纯记录锁
B: insert into t_gap values (7,0); → 成功(0.00 秒,完全没被阻塞)
两条锁都变成了 REC_NOT_GAP,间隙没了,B 一插就进。
这就是为什么很多团队把隔离级别改成 RC:锁的范围小得多,死锁也少得多。
代价是幻读会重新出现——所以业务代码里但凡依赖「同一事务内范围查询结果不变」的地方,改 RC 之前必须先排查。
12.7最容易被忽略的线上事故:for update 没走索引
这一节的内容,我在第 11 章讲索引时埋了个伏笔,现在收回。
很多人以为 for update 只锁「查出来的那几行」——前提是 where 条件走了索引。不走索引,锁的会是整张表。
表 account 只有 3 行(id = 1、5、10),name 列没有索引:
### 实验:name 无索引
A: begin;
A: select * from account where name='a' for update;
(只命中 1 行)
account None TABLE IX None
account PRIMARY RECORD X supremum pseudo-record
account PRIMARY RECORD X 1
account PRIMARY RECORD X 5
account PRIMARY RECORD X 10
B: update account set balance=balance+1 where id=10;
→ 阻塞 3.0 秒后报错 (1205, 'Lock wait timeout exceeded')
看清这四条锁:id=1、5、10 全被锁了,而且还有一条 supremum pseudo-record——
意思是「最后一条记录之后的全部空间也锁了」,未来的插入同样进不来。
命中 1 行,锁住 3 行加无限空间。原因是:name 没有索引,只能全表扫描;扫描过程中每经过一行都要判断一次
「这行符合条件吗」,而加锁发生在判断之前——所以每扫到的行都加锁,扫完整张表就是锁了整张表。
再给 name 建上索引,同一句 SQL:
### 实验:给 name 建索引后
A: begin;
A: select * from account where name='a' for update;
(同样命中 1 行)
account PRIMARY RECORD X,REC_NOT_GAP 1
account idx_name RECORD X 'a', 1
account idx_name RECORD X,GAP 'e', 5
B: update account set balance=balance+1 where id=10;
→ 成功(0.00 秒,完全不被牵连)
锁从「整表 + supremum」缩到 3 条记录级锁,而且只落在 name='a' 附近。B 改 id=10 立刻成功。
select ... for update、update ... where 非索引列、
delete ... where 非索引列——这三类语句的 where 条件必须走索引,
否则行锁会退化成表级效果,把整个表的所有写操作全部堵死。
这类事故的特征是「突然所有接口都超时,但 CPU 和 QPS 都不高」——因为都在等锁。
12.8死锁:亲手造一次,把日志读懂
死锁不用背定义,造一次就忘不掉。规则很简单:两个事务,各自先锁一行,再申请对方那一行。
### 实验:表 t_dl 有 id=1、2 两行
A: begin; update t_dl set v=1 where id=1; ← A 持有 1
B: begin; update t_dl set v=2 where id=2; ← B 持有 2
A: update t_dl set v=10 where id=2; ← 等 B
B: update t_dl set v=20 where id=1; ← 触发死锁检测
结果:
A 拿到锁(0.61 秒后)—— 因为 B 被选为牺牲者回滚了,锁释放
B 立即报错(0.01 秒): (1213, 'Deadlock found when trying to get lock; try restarting transaction')
注意 0.01 秒这个数字。它不是等到 innodb_lock_wait_timeout(默认 50 秒)才失败的,
而是 InnoDB 一发现有环就立刻挑一个事务回滚。
所以「死锁」和「锁等待超时」是两个错误码,处理方式也不同:
| 错误码 | 报错文本开头 | 含义 |
|---|---|---|
| 1213 | Deadlock found when trying to get lock | 检测到环,立刻回滚一个。整个事务被回滚 |
| 1205 | Lock wait timeout exceeded | 等太久(默认 50 秒)。只回滚当前语句,事务还在 |
这个区别是实打实的坑:1213 会让整个事务没了,1205 只回滚一条语句、事务还能继续。 如果你的代码 catch 了 1205 然后继续执行后续 SQL,很可能在一个「半截状态」的事务里继续操作。
死锁日志长什么样,怎么读
死锁发生后,show engine innodb status 的 LATEST DETECTED DEADLOCK 段会留下完整现场。
下面是我这次实测抓到的原文(略去重复的十六进制行):
LATEST DETECTED DEADLOCK
------------------------
2026-11-05 13:40:23 0x255c
*** (1) TRANSACTION:
TRANSACTION 71681, ACTIVE 0 sec starting index read
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
update t_dl set v=10 where id=2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `mvcc_lab`.`t_dl` trx id 71681 lock_mode X locks rec but not gap
0: len 4; hex 80000001 ← 持有 id=1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X locks rec but not gap waiting
0: len 4; hex 80000002 ← 想要 id=2
*** (2) TRANSACTION:
TRANSACTION 71682, ACTIVE 1 sec starting index read
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
update t_dl set v=20 where id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X locks rec but not gap
0: len 4; hex 80000002 ← 持有 id=2
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X locks rec but not gap waiting
0: len 4; hex 80000001 ← 想要 id=1
*** WE ROLL BACK TRANSACTION (2)
读这份日志只需要看四行,其余全是细节:
| 步骤 | 看哪里 | 读出什么 |
|---|---|---|
| 1 | *** (1) TRANSACTION 下面那行 SQL | 事务 1 最后在干什么 |
| 2 | (1) HOLDS 的十六进制 | 它手里有哪把锁(80000001 = id 1) |
| 3 | (1) WAITING 的十六进制 | 它想拿哪把锁(80000002 = id 2) |
| 4 | 末尾 WE ROLL BACK TRANSACTION (2) | InnoDB 选了谁当牺牲者 |
那个 hex 80000001 是 InnoDB 内部的编码:int 类型主键会加一个最高位符号位(0x80)来保证负数排在有符号序上,
所以 80000001 读作 1,80000002 读作 2。
不用记规则,把 8000000 后面当十六进制数看就行。
InnoDB 凭什么选 (2) 而不是 (1)
看两个事务的 undo log entries,都是 1,一样。再看 ACTIVE 时间:事务 1 是 0 秒,事务 2 是 1 秒。
InnoDB 的取舍原则是回滚代价小的:修改的行数少、undo 少、锁少。这里两者接近,就按内部顺序选了 (2)。
不要依赖「谁会被回滚」这个行为做业务设计——它不保证,你的代码必须能处理任意一边被回滚。
死锁的四个必要条件,和怎么破
操作系统课上的四个条件是通用的:互斥、占有且等待、不可抢占、循环等待。破掉任意一个就能破死锁, 数据库里能做的是后两个:
| 策略 | 具体做法 | 代价 |
|---|---|---|
| 固定加锁顺序 | 多个资源永远按同一个顺序加锁(比如永远先锁 id 小的) | 最有效,但要求代码统一 |
| 缩小事务范围 | 事务里只做必要的 SQL,不要在事务里调 RPC / 等用户输入 | 需要改代码结构 |
| 降低隔离级别到 RC | 没有间隙锁,锁范围小 | 幻读回来,binlog 要 row 格式 |
| 给 where 加索引 | 避免行锁升级成表锁(见 12.7) | 无 |
| 业务层重试 | catch 1213 后整事务重来,不是重试单条语句 | 要有重试上限,防雪崩 |
12.9长事务的代价:undo 堆着不走
前面所有机制都有一个共同的隐含前提:事务会很快结束。一旦事务拖长,MVCC 就从「并发利器」变成「内存杀手」。
原因是版本链:只要有任何一个 ReadView 还停在旧时刻,它可能需要看到的所有旧版本就都不能删。 purge 线程只能清掉「所有活跃 ReadView 都不需要」的 undo,所以一个长事务会挡住整条回收线。
实测:A 开一个事务、只做一次快照读(不持任何行锁)、然后一直不提交;B 每轮更新 80 次并提交。
### 实验:观察 History list length
A: begin; select balance from account where id=1; → 100(然后挂着不提交)
第 1 轮:B 提交 80 次更新 | History list length = 88
第 2 轮:B 提交 80 次更新 | History list length = 168
第 3 轮:B 提交 80 次更新 | History list length = 248
这个数字几乎严格跟着 B 的更新次数走:B 每改一次就产生一个 undo 版本,而 A 的快照让这些版本一个都删不掉。 88、168、248,每轮正好 +80。
History list length 是 InnoDB 里最该被监控的指标之一,含义是「待清理的 undo 版本数」。
它持续上涨,说明有长事务或大事务在挡着。真实线上它涨到几十万、几百万,就是 undo 表空间爆满、磁盘写满的前兆。
show engine innodb status 里的 Purge done for trx's n:o 有没有跟着
Trx id counter 往前走。
怎么找出长事务
不用猜,一张系统表就能列出来:
select trx_id,
trx_state,
trx_started,
timestampdiff(second, trx_started, now()) as 持续秒数,
trx_rows_locked,
trx_rows_modified
from information_schema.innodb_trx
order by trx_started;
再配合连接维度的视角,就能定位到「哪个应用、哪条 SQL」:
select id, user, host, db, command, time, state, left(info, 80)
from information_schema.processlist
where command <> 'Sleep' or time > 60
order by time desc;
command = 'Sleep' 但 time 很大,是很典型的信号:连接没在跑 SQL,但事务还开着——
通常是代码里 begin 之后忘了 commit,或者把事务包在了等待外部接口的位置上。
| 长事务造成的后果 | 机理 |
|---|---|
| undo 表空间膨胀 | 旧版本删不掉,磁盘占用只增不减 |
| 快照读变慢 | 版本链变长,找一个可见版本要往回走更多节点 |
| 锁持有时间长 | 别的写操作全在等,QPS 掉但 CPU 不高 |
| 主从延迟 | 大事务在从库上要执行很久,binlog 追不上 |
| DDL 被阻塞 | 加字段、加索引都要等长事务释放元数据锁 |
innodb_lock_wait_timeout 和业务层超时都要有),
宁可失败也不要挂着——挂着的代价是整条回收线被堵住。
12.10本章速记卡与自测
五张速记卡
| 卡 | 内容 |
|---|---|
| 卡 1 · 三个现象 | 脏读 = 读到未提交|不可重复读 = 值变了|幻读 = 行数变了。 前者锁行能解决,后者必须锁间隙。 |
| 卡 2 · MVCC 三积木 | 隐藏列(DB_TRX_ID / DB_ROLL_PTR)→ undo 版本链(单链表,往旧走)→ ReadView(四个数字)。 |
| 卡 3 · ReadView 四步 | 自己的 → 可见;< min → 可见;>= max → 不可见;
区间内看是否在 m_ids:在 → 不可见,不在 → 可见。不可见就沿 DB_ROLL_PTR 往回走。 |
| 卡 4 · RC vs RR | 唯一差别是 ReadView 创建时机:RC 每条 select 建一次,RR 事务首次快照读建一次并复用。 |
| 卡 5 · 快照读 vs 当前读 | select = 快照读(MVCC)|for update / lock in share mode / update / delete / insert = 当前读(最新数据 + 加锁)。 |
六个自测(能脱口而出才算过)
- 为什么
update t set v = v + 1是安全的,而「先 select 再 set 回去」不安全? - RR 隔离级别下,为什么同一事务里两次
select结果一样,但加for update后就变了? select * from t where id = 8 for update,id=8 不存在,加了什么锁?锁的范围是多大?- RC 为什么没有间隙锁?改成 RC 会带来什么问题?
- 报错 1213 和 1205 有什么区别?哪个会把整个事务回滚掉?
- 一个长事务为什么会挡住 undo 回收?用什么指标能看出来?
动手清单与自查表
| # | 动手项 | 可观察的判据(做到了会看到什么) |
|---|---|---|
| 1 | 开两个 MySQL 窗口,复现 12.4 的可重复读 | A 第一次读 100,B 改并提交,A 再读仍是 100;A 提交后再读变 200 |
| 2 | 复现 12.5 的「update 是当前读」 | A 快照读给 100,执行 balance=balance+10 后提交,值变成 210 而不是 110 |
| 3 | 复现 12.6 的幻读差异 | 同一事务内 count(*) 是 2,加 for update 变成 3 |
| 4 | 查 performance_schema.data_locks 看真实加锁 |
能看到 X,REC_NOT_GAP / X,GAP / supremum pseudo-record 三种记号 |
| 5 | 制造一次死锁并抓到日志 | show engine innodb status 里出现 LATEST DETECTED DEADLOCK 段,
末尾有 WE ROLL BACK TRANSACTION (n) |
| 6 | 观察长事务的 undo 堆积 | History list length 随对方更新次数单调上涨,不随 commit 立刻回落 |
name 建索引,
跑一次 for update 确认 B 不被阻塞;然后 drop index,同样的语句再跑一次,
B 立刻变成等 3 秒超时。同一条 SQL、同一份数据,只因为索引没了,锁的范围从 1 行变成整表。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不知道锁加在哪 | performance_schema.data_locks(8.0 起,替代老的 innodb_locks) |
| 不知道谁在等锁 | performance_schema.data_lock_waits |
| 不知道有哪些活跃事务 | information_schema.innodb_trx |
| 死锁现场没了 | show engine innodb status 的 LATEST DETECTED DEADLOCK,只留最近一次 |
| 想长期留证据 | 开 innodb_print_all_deadlocks = ON,死锁会写进 error log |