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动态构建出的'历史视图'。它是高并发下读写并行的核心秘密。"

相关推荐
2301_77355362几秒前
golang如何理解编译指示pragma_golang编译指示pragma策略
jvm·数据库·python
qq_342295821 分钟前
c++字符串运算_连接、比较、输入输出等运算符重载应用
jvm·数据库·python
m0_746752302 分钟前
如何生成ADDM报告_@addmrpt.sql自动数据库诊断监控工具
jvm·数据库·python
2301_814809868 分钟前
如何快速查询SQL中的重复记录:GROUP BY与COUNT统计
jvm·数据库·python
m0_684501988 分钟前
如何配置DG的备库延迟应用_DELAY参数实现在备库防范主库人为误操作逻辑错误
jvm·数据库·python
m0_5150984210 分钟前
Redis怎样强行终止陷入死循环的Lua脚本
jvm·数据库·python
2301_8176722612 分钟前
SQL中RIGHT JOIN真的很少用吗_数据完整性检查与反向关联分析
jvm·数据库·python
M ? A12 分钟前
Vue v-bind 转 React:VuReact 怎么处理?
前端·javascript·vue.js·经验分享·react.js·面试·vureact
2501_9142459315 分钟前
mysql如何进行表空间传输恢复_mysql transport tablespace实战
jvm·数据库·python
qq_3300379917 分钟前
MongoDB的聚集索引怎么用_Clustered Collections的插入性能优化
jvm·数据库·python