Skip to content
Go back

MySQL MVCC——ReadView的四个字段如何决定可见性

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.hReadView 的真实字段定义:

 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.ccReadView::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_idtrx_sys_get_next_trx_id_or_no()——即「下一个待分配的事务 ID」,也就是高水位线;m_idstrx_sys->rw_trx_ids(当前所有活跃读写事务的 ID 列表)复制而来;m_up_limit_idm_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_searchm_ids 里二分查找,不在集合中(对结果取反)说明已提交、可见。注意 m_ids 是升序存储的,所以才能用二分,这也比线性扫描快得多。

真正沿版本链回溯的循环在 row/row0vers.ccrow_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_copyin_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 即高水位),记住语义别记名字。


Share this post on:

Previous Post
MySQL Undo Log 详解:回滚段、MVCC 版本链与长事务之殇
Next Post
MySQL MHA 故障切换与脑裂防护:从 STONITH 到 VIP 切换