超详细解读:数据库MVCC机制

之前文章:Mysql锁_exclusivelock for update写锁-CSDN博客 中有提到通过MVCC来实现快照读,从而解决幻读问题,这里详细介绍下MVCC。

一、前言

|----|---|
| id | k |
| 1 | 1 |
| 2 | 2 |
[表1:实例表t]

|--------------------------------------------|--------------------------------------------------------------|--------------------------------|
| 事务A | 事务B | 事务C |
| start transaction with consistent snaption | | |
| | start transaction with consistent snaption | |
| | | update t set k=k+1 where id =1 |
| | update t set k=k+1 where id =1; select k from t where id =1; | |
| select k from t where id =1; commit; | | |
| | commit | |
[表2:事务A、B、C的执行流程]

先看上面执行流程,先思考下事务A和B两次查询结果都是什么。

注:

1、begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句(第一个快照读语句),事务才真正启动。这里使用start transaction with consistent snaption命令立即开始事务。

2、事务C没有使用命令开启事务,因为update语句本身就是一个事务,执行完毕后执行commit

二、MVCC 的核心思想

MVCC 的核心是通过 数据多版本一致性视图(Consistent Read View) 来实现高并发下的读写隔离。其核心思想是:

  1. 每个数据行有多个版本,每次更新生成新版本,旧版本通过 undo log 保留。

  2. 事务根据可见性规则判断应读取哪个版本,而非直接读取最新数据。

1. 事务ID与行版本

  • 事务ID(Transaction ID) :每个事务启动时,InnoDB会为其分配一个全局唯一且递增的ID(trx_id)。

  • 行数据的版本 :每次事务修改数据时,会生成一个新的数据版本,并将事务ID记录在该版本的 row trx_id 字段中。旧版本的数据通过 Undo Log 保存,形成版本链。

图1:行状态变更图

上图就是一个记录被多个事务连续更新后的状态。图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id 为25的事务更新的,因此它的row trx_id也是25。U1,U2,U3则是undo log的记录的日志。

2. 一致性读视图(Consistent Read View)

事务启动时(RR 级别)或语句执行时(RC 级别),InnoDB 会生成一个 一致性视图,用于判断数据版本的可见性。

Read View 的四大核心属性

  1. trx_ids(活跃事务 ID 集合)
  • 含义:生成 Read View 时,当前系统中所有未提交的活跃事务 ID 的集合。
  • 作用:用于判断数据版本的事务是否在 Read View 生成时处于活跃状态。若在集合中,则该版本对当前事务不可见(除了自身事务,自身事务对于表的修改对于自己当然是可见的)。
  1. up_limit_id(最小活跃事务 ID)
  • 含义trx_ids集合中的最小事务 ID。
  • 作用:若数据版本的事务 ID < low_trx_id → 该版本在 Read View 生成前已提交,可见。
  1. low_limit_id(最大事务 ID 上限)
  • 含义:生成 Read View 时,系统中尚未分配的下一个事务 ID(并非实际存在的事务 ID)。
  • 作用:若数据版本的事务 ID ≥ up_trx_id → 该版本在 Read View 生成后才被创建,不可见。
  1. creator_trx_id(当前事务 ID)
  • 含义:生成该 Read View 的当前事务 ID。
  • 作用:若数据版本的事务 ID == creator_trx_id → 当前事务自己修改的数据,可见。

数据可见性判断规则

当事务读取一行数据时,需根据以下条件判断版本是否可见:

  1. 版本事务 ID < up_limit_id → 可见(已提交且早于 Read View 生成)。
  2. 版本事务 ID >= low_limit_id → 不可见(生成时间晚于 Read View)。
  3. 版本事务 ID 在 [up_limit_id, low_limit_id) 区间内
    • 若在 trx_ids中 → 不可见(活跃未提交)。
    • 若不在 trx_ids中 → 可见(已提交且在 Read View 生成后提交)。
  4. 版本事务 ID == creator_trx_id → 可见(当前事务自己修改的)。

注意:一旦一个Read View被创建,这三个参数将不再发生变化,其中low_limit_id 和 up_limit_id分别是 trx_Ids数组的上下界(注意:从单词上来区分的话很容易弄反)。

三、MVCC 如何实现隔离级别

1. 可重复读(RR)

  • 视图创建时机:事务启动时创建一致性视图,后续所有读操作基于此视图。

  • 效果:事务内看到的数据始终一致,不受其他事务提交的影响。

示例分析:

如上面表格2中:

假设事务开始前,当前活跃事务id=99,则事务A、B、C的事务id依次是100、101、102,事务开始前id=1 k=1的这一行数据的row trx_ids是90。(版本V1)

那么,我们看下在 RR 隔离级别下执行过程:

视图建立时,事务A的trx_ids=[99,100],同样事务B的trx_ids=[99,100,101]、事务C的trx_ids=[99,100,101,102]。先执行事务C的update,当前版本从V1(k=1)变成V2(k=2),V1则变为历史版本,执行事务B的update,历史版本从V2(k=2)变成V3(k=3),V2变成历史版本。

这里为啥执行事务B,从k=2变成3,不是事务隔离吗?这个我们在后面解释。

事务B执行查询操作时:

trx_ids: [99,100,101]

up_limit_id: [99]

low_limit_id: [102]

creator_trx_id=101,先查看V3版本,事务id=101在trx_ids中,但是等于我们当前事务,所以可见,所以最后结果k=3

事务A执行查询操作时:

trx_ids: [99,100]

up_limit_id: [99]

low_limit_id: [101]

creator_trx_id=100,先查看V3版本,事务id=101 >= low_limit_id 不可见,然后查找V2版本,事务id=102 >= low_limit_id,不可见,在查找V1版本,事务id=90,不在trx_ids中,可见。所有我们通过undo log,从V3 -> V2 -> V1,我们获取数据,最后结果k=1

这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读

2. 读提交(RC)

  • 视图创建时机:每条语句执行前重新生成一致性视图。

  • 效果:每次查询能看到已提交的最新数据。

示例分析

表2中的"start transaction with consistent snapshot; "的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。

在 RC 隔离级别下:

事务B的查询结果和RR一致。

  • 事务 C 已提交(ID=102),不在活跃事务数组中。

  • 事务 B 未提交(ID=101),仍在活跃事务数组中。

  • 事务 A 执行查询时:

    • trx_ids:[101](仅事务 B 未提交)。

    • up_limit_id:101(活跃事务最小 ID)。

    • low_limit_id:103(当前最大事务 ID=102,+1 后为 103)。

    数据可见性判断

    • 若数据的最新版本由事务 B(ID=101)更新:

      • row trx_id=101 在活跃数组中,不可见

      • 继续查找历史版本,找到事务 C(ID=102)提交的版本:

        • row trx_id=102 < 高水位(103),且不在活跃数组中,可见
    • 因此,事务 A 的第一次查询会读到事务 C 提交后的数据,即k=2。

如果事务B提交后,事务A再执行一次查询呢?

事务 B 提交后

  • 事务 B 提交后,ID=101 不再属于活跃事务。

  • 事务 A 执行第二次查询:

    • 活跃事务数组[](无未提交事务)。

    • 低水位:无(数组为空)。

    • 高水位:103(最大事务 ID 仍为 102,+1 后为 103)。

    数据可见性判断

    • 数据的最新版本由事务 B(ID=101)提交:

      • row trx_id=101 < 高水位(103),且不在活跃数组中,可见
    • 因此,事务 A 的第二次查询会读到事务 B 提交后的数据。

四、当前读与一致性读

MVCC 的读操作分为两种模式:

  1. 一致性读(Consistent Read) :基于视图读取历史版本,用于普通 SELECT

  2. 当前读(Current Read) :读取最新数据并加锁,用于更新操作(如 UPDATESELECT ... FOR UPDATE)。

为什么更新需要当前读?

假设事务 B 要更新数据:

  • 若使用一致性读,可能基于旧版本数据计算新值,导致其他事务的更新丢失。

  • 因此,更新操作必须读取最新版本(当前读),并对记录加锁,确保数据一致性。

所以这里可以解释,为什么事务B执行update操作k是从2变成3,读到了事务C提交的数据,因为在更新的时候,当前读拿到的数据是(k=2),更新后生成了新版本的数据(k=3),这个新版本的row trx_id是101。所以,在执行事务B查询语句的时候,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。

五、案例分析

除了文章开始案例,我们这里再列举几个案例分析下。

案例1

|--------------------------------------------|--------------------------------------------------------------|-----------------------------------------------------------------------------|
| 事务A | 事务B | 事务C |
| start transaction with consistent snaption | | |
| | start transaction with consistent snaption | |
| | | start transaction with consistent snaption; update t set k=k+1 where id =1; |
| | update t set k=k+1 where id =1; select k from t where id =1; | |
| select k from t where id =1; commit; | | commit |
| | commit | |

我们看上面实例,跟前面分析相比,事务C执行update操作后并没有立即提交,那么如何执行呢。

这里我们需要介绍二阶段协议:

在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

执行流程:

  1. 事务C启动

    • 更新 id=1 的行,将 k1 改为 2,但未提交(持有行锁)。

    • 此时数据版本链为:V1(row trx_id=90, k=1)V2(row trx_id=102, k=2)(未提交)。

  2. 事务B启动

    • 尝试执行 UPDATE t SET k=k+1 WHERE id=1,需要获取行锁。

    • 因事务C未提交,事务B被阻塞,进入锁等待状态

  3. 事务A启动(RR隔离级别):

    • 执行 SELECT k FROM t WHERE id=1

    • 根据一致性视图规则,事务A的视图数组包含启动时活跃事务(如事务C的ID=102)。

    • 数据版本链中,V2row trx_id=102 在活跃事务数组中,不可见;最终读取 V1k=1)。

  4. 事务C提交

    • 提交后释放行锁,数据版本 V2row trx_id=102 变为已提交。

    • 事务B获得锁,执行当前读,读取最新版本 V2k=2),更新为 k=3,生成新版本 V3(row trx_id=101)

  5. 事务A再次查询

    • 仍基于启动时的视图,不可见事务B和C'的提交,结果仍为 k=1
  6. 事务B提交

    • 提交后数据版本 V3row trx_id=101 变为已提交。

    • 新事务查询会看到 k=3

注意:上面没有死锁风险,因为只有事务C和事务B在竞争同一行的锁,且是单向等待(事务B等待事务C释放锁),无循环依赖,因此不会死锁。

案例2

|--------------------------------------------|--------------------------------------------|-----------------------------------------------------------------------------|
| 事务A | 事务B | 事务C |
| start transaction with consistent snaption | | |
| | start transaction with consistent snaption | |
| | | start transaction with consistent snaption; update t set k=k+1 where id =1; |
| | update t set k=k+1 where id =2; | |
| select k from t where id =1; commit; | | update t set k=k+1 where id =2; |
| | update t set k=k+1 where id =1; | |
| | commit | commit |

如果出现上面场景呢?

执行流程

  1. 事务C:更新行id=1 → 持有行1的锁。

  2. 事务B:更新行id=2 → 持有行2的锁。

  3. 事务C:尝试更新行2 → 等待事务B释放行2的锁。

  4. 事务B:尝试更新行1 → 等待事务C释放行1的锁。

此时,事务B和事务C互相等待对方释放资源,形成循环依赖,触发死锁。

死锁相关分析可以参考:Mysql死锁_mysql 死锁的条件-CSDN博客

案例3

|-------------------------------------------------|--------------------|
| 事务A | 事务B |
| begin | |
| select k from t | |
| | update t set k=k+1 |
| update t set k=0 where id = k; select k from t; | |

上面运行结果:数据库会拒绝事务A的修改(如报错或阻塞),"数据无法修改"。

六、MVCC 的优缺点

优点

  • 高并发:读写不互相阻塞,读操作无需加锁。

  • 避免脏读和不可重复读:通过版本链和可见性规则实现隔离。

缺点

  • 存储开销:需保留多个数据版本和 undo log。

  • 长事务问题:长事务可能导致大量历史版本无法清理,占用存储空间。


六、实际应用建议

  1. 避免长事务 :监控 information_schema.innodb_trx,及时终止长时间未提交的事务。

  2. 优先使用 RC 隔离级别:若业务允许,RC 比 RR 更节省资源。

  3. 更新前显式加锁 :如需确保数据一致性,使用 SELECT ... FOR UPDATE 明确加锁。

相关推荐
pwzs39 分钟前
理解最左前缀原则:联合索引命中规则全解析(含流程图)
数据库·sql·mysql
程序员总部1 小时前
Python正则表达式有哪些常用匹配字符?
python·mysql·正则表达式
小卡车5551 小时前
MySQL5.7递归查询
数据库
匹马夕阳1 小时前
(二十二)安卓开发中的数据存储之SQLite简单使用
android·数据库·sqlite
Always_away1 小时前
数据库系统概论|第三章:关系数据库标准语言SQL—课程笔记4
数据库·笔记·sql·学习
cg50171 小时前
Spring Boot 中的自动配置原理
java·前端·数据库
杭州杭州杭州2 小时前
ubuntu 18.04安装tomcat,zookeeper,kafka,hadoop,MySQL,maxwell
hadoop·mysql·ubuntu·zookeeper·kafka·tomcat
程序猿John3 小时前
Mysql读写分离(2)-中间件mycat和实践方案
数据库·mysql·中间件
FreeBuf_3 小时前
美国国土安全部终止资助,CVE漏洞数据库项目面临停摆危机
数据库·安全·web安全
kinlon.liu3 小时前
使用Redis实现分布式限流
数据库·redis·分布式·缓存