Theory · MySQL · InnoDB
隐藏列 + undo 版本链 + ReadView —— 快照读不加锁,让读不阻塞写、写不阻塞读
普通 SELECT 沿版本链挑一个"对自己可见"的历史版本,不持任何锁(consistent nonlocking reads)
四步判定 + 沿 DB_ROLL_PTR 逐版回溯;源码 read0types.h 的 changes_visible() 逐行对齐
实现完全相同,只差 ReadView 建立时机:RR 首次快照读建、全程复用;RC 每读新建
Why MVCC
事务 A 在改第 5 行(持 X 锁),事务 B 想"只是读一下"也得等 A 提交才能拿 S 锁。读多写少的业务里,大量读请求被少量写阻塞,吞吐崩塌——隔离性靠"排队"硬扛。
改数据时把旧版本存进 undo log;读事务按自己的"快照视图"挑一个对自己可见的历史版本返回。普通 SELECT 全程无锁——手册称 consistent nonlocking reads(一致性非锁定读)。
| 维度 | 效果 |
|---|---|
| 读 vs 写 | 互不阻塞:读读旧版本,写写新版本,各行其是——这是 MVCC 的核心收益 |
| 写 vs 写 | 仍靠行锁(X 锁)串行化,MVCC 不解决写写冲突 |
| 作用范围 | InnoDB 行级多版本;只服务 RC / RR 下的快照读(RU 是脏读、SERIALIZABLE 走锁,见后文) |
Snapshot Read vs Current Read
| 语句 | 读方式 | 说明 |
|---|---|---|
| SELECT ... | 快照读 | 走 ReadView 判定,不加锁、不等待,可能读到历史版本 |
| SELECT ... FOR UPDATE / FOR SHARE | 当前读 | 读最新已提交版本,加 X / S 锁(8.0 前 LOCK IN SHARE MODE) |
| UPDATE / DELETE | 当前读 | 必须基于最新版本修改,加 X 锁 |
| INSERT | 当前读 | 写入新版本(insert undo 只服务回滚,不服务快照读) |
手册:autocommit 关闭时,"InnoDB implicitly converts all plain SELECT statements to SELECT ... FOR SHARE"。autocommit 开启时每个 SELECT 是独立只读事务,仍可走一致性读。
手册:SELECT 非锁定,"but a possible earlier version of a row might be used"——读到哪个版本没有视图保证,即脏读。行为像 RC 但不一致。
Overview · One Snapshot Read
Hidden Columns · §17.3
最后插入或更新该行的事务 id。删除被内部当作更新处理:置行上的特殊位作为删除标记。它是与 ReadView 对话的唯一坐标。
回滚指针,指向 rollback segment 中的一条 undo log 记录;该记录包含重建"更新前内容"所需的全部信息——版本链的钥匙。
随插入单调递增的行 id。仅当 InnoDB 自动生成聚簇索引(无主键且无非空唯一索引)时作为索引键;否则不出现在任何索引中。
| 追问 | 答案 |
|---|---|
| 这三个字段在二级索引记录里吗? | 不在。二级索引记录不含隐藏列、也不原地更新(见第 14 页)——可见性判定必须回聚簇索引 |
| delete 的行去哪了? | 内部按 update 处理:置删除标记位,DB_TRX_ID 改为删除事务的 id;物理删除由 purge 完成 |
| 无主键表会怎样? | InnoDB 自动以 DB_ROW_ID 建聚簇索引——"表必须有主键"的底层原因之一(显式主键可控且不占额外列) |
Undo Version Chain · §17.3
| undo 分类 | 服务谁 | 何时可丢弃 |
|---|---|---|
| insert undo log | 仅事务回滚(没有别的视图会读插入的新版本) | 事务提交即可丢弃(手册:discarded as soon as the transaction commits) |
| update undo log | 回滚 + 一致性读的版本链 | 须等"没有任何快照还可能需要它"——由 purge 判断 |
ReadView · read0types.h
| 面试口径 | 源码字段 | 源码注释(原文意译) |
|---|---|---|
| m_ids | ids_t m_ids | "Set of RW transactions that was active when this snapshot was taken"——快照时活跃的读写事务集合,有序数组 |
| min_trx_id | m_up_limit_id | "The read should see all trx ids which are strictly smaller"——低水位:比它小的一律可见 |
| max_trx_id | m_low_limit_id | "The read should not see any transaction with trx id ≥ this value"——高水位:创建视图时"下一个将分配"的 id |
| creator_trx_id | m_creator_trx_id | 创建该视图的事务 id(自己改的当然可见) |
m_low_limit_id 是高水位、m_up_limit_id 是低水位——注释原话 "this is the 'high water mark'"。背"low=min"必错,记住"low_limit 管上界"。// storage/innobase/include/read0types.h // · mysql-server 8.0 · class ReadView m_ids // 活跃事务集合(有序) m_up_limit_id // 低水位 = min(m_ids) // (m_ids 为空时 = low_limit) m_low_limit_id // 高水位 = 下一个待分配 id // ≥ 活跃事务的最大 id m_creator_trx_id // 判定入口: bool changes_visible(trx_id_t id) { if (id < m_up_limit_id || id == m_creator_trx_id) return true; if (id >= m_low_limit_id) return false; if (m_ids.empty()) return true; return !binary_search(m_ids, id); }
判定逻辑与左表一一对应;下一步是"不可见沿 roll_ptr 回退重判"
Visibility Decision Tree
ReadView Timing · §17.7.2.3
START TRANSACTION WITH CONSISTENT SNAPSHOT 才把建立时点提前到事务开始。② 想刷新 RR 的快照:提交当前事务再发起新查询(手册原话)。Walkthrough
| 时刻 | 操作 | RR 读到 | RC 读到 | 原因(ReadView 视角) |
|---|---|---|---|---|
| t1 | A:BEGIN;SELECT * FROM t WHERE id=1 | a=1 | a=1 | RR 此刻建 ReadView R;RC 建视图 R1——两者一致 |
| t2 | B:UPDATE id=1 SET a=2;COMMIT | — | — | B 的 trx_id=50 对 A 的视图不可见(未提交 / 未来事务) |
| t3 | A:SELECT * FROM t WHERE id=1 | a=1 | a=2 | RR 复用 R:50 不可见 → 沿 undo 回溯读 a=1;RC 新建 R2:50 已提交且 < R2.min → 可见,读 a=2 |
| t4 | A:UPDATE id=1 SET a=3;再 SELECT | a=3 | a=3 | UPDATE 是当前读:基于 a=2 最新版本改成 a=3;改完 trx_id=A 自己 → 必可见 |
RR 下 A 在 t3 看不见 a=2,t4 却基于 a=2 改出 a=3——快照读看不见的行,当前读改得到(改完自己可见)。这就是"RR 没完全解决幻读"的演示场。
先给结论(RR 可重复读 / RC 读已提交),再补一句机制(ReadView 时机),最后主动展示 t4 这种混合场景——三层递进,面试官就不用追问了。
Boundaries · §17.7.2.3
手册官方示例:RR 事务里 SELECT COUNT(c1) WHERE c1='xyz' 返回 0,紧接着 DELETE WHERE c1='xyz' 却删掉多行——删的是别的事务刚提交的行;自己 update 过的行立刻对自己可见。
FOR SHARE / FOR UPDATE 读最新已提交版本并加锁。手册:想看"最新状态",要么用 RC,要么用锁定读。
DROP TABLE 直接失效;拷表类 ALTER TABLE 之后,新表行在快照里"不存在"——事务返回 ER_TABLE_DEF_CHANGED,须重开事务。
默认 InnoDB 对它用更强的锁,且 SELECT 部分按 RC 语义执行:即使同一事务内,也每次读新快照(手册 §17.7.2.3 默认行为)。
Phantom Rows · §17.7.1
| 读方式 | 出现"幻"? | 机制 |
|---|---|---|
| 快照读(普通 SELECT) | 不会 | 整个事务复用同一 ReadView,后来插入的行 trx_id ≥ max → 沿链回溯也找不到 → "不存在" |
| 当前读(FOR UPDATE / UPDATE / DELETE) | 被阻断 | RR 下用 next-key lock(记录锁 + 记录前的间隙锁)锁住扫描范围,别的会话插不进来 |
| RC 下的当前读 | 会 | RC 禁用 gap lock(外键/重复键检查除外),间隙可自由插入 → 幻读仍在 |
手册定义:"a combination of a record lock on the index record and a gap lock on the gap before the index record"。索引值 10,11,13,20 的可行锁区间:(−∞,10]、(10,11]、(11,13]、(13,20]、(20,+∞)——最后一段靠 supremum 伪记录兜住正无穷。gap 锁"纯抑制性":只阻止插入、可共存、不分 S/X。
先快照读看不见新行,再 UPDATE 同条件却更新了它(当前读),随后自己也能看见了——官方甚至不建议在同一 RR 事务里混用 locking 与 nonlocking SELECT:"typically in such cases you want SERIALIZABLE"。
Purge & Long Transactions · §17.3
手册:"InnoDB only physically removes the corresponding row and its index records when it discards the update undo log record written for the deletion. This removal operation is called a purge。"——delete 只打标,等没有任何视图还需要对应的 update undo 时,由后台 purge 线程连行带索引记录一起清。
insert undo:事务提交即可丢(没视图会读新插入版本)。
update undo:手册措辞——须等"there is no transaction present for which InnoDB has assigned a snapshot that ... could require the information":任何活跃快照都可能还引用它。
innodb_max_purge_lag 延迟新写入以压制 purge 落后。"commit transactions regularly, including transactions that issue only consistent reads"——只读事务也要及时提交,否则一样钉住 update undo。
information_schema.INNODB_TRX 里看 trx_started 找长事务;SHOW ENGINE INNODB STATUS 的 History list length 反映待 purge 的 undo 量。
Secondary Indexes · §17.3
// 聚簇索引记录(原地更新) [ key | cols | DB_TRX_ID | DB_ROLL_PTR ] // 二级索引记录(不原地更新) [ index_key | PK ] // 无隐藏列! // 更新方式:delete-mark + 插入新记录 // 旧记录留原地,等 purge 物理删除
PAGE_MAX_TRX_ID——"highest id of a trx which may have modified a record on the page",仅二级索引页有此字段(page0types.h)。用它判断"本页是否存在视图看不见的修改",多数页可免回表。| 情形 | InnoDB 的动作 |
|---|---|
| 记录正常、页无新事务改动 | 直接使用二级索引记录(覆盖索引可用) |
| 记录 delete-marked,或页被较新事务更新过 | 回聚簇索引:按 DB_TRX_ID 判定,必要时用 undo 重建正确版本 |
| 上述情形撞上覆盖索引查询 | 手册:"the covering index technique is not used"——放弃覆盖、照样回表 |
| 开启 ICP | WHERE 中只用索引列的部分照常下推过滤;未命中则免回表,命中(含删标记录)才回表 |
Isolation Levels · §17.7.2.1
| 隔离级别 | 快照 / 一致性读 | 加锁行为 | 幻读 | 备注 |
|---|---|---|---|---|
| READ UNCOMMITTED | 无保证——"a possible earlier version of a row might be used" | SELECT 不加锁 | 会(脏读) | 其余行为类似 RC |
| READ COMMITTED | 每条快照读新建 ReadView | 只锁索引记录;gap 禁用(外键 / 重复键检查除外);UPDATE 半一致读 | 会 | 仅支持 row 格式 binlog |
| REPEATABLE READ(InnoDB 默认) | 第一次快照读建立,全程复用 | 扫描加 gap / next-key lock;唯一索引唯一条件只锁记录 | 快照读不会;当前读被临键锁阻断 | 不建议混用两类 SELECT |
| SERIALIZABLE | 同 RR | autocommit=0:普通 SELECT 隐式转 FOR SHARE | 不会 | autocommit=1 时 SELECT 是独立只读事务,仍一致性读 |
① 半一致读:UPDATE 扫描遇被锁行,先返回最新已提交版本给 server 判 WHERE,不匹配则不等锁放过;② gap 锁禁用让插入更自由——两者共同减少死锁,代价是幻读与只支持 row binlog。
MVCC 依赖事务基础设施:trx_id、undo 版本链、ReadView、purge 全在 InnoDB 事务系统里;MyISAM 无事务、表级锁,无从谈起——这也是业务表默认选 InnoDB 的底层原因。
Interview QA · 1/2
InnoDB 行级多版本并发控制:更新时旧版本进 undo,快照读按 ReadView 挑可见版本、全程无锁。解决读写互斥——读不阻塞写、写不阻塞读;写写冲突仍靠行锁。
快照读 = 普通 SELECT,走版本链不加锁;当前读 = SELECT ... FOR UPDATE / FOR SHARE、UPDATE、DELETE、INSERT,读最新已提交版本并加锁。SERIALIZABLE(autocommit=0)下普通 SELECT 也变当前读。
四字段:活跃事务集合 m_ids、低水位 min_trx_id、高水位 max_trx_id、creator_trx_id。四步判定:=creator 可见;<min 可见;≥max 不可见;∈m_ids 活跃则不可见否则可见;不可见沿 roll_ptr 回退重判。
唯一区别是 ReadView 时机:RR 在第一次快照读建立、全程复用 → 可重复读;RC 每条快照读新建 → 总读最新已提交。隐藏列、undo 链、判定算法完全一样。
不是。BEGIN 只开事务,第一次快照读才建(手册:established by the first such read)。START TRANSACTION WITH CONSISTENT SNAPSHOT 才是开局即建。刷新快照只能提交后重开。
快照读:解决(复用视图,新插入行不可见)。当前读:靠 next-key lock 阻断插入。缝在混用:看不见的行 UPDATE 改得到,改完自己可见——官方不建议同一 RR 事务混用两类读。
能。快照只约束 SELECT,DML 是当前读。手册示例:RR 下 COUNT(c1)='xyz' 为 0,DELETE 同条件却删掉多行;UPDATE 改完 10 行后自己 COUNT 就能看到 10 行。
不是。它是建视图时下一个将分配的事务 id(源码注释 "high water mark"),大于等于活跃集合的最大 id。所以 id ≥ max 的一定是视图创建后才启动的事务——判"未来"的依据。
Interview QA · 2/2
不立即删:内部按 update 处理,行上置删除标记位,trx_id 改为删除事务。当 purge 判定"没有任何视图还需要该 update undo"时,才物理删除行与索引记录——此前快照读沿链还能读到旧版本。
长事务的 ReadView 一直被引用 → update undo 全不能清 → undo 膨胀可填满 undo 表空间;版本链变长一致性读回溯变慢。手册要求只读事务也要定期提交;运维看 History list length 与 INNODB_TRX。
二级索引记录无隐藏列、不原地更新(改 = 删标 + 插新)。删标或页被新事务动过(页头 PAGE_MAX_TRX_ID 快筛)→ 回聚簇索引按 DB_TRX_ID 判定。代价:覆盖索引失效;ICP 能先用索引过滤,未命中免回表。
MVCC 依赖 trx_id、undo 版本链、ReadView、purge 这套事务基础设施,全在 InnoDB;MyISAM 无事务、表级锁,没有多版本的承载物。业务表默认 InnoDB 的底层原因即此。
部分在。autocommit=1:每个 SELECT 是独立只读事务,仍走一致性读;autocommit=0:普通 SELECT 隐式转 FOR SHARE,全部当前读加锁——用 MVCC 换串行化。RU 则是另一个极端:不加锁但无视图保证,脏读。
RC 下 UPDATE 扫描遇已锁定行:先返回该行最新已提交版本给 server 判 WHERE——不匹配就不等锁直接放过,匹配才回头加锁/等锁。手册明确它"greatly reduces the probability of deadlocks"。仅 RC 有。
RC 下 gap 锁禁用、半一致读提前放锁,语句的加锁范围与执行顺序不受控,statement 格式回放到从库可能产生不同结果——手册直接规定 READ COMMITTED "only row-based binary logging is supported"(MIXED 自动转 row)。
分工而非替代:MVCC 优化"读-写"冲突(读走无锁快照);"写-写"冲突仍靠行锁串行化;当前读的幻读还要 gap/next-key lock 补位。隔离性 = MVCC(快照)+ 锁(当前读)的组合拳。
Related & References
InnoDB 锁体系 →(record / gap / next-key / 意向锁 + 死锁排查)
三大日志与 crash-safe →(WAL、两阶段提交、崩溃恢复)
B+ 树索引 →(与第 14 页二级索引 MVCC 呼应)
事务与 ACID →(原子性/持久性由谁保证:undo vs redo 分工)
快照读 → ReadView 四步判定 → undo 版本链回溯
当前读 → 最新已提交 + 行锁(RR 再叠 next-key lock)
RC / RR → 只差 ReadView 建立时机
旧版本生命周期 → purge → 长事务治理
参考来源(本 deck 全部结论可溯源至下列一手材料)
| dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html | §17.3:隐藏列定义、insert/update undo 分类、purge、二级索引多版本与覆盖索引失效 |
| dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html | §17.7.2.3:RR/RC 快照时机、WITH CONSISTENT SNAPSHOT、DML/DDL 例外(ER_TABLE_DEF_CHANGED) |
| dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html | §17.7.2.1:四级别锁行为、半一致读、RC 仅 row binlog、SERIALIZABLE 隐式 FOR SHARE |
| dev.mysql.com/doc/refman/8.0/en/innodb-locking.html | §17.7.1:record / gap / next-key lock 定义、gap 纯抑制性、supremum 伪记录 |
| github.com/mysql/mysql-server (8.0) · include/read0types.h | ReadView 四字段注释(high/low water mark 原文)与 changes_visible() 判定实现 |
| github.com/mysql/mysql-server (8.0) · include/page0types.h | PAGE_MAX_TRX_ID:仅二级索引页维护的"可能改动本页的最大事务 id" |