MVCC 与 Read View
InnoDB
的一致性读不在堆页上堆多版本行,而是在聚簇索引里只保留最新版本,用隐藏列
DB_TRX_ID 与 DB_ROLL_PTR 串起 undo
历史。读路径问的不是「这一页上哪一版可见」,而是「当前行的创建事务对
Read View 是否可见;不可见则沿 undo 回溯重建」。
本文锚定 MySQL 8.0.36 的
storage/innobase/。隔离级别语义(幻读、gap
lock)留给 第
08 章;跨引擎变体概览见 MVCC
实现变体;PostgreSQL 的 CLOG / hint bit 见 PG
第 03 章。
本写作环境未常驻 MySQL 进程。实验节给出 Docker 可复现步骤;不粘贴未在本机执行的命令输出。
一、问题:为什么「最新行 + undo」也能做快照
一致性读至少要回答三件事:
- 某行当前聚簇记录上的
DB_TRX_ID,对本查询是否可见? - 若不可见,旧版本存在哪里、如何重建?
- RC 与 RR 在「何时打开 / 关闭 Read View」上差在哪?
常见误区:
- 以为 InnoDB 和 PostgreSQL 一样在数据页上留多份 tuple;
- 以为「RC 看不到别的事务的提交」——RC 的语义是语句级快照,不是事务级;
- 把 Server 层的
trx_t生命周期与 InnoDB mini-transaction(mtr)混为一谈。
flowchart TD
Start[Consistent read] --> ReadClust[Read clustered record]
ReadClust --> Vis{ReadView.changes_visible trx_id}
Vis -->|yes| Return[Return current version]
Vis -->|no| Undo[Follow DB_ROLL_PTR]
Undo --> Build[trx_undo_prev_version_build]
Build --> Vis2{changes_visible on older trx_id}
Vis2 -->|yes| ReturnOld[Return reconstructed version]
Vis2 -->|no| Undo
上图对应源码主链:trx_assign_read_view() →
ReadView::changes_visible() → 必要时
row_vers_build_for_consistent_read()。
二、行上的两个隐藏列
聚簇索引记录携带系统列(dict0mem.h /
页格式文档约定):
| 列 | 作用 |
|---|---|
DB_ROW_ID |
无显式主键时由引擎生成的行 ID |
DB_TRX_ID |
最后修改该聚簇版本的事务 ID |
DB_ROLL_PTR |
指向 undo 中上一版本的回滚指针 |
更新时:在 undo
写入足以重建旧行的记录,聚簇页上改成新值并更新
DB_TRX_ID / DB_ROLL_PTR。插入 undo
与更新 undo 的分段见 第 05 章 Undo
Log。
一致性读若判定当前 DB_TRX_ID 不可见,就沿
DB_ROLL_PTR 调用
trx_undo_prev_version_build(),在内存里拼出旧版本——不把旧版本拷回聚簇页。
二级索引不保存完整旧行。源码文件头注释(read/read0read.cc
FACT A)写明:二级索引上看到可疑的 trx_id
时,必须回到聚簇索引做可见性判定并按需重建。这避免了「二级索引已更新、却仍按旧快照返回新键」的错误。
三、ReadView:三个边界 + 一份活跃事务表
3.1 字段含义
ReadView 定义在
include/read0types.h(mysql-8.0.36)。可见性判定用到的核心字段:
| 字段 | 含义 |
|---|---|
m_creator_trx_id |
打开该视图的事务自己的 ID;自己的修改始终可见 |
m_up_limit_id |
严格小于此 ID 的事务,其修改对本视图可见(已提交且早于视图) |
m_low_limit_id |
大于等于此 ID 的事务,其修改对本视图不可见(在视图创建之后才分配) |
m_ids |
打开视图时仍活跃的读写事务 ID 列表(有序);落在中间区间且出现在列表中的事务,其修改不可见 |
m_low_limit_no |
与 purge / 序列化编号相关的下界;purge 用最老视图决定能否回收 undo |
直观区间:
\[ \begin{aligned} \mathrm{trx\_id} &< m\_up\_limit\_id &&\Rightarrow \text{visible} \\ \mathrm{trx\_id} &\ge m\_low\_limit\_id &&\Rightarrow \text{invisible} \\ m\_up\_limit\_id &\le \mathrm{trx\_id} < m\_low\_limit\_id &&\Rightarrow \text{search } m\_ids \end{aligned} \]
3.2
changes_visible(摘录,删减注释)
源码位置:storage/innobase/include/read0types.h,ReadView::changes_visible(mysql-8.0.36):
bool changes_visible(trx_id_t id, const table_name_t &name) const {
ut_ad(id > 0);
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) {
return (false);
} else if (m_ids.empty()) {
return (true);
}
const ids_t::value_type *p = m_ids.data();
return (!std::binary_search(p, p + m_ids.size(), id));
}要点:中间地带用二分查找判断
id
是否仍在打开视图时的活跃集合里;在集合中则不可见,否则可见(说明该事务已在打开视图前结束,且其
ID 未进入 m_ids)。
3.3
打开视图:ReadView::prepare +
MVCC::view_open
ReadView::prepare(trx_id_t id)(read/read0read.cc)在持有
trx_sys->mutex 时:
- 记下
m_creator_trx_id = id; m_low_limit_no = trx_get_serialisation_min_trx_no();m_low_limit_id = trx_sys_get_next_trx_id_or_no()(此后新分配的事务 ID 一律不可见);- 从
trx_sys->rw_trx_ids拷贝活跃读写事务到m_ids; m_up_limit_id = m_ids.empty() ? m_low_limit_id : m_ids.front()(活跃集合中最小 ID)。
MVCC::view_open(ReadView *&view, trx_t *trx)
负责从空闲链表取视图或分配新视图,调用
prepare,再把视图挂到 m_views
链表头部。只读自动提交路径上,若无活跃读写事务且
m_low_limit_id
未推进,可复用已关闭视图——这是性能优化,不改变 RR
下「事务内复用同一快照」的语义。
事务侧入口是
trx_assign_read_view(trx_t *trx)(trx/trx0trx.cc):若当前没有活跃视图,则调用
trx_sys->mvcc->view_open。
四、RC 与 RR:差在关闭时机,不在判定函数
changes_visible 对 RC / RR
同一套。差别在 Server/Handler
何时关闭视图,从而迫使下一条语句重新
prepare。
4.1 RR:事务内复用
START TRANSACTION WITH CONSISTENT SNAPSHOT
仅在 TRX_ISO_REPEATABLE_READ
下生效:ha_innobase 里会立刻
trx_assign_read_view(trx);其它隔离级别会告警并忽略该短语(ha_innodb.cc,mysql-8.0.36)。
RR 事务在显式事务块内保持同一
trx->read_view,因此两次 SELECT
看到同一快照(仍受锁与幻读规则约束,见第 08 章)。
4.2 RC:语句结束关闭视图
在 ha_innobase::external_lock
路径上,当语句结束(n_mysql_tables_in_use 降到
0)且仍处于显式事务中时:
isolation_level <= TRX_ISO_READ_COMMITTED
&& MVCC::is_view_active(trx->read_view)
→ trx_sys->mvcc->view_close(trx->read_view, true)
下一条语句再次进入表时,trx_assign_read_view
发现视图未激活,会重新 view_open /
prepare,于是能看到本语句开始前已提交的修改。这就是「语句级快照」在源码里的落点,而不是另一套可见性公式。
external_lock 开头在
lock_type != TL_IGNORE && n_mysql_tables_in_use == 0
时也会对 RC 主动
view_close,注释写明:低隔离级别让每次一致性读建立自己的快照。
五、不可见时如何重建旧版本
当聚簇记录的 trx_id 对当前视图不可见时,调用
row_vers_build_for_consistent_read()(row/row0vers.cc):
- 断言入口处
!view->changes_visible(trx_id, ...); - 循环调用
trx_undo_prev_version_build(...)得到prev_version; - 读取上一版本的
DB_TRX_ID,再问changes_visible; - 一旦可见,把该版本拷到调用方堆上返回;若回溯到插入版本之前则返回空(行对本快照不存在)。
若 purge 已经回收了仍被需要的
undo,trx_undo_prev_version_build 会失败并返回
DB_MISSING_HISTORY——这对应生产里「undo 被清太狠
/ 历史丢失」类故障,监控侧要看 history list 长度与长事务(第
05、17 章)。
Purge 自身克隆最老 Read
View(MVCC::clone_oldest_view),保证不会清掉仍可能被活跃视图看见的版本(read0read.cc
FACT C)。
六、学术谱系、争论与开放问题
6.1 谱系
多版本并发控制的问题定义可追溯到 Reed(1978)与 Bernstein & Goodman(ACM Computing Surveys, 1981)。工程上两条主流落地:
- 堆内多版本(PostgreSQL):旧版本留在表页,事务状态进 CLOG;
- 最新版 + undo 链(InnoDB):聚簇紧凑,历史进 rollback segment。
InnoDB 选择第二条,与 Heikki Tuuri 时代「聚簇索引即主存行、回滚/崩溃共用 undo」的设计一致;Read View 则是把「事务开始时刻的活跃集合」物化成可二分查找的结构。
6.2 争论:长事务代价落在哪
| 立场 | 代表现象 | 文献 / 工程证据 |
|---|---|---|
| 堆内多版本 | 表膨胀、VACUUM 跟不上 | PG 运维实践;本站 PG 第 03 / 08 章 |
| undo 链 | history list 膨胀、purge 延迟、临时表空间涨 | InnoDB STATUS 的
History list length;Percona /
官方博客中的长事务案例 |
两边都把「旧版本生命周期」绑在最老快照上;差别是膨胀出现在表堆还是
undo 表空间。没有「绝对更好」——批量 UPDATE 后
PG 更痛表体积,InnoDB 更痛 undo 与 purge。
6.3 开放问题
- 只读视图规模:
m_ids随并发读写事务线性增长,极端高并发下打开视图时拷贝rw_trx_ids的成本与锁持有时间,仍是可测的扩展性问题。 - 二级索引与 purge 的交互:FACT A/C 已给出正确性论证,但高频更新 + 延迟 purge 下二级索引「幽灵」条目的扫描成本,缺少像 TPC 那样的统一公开基准(本篇不编造数字)。
- 与 HTAP / 列存副本的边界:TiFlash 等 learner 新鲜度模型是否应复用 Read View 语义,见 TiKV / HTAP。
七、与 PostgreSQL 的对照(精表)
| 维度 | InnoDB | PostgreSQL |
|---|---|---|
| 旧版本位置 | undo(DB_ROLL_PTR) |
堆页多版本(xmin/xmax/ctid) |
| 事务状态 | 事务系统 + undo 头 | CLOG(pg_xact) |
| 快照结构 | ReadView:up/low/ids |
SnapshotData:xmin/xmax/xip |
| 判定热点 | changes_visible + 可选 undo 回溯 |
HeapTupleSatisfiesMVCC + hint bit /
CLOG |
| 长事务症状 | undo history / purge 滞后 | 表膨胀 / VACUUM / XID wraparound |
| RC 实现要点 | 语句结束 view_close |
语句级快照另有路径;默认常讨论 RR/SI |
细节与 CLOG 机制以 PG 第 03 章 为准;本节只固定对照轴,避免重复展开。
八、工程坑点
- 长事务:一个未提交的读写事务会拖住最老视图,purge
无法推进——先查
information_schema.innodb_trx/ Performance Schema,而不是先加 buffer pool。 - 半一致读(semi-consistent read):锁冲突时 RC 可能读已提交最新版,路径与纯一致性读不同,不要用同一套「Read View 复用」直觉解释锁等待下的返回值。
- 二级索引覆盖:覆盖索引仍可能因
trx_id比较回到聚簇索引;「覆盖」省的是回表列,不是可见性判定。 - 版本边界:本文符号以 8.0.36 为准;8.0 早期与 5.7 的视图复用细节有差异,升级时对照 Release Notes。
九、可复现实验(Docker)
在具备 Docker 的环境执行(示例 tag 与正文一致):
docker run --rm -d --name innodb-mvcc \
-e MYSQL_ROOT_PASSWORD=pass \
mysql:8.0.36
docker exec -it innodb-mvcc mysql -uroot -ppassCREATE DATABASE IF NOT EXISTS mvcc_lab;
USE mvcc_lab;
CREATE TABLE t (
id INT PRIMARY KEY,
v INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO t VALUES (1, 10);
COMMIT;会话 A(RR):
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM t WHERE id = 1; -- 记下 v
-- 不要提交会话 B:
UPDATE t SET v = 20 WHERE id = 1;
COMMIT;会话 A 再执行:
SELECT * FROM t WHERE id = 1; -- RR 下仍应为 10
COMMIT;将隔离级别改为 READ COMMITTED 后重复:第二个
SELECT 应看到 20。可同时观察:
SHOW ENGINE INNODB STATUS\G
-- 关注 TRANSACTIONS 段与 History list length记录:镜像
tag、SELECT VERSION()、隔离级别、两次
SELECT 结果。本仓库写作机若未跑通
Docker,只保留上述步骤,不伪造输出。
十、源码阅读地图(相对
storage/innobase/)
| 符号 / 主题 | 文件 |
|---|---|
ReadView、changes_visible |
include/read0types.h |
MVCC::view_open /
view_close、ReadView::prepare |
read/read0read.cc |
trx_assign_read_view |
trx/trx0trx.cc |
RC 语句结束 view_close |
handler/ha_innodb.cc(external_lock) |
row_vers_build_for_consistent_read |
row/row0vers.cc |
| undo 上一版本 | trx/trx0rec.cc(经
trx_undo_prev_version_build) |
十一、小结
- InnoDB MVCC = 最新聚簇行 + undo 链 + Read View,不是堆内多版本。
- 可见性由
m_up_limit_id/m_low_limit_id/m_ids三分,实现集中在changes_visible。 - RC / RR 共用判定函数;RC 在语句边界
view_close,RR 在事务内复用视图。 - 不可见时沿
DB_ROLL_PTR重建;purge 受最老视图约束。 - 与 PG 的对照轴是「膨胀落在表还是 undo」,不是「谁更符合 MVCC 定义」。
上一篇:Doublewrite
下一篇:隔离级别与幻读
参考资料
源码
- MySQL Server
mysql-8.0.36:storage/innobase/include/read0types.h,read/read0read.cc,trx/trx0trx.cc,row/row0vers.cc,handler/ha_innodb.cc
论文 / 经典文献
- Bernstein, P. A., & Goodman, N. (1981). Concurrency Control in Distributed Database Systems. ACM Computing Surveys.
- Reed, D. P. (1978). Naming and Synchronization in a Decentralized Computer System. MIT PhD thesis.(多版本思想源头之一)
文档与对照
- MySQL 8.0 Reference Manual:InnoDB multi-versioning / transaction isolation
- 本站 PG 内核 · MVCC / CLOG
- 本站 Undo Log、隔离级别
- 本站 MVCC 实现变体
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【MySQL InnoDB 内核】InnoDB 存储引擎机制深度拆解
从线程模型到页格式、从 undo log MVCC 到 binlog 两阶段提交——对 MySQL InnoDB 做源码级拆解,并与 PostgreSQL 内核系列逐章对照。20 篇覆盖内核机制与生产运维实战,面向 MySQL DBA、从 PG 转 MySQL 的后端与数据库内核开发者。
【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性
在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。
【MySQL InnoDB 内核】InnoDB 架构与线程模型
InnoDB handler 边界、Master/Purge/IO/Page Cleaner 线程、内存布局与 srv0srv.cc 启动路径。
【MySQL InnoDB 内核】页结构与行格式
FIL 页头、Infimum/Supremum、聚簇/二级索引、ROW_FORMAT 与 rem0rec.h 行头字段。