土法炼钢兴趣小组的算法知识备份

【MySQL InnoDB 内核】MVCC 与 Read View

文章导航

分类入口
databasekernel
标签入口
#mysql#innodb#mvcc#read-view#undo-chain#read0read#DB_TRX_ID#changes_visible

目录

MVCC 与 Read View

InnoDB 的一致性读不在堆页上堆多版本行,而是在聚簇索引里只保留最新版本,用隐藏列 DB_TRX_IDDB_ROLL_PTR 串起 undo 历史。读路径问的不是「这一页上哪一版可见」,而是「当前行的创建事务对 Read View 是否可见;不可见则沿 undo 回溯重建」。

本文锚定 MySQL 8.0.36storage/innobase/。隔离级别语义(幻读、gap lock)留给 第 08 章;跨引擎变体概览见 MVCC 实现变体;PostgreSQL 的 CLOG / hint bit 见 PG 第 03 章

本写作环境未常驻 MySQL 进程。实验节给出 Docker 可复现步骤;不粘贴未在本机执行的命令输出


一、问题:为什么「最新行 + undo」也能做快照

一致性读至少要回答三件事:

  1. 某行当前聚簇记录上的 DB_TRX_ID,对本查询是否可见?
  2. 若不可见,旧版本存在哪里、如何重建?
  3. RC 与 RR 在「何时打开 / 关闭 Read View」上差在哪?

常见误区:

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.hReadView::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 时:

  1. 记下 m_creator_trx_id = id
  2. m_low_limit_no = trx_get_serialisation_min_trx_no()
  3. m_low_limit_id = trx_sys_get_next_trx_id_or_no()(此后新分配的事务 ID 一律不可见);
  4. trx_sys->rw_trx_ids 拷贝活跃读写事务到 m_ids
  5. 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):

  1. 断言入口处 !view->changes_visible(trx_id, ...)
  2. 循环调用 trx_undo_prev_version_build(...) 得到 prev_version
  3. 读取上一版本的 DB_TRX_ID,再问 changes_visible
  4. 一旦可见,把该版本拷到调用方堆上返回;若回溯到插入版本之前则返回空(行对本快照不存在)。

若 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)。工程上两条主流落地:

  1. 堆内多版本(PostgreSQL):旧版本留在表页,事务状态进 CLOG;
  2. 最新版 + 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 开放问题

  1. 只读视图规模m_ids 随并发读写事务线性增长,极端高并发下打开视图时拷贝 rw_trx_ids 的成本与锁持有时间,仍是可测的扩展性问题。
  2. 二级索引与 purge 的交互:FACT A/C 已给出正确性论证,但高频更新 + 延迟 purge 下二级索引「幽灵」条目的扫描成本,缺少像 TPC 那样的统一公开基准(本篇不编造数字)。
  3. 与 HTAP / 列存副本的边界:TiFlash 等 learner 新鲜度模型是否应复用 Read View 语义,见 TiKV / HTAP

七、与 PostgreSQL 的对照(精表)

维度 InnoDB PostgreSQL
旧版本位置 undo(DB_ROLL_PTR 堆页多版本(xmin/xmax/ctid
事务状态 事务系统 + undo 头 CLOG(pg_xact
快照结构 ReadViewup/low/ids SnapshotDataxmin/xmax/xip
判定热点 changes_visible + 可选 undo 回溯 HeapTupleSatisfiesMVCC + hint bit / CLOG
长事务症状 undo history / purge 滞后 表膨胀 / VACUUM / XID wraparound
RC 实现要点 语句结束 view_close 语句级快照另有路径;默认常讨论 RR/SI

细节与 CLOG 机制以 PG 第 03 章 为准;本节只固定对照轴,避免重复展开。


八、工程坑点


九、可复现实验(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 -ppass
CREATE 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/

符号 / 主题 文件
ReadViewchanges_visible include/read0types.h
MVCC::view_open / view_closeReadView::prepare read/read0read.cc
trx_assign_read_view trx/trx0trx.cc
RC 语句结束 view_close handler/ha_innodb.ccexternal_lock
row_vers_build_for_consistent_read row/row0vers.cc
undo 上一版本 trx/trx0rec.cc(经 trx_undo_prev_version_build

十一、小结

上一篇Doublewrite

下一篇隔离级别与幻读


参考资料

源码

论文 / 经典文献

文档与对照

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-18 · database / kernel

【MySQL InnoDB 内核】InnoDB 存储引擎机制深度拆解

从线程模型到页格式、从 undo log MVCC 到 binlog 两阶段提交——对 MySQL InnoDB 做源码级拆解,并与 PostgreSQL 内核系列逐章对照。20 篇覆盖内核机制与生产运维实战,面向 MySQL DBA、从 PG 转 MySQL 的后端与数据库内核开发者。

2026-06-16 · database / kernel

【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性

在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。


By .