Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 12MySQL 事务与 MVCC

上一章解决「快不快」,这一章解决「对不对」。 面试官问完索引,下一句几乎必然是「那你说说事务」——因为索引决定性能,事务决定钱会不会算错。 这一章的所有结论都来自本机 MySQL 8.0.44 上两个连接对着跑的真实输出: 可见性、间隙锁、锁等待超时报错、一次完整的死锁日志,都能你自己复现。

学习目标
能画出 undo 版本链 + ReadView,说清「为什么我看不到你刚提交的数据」
前置要求
读过第 11 章;知道单链表怎么遍历
本章产出
10 组双连接实测 + 1 份真死锁日志 + 5 张速记卡
建议学时
4–5 小时(务必两个窗口对着跑)

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 会用实测数据把这件事讲死。 这一格是面试的分水岭,答「能防」和答「不能防」的人都拿不到分,答「看是快照读还是当前读」才有分。

一个常被忽略的细节:MySQL 的默认隔离级别是 REPEATABLE-READ, 但很多公司会主动改成 READ-COMMITTED(比如阿里系不少团队)。 原因不是 RC 更安全,而是 RC 没有间隙锁,锁的范围更小、死锁更少。 代价是主从复制需要 row 格式的 binlog。 面试被问「你们用什么隔离级别、为什么」,能答出这层取舍的人不多。

12.3MVCC 的三块积木

MVCC 的全称是 Multi-Version Concurrency Control,多版本并发控制。这个名字最大的问题是: 它听起来像个「机制」,其实是三个东西拼起来的。答不出这三个,就等于没答。

积木一:每行数据的两个隐藏列

你建表时只写了 id / balance / name,但 InnoDB 在磁盘上实际存的每行都有额外两列:

隐藏列长度作用
DB_TRX_ID6 字节最后一次修改这行的事务 ID。每次 update 都会刷新它
DB_ROLL_PTR7 字节回滚指针,指向 undo log 里的上一个版本
DB_ROW_ID6 字节没有主键时自动生成的隐藏主键(有主键就不需要)

关键是第二个: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_idm_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 做判断。 四步,顺序不能乱:

步骤条件结论为什么
1trx_id == creator_trx_id可见是我自己改的,当然要能看见
2trx_id < min_trx_id可见比我快照里最老的事务还老,说明早就提交了
3trx_id >= max_trx_id不可见它是我建快照之后才开启的事务,未来人
4min_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」。

这解释了一个常见的线上困惑:「我在一个事务里查了三次,数据一直不变,是不是缓存了?」 不是缓存,是你的 ReadView 没换。这也是为什么长事务会读到很久以前的数据—— 它的快照一直停在事务开始的那一刻。12.9 会讲这个代价。

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 一发现有环就立刻挑一个事务回滚。 所以「死锁」和「锁等待超时」是两个错误码,处理方式也不同:

错误码报错文本开头含义
1213Deadlock found when trying to get lock检测到环,立刻回滚一个。整个事务被回滚
1205Lock 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 后整事务重来,不是重试单条语句要有重试上限,防雪崩
最常被问的一句:「死锁了怎么办?」 答题顺序应该是:① 看日志确认是不是真死锁(1213 还是 1205)→ ② 找出两个事务的加锁顺序 → ③ 让它们顺序一致 → ④ 加 1213 重试兜底。 说「重启数据库」等于说「我不会」。死锁是正常现象,InnoDB 本来就设计了自动检测和回滚,要解决的是「为什么会有环」。

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 表空间爆满、磁盘写满的前兆。

注意一个容易被误解的点:事务提交了也不等于 undo 立刻消失。 回收是 purge 线程异步做的,不是 commit 时同步释放。 所以「我事务已经提交了为什么 undo 还在涨」是个错误提问——该问的是「purge 有没有在推进」。 判断方法:看 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 被阻塞加字段、加索引都要等长事务释放元数据锁
工程上的三条硬规矩: ① 事务里绝不调外部接口、发消息、等用户输入; ② 能拆就拆,一批 10 万行改成 100 批 1000 行; ③ 给事务加超时(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 = 当前读(最新数据 + 加锁)。

六个自测(能脱口而出才算过)

  1. 为什么 update t set v = v + 1 是安全的,而「先 select 再 set 回去」不安全?
  2. RR 隔离级别下,为什么同一事务里两次 select 结果一样,但加 for update 后就变了?
  3. select * from t where id = 8 for update,id=8 不存在,加了什么锁?锁的范围是多大?
  4. RC 为什么没有间隙锁?改成 RC 会带来什么问题?
  5. 报错 1213 和 1205 有什么区别?哪个会把整个事务回滚掉?
  6. 一个长事务为什么会挡住 undo 回收?用什么指标能看出来?
一句话记忆:MVCC 让你读得到过去,当前读让你改得对现在, 间隙锁让你挡得住未来——三套机制各管一段时间,缝就在「先读过去、再改现在」的交接处。

动手清单与自查表

#动手项可观察的判据(做到了会看到什么)
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 立刻回落
反例实验(做了会记一辈子):把 12.7 的第二段反过来做——先给 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