Mysql 多版本并发控制 MVCC

环境为 MySQL 5.7.41 InnoDB 引擎 RR 隔离级别。

是什么

MVCC(Multi-Version Concurrency Control)
全称:多版本并发控制

它是一种数据库并发控制机制,用于让多个事务可以同时安全地访问同一份数据 ,而彼此之间互不干扰


在 MySQL 的 InnoDB 引擎中,MVCC 是通过以下三部分实现的:

  • 隐藏字段DB_TRX_IDDB_ROLL_PTRDB_ROW_ID
  • Undo Log(回滚日志)
  • ReadView(可见性判断机制)

解决的问题

在没有 MVCC 的时代,数据库想要保证事务隔离,只能通过"加锁"来实现,吞吐受限。

这样虽然安全,但性能极差。并发多了就像"单线程排队"。

  • A 正在读一行数据时,B 想修改这行 → 等 A 读完再改;
  • B 改完没提交时,A 想读这行 → 也得等。
问题类型 MVCC 是否解决 说明
脏读(Dirty Read) 解决 因为读到的永远是已提交事务的旧版本
不可重复读(Non-repeatable Read) (在 RR 隔离级别下) 多次查询使用同一个快照(ReadView)
幻读(Phantom Read) ⚠️ 部分解决 快照读仅避免"读时幻",无法阻止其他事务插入新行;需配合 Next-Key Lock 防止范围插入。
读写冲突性能低下 解决 因为读操作不加锁(快照读)

当前读与快照读

在 InnoDB 中,读取分为两类:当前读快照读

快照读(非锁定读)

普通的 SELECT 语句属于快照读。它不会对记录加锁,而是依据当前事务的 Read View 来决定可见版本,从行记录的 roll_pointer 沿着 undo log 追溯,读取符合可见性规则的历史版本。

即使目标行正被其他事务持有排他锁(X 锁),快照读仍能读取到旧版本数据,不会被阻塞。这是 MVCC(多版本并发控制)的核心机制。


当前读(锁定读)

SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE 语句中,InnoDB 会对目标行加锁:

  • FOR UPDATE 加排他锁(X 锁),阻止其他事务修改或读取该行的最新版本;
  • LOCK IN SHARE MODE 加共享锁(S 锁),允许其他事务读取但不允许修改。

当前读读取的是 最新版本数据,必须等待持锁事务释放后才能执行。

简言之:

  • 快照读 :读历史版本,不加锁,基于 MVCC。
  • 当前读:读最新版本,加锁,确保数据一致性。

核心三要素

行记录隐藏字段

在 MySQL InnoDB 引擎下的数据表除了我们自己定义的字段,还有三个内置的隐藏字段 -> 官方说明

InnoDB ****是一个多版本存储引擎。它会保存被修改行的旧版本信息,用来支持事务的并发控制和回滚等特性。存储在 undo log 中,为了让多个历史版本建立引用关系以及让版本和事务绑定,行记录内部添加了以下字段。

  • DB_ROW_ID :是每行的内部唯一标识符,仅在 InnoDB 需要自动生成聚簇索引 时才被用于索引结构;否则,它只是内部字段,不参与查询或索引。
  • DB_TRX_ID :是 InnoDB 在每行中维护的 6 字节隐藏字段,记录该行最后一次插入或更新的事务 ID。。此外,内部将删除视为一个更新操作,其中在行中设置一个特殊位来标记其为已删除。
  • DB_ROLL_PTR :是 InnoDB 每行中的 7 字节隐藏字段,称为 回滚指针它指向当前行数据的上一个版本,用它来找到上一个事务更新后产生的历史版本数据

Undo Log 版本链

因为 Undo Log 记录的是行数据的多个版本,所以以上这些字段 undo log 日志中也存在。

事务 ID -> DB_TRX_ID 是自增的。后开始的事务 ID > 先开始的事务 ID

ReadView

在 InnoDB 中,每条记录通过 undo log 和隐藏字段 DB_TRX_IDDB_ROLL_PTR 维护多版本。事务要读取一条记录的正确版本,需要一个 可见性规则

ReadView 的作用

ReadView 是 InnoDB 为每个事务创建的结构体,用于判断 undo log 中哪些版本对当前事务可见。

执行过程: 创建一个 ReadView,根据 ReadView 的字段和规则,从行记录的最新版本也就是 DB_TRX_ID 最大沿着 undo log 链向前查找,返回第一个对事务可见的版本。

ReadView 结构体

arduino 复制代码
class ReadView {
private:
    // 禁止拷贝
    ReadView(const ReadView&);
    ReadView& operator=(const ReadView&);
    
private:
    /** 高水位线: trx id >= m_low_limit_id 的事务不可见
     * 创建快照时,被赋值为"下一个将要分配的事务ID"
     */
    trx_id_t m_low_limit_id;
    
    /** 低水位线: trx id < m_up_limit_id 的事务都可见
     * 是活跃事务ID列表的最小值
     */
    trx_id_t m_up_limit_id;
    
    /** 创建此 ReadView 的事务ID
     * 对于空闲视图设置为 TRX_ID_MAX
     */
    trx_id_t m_creator_trx_id;
    
    /** 创建快照时活跃的读写事务ID集合
     * 这是一个有序列表
     */
    ids_t m_ids;
    
    /** 事务号严格小于此值的undo日志可以被purge
     */
    trx_id_t m_low_limit_no;
    
    /** AC-NL-RO 事务视图是否已关闭
     */
    bool m_closed;
    
    typedef UT_LIST_NODE_T(ReadView) node_t;
    
    /** trx_sys 中的 read view 列表
     */
    byte pad1[64 - sizeof(node_t)];
    node_t m_view_list;
};
  • m_ids:系统当前活跃事务的 ID 集合;
  • m_low_limit_id:高水位标识,生成 ReadView 时分配给下一个新事务的 ID,任何 DB_TRX_ID > m_low_limit_id 的版本不可见;
  • m_up_limit_id:低水位标识,当前活跃事务的最小 ID,DB_TRX_ID < m_up_limit_id 的版本可见;
  • m_creator_trx_id:创建该 ReadView 的事务 ID,本事务生成的版本可见。

在事务中,查询语句访问某条记录的时候会先创建 ReadView,给 ReadView 字段赋值完毕之后,根据字段以及相应规则来搜索可见的 Undo Log 版本 。

ReadView 在 RC RR 创建时机

在 InnoDB 中,ReadView 的创建时机 取决于事务的隔离级别,它决定了快照(可见性视图)何时生成。创建时机会影响可重复读与读已提交的行为差异。

隔离级别 ReadView 创建时机 行为特征
READ UNCOMMITTED 不创建 可读未提交数据
READ COMMITTED 每次快照读时创建 不可重复读
REPEATABLE READ 第一次快照读时创建 可重复读,避免幻读
SERIALIZABLE 不使用 ReadView(强制加锁) 串行一致性
READ COMMITTED(读已提交)
  • ReadView 在每次执行快照读时都会重新创建。
  • 每次执行 SELECT,都会重新获取一份最新的活跃事务快照。
  • 因此,同一个事务中两次查询,可能读到不同版本(因为其他事务可能已经提交)。
  • ✅ 特征:不可重复读 可能出现。
sql 复制代码
T1: BEGIN;
T1: SELECT * FROM user WHERE id=1;  -- 创建 ReadView1
T2: UPDATE user SET name='B' WHERE id=1; COMMIT;
T1: SELECT * FROM user WHERE id=1;  -- 创建 ReadView2(能看到 T2 修改)
REPEATABLE READ(可重复读)
  • ReadView 只在第一次执行快照读时创建。
  • 之后同一事务内的所有快照读,都会复用这份 ReadView。
  • 所以事务中多次查询同一行,能读到一致版本,不会受外部提交影响。
  • ✅ 特征:可重复读 保证成立,避免了幻读(通过间隙锁或 next-key lock 实现)。
sql 复制代码
T1: BEGIN;
T1: SELECT * FROM user WHERE id=1;  -- 创建 ReadView1
T2: UPDATE user SET name='B' WHERE id=1; COMMIT;
T1: SELECT * FROM user WHERE id=1;  -- 复用 ReadView1(仍看到旧数据)
READ UNCOMMITTED(读未提交)
  • 不使用 ReadView。
  • 可以读取到其他事务未提交的最新版本数据(脏读)。
SERIALIZABLE(可串行化)
  • 不依赖 ReadView。
  • 所有读都上锁,等价于串行执行。

判断 Undo Log 可见性的规则

当我们创建完 ReadView 之后,就可以用它来判断 Undo Log 中的某个历史版本是否对当前事务可见。需要注意的是,InnoDB 针对不同索引类型的查询有不同的处理逻辑:对于二级索引查询,可能需要回表到聚簇索引获取完整记录。本文重点讨论直接通过聚簇索引查询时,MVCC 如何利用 ReadView 来遍历 Undo Log 版本链并判断版本可见性。

可见性判断的核心流程

当我们拿到一个 Undo Log 版本记录后,会将其 DB_TRX_ID(创建该版本的事务ID)与 ReadView 中的几个关键字段进行比对,判断逻辑如下:

规则1: DB_TRX_ID < m_up_limit_id (已提交的老事务,直接可见)

说明生成这个版本的事务 ID 小于当前所有活跃事务的最小值(低水位),这意味着该事务在 ReadView 创建之前就已经提交了。

结论:该版本可见


规则2:DB_TRX_ID == m_creator_trx_id (自己的修改,可见)

说明这个版本是当前事务自己修改产生的。一个事务当然能看到自己所做的修改。

结论:该版本可见


规则3:DB_TRX_ID >= m_low_limit_id (未来的事务,一定不可见)

说明这个版本的事务 ID 大于等于"下一个将要分配的事务ID"(高水位),也就是说这个版本是在 ReadView 创建之后才产生的。根据快照隔离的原则,我们不能看到未来的修改。

结论:该版本不可见


规则4:m_up_limit_id ≤ DB_TRX_ID < m_low_limit_id (区间内的事务,需要精确判断如)

这是最复杂的情况。此时事务 ID 落在低水位和高水位之间,需要进一步判断:

  • 检查 m_ids 列表 :如果 DB_TRX_ID 存在于活跃事务列表 m_ids 中,说明创建 ReadView 时该事务还未提交,根据隔离性原则,该版本不可见
  • 不在列表中 :如果 DB_TRX_ID 不在 m_ids 中,说明虽然这个事务 ID 在区间内,但它在 ReadView 创建前已经提交了(它启动较早但提交也较早),该版本可见。

版本链遍历过程

如果当前版本不可见,InnoDB 会通过记录中的 DB_ROLL_PTR 指针,沿着 Undo Log 版本链继续向前查找更老的版本,重复上述判断流程,直到:

  • 找到一个可见的版本,返回该版本数据
  • 或者遍历到版本链末尾,说明该记录对当前事务完全不可见

这就是 MVCC 实现一致性非锁定读的核心机制:通过 ReadView 快照和版本链,让每个事务都能看到属于自己"时间点"的数据视图。



ReadView 结合 Undo Log版本链示例

事务 5 执行查询时 ReadView 各个属性值如右边黄色所示,此时 id=1 行记录的 Undo Log 版本链如下

当前事务执行查询时:

  1. 首先读取当前行数据(age=40, DB_TRX_ID=4)发现 DB_TRX_ID=4 在活跃事务列表中,该版本不可见
  2. 继续通过 DB_ROLL_PTR 回溯到第二条记录(age=30, DB_TRX_ID=3)发现 DB_TRX_ID=3 也在活跃事务列表中,该版本不可见
  3. 继续回溯到第三条记录(age=20, DB_TRX_ID=2)DB_TRX_ID=2 < m_up_limit_id=3,说明事务2已提交,该版本可见
  4. 返回查询结果:age = 20

上述规则源码位置: storage/innobase/include/read0types.h

kotlin 复制代码
  /** Check whether the changes by id are visible.
  @param[in]    id      transaction id to check against the view
  @param[in]    name    table name
  @return whether the view sees the modifications of id. */
  [[nodiscard]] 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));
  }

MVCC 真的解决了幻读吗?

在 RR 的隔离级别下,如果两次快照读之间夹杂了排它锁操作那么第二次快照读的数据会幻读吗?

如上图所示,事务 A 第二次 Select 操作的时候由于是 RR 隔离级别会复用第一次生成的 ReadView,查询结果不变。

当执行完排它锁操作(update)之后,再次 select 则查询到两条记录(原数据 + 事务 B 插入后自己 update 过的数据)

id user_id age
1 1001 30
2 1002 30

现象:事务A执行update后,能读取到原本不可见的事务B插入的数据,这是一种幻读。

原因:

  1. 事务B插入的数据,事务A原本不可见
  2. 事务A执行update操作后,该数据的db_trx_id被更新为事务A的事务ID
  3. 根据"事务内可见"原则,事务A能查询到自己修改的记录
  4. 因此事务A读到了事务B插入的数据

结果: update操作改变了数据的版本归属,使原本不可见的数据变为可见。

所以我们可以发现特定的两次非锁定读之间夹杂排他锁的场景下,MVCC 无法解决幻读问题。这是个特例场景。

相关推荐
回家路上绕了弯4 小时前
外卖员重复抢单?从技术到运营的全链路解决方案
分布式·后端
考虑考虑4 小时前
解决idea导入项目出现不了maven
java·后端·maven
数据飞轮4 小时前
不用联网、不花一分钱,这款开源“心灵守护者”10分钟帮你建起个人情绪疗愈站
后端
Amos_Web4 小时前
Rust实战课程--网络资源监控器(初版)
前端·后端·rust
程序猿小蒜4 小时前
基于springboot的基于智能推荐的卫生健康系统开发与设计
java·javascript·spring boot·后端·spring
渣哥4 小时前
IOC 容器的进化:ApplicationContext 在 Spring 中的核心地位
javascript·后端·面试
Gu_yyqx4 小时前
Spring 框架
java·后端·spring
demo007x5 小时前
如何让 Podman 使用国内镜像源,这是我见过最牛的方法
后端·程序员
疯狂踩坑人5 小时前
别再说我不懂Node"流"了
后端·node.js