第 22 章 事务、隔离级别与 MVCC
本章把"并发事务互相看不见"这件事拆到引擎内部的字段级:一行数据的多个历史版本怎么存、读的时候凭什么判定"这个版本我能看"。
学习目标
读完本章,你应当能够:
- 把 ACID 四条逐一对应到具体机制(undo log / redo log + fsync / 锁 + MVCC / 约束 + 前三者),并说明"一致性不是独立机制"这句话的确切含义;
- 推导 SQL 标准的四种隔离级别与脏读、不可重复读、幻读三类异常的对应关系,并辨析 InnoDB 与 PostgreSQL 在默认级别上的差异及其原因;
- 说出 MVCC 的三要素(隐藏字段、undo 版本链、ReadView),画出 undo 版本链的结构图;
- 按
m_ids/min_trx_id/max_trx_id/creator_trx_id逐步执行可见性判断算法,并解释"不可见时沿roll_pointer回溯"的终止条件; - 用"ReadView 的创建时机"解释 RC 与 RR 的全部行为差异,指出"两个级别看的是不同快照数据"这一说法错在哪;
- 列出快照读与当前读的完整语句清单,举出 RR 下仍然出现幻读的两类具体场景;
- 定位并量化长事务的三类危害,写出基于
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_ID | 6 字节 | 最近一次修改该行的事务 ID,MVCC 可见性判断的输入 | 永远存在 |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 中该行的上一个版本记录 | 永远存在,无旧版本时为 NULL |
DB_ROW_ID | 6 字节 | 行 ID,仅在表没有主键也没有唯一非空索引时由 InnoDB 生成并作为聚簇索引键 | 有主键或有唯一非空索引时不存在 |
提示
记忆锚点:DB_TRX_ID 回答"这个版本是谁改的",DB_ROLL_PTR 回答"上一个版本在哪",DB_ROW_ID 回答"这行凭什么唯一"。三个字段分别对应可见性判断的三个输入、版本链的遍历指针、聚簇索引的兜底键。
22.3.2 undo 版本链的结构
每次修改一行,InnoDB 把旧版本写进 undo log,并让新记录的 DB_ROLL_PTR 指向它。于是同一行在逻辑上形成一条单向链表,表头是最新版本,越往后越旧。trx_id 标记每个版本由哪个事务产生:
| |
三个必须记住的细节:
- undo 记录存的是"如何回到上一个版本",即 update undo 里保存的是被覆盖列的旧值。所以沿链回溯不是"读一份完整快照",而是"用 undo 逐版本倒推",这也是长链遍历昂贵的原因。
- 链的方向与判断顺序相反。可见性判断总是从最新版本开始,一旦命中可见就立刻停止回溯——这是性能上的关键优化,而不是遍历全链再挑。
- 链的回收由 purge 线程负责,条件是"没有任何活跃 ReadView 还需要比它更旧的版本"(见 22.7 节)。
警告
常见错误回答:把 undo 版本链说成"每个事务保存一份数据快照"。MVCC 是按行保留旧版本,不是按事务拷贝整库;一个事务修改了 5 行就只产生 5 个版本,未被修改的行共享同一个最新版本。把 MVCC 理解成"快照隔离的整库拷贝"会在被追问内存开销时答崩。
22.4 ReadView 的四个字段与可见性判断算法
ReadView 是"本事务在创建这一刻,系统里哪些事务还没结束"的名单快照。它只有四个字段:
| 字段 | 含义 | 在判断中的角色 |
|---|---|---|
m_ids | 创建 ReadView 时当前活跃(已启动未提交)的事务 ID 列表 | 判定"中间地带"版本的可见性 |
min_trx_id | m_ids 中的最小值(无活跃事务时等于下一个待分配 ID) | 第一条分界线 |
max_trx_id | 创建 ReadView 时下一个将被分配的事务 ID(即已出现的最大 ID + 1) | 第二条分界线 |
creator_trx_id | 创建这个 ReadView 的事务自身 ID | 自己改的行永远可见 |
判断算法的完整流程(每一步都可直接执行):
| |
做一个完整算例,把四条规则各命中一次。设当前事务 ID = 300,执行第一条 SELECT 时创建的 ReadView 为 m_ids = {200, 250},则 min_trx_id = 200、max_trx_id = 301(下一个待分配的 ID)、creator_trx_id = 300。
| |
重要
边界必须记准: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 COMMITTED | REPEATABLE READ |
|---|---|---|
| ReadView 创建时机 | 每次执行 SELECT 都新建一个 | 事务内第一次 SELECT 时创建,之后复用 |
| 事务内多次快照读 | 每次都是新的名单,别人已提交的修改立刻可见 | 整个事务使用同一份名单,看到的是同一个快照 |
| 因此不可重复读 | 必然可能(两次读两个 ReadView) | 不可能(名单不变,可见性结论不变) |
| 因此幻读(快照读) | 可能(新插入的行在第二次的名单中已提交) | 基本不可能(第一次的名单里没有那个事务) |
| 间隙锁 | 基本不用(RC 下间隙锁被禁用) | 使用临键锁(见第 24 章 24.3 节) |
| 适用场景 | 写多、需要尽快看到他人提交、对复制延迟敏感 | 需要事务内视图自洽的报表、对账类逻辑 |
必须澄清一个非常普遍的误解:这不是"两个级别看到的快照数据不同",而是"取快照名单的时刻不同"。 存储的数据版本完全一样,undo 链也一样,唯一的区别是"你在什么时刻问系统’现在有哪些事务还活着’"。
| |
在实际代码里能观察到的差异:同一个 @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 的插入若发生在加锁之后就会被阻塞——加锁能否挡掉幻读,取决于两个事务的先后顺序。
场景二:同一事务内快照读与当前读混用,结果前后矛盾。 这是更隐蔽的一种:
| |
同一个事务里,同一个条件,一次看到 4 行、一次看到 3 行。这种前后不一致本身就是 RR 隔离级别下的幻读,也是"RR 到底能不能防幻读"这个问题的标准答案落点。要靠当前读做唯一性校验的业务(例如"先 SELECT 判断不存在再 INSERT"),必须意识到两次读的语义不同,正确写法是依赖唯一索引让 INSERT 直接报冲突。
22.7 长事务的三类危害与监控
长事务在面试里的标准答法是"undo 膨胀、回滚慢、锁持有时间长",但真正把面试官说服的是说清危害的作用链条。这里的机制与 22.4 节的可见性判断直接相连:
- undo 版本链无法回收,链变长。
purge线程要回收一个旧版本,前提是"不存在还需要它的 ReadView"。一个开了 10 分钟没提交(甚至只读)的事务,它的 ReadView 一直有效,于是这 10 分钟内产生的所有旧版本都不能删。链一长,22.4 节步骤 4 的回溯次数就线性上涨——危害不是只作用于这个长事务,而是作用于之后所有事务,因为每个新 ReadView 都可能要跨过这些陈旧版本。 m_ids变大,判断落进"中间地带"的行变多。 活跃事务越多,m_ids越长,二分查找本身代价上升;更严重的是更多版本落进步骤 3 的分支,本来一步能判定的现在要多走几次链。- 占用连接与锁。 事务不结束,连接归还不到池子里;写事务持有的行锁要等到提交才释放,
innodb_lock_wait_timeout默认 50 s 一旦打满就是接口大面积超时。此外事务期间 undo 无法清理、崩溃恢复时要回滚的数据量也成倍增长(回滚与崩溃恢复见第 21 章 21.2 节)。
警告
只读事务同样有害,而且更隐蔽:它不加锁、不报阻塞、SHOW PROCESSLIST 里可能只有一条不起眼的 Sleep,却能把 purge 一直挡在后面。排查"undo 表空间为什么只涨不跌",第一件事就是找长时间未提交的只读事务。
监控 SQL(innodb_trx 里的时间字段是秒,不是微秒,这点常被记错):
| |
| |
配套的运维手段还有两类:一是把跑批、导数据这类长任务拆成分批提交的小事务(每批 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 复制的兼容性 |
|---|---|---|---|---|
| 0 | traditional | 语句级 AUTO-INC 表锁,持有到语句结束 | 同一语句内连续、无空洞 | 安全 |
| 1 | consecutive | 轻量互斥量,分配完立即释放(批量插入等"不知道行数"的情形仍用表锁) | 简单插入(行数可预知)连续;批量插入可能不连续 | 安全 |
| 2 | interleaved | 轻量互斥量,并发语句交错分配 | 值不连续,同一语句内也可能有空洞 | 不安全(从库重放会得到不同的自增值) |
模式 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。
| |
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 默认就是 2 | 22.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 节表格中"标准"一列的规范依据,注意标准只描述可观察现象、不规定实现。