MVCC 与事务隔离:MySQL 如何实现“读不阻塞写”?

很多开发者每天都在用@Transactional,却从未真正理解事务隔离的底层实现。

  • 当面试官问:"MySQL的RR级别是如何解决幻读的?"或者"MVCC到底是怎么工作的?"如果你只能回答"通过锁"或者"通过多版本",那还停留在"知其然"的层面。

今天,我们要深入到InnoDB的行记录结构、Undo Log版本链和Read View的生成逻辑,彻底拆解MVCC的"平行宇宙"机制,看看它是如何实现"读不阻塞写"的。

事务隔离的痛点:锁的代价

在没有MVCC之前,数据库要实现隔离性,只能靠

  • 读操作加共享锁(S锁):读的时候,别人不能写。
  • 写操作加排他锁(X锁):写的时候,别人不能读。

这种机制在高并发下是灾难性的。想象一下,电商系统的商品详情页(高频读)和库存扣减(高频写),如果读和写互相阻塞,系统吞吐量会直线下降。

MVCC的出现,就是为了解决这个问题:让读操作不加锁,让写操作不阻塞读。

MVCC的底层解剖:行记录的"隐藏字段"

在InnoDB中,每一行数据(聚簇索引记录)除了我们定义的列,还隐式包含三个隐藏字段

  • DB_TRX_ID(6字节):最近修改该行数据的事务ID。

  • DB_ROLL_PTR(7字节):回滚指针,指向该行数据在Undo Log中的上一个版本。

  • DB_ROW_ID(6字节):行ID,如果没有主键,InnoDB会自动生成。

    // InnoDB行记录的简化结构
    struct row_t {
    // 用户定义的列
    int id;
    char name[20];

    复制代码
      // 隐藏字段
      uint64_t DB_TRX_ID;      // 最后一次修改的事务ID
      uint64_t DB_ROLL_PTR;    // 指向Undo Log中的旧版本
      uint64_t DB_ROW_ID;      // 行ID

    };

当一个事务修改数据时,InnoDB会:

  1. 将旧版本数据写入Undo Log。
  2. 更新当前行的DB_TRX_ID为当前事务ID。
  3. 更新DB_ROLL_PTR指向Undo Log中的旧版本。

这样,所有版本的数据通过DB_ROLL_PTR串联成一个版本链

Undo Log:后悔药与版本链

Undo Log不仅仅是用来回滚的,它还是MVCC的"历史档案馆"。

版本链的形成

假设初始数据是id=1, name='Alice',事务ID为100。

  1. 事务200执行UPDATE user SET name='Bob' WHERE id=1

    • name='Alice'写入Undo Log。
    • 更新当前行name='Bob'DB_TRX_ID=200DB_ROLL_PTR指向Undo Log中的Alice版本。
  2. 事务300执行UPDATE user SET name='Charlie' WHERE id=1

    • name='Bob'写入Undo Log。
    • 更新当前行name='Charlie'DB_TRX_ID=300DB_ROLL_PTR指向Undo Log中的Bob版本。

    当前行: name='Charlie', DB_TRX_ID=300, DB_ROLL_PTR -> Undo Log
    |
    Undo Log: name='Bob', DB_TRX_ID=200, DB_ROLL_PTR -> Undo Log
    |
    Undo Log: name='Alice', DB_TRX_ID=100, DB_ROLL_PTR -> NULL

Read View:判断版本可见性的"时光机"

这是不是挺难的,没关系、其实很好理解,下面咱们来看一下:

有了版本链,事务如何判断哪个版本对自己是可见的?这就需要一个Read View(读视图)

复制代码
struct ReadView {
    vector<uint64_t> m_ids;      // 生成Read View时,所有活跃(未提交)事务ID集合
    uint64_t min_trx_id;         // m_ids中的最小值
    uint64_t max_trx_id;         // 生成Read View时,下一个将要分配的事务ID
    uint64_t creator_trx_id;     // 创建该Read View的事务ID
};

可见性判断规则

当事务访问某行数据时,从最新版本开始,沿着版本链依次检查每个版本的DB_TRX_ID

  1. 如果DB_TRX_ID == creator_trx_id:当前事务自己修改的,可见
  2. 如果DB_TRX_ID < min_trx_id:该版本的事务在Read View生成前已提交,可见
  3. 如果DB_TRX_ID >= max_trx_id:该版本的事务在Read View生成后才启动,不可见
  4. 如果min_trx_id <= DB_TRX_ID < max_trx_id
    • 如果DB_TRX_IDm_ids中:该版本的事务在Read View生成时未提交,不可见
    • 如果DB_TRX_ID不在m_ids中:该版本的事务在Read View生成时已提交,可见

代码模拟:可见性判断

复制代码
public boolean isVisible(RowVersion version, ReadView readView) {
    uint64_t trxId = version.getDB_TRX_ID();
    
    if (trxId == readView.getCreatorTrxId()) {
        return true; // 自己修改的,可见
    }
    
    if (trxId < readView.getMinTrxId()) {
        return true; // 已提交,可见
    }
    
    if (trxId >= readView.getMaxTrxId()) {
        return false; // 未启动,不可见
    }
    
    // 在min和max之间,检查是否在活跃事务列表中
    return !readView.getMIds().contains(trxId);
}

RC与RR:Read View生成时机的差异

READ COMMITTED(RC):每次查询都生成新的Read View

  • 事务A启动,生成Read View 1。
  • 事务B修改数据并提交。
  • 事务A再次查询,生成新的Read View 2,能看到事务B的修改。
  • 结果:不可重复读

REPEATABLE READ(RR):第一次查询生成Read View,后续复用

  • 事务A启动,第一次查询时生成Read View 1。
  • 事务B修改数据并提交。
  • 事务A再次查询,复用Read View 1,看不到事务B的修改。
  • 结果:可重复读

RR级别下的幻读问题

在RR级别下,MVCC只能解决"快照读"的幻读(普通SELECT)。

对于"当前读"(SELECT ... FOR UPDATEUPDATEINSERT),InnoDB使用Next-Key Lock(间隙锁+记录锁)来防止幻读。

当前读 vs 快照读

快照读(Snapshot Read)

  • 普通的SELECT ...语句。
  • 基于MVCC,读取历史版本。
  • 不加锁,不阻塞写。

当前读(Current Read)

  • SELECT ... FOR UPDATEUPDATEINSERTDELETE
  • 读取最新版本,加锁。
  • 阻塞其他事务的写操作。

为什么SELECT ... FOR UPDATE不走MVCC?

因为它需要获取排他锁,必须读取最新数据,否则会导致数据不一致。

总结

MVCC不是真的复制了多份数据,而是利用Undo Log版本链 + Read View动态构建出的"历史视图"。

  • Undo Log:存储历史版本,形成版本链。
  • Read View:判断版本可见性,实现隔离性。
  • 隐藏字段:连接当前数据和历史版本。

最后,送师金句

"MVCC不是真的复制了多份数据,而是利用Undo Log版本链 + Read View动态构建出的'历史视图'。它是高并发下读写并行的核心秘密。"

相关推荐
要开心吖ZSH2 小时前
MP4 转 WAV 音频转码方案详解(互联网医院病历AI实战-JAVE2方案)
java·ffmpeg
凸头2 小时前
从聊天机器人到业务执行者:Agentic Orchestration 如何重构 Java 后端体系
java·开发语言·重构
m0_738120722 小时前
渗透测试——Ripper靶机详细横向渗透过程(rips扫描文件,水平横向越权,Webmin直接获取root权限)
linux·网络·数据库·安全·web安全·php
希望永不加班2 小时前
SpringBoot 跨域问题(CORS)彻底解决方案
java·spring boot·后端·spring
爱丽_2 小时前
AQS 的 `state`:volatile + CAS 如何撑起原子性与可见性
java·前端·算法
大能嘚吧嘚2 小时前
Redis客户端框架-Redisson
数据库·redis·缓存
神龙斗士2402 小时前
MySQL在Navicat中 库的操作 表的操作
数据库·mysql
zxfBdd2 小时前
idea + spark 报错:object hy is not a member of package com.cmcc
java·ide·intellij-idea
攒了一袋星辰2 小时前
10万级用户数据日更与定向推送系统的可靠性设计
java·数据库·算法