MySQL MVCC:ReadView 四个字段的可见性判断算法
一句话结论(30s)
MVCC 的本质是无锁快照读——因为每个事务通过 ReadView 的四个字段(creator_trx_id、m_ids、min_trx_id、max_trx_id)在 undo log 版本链上逐版本判断可见性,读操作无需加锁就能看到事务一致的快照。关键设计是用「活跃事务集合 m_ids + 高低水位线」裁定一个版本是否已提交;核心权衡是 RC 每次 SELECT 重建 ReadView(读最新提交)、RR 复用到事务结束(保证可重复读),这正是 InnoDB 读写不阻塞的根本保障。
核心原理(2min)
InnoDB 每行有三个隐藏字段,其中 DB_TRX_ID 记录最后修改该行的事务 ID,DB_ROLL_PTR 把同一行的所有历史版本串成版本链(最新版本在数据页、旧版本在 undo log)。思考:MVCC 为什么要 ReadView? 读操作不加锁,又要读到”事务一致的快照”,唯一办法就是给这次读拍一张快照——ReadView 就是这张快照的元数据:它记下拍快照那一刻还有哪些事务没提交,据此裁定每个历史版本对我是否可见。SELECT 执行时生成 ReadView,包含 creator_trx_id、m_ids(活跃事务集合)、min_trx_id(低水位线)、max_trx_id(高水位线,等于当前最大事务 ID + 1)。从当前版本开始沿版本链向旧版本遍历:trx_id == creator_trx_id 是自己改的,可见;trx_id < min_trx_id 说明在 ReadView 生成前已提交,可见;trx_id >= max_trx_id 说明生成后才开始,不可见;trx_id 落在 m_ids 中说明仍未提交,不可见;落在 [min_trx_id, max_trx_id) 且不在 m_ids 中则已提交,可见。不可见就沿 DB_ROLL_PTR 回溯上一个版本,直到找到第一个可见版本。思考:RR 和 RC 的 ReadView 生成时机为何不同? RC 要读到”最新已提交”,所以每次 SELECT 都重拍一张快照;RR 要保证”整个事务看到同一张快照”,所以只在第一次 SELECT 拍一次、之后复用——可见性算法完全相同,唯一的变量是快照的”有效期”。RC 与 RR 的 MVCC 实现完全相同,唯一区别是 ReadView 生命周期——RC 每次重建,RR 只在第一次 SELECT 生成后复用。
底层深入(5-10min)
MVCC 的物理基础
InnoDB 每行数据有三个隐藏字段:
DB_ROW_ID: 行ID(无主键时自动生成)
DB_TRX_ID: 最后修改此行的事务ID
DB_ROLL_PTR: 指向undo log的上一个版本的指针(版本链)
DB_ROLL_PTR 将同一行的所有历史版本串联成一条链,最新版本在数据页中,旧版本在 undo log 中。
ReadView:事务的快照
当 SELECT 语句执行时(RC 隔离级别)或事务的第一个 SELECT 执行时(RR 隔离级别),InnoDB 生成一个 ReadView,包含四个字段:
creator_trx_id: 生成此 ReadView 的事务ID
m_ids: 生成 ReadView 时,所有活跃(已开始但未提交)事务ID的集合
min_trx_id: m_ids 的最小值(低水位线)
max_trx_id: 下一个待分配的事务ID(高水位线,等于当前最大事务ID + 1)
思考:为什么既要有”低水位线”又要有”活跃事务集合 m_ids”? 单靠一条水位线不够——两条线之间既夹着”已提交”的事务,也夹着”已开始但未提交”的事务,肉眼分不清,必须靠 m_ids 逐个点名,才能把”区间内已提交”和”区间内仍活跃”区分开。
源码命名提醒:上文
min_trx_id/max_trx_id是概念名,InnoDB 源码里的真实字段叫m_up_limit_id(低水位线 = min)和m_low_limit_id(高水位线 = max),名字与直觉相反,别搞混。
include/read0types.h 中 ReadView 的真实字段定义:
private:
/** The read should not see any transaction with trx id >= this
value. In other words, this is the "high water mark". */
trx_id_t m_low_limit_id;
/** The read should see all trx ids which are strictly
smaller (<) than this value. In other words, this is the
low water mark". */
trx_id_t m_up_limit_id;
/** If the view is open, then this is a trx->id of the transaction which has
created this view, used to let this view see the changes of this transaction. */
trx_id_t m_creator_trx_id;
/** Set of RW transactions that was active when this snapshot
was taken */
ids_t m_ids;
/** The view does not need to see the undo logs for transactions
whose transaction number is strictly smaller (<) than this value:
they can be removed in purge if not needed by other views */
trx_id_t m_low_limit_no;
分析:四个字段的职责在注释里写得很直白——m_low_limit_id 是高水位线(trx_id >= 它的都不可见)、m_up_limit_id 是低水位线(trx_id < 它的都可见)、m_creator_trx_id 是创建本快照的事务自己(自己的修改永远可见)、m_ids 是快照建立时仍在活跃的读写事务集合。m_low_limit_no 是 purge 专用清理下界,MVCC 可见性判断本身用不到它,但 purge 线程要靠它决定哪些 undo 能删。
这四个字段是如何被填充的?read/read0read.cc 的 ReadView::prepare():
void ReadView::prepare(trx_id_t id) {
ut_ad(trx_sys_mutex_own());
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();
ut_a(m_low_limit_no <= m_low_limit_id);
if (!trx_sys->rw_trx_ids.empty()) {
copy_trx_ids(trx_sys->rw_trx_ids);
} else {
m_ids.clear();
}
/* The first active transaction has the smallest id. */
m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;
ut_a(m_up_limit_id <= m_low_limit_id);
m_closed.store(false);
}
分析:m_low_limit_id 取 trx_sys_get_next_trx_id_or_no()——即「下一个待分配的事务 ID」,也就是高水位线;m_ids 从 trx_sys->rw_trx_ids(当前所有活跃读写事务的 ID 列表)复制而来;m_up_limit_id 取 m_ids.front(),因为活跃事务列表是升序存储,第一个就是最小的活跃事务 ID,即低水位线。三个值在 trx_sys->mutex 保护下一次取全,保证快照视图自洽。
可见性判断 — 逐版本遍历
思考:为什么要沿版本链从新到旧逐个回溯,而不是直接定位? 因为可见性对”每个版本”单独成立——当前版本不可见不代表前一个也不可见,必须从最新版本一路往回找,直到命中第一个”已提交且早于快照”的版本。
从行记录当前版本开始,沿 DB_ROLL_PTR 版本链向旧版本遍历,对每个版本的 trx_id 做判断:
行版本的 trx_id:
1. trx_id == creator_trx_id
→ 是自己改的 → 可见
2. trx_id < min_trx_id
→ 修改这行的事务在 ReadView 生成前已提交 → 可见
3. trx_id >= max_trx_id
→ 修改这行的事务在 ReadView 生成后才开始 → 不可见 → 沿版本链取上一版本
4. trx_id 在 m_ids 中
→ 修改这行的事务在 ReadView 生成时仍未提交 → 不可见 → 沿版本链取上一版本
5. min_trx_id <= trx_id < max_trx_id 且不在 m_ids 中
→ 修改这行的事务在 ReadView 生成前已提交 → 可见
不可见时去版本链的前一个版本继续判断,直到找到第一个可见版本。
真实源码里,这五条规则被压缩成 ReadView::changes_visible() 一个函数(include/read0types.h):
[[nodiscard]] bool changes_visible(trx_id_t id) const override {
ut_ad(id > 0);
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;
}
const ids_t::value_type *p = m_ids.data();
return !std::binary_search(p, p + m_ids.size(), id);
}
分析:对照前面的伪代码——id < m_up_limit_id 即「低水位线以下、快照前已提交」,id == m_creator_trx_id 即「自己改的」,二者都直接可见;id >= m_low_limit_id 即「高水位线以上、快照后才开始」,直接不可见;最后落在区间内的用 std::binary_search 在 m_ids 里二分查找,不在集合中(对结果取反)说明已提交、可见。注意 m_ids 是升序存储的,所以才能用二分,这也比线性扫描快得多。
真正沿版本链回溯的循环在 row/row0vers.cc 的 row_vers_build_for_consistent_read(),它拿着 ReadView 一路回溯:
trx_id = row_get_rec_trx_id(rec, index, *offsets);
version = rec;
for (;;) {
mem_heap_t *prev_heap = heap;
heap = mem_heap_create(1024, UT_LOCATION_HERE);
/* If purge can't see the record then we can't rely on
the UNDO log record. */
bool purge_sees =
trx_undo_prev_version_build(rec, mtr, version, index, *offsets, heap,
&prev_version, nullptr, vrow, 0, lob_undo);
err = (purge_sees) ? DB_SUCCESS : DB_MISSING_HISTORY;
if (prev_heap != nullptr) {
mem_heap_free(prev_heap);
}
if (prev_version == nullptr) {
/* It was a freshly inserted version */
*old_vers = nullptr;
break;
}
*offsets = rec_get_offsets(prev_version, index, *offsets, ULINT_UNDEFINED,
UT_LOCATION_HERE, offset_heap);
trx_id = row_get_rec_trx_id(prev_version, index, *offsets);
if (view->changes_visible(trx_id)) {
/* The view already sees this version: we can copy
it to in_heap and return */
buf =
static_cast<byte *>(mem_heap_alloc(in_heap, rec_offs_size(*offsets)));
*old_vers = rec_copy(buf, prev_version, *offsets);
rec_offs_make_valid(*old_vers, index, *offsets);
if (vrow && *vrow) {
*vrow = dtuple_copy(*vrow, in_heap);
dtuple_dup_v_fld(*vrow, in_heap);
}
break;
}
version = prev_version;
}
分析:每次循环调用 trx_undo_prev_version_build() 沿 DB_ROLL_PTR 取前一个版本,再 row_get_rec_trx_id() 读出该版本的 DB_TRX_ID,交给 view->changes_visible() 判断;一旦可见就把旧版本 rec_copy 到 in_heap 返回给上层。若一路回溯到 prev_version == nullptr(首次插入的版本)仍不可见,说明该行对本快照根本不存在;而 DB_MISSING_HISTORY 意味着 undo 已被 purge 清掉、历史版本读不到了。
RC vs RR:唯一区别在 ReadView 的生命周期
-- RC(Read Committed):每次 SELECT 都重新生成 ReadView
-- 事务A:
BEGIN;
SELECT * FROM t WHERE id=1; -- 生成 ReadView1
-- 事务B: UPDATE t SET name='new' WHERE id=1; COMMIT;
SELECT * FROM t WHERE id=1; -- 重新生成 ReadView2,能看到 'new'
COMMIT;
-- RR(Repeatable Read):只在第一次 SELECT 生成 ReadView,后续复用
-- 事务A:
BEGIN;
SELECT * FROM t WHERE id=1; -- 生成 ReadView,读取 'old'
-- 事务B: UPDATE t SET name='new' WHERE id=1; COMMIT;
SELECT * FROM t WHERE id=1; -- 复用原 ReadView,仍然读到 'old'
COMMIT;
两者的 MVCC 实现完全相同,唯一区别是 ReadView 生成的时机。 RC 每次都生成(能看到已提交的新数据),RR 只在第一次 SELECT 生成(看不到中途提交的变更)。
RR 隔离级别下的”幻读盲区”
MVCC 的快照读解决了幻读——事务内的普通 SELECT 总是看到同一个快照。但当前读(SELECT ... FOR UPDATE)不走 MVCC,走的是最新已提交版本 + 加锁:
-- RR 事务:
BEGIN;
SELECT * FROM t WHERE id > 5; -- 快照读,看到 6 行
-- 另一事务: INSERT INTO t VALUES (10); COMMIT;
SELECT * FROM t WHERE id > 5 FOR UPDATE; -- 当前读,读到 7 行!
第一个是快照读(走 ReadView),第二个是当前读(走最新版本,加上临键锁防幻读)。这就是”RR 隔离级别不能完全避免幻读”的真正含义——它只避免了快照读的幻读,当前读的幻读需要由间隙锁来保证。
长事务的隐患
一个事务的 ReadView 持有对 m_ids 中所有事务的引用,InnoDB 的 purge 线程不能清理 m_ids 中任何事务相关的 undo 记录。一个事务开启超过 1 小时,这一小时内的所有 undo log 都无法被清理,undo 表空间膨胀。这就是”长事务会导致 undo log 膨胀”的底层原因。
总结
MVCC 通过 ReadView 的四个字段和 undo log 版本链,让读操作无需加锁就能看到事务一致的快照。RC 和 RR 的唯一区别是 ReadView 的生命周期——RC 每次重建(读最新提交),RR 复用到事务结束(保证可重复读)。这是 InnoDB 读写不阻塞的根本保障。
章末提问
1. ReadView 四个字段分别管什么? 结论先行:四个字段共同裁定一个版本是否可见。因为 creator_trx_id 保证看见自己的修改,min/max 是两条水位线框定范围,m_ids 点名仍在活跃的事务——三组信息正好覆盖”自己 / 已提交 / 未提交 / 未来”四种情况。
2. RR 和 RC 的 MVCC 实现一样吗? 结论先行:可见性判断代码完全一样,唯一区别是 ReadView 的生命周期。因为 RC 每次 SELECT 重建 ReadView 读最新提交,RR 只在第一次 SELECT 生成后复用,同一套算法靠”快照有效期”的不同实现不同隔离语义。
3. RR 为什么不能完全避免幻读?
结论先行:因为只有快照读走 MVCC,当前读(FOR UPDATE)走最新版本 + 加锁。因为快照读看的是 ReadView 那张旧快照,而当前读要锁最新数据,间隙里的新插入必须靠间隙锁来堵。
4. 长事务为什么会让 undo log 膨胀? 结论先行:因为长事务的 ReadView 一直引用着 m_ids 里的活跃事务,purge 线程不敢清理它们相关的 undo。因为这些事务随时可能被这个快照回溯读取,purge 只能等它们全部结束。
5. 源码里 m_up_limit_id 和 m_low_limit_id 为什么”名字反了”? 结论先行:m_up_limit_id 是低水位线、m_low_limit_id 是高水位线,命名与直觉相反。因为源码注释定义的是”严格小于它就可见”(up_limit 即低水位)和”大于等于它就不可见”(low_limit 即高水位),记住语义别记名字。