本章把"并发事务互相看不见"这件事拆到引擎内部的字段级:一行数据的多个历史版本怎么存、读的时候凭什么判定"这个版本我能看"。

学习目标

读完本章,你应当能够:

  1. 把 ACID 四条逐一对应到具体机制(undo log / redo log + fsync / 锁 + MVCC / 约束 + 前三者),并说明"一致性不是独立机制"这句话的确切含义;
  2. 推导 SQL 标准的四种隔离级别与脏读、不可重复读、幻读三类异常的对应关系,并辨析 InnoDB 与 PostgreSQL 在默认级别上的差异及其原因;
  3. 说出 MVCC 的三要素(隐藏字段、undo 版本链、ReadView),画出 undo 版本链的结构图;
  4. 按 m_ids / min_trx_id / max_trx_id / creator_trx_id 逐步执行可见性判断算法,并解释"不可见时沿 roll_pointer 回溯"的终止条件;
  5. 用"ReadView 的创建时机"解释 RC 与 RR 的全部行为差异,指出"两个级别看的是不同快照数据"这一说法错在哪;
  6. 列出快照读与当前读的完整语句清单,举出 RR 下仍然出现幻读的两类具体场景;
  7. 定位并量化长事务的三类危害,写出基于 information_schema.innodb_trx 的监控 SQL。

前置知识检查

你需要知道在哪一节补齐
InnoDB 页结构、行格式与隐藏列的物理布局见第 20 章 20.4 节;本章只用 DB_TRX_ID/DB_ROLL_PTR 两个字段
undo log 的逻辑结构与 redo log 的 WAL 顺序、LSN、组提交见第 21 章 21.1~21.3 节;本章把 undo 当作"版本链的存储介质"使用
间隙锁、临键锁、意向锁的加锁规则见第 24 章 24.2、24.3 节;本章只说明"哪些语句会去加锁"
binlog 的 STATEMENT/ROW/MIXED 与两阶段提交见第 21 章 21.4、21.5 节;本章只讨论隔离级别对复制安全性的约束

22.1 ACID 每一条对应哪个机制

“事务有 ACID 四个特性"是本科内容;面试真正在问的是这四条各自靠什么实现、少一个会怎样。把它们对应到机制之后,“一致性(consistency)“的特殊地位就露出来了:它不是某个组件的功能,而是前三者的共同结果,再加上数据库自身的完整性约束。

特性承载机制精确表述缺了会怎样
原子性(atomicity)undo log每条修改在写入前先记一条逻辑反向操作(“插入 → 删除主键”、“更新 A→B → 更新 B→A”);回滚时逆序执行事务中途失败会留下半截修改;但注意回滚是逻辑回滚,不是恢复页的物理镜像
持久性(durability)redo log + fsync提交时把 redo 记录刷到磁盘(innodb_flush_log_at_trx_commit=1);数据页可以晚点再刷,崩溃后用 redo 重放已提交事务在断电后消失。持久性的边界取决于刷盘策略,=0/2 时"提交成功"不等于"落盘成功”
隔离性(isolation)锁 + MVCC写—写用锁串行化;读—写用 MVCC 让读不加锁也能拿到一致快照并发事务互相看到中间状态。隔离性不是回滚能补的,回滚只解决原子性
一致性(consistency)前三者 + 完整性约束事务把一个满足约束的状态映射到另一个满足约束的状态这是目标而非机制。约束(唯一索引、外键、CHECK、NOT NULL)是数据库替应用守住的底线,业务语义上的一致性只能由应用保证

这里有一个反直觉点必须讲清楚:undo log 同时服务两个完全不同的目的——回滚(原子性)和 MVCC 版本链(隔离性)。同一条 undo 记录,回滚时被"反向执行”,MVCC 时被当作"历史版本读取”。这解释了一个常见困惑:为什么事务提交后 undo 不能立刻删?因为可能还有别的 ReadView 需要读它,必须等 purge 确认没人需要了才回收。purge 的判定条件见 22.7 节。

重要

面试标准答法是"原子性靠 undo log、持久性靠 redo log、隔离性靠锁加 MVCC、一致性靠前三者加约束"。只答"ACID 是原子性一致性隔离性持久性"会被立刻打断,追问方向是"你说的一致性,数据库具体做了什么来保证它"。

注意

约束只能在单机单库范围内保证一致性。一旦分库分表或跨服务,唯一索引不再全局唯一、外键无法跨库,这部分一致性就回到应用层(见第 26 章 26.5 节)。这是"数据库不保证分布式一致性"的准确出处。

22.2 四种隔离级别与三类异常:标准与 InnoDB 实际行为的差异

三类异常的定义必须用同一事务内先后两次读的结果来描述,否则说不清区别:

  • 脏读(dirty read):读到了别的事务尚未提交、且可能回滚的修改;
  • 不可重复读(non-repeatable read):同一事务内两次读同一行,值不同(因为中间有别的事务提交了修改);
  • 幻读(phantom read):同一事务内两次执行同一个范围查询,第二次多出了行(因为中间有别的事务提交了插入)。
隔离级别脏读不可重复读幻读InnoDB 实际行为与标准不同之处
READ UNCOMMITTED(RU)可能可能可能InnoDB 下快照读不做可见性判断,直接读最新版本,因此脏读确实存在(见 22.6 节)
READ COMMITTED(RC)不可能可能可能与标准一致;但 RC 每次读都新建 ReadView,所以"不可重复读"是必然而不是偶然
REPEATABLE READ(RR)不可能不可能标准说可能快照读下基本不出现幻读:整个事务复用同一个 ReadView,别的事务插入的行对本事务不可见
SERIALIZABLE不可能不可能不可能InnoDB 把普通 SELECT 隐式升级为 SELECT ... LOCK IN SHARE MODE(快照读全部变成当前读),代价是并发度归零

小心

“InnoDB 的 RR 完全解决了幻读"是高风险错误。准确表述是”RR 的快照读不会出现幻读,但当前读会出现",两类场景见 22.6 节。面试官问的就是这个"但是"。

标准与实现之所以分叉,原因是 SQL 标准只描述可观察到的现象,不规定实现手段。InnoDB 用 MVCC 提供了一致性读(consistent read),这是一个标准里没有的概念,它顺带把 RR 下的幻读也压掉了。

22.2.1 为什么 PostgreSQL 默认 RC 而 MySQL 默认 RR

这不是口味问题,而是两种 MVCC 实现路线的历史产物:

  • MySQL 的默认值来自早期复制格式的约束。binlog_format=STATEMENT 时,从库重放的是 SQL 文本,主库上语句的执行顺序直接决定从库结果,因此主库需要更强的隔离(RR)来压制主从数据不一致;MySQL 8.0 起 binlog_format 默认已是 ROW,这个约束的限制作用已经消失,但默认级别出于兼容性未改。
  • PostgreSQL 从 9.1 起默认 RC,因为它的 RC 在每个语句开始时取一次快照,已经能避免语句内部的"半更新状态",把大部分不可重复读问题留给应用显式处理;这样长事务不会阻塞 vacuum 回收——PostgreSQL 的 VACUUM 与 InnoDB 的 purge 面临同一个问题,降低默认隔离级别是缓解手段之一。

工程上的取舍结论:写多且复制延迟敏感的场景倾向 RC(锁范围小、无间隙锁、主从一致性对 ROW 格式不敏感),需要"整个事务内多次读结果自洽"的报表类场景倾向 RR。MySQL 官方手册在"事务隔离级别"章节给出的实践建议也是把 RC 与 binlog_format=ROW 配对使用。

22.3 MVCC 的三要素:隐藏字段、undo 版本链、ReadView

MVCC(multi-version concurrency control,多版本并发控制)在 InnoDB 里由三个组件协作完成。三者缺一不可:隐藏字段提供定位信息,undo 版本链提供历史数据存储,ReadView 提供可见性规则。

22.3.1 隐藏字段

InnoDB 的聚簇索引记录(COMPACT/DYNAMIC 行格式)除用户列外还有三个隐藏列:

隐藏字段长度作用何时不存在
DB_TRX_ID6 字节最近一次修改该行的事务 ID,MVCC 可见性判断的输入永远存在
DB_ROLL_PTR7 字节回滚指针,指向 undo log 中该行的上一个版本记录永远存在,无旧版本时为 NULL
DB_ROW_ID6 字节行 ID,仅在表没有主键也没有唯一非空索引时由 InnoDB 生成并作为聚簇索引键有主键或有唯一非空索引时不存在

提示

记忆锚点:DB_TRX_ID 回答"这个版本是谁改的",DB_ROLL_PTR 回答"上一个版本在哪",DB_ROW_ID 回答"这行凭什么唯一"。三个字段分别对应可见性判断的三个输入、版本链的遍历指针、聚簇索引的兜底键。

22.3.2 undo 版本链的结构

每次修改一行,InnoDB 把旧版本写进 undo log,并让新记录的 DB_ROLL_PTR 指向它。于是同一行在逻辑上形成一条单向链表,表头是最新版本,越往后越旧。trx_id 标记每个版本由哪个事务产生:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 表 t 中主键 id = 1 的这一行(当前最新版本在聚簇索引叶子上)

   聚簇索引记录
  ┌──────────────────────────────────────┐
  │ id=1  name='C'  age=30               │
  │ DB_TRX_ID  = 300   ← 谁写了这个版本   │
  │ DB_ROLL_PTR ──────┐                  │
  └───────────────────┼──────────────────┘
                      │ ① 沿回滚指针进入 undo
                      ▼
   undo log(版本链,越深越旧)
  ┌──────────────────────────────────────┐
  │ undo 记录 A:name='B'  age=20        │
  │   trx_id = 200     (由事务 200 写入)│
  │   roll_ptr ───────┐                  │
  └───────────────────┼──────────────────┘
                      │ ② 继续回溯
                      ▼
  ┌──────────────────────────────────────┐
  │ undo 记录 B:name='A'  age=10        │
  │   trx_id = 100     (由事务 100 写入)│
  │   roll_ptr = NULL  (更早没有版本了) │
  └──────────────────────────────────────┘

  版本从新到旧:300 → 200 → 100
  一次可见性判断失败就沿指针走一格,最多走到链尾(NULL)为止

三个必须记住的细节:

  1. undo 记录存的是"如何回到上一个版本",即 update undo 里保存的是被覆盖列的旧值。所以沿链回溯不是"读一份完整快照",而是"用 undo 逐版本倒推",这也是长链遍历昂贵的原因。
  2. 链的方向与判断顺序相反。可见性判断总是从最新版本开始,一旦命中可见就立刻停止回溯——这是性能上的关键优化,而不是遍历全链再挑。
  3. 链的回收由 purge 线程负责,条件是"没有任何活跃 ReadView 还需要比它更旧的版本"(见 22.7 节)。

警告

常见错误回答:把 undo 版本链说成"每个事务保存一份数据快照"。MVCC 是按行保留旧版本,不是按事务拷贝整库;一个事务修改了 5 行就只产生 5 个版本,未被修改的行共享同一个最新版本。把 MVCC 理解成"快照隔离的整库拷贝"会在被追问内存开销时答崩。

22.4 ReadView 的四个字段与可见性判断算法

ReadView 是"本事务在创建这一刻,系统里哪些事务还没结束"的名单快照。它只有四个字段:

字段含义在判断中的角色
m_ids创建 ReadView 时当前活跃(已启动未提交)的事务 ID 列表判定"中间地带"版本的可见性
min_trx_idm_ids 中的最小值(无活跃事务时等于下一个待分配 ID)第一条分界线
max_trx_id创建 ReadView 时下一个将被分配的事务 ID(即已出现的最大 ID + 1)第二条分界线
creator_trx_id创建这个 ReadView 的事务自身 ID自己改的行永远可见

判断算法的完整流程(每一步都可直接执行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 输入:某个版本记录的 DB_TRX_ID(记作 trx_id),当前事务的 ReadView
 输出:该版本对本事务是否可见

 步骤 0  若 trx_id == creator_trx_id
            → 可见(自己改的数据自己一定能看到,与名单无关)
            否则进入步骤 1

 步骤 1  若 trx_id < min_trx_id
            → 可见(这个版本由"名单之前就已结束"的事务产生,一定是已提交的)
            否则进入步骤 2

 步骤 2  若 trx_id ≥ max_trx_id
            → 不可见(这个版本由 ReadView 创建之后才启动的事务产生,必须回滚)
            否则进入步骤 3

 步骤 3  此时 min_trx_id ≤ trx_id < max_trx_id,落在"中间地带"
         在 m_ids 中查找 trx_id:
            找到了   → 不可见(该事务创建 ReadView 时还活跃,可能未提交或已回滚)
            没找到   → 可见(该事务在创建 ReadView 之前已提交,只是 ID 在区间内)

 步骤 4  判为不可见时,沿 DB_ROLL_PTR 取上一个版本,
         把新版本的 trx_id 重新代入步骤 0。
         若指针为 NULL(已到链尾)→ 该行对本事务"不存在"
         (表现为范围查询里这一行不出现,即幻读被压制的来源)

做一个完整算例,把四条规则各命中一次。设当前事务 ID = 300,执行第一条 SELECT 时创建的 ReadView 为 m_ids = {200, 250},则 min_trx_id = 200、max_trx_id = 301(下一个待分配的 ID)、creator_trx_id = 300。

1
2
3
4
5
6
7
8
9
 版本链: 300 → 250 → 200 → 100 → NULL

 判断 300:等于 creator_trx_id                 → 可见(步骤 0 命中)
 判断 250:200 ≤ 250 < 301,且 250 ∈ m_ids     → 不可见(步骤 3 命中"在名单中")
 判断 200:200 ≤ 200 < 301,且 200 ∈ m_ids     → 不可见(同上,"小于"不含等于)
 判断 100:100 < min_trx_id = 200              → 可见(步骤 1 命中)

 结论:本事务看到的这一行是 trx_id = 100 产生的那个版本。
       事务 200 与 250 的修改虽然已经写进页里,但都在名单内,一律当作不存在。

重要

边界必须记准:min_trx_id 是活跃列表的最小值,所以规则是 trx_id < min_trx_id 而不是 ≤;max_trx_id 是下一个待分配 ID,所以规则是 trx_id ≥ max_trx_id 而不是 >。把这两处写成 ≤ / >,会在"恰好在边界上"的用例里判错——这是面试官最爱设的陷阱。

注意

面试官会追问的下一层:这个判断做几次? 每读一行做一次。所以一个扫描 100 万行的范围查询,如果 undo 链很长,最坏情况是每行都要沿链回溯若干格,代价与"链长 × 行数"成正比。这正是 22.7 节长事务危害的性能机理。另外 m_ids 在实现上是按 ID 排序的数组,判断"是否在名单中"用二分而不是线性扫描。

22.5 RC 与 RR 的差异根因:ReadView 的创建时机

这是全章最高频的考点,也是最容易答偏的地方。RC 与 RR 的差别只有一处:ReadView 在什么时候创建。 两者的可见性判断步骤 0~4 完全一样。

维度READ COMMITTEDREPEATABLE READ
ReadView 创建时机每次执行 SELECT 都新建一个事务内第一次 SELECT 时创建,之后复用
事务内多次快照读每次都是新的名单,别人已提交的修改立刻可见整个事务使用同一份名单,看到的是同一个快照
因此不可重复读必然可能(两次读两个 ReadView)不可能(名单不变,可见性结论不变)
因此幻读(快照读)可能(新插入的行在第二次的名单中已提交)基本不可能(第一次的名单里没有那个事务)
间隙锁基本不用(RC 下间隙锁被禁用)使用临键锁(见第 24 章 24.3 节)
适用场景写多、需要尽快看到他人提交、对复制延迟敏感需要事务内视图自洽的报表、对账类逻辑

必须澄清一个非常普遍的误解:这不是"两个级别看到的快照数据不同",而是"取快照名单的时刻不同"。 存储的数据版本完全一样,undo 链也一样,唯一的区别是"你在什么时刻问系统’现在有哪些事务还活着’"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
 时间轴(同一行 x=1,事务 T1 是 RR,T2/T3 是并发写事务)

 T1: BEGIN ──① SELECT x  ────────────────② SELECT x ──③ SELECT x ── COMMIT
                │                              │            │
                │ 创建 ReadView V1             │ 复用 V1    │ 复用 V1
                │ m_ids={T2}                   │            │
                │                              │            │
 T2:      BEGIN ── UPDATE x=2 ── COMMIT
                                      │
                                      └─ 提交时刻:T1 的 V1 里 T2 在名单中
                                         ⇒ T2 的版本对 T1 永远不可见

 T3:                                        BEGIN ── UPDATE x=3 ── COMMIT
                                                                  │
                                                                  └─ T3 的 ID ≥ V1.max_trx_id
                                                                     ⇒ 同样不可见

 RR 下 ①②③ 全部读到 x=1;RC 下 ② 读到 2、③ 读到 3(因为每次重建名单)

在实际代码里能观察到的差异:同一个 @Transactional 方法内先查一次、业务处理耗时若干秒、再查一次,MySQL 默认 RR 下两次结果一致;如果误配成 RC,第二次可能读到别人刚提交的值,导致"校验通过但更新时数据已经变了"这类偶发故障。这也是用 SELECT ... FOR UPDATE 做悲观锁(见 22.6 节)而不用"先查再改"的原因。

22.6 快照读与当前读:什么时候读的不是历史版本

到 22.5 节为止讲的是快照读(snapshot read):不加锁,读 ReadView 判定后的历史版本。与它相对的是当前读(current read):读最新已提交数据,并且会加锁。

语句类别读到什么是否加锁
SELECT ...(普通查询)快照读ReadView 判定的历史版本不加锁
SELECT ... LOCK IN SHARE MODE(8.0 亦可写 FOR SHARE)当前读最新已提交版本加读锁(共享)
SELECT ... FOR UPDATE当前读最新已提交版本加写锁(排他)
UPDATE ... / DELETE ...当前读先当前读定位行,再修改加写锁
INSERT ...当前读不读旧值,直接写入并做唯一性冲突检测加隐式锁 / 冲突时加锁
INSERT ... ON DUPLICATE KEY UPDATE、REPLACE INTO当前读读取判断冲突加锁

SELECT ... FOR UPDATE 的执行语义值得单独说:它先做一次当前读拿到最新提交版本(这一步绕过了 ReadView),然后对匹配的记录加排他锁,并把加锁结果返回。由于它读的是当前数据,读到的行数与内容和快照读可能不同——这就是 RR 下幻读的来源。具体的加锁范围推导(唯一索引等值、非唯一索引等值、范围查询分别加什么锁)归第 24 章 24.3 节,本章只界定"哪些语句会去加锁"。

22.6.1 RR 下仍然出现幻读的两类场景

重要

这个"但是"是本章最值钱的一段。答出"RR 快照读不幻读"只能算及格,能主动说出这两个场景才算过关。

场景一:当前读本身不属于快照读。 事务 A 中 SELECT * FROM t WHERE id > 100 快照读返回 3 行;事务 B 插入 id = 104 并提交;事务 A 再执行 SELECT * FROM t WHERE id > 100 FOR UPDATE,读到 4 行。原因是第二次是当前读,直接看最新已提交数据,ReadView 完全不参与。同时因为 FOR UPDATE 在 id > 100 的区间上加临键锁(见第 24 章 24.3 节),事务 B 的插入若发生在加锁之后就会被阻塞——加锁能否挡掉幻读,取决于两个事务的先后顺序。

场景二:同一事务内快照读与当前读混用,结果前后矛盾。 这是更隐蔽的一种:

1
2
3
4
5
6
7
8
-- 事务 A(RR 隔离级别,全程未提交)
BEGIN;
SELECT COUNT(*) FROM t WHERE id > 100;          -- 快照读,返回 3:本事务的快照里没有 id=104
-- 此时事务 B 执行 INSERT INTO t VALUES (104, ...) 并 COMMIT

SELECT * FROM t WHERE id > 100 FOR UPDATE;      -- 当前读,返回 4 行:多出来的 id=104 就是幻影行
SELECT COUNT(*) FROM t WHERE id > 100;          -- 又变回 3:复用同一个 ReadView,幻影行再次"消失"
COMMIT;

同一个事务里,同一个条件,一次看到 4 行、一次看到 3 行。这种前后不一致本身就是 RR 隔离级别下的幻读,也是"RR 到底能不能防幻读"这个问题的标准答案落点。要靠当前读做唯一性校验的业务(例如"先 SELECT 判断不存在再 INSERT"),必须意识到两次读的语义不同,正确写法是依赖唯一索引让 INSERT 直接报冲突。

22.7 长事务的三类危害与监控

长事务在面试里的标准答法是"undo 膨胀、回滚慢、锁持有时间长",但真正把面试官说服的是说清危害的作用链条。这里的机制与 22.4 节的可见性判断直接相连:

  1. undo 版本链无法回收,链变长。 purge 线程要回收一个旧版本,前提是"不存在还需要它的 ReadView"。一个开了 10 分钟没提交(甚至只读)的事务,它的 ReadView 一直有效,于是这 10 分钟内产生的所有旧版本都不能删。链一长,22.4 节步骤 4 的回溯次数就线性上涨——危害不是只作用于这个长事务,而是作用于之后所有事务,因为每个新 ReadView 都可能要跨过这些陈旧版本。
  2. m_ids 变大,判断落进"中间地带"的行变多。 活跃事务越多,m_ids 越长,二分查找本身代价上升;更严重的是更多版本落进步骤 3 的分支,本来一步能判定的现在要多走几次链。
  3. 占用连接与锁。 事务不结束,连接归还不到池子里;写事务持有的行锁要等到提交才释放,innodb_lock_wait_timeout 默认 50 s 一旦打满就是接口大面积超时。此外事务期间 undo 无法清理、崩溃恢复时要回滚的数据量也成倍增长(回滚与崩溃恢复见第 21 章 21.2 节)。

警告

只读事务同样有害,而且更隐蔽:它不加锁、不报阻塞、SHOW PROCESSLIST 里可能只有一条不起眼的 Sleep,却能把 purge 一直挡在后面。排查"undo 表空间为什么只涨不跌",第一件事就是找长时间未提交的只读事务。

监控 SQL(innodb_trx 里的时间字段是秒,不是微秒,这点常被记错):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 定位超过 60 秒仍未结束的事务;按持锁行数降序,优先处理真正在写的那些
SELECT trx_id,
       trx_state,                                  -- RUNNING / LOCK WAIT,LOCK WAIT 说明它自己在等锁
       trx_started,                                -- 事务开始时刻,与 NOW() 相减得到已运行时长
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS elapsed_s,
       trx_rows_locked,                            -- 持有或等待的行锁数,量级大说明加锁范围大
       trx_rows_modified,                          -- 已修改行数,直接对应 undo 体积
       trx_is_read_only,                           -- 只读长事务最容易被漏掉,必须单独看这一列
       trx_mysql_thread_id                         -- 需要 kill 时用这个连接 ID
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_rows_locked DESC, elapsed_s DESC;
1
2
3
4
5
-- 从"服务端总览"角度确认是否真的存在陈旧版本积压:History list length 持续上涨即为信号
SHOW ENGINE INNODB STATUS\G
-- 关注输出中的 TRANSACTIONS 段:
--   History list length 12345      ← undo 版本链总长度,长期高位说明 purge 被拖住
--   Purge done for trx's n:o < ... ← purge 推进到的位置,长时间不动就是被长事务卡住

配套的运维手段还有两类:一是把跑批、导数据这类长任务拆成分批提交的小事务(每批 500~1000 行提交一次),把事务时长从"小时级"降到"秒级";二是给只读报表设置 SET SESSION TRANSACTION READ ONLY(本身不解决 purge 阻塞)与合理的超时,并在应用侧用读超时兜底,而不是让一个 @Transactional 方法包住整段远程调用。

22.8 隔离级别对复制安全性的两个约束

隔离级别不只影响本机并发行为,还通过 binlog 约束主从一致性。这是把第 22 章与第 21、26 章串起来的两道题。

第一道:RC 与 binlog_format = ROW 的配合。 STATEMENT 格式记录的是 SQL 文本,从库重放时会用自己的数据重新执行一遍。RC 下同一个事务内两次读到的数据可能不同(见 22.5 节),主库执行 UPDATE ... WHERE 时依赖的行集合,到从库重放时可能已经变了,于是主从数据分叉。ROW 格式记录的是每行修改前后的镜像,从库不再重新求值,执行顺序与隔离级别都不影响结果,所以 RC 配 ROW 是安全的。MySQL 官方手册因此建议:使用 RC 时把 binlog_format 设为 ROW。反过来,STATEMENT 格式在 RC 下是明确不安全的组合。

第二道:innodb_autoinc_lock_mode 三种模式与主从一致性。

模式名称加锁行为自增值特点与 STATEMENT 复制的兼容性
0traditional语句级 AUTO-INC 表锁,持有到语句结束同一语句内连续、无空洞安全
1consecutive轻量互斥量,分配完立即释放(批量插入等"不知道行数"的情形仍用表锁)简单插入(行数可预知)连续;批量插入可能不连续安全
2interleaved轻量互斥量,并发语句交错分配值不连续,同一语句内也可能有空洞不安全(从库重放会得到不同的自增值)

模式 2 与 ROW 格式配合才安全,原因与第一道题完全同源:ROW 格式里自增列的值是随行镜像一起记录的,从库不需要自己再生成,因此不连续也不影响一致性。MySQL 8.0 的默认组合是 innodb_autoinc_lock_mode=2 加 binlog_format=ROW;5.7 的默认是模式 1。这也解释了一个现象:同一张表在批量插入后自增 ID 出现空洞,在模式 2 下是设计使然,不是 bug,也不能靠 AUTO_INCREMENT 重置彻底解决。

注意

面试官会追问的下一层:自增 ID 空洞除了上面这个原因,还有哪些?——插入失败回滚、INSERT ... ON DUPLICATE KEY UPDATE 的预分配、事务回滚、以及 MySQL 8.0 之前 AUTO_INCREMENT 计数器不持久化导致重启后回退(8.0 起写入 redo 日志,重启不再回退)。能在答完模式差异后主动补这条,说明你不是背表格。

本章小结

关键结论一句话表述常见错误
ACID 的实现依赖原子性→undo log、持久性→redo log+fsync、隔离性→锁+MVCC、一致性→前三者+约束把一致性说成有独立机制,或说"靠两阶段提交保证一致性"(两阶段提交保证的是 redo 与 binlog 的一致)
MVCC 三要素隐藏字段提供定位、undo 版本链提供历史版本、ReadView 提供可见性规则,缺一不可只答 ReadView,漏掉 DB_TRX_ID/DB_ROLL_PTR,或把 MVCC 说成"每个事务一份快照"
可见性判断先比 creator_trx_id,再比 min_trx_id/max_trx_id,落在中间查 m_ids;不可见就沿 DB_ROLL_PTR 回溯把边界写成 ≤ min_trx_id、> max_trx_id;或说"不可见就返回空"而忘了回溯
RC 与 RR 的根因差别只在 ReadView 的创建时机:RC 每次 SELECT 新建,RR 复用第一次的说成"RR 用的是快照数据、RC 用的是实时数据"——数据版本相同,名单时刻不同
RR 下的幻读当前读(FOR UPDATE、UPDATE、DELETE)绕过 ReadView 读最新数据,会看到快照读看不到的行断言"RR 完全不会幻读";说不出"同一事务内前后不一致"这个判据
长事务危害拖住 purge 导致 undo 链变长、m_ids 变大、连接与锁被占,危害波及后续所有事务只答"占内存",说不出"旧版本无法回收"这一条因果链
复制安全RC 必须配 binlog_format=ROW;innodb_autoinc_lock_mode=2 只在 ROW 下安全答"RC 更安全所以随便配",或把自增空洞当成故障

自测题

Q1(判断)“InnoDB 的 REPEATABLE READ 通过加间隙锁彻底避免了幻读,所以 RR 是不会有幻读的。“请判断并给出准确表述。

答案与解析

判断:错,而且有两处错。

第一处:RR 抑制幻读的第一功臣不是锁,是 MVCC。当前读之外的普通 SELECT 完全不加锁,它看不到别的事务插入的行,是因为那些行的 DB_TRX_ID 要么落在 m_ids 里、要么 ≥ max_trx_id,在 22.4 节的判断里被判为不可见。间隙锁只在当前读路径上起作用,用来阻止"我加锁的区间里被插入”。

第二处:RR 下幻读仍会发生,两条路径见 22.6.1 节:① 当前读绕过 ReadView 读最新数据,会看到快照读看不到的新行;② 同一事务内快照读与当前读混用,出现"一会儿 4 行、一会儿 3 行"的前后不一致。

错在哪:把"隔离级别定义"与"实现的效果"混为一谈,并且把 MVCC 的功劳记在间隙锁上。追问方向:“那你说的这个’当前读导致幻读’,SERIALIZABLE 是怎么避免的?“答案是 SERIALIZABLE 把普通 SELECT 隐式升级为 LOCK IN SHARE MODE,让所有读都变成当前读并加锁,用并发度换掉了不一致。

Q2(推导)事务 T(ID = 500)第一次 SELECT 时系统活跃事务为 {400, 450}。某行的版本链为 520 → 480 → 450 → 400 → 300 → NULL。请逐步给出每个版本的可见性判定结果,并说明 T 最终读到哪个版本。

答案与解析

先确定 ReadView:m_ids = {400, 450}、min_trx_id = 400、max_trx_id = 501(下一个待分配 ID)、creator_trx_id = 500。

1
2
3
4
5
 520:520 ≥ 501                      → 不可见(步骤 2:ReadView 创建后才启动的事务)
 480:400 ≤ 480 < 501,480 ∉ m_ids   → 可见  ← 判断终止
 450:400 ≤ 450 < 501,450 ∈ m_ids   → 不可见
 400:400 ≤ 400 < 501,400 ∈ m_ids   → 不可见("小于"不含等于,这是边界陷阱)
 300:300 < 400                      → 可见

T 最终读到 trx_id = 480 产生的版本:从 520 回溯一格即命中,后续的 450/400/300 根本不需要判断。

错在哪:最常见的两个错误是把 520 判成可见(忘了 max_trx_id 是"下一个待分配 ID”,≥ 即不可见)、把 400 判成可见(把规则记成 ≤ min_trx_id)。另一个错误是"遍历整条链,取最旧的那个可见版本”——正确行为是从最新往旧找到第一个可见版本就停。追问方向:“如果 480 也不可见,链尾是 NULL 呢?“答案是这一行对本事务不存在,范围查询里直接少一行,这正是快照读抑制幻读的具体机制。

Q3(辨析)“RC 和 RR 的区别在于:RR 读的是事务开始时的数据快照,RC 读的是最新已提交的数据。“请指出这句话错在哪里,并给出正确表述。

答案与解析

错在把差异描述成数据内容的不同。数据库里存的版本、undo 链、以及任何时刻"最新已提交版本"都是同一份,两个级别没有读不同的数据源。

正确表述:两个级别使用完全相同的可见性判断算法(22.4 节步骤 0~4),差别只在 ReadView 的创建时机——RC 在每次执行 SELECT 时新建 ReadView,所以每次读都用"此刻的活跃名单”,别人刚提交的事务不在名单里,于是它写的新版本可见;RR 在事务内第一次 SELECT 时创建 ReadView 并在整个事务内复用,名单固定,后续提交的事务要么在 m_ids 里、要么 ≥ max_trx_id,一律判为不可见。

还有一个细节这个说法也漏了:RR 的 ReadView 不是 BEGIN 时创建的,而是第一次快照读时创建的。所以 BEGIN 之后先做一堆与读无关的工作再查,看到的快照是"第一次查的时刻”,不是"BEGIN 的时刻”。这一点直接解释了"为什么 RR 不能保证读到事务开始时刻的数据”。

追问方向:“那 RR 下我能不能主动读最新数据?“可以,用当前读(SELECT ... FOR UPDATE / LOCK IN SHARE MODE),但代价是加锁,加锁规则见第 24 章 24.3 节。

Q4(推导/计算)某核心交易库出现"上午开始响应变慢,下午 P99 从 20 ms 涨到 800 ms,CPU 与磁盘 IO 都不高”。请给出一个基于本章机制的假设、验证 SQL,以及"看到什么数字就确认这个假设"的判据。

答案与解析

假设:存在长事务,导致 undo 版本链无法回收,所有查询的可见性判断都要沿链回溯多格,代价随链长线性上升;同时活跃事务多使 m_ids 变大,更多版本落进"中间地带"分支。CPU 不高是因为这不是计算密集,而是内存随机访问与指针追逐(每次回溯都要读 undo 页,undo 页在 buffer pool 里则约 100 ns 量级,落到 SSD 则约 100 μs 量级,量级一出就是几百倍差异)。

验证 SQL:用 22.7 节的 information_schema.innodb_trx 查询按 elapsed_s 降序,重点看 trx_is_read_only = 1 的长事务(只读长事务不加锁,不会出现在锁等待里,是最容易漏的元凶);再看 SHOW ENGINE INNODB STATUS 的 TRANSACTIONS 段。

判据:① 存在 elapsed_s 达数百秒甚至上千秒的事务;② trx_rows_modified 很大(写长事务)或 trx_is_read_only = 1(只读长事务);③ History list length 持续在万级以上且不下降。三条同时成立即可确认。确认后的动作是 KILL 该连接(用 trx_mysql_thread_id),并检查它是否被长事务拖慢。

错在哪:“CPU 不高、IO 不高,所以是网络问题”——忽略了"数据库内部的指针追逐与内存放大"这一整类归因;或者只查锁等待,而只读长事务根本不进锁等待视图。追问方向:“为什么只读长事务也要管?“因为它同样让 ReadView 存活,同样挡住 purge;它不加锁反而更难被常规监控发现。

Q5(设计)一个"账户余额校验 + 扣减"的接口,业务要求:同一账户的并发扣减不能超扣,且需要记录校验时的余额快照。请分别给出在 RC 和 RR 下的正确实现思路,说明各自为什么成立,并指出只写 SELECT 再 UPDATE 会出什么问题。

答案与解析

反面写法:@Transactional 里先 SELECT balance FROM account WHERE id = 1(快照读),在应用层判断 balance >= amount,再 UPDATE account SET balance = balance - ? WHERE id = 1。问题在于:快照读不加锁,两个并发事务可以同时读到 balance = 100 并都通过校验,随后各自扣减,总扣减量超过余额(写偏斜,write skew)。RR 并不能阻止,因为快照读之间不存在锁冲突。这是"RR 下也不会幻读所以很安全"这个直觉最典型的翻车现场。

RR 下的正确思路:把校验和扣减放进同一条语句,让数据库在写锁下完成比较——UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?,用受影响行数(affectedRows = 0 即余额不足)判断结果。这条语句是当前读 + 排他锁,同一行的并发扣减被串行化。若确实需要"先拿到余额快照再判断”,必须用 SELECT balance FROM account WHERE id = ? FOR UPDATE 让快照读变成当前读并加锁,此时 RR 的 ReadView 复用不再影响结果(当前读不参与可见性判断),因为锁已经保证了串行。

RC 下的正确思路:同样推荐单语句 UPDATE ... WHERE balance >= ?,与 RR 无差别。差别在于如果用 FOR UPDATE 实现,RC 下每次读都是当前读,RR 下第一次读建立 ReadView 之后当前读仍然是当前读——两者在"加锁读"这一路径上行为一致,真正需要注意的差别是 RC 下同一事务内非加锁读的结论会变,因此 RC 下更要避免"读一次、判断、再读一次"的写法。

加分点:说明为什么不需要分布式锁——单行更新已经由 InnoDB 的行锁保证原子性(加锁规则见第 24 章 24.3 节);只有跨库或多资源时才需要外部锁。

追问方向:“如果账户在分库分表的两个分片上要互相转账呢?“这就超出单机事务范围,需要分布式事务方案,见第 26 章 26.6 节。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
你说事务有 ACID 四个特性,那这四个特性各自是靠什么机制实现的?是否能把特性对应到组件错误回答:把四条再复述一遍,或说"一致性靠两阶段提交保证”。加分:逐条对应 undo / redo+fsync / 锁+MVCC / 约束,并指出一致性是目标而非机制22.1
MVCC 是怎么实现的?请把用到的数据结构说完整。三要素是否齐全错误回答:只答"ReadView 判断可见性”,说不出 DB_TRX_ID、DB_ROLL_PTR 与 undo 版本链。加分:点出 DB_ROW_ID 只在无主键时存在22.3
ReadView 里有哪几个字段?给我讲一下可见性判断的过程。算法是否可执行错误回答:规则边界写成 ≤ min_trx_id 与 > max_trx_id,或说"不可见就返回 NULL"而不知道要沿 DB_ROLL_PTR 回溯。加分:主动给出"从最新版本回溯、命中即可见就停"的终止条件22.4
RC 和 RR 的差别到底在哪?是否抓住唯一根因错误回答:“RR 读快照数据、RC 读最新数据”——把差异说成数据不同。加分:指出"可见性判断算法完全相同,差别只在 ReadView 的创建时机”,并补一句"RR 的 ReadView 是第一次快照读时建的,不是 BEGIN 时”22.5
那 RR 隔离级别下还会不会出现幻读?是否知道"但是”错误回答:直接答"不会,RR 已经解决了幻读"。加分:说明当前读绕过 ReadView,给出"快照读 3 行、FOR UPDATE 4 行、再快照读又 3 行"的具体场景22.6.1
为什么 MySQL 默认是 RR 而 PostgreSQL 默认是 RC?实现路线与历史约束错误回答:“RR 更安全所以 MySQL 更好"或"PG 设计得差”。加分:说到 MySQL 早期 STATEMENT 复制的约束、PG 的语句级快照与 vacuum 压力22.2.1
长事务有什么危害?危害链条是否完整错误回答:“占内存、占连接、回滚慢”——说不出 undo 旧版本无法回收、purge 被挡住、危害波及后续所有事务这条因果链。加分:补上"只读长事务同样有害且更隐蔽",并给出 innodb_trx 的查询22.7
为什么 MySQL 建议用 RC 的时候要把 binlog 格式设成 ROW?隔离级别与复制的耦合错误回答:“因为 ROW 更省空间/更快”(都不对,ROW 通常更占空间)。加分:说清 STATEMENT 记录 SQL 文本、从库重放要重新求值,而 RC 下同一事务两次读结果可能不同22.8
innodb_autoinc_lock_mode = 2 会不会导致主从自增 ID 不一致?是否理解模式与格式的绑定关系错误回答:“会,所以线上不能开 2”。加分:说明模式 2 只在 binlog_format=STATEMENT 下不安全,配 ROW 时自增值随行镜像一起记录,并补一句 8.0 默认就是 222.8
我把隔离级别设成 READ UNCOMMITTED,为什么脏读好像没生效?InnoDB 的实际行为错误回答:“因为 InnoDB 不支持 RU”。加分:说明 InnoDB 下 RU 的快照读不做可见性判断直接读最新版本,脏读确实存在;同时指出隔离级别只是读的规则,不会取消行锁,写—写仍然互斥22.2、22.6
同一事务里我先 SELECT 再 UPDATE,为什么 UPDATE 影响的行数和刚才查到的不一样?快照读与当前读的语义差错误回答:“MySQL 有 bug"或"是缓存问题”。加分:指出 SELECT 是快照读、UPDATE 是当前读,两者看到的数据集天然可以不同,唯一性校验必须靠唯一索引而不是"先查再插"22.6

延伸阅读

  • MySQL 官方手册 InnoDB Locking and Transaction Model 章节,尤其是 “Consistent Nonlocking Reads” 与 “Transaction Isolation Levels” 两节:ReadView 语义、RR 与 RC 的行为差异、binlog_format 与隔离级别的配合建议均以此为准。
  • MySQL 官方手册 InnoDB Multi-Versioning 一节:DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID 三个隐藏字段的定义与 undo 版本链的描述。
  • MySQL 官方手册 AUTO_INCREMENT Handling in InnoDB 一节:innodb_autoinc_lock_mode 三种模式的定义与各模式对基于语句的复制的影响。
  • MySQL 官方手册 information_schema.INNODB_TRX 表:trx_started、trx_rows_locked、trx_rows_modified、trx_is_read_only 等字段的准确口径(时间字段单位是秒)。
  • PostgreSQL 官方文档 Transaction Isolation 一章,特别是 “Read Committed Isolation Level” 一节:解释 PG 每个语句取一次快照的语义,可与本章 22.2.1 节对照阅读。
  • SQL 标准(ISO/IEC 9075)中的隔离级别与三类现象定义:本章 22.2 节表格中"标准"一列的规范依据,注意标准只描述可观察现象、不规定实现。