MySQL 之 MVCC 机制

0 阅读17分钟

前言

前面在学习锁的时候有提到,对于并发事务可以通过各种锁机制来保证各场景下的线程安全,从而避免脏读、不可重复度、幻读等问题的出现,但是从锁机制来看,锁的使用会阻塞其他事务,尤其是写锁之间的互斥,这可能会写操作的相关事务串行化,这严重影响了效率

所以有没有办法解决这个问题,其实思路是有一个,既然锁的使用会影响其他事务的执行,那有没有办法就是读写是不互斥的,这能有效提高多事务并发的效率,于是就有了 MVCC机制MVCC(多版本并发控制)是数据库系统中另一种不同的并发控制机制

可能这么说,还是不太能了解 MVCC机制 和 锁机制的关系以及区别,现在将二者做个对比:

  1. 互补关系

    • MVCC通过维护数据的多个版本来实现读不阻塞写、写不阻塞读
    • 锁机制则用于保证写操作的互斥性
    • 两者通常结合使用,MVCC处理读并发,锁处理写并发
  2. MVCC减少了锁的使用

    • 在纯锁机制中,读操作需要加共享锁,会阻塞写操作
    • MVCC允许读操作读取旧版本数据,避免了读锁的需求
    • 写操作仍然需要加锁以保证数据一致性
  3. 实现层面的关联

    • 即使使用MVCC,数据库在修改数据时仍需加锁(如行锁)
    • MVCC的版本链管理本身也需要一定的锁机制来保证原子性
  4. 关键区别:

特性MVCC锁机制
并发度高(读写不冲突)较低(读写可能互相阻塞)
实现方式维护多版本数据加锁控制访问权限
适用场景读多写少环境写冲突严重场景
资源消耗需要额外存储空间维护版本数据锁管理开销
隔离级别通常实现RC和RR级别可实现所有隔离级别
性能特点读操作无阻塞,写操作需要版本维护读写都可能阻塞,但实现简单

MVCC通过 空间换时间(存储多版本) 提高了并发性能,而锁则通过限制访问保证了强一致性,两者协同工作才能实现高效的并发控制

并发事务的四种场景

对于并发事务,无非就是四种场景:读-读、读-写、写-读、写-写

读-读 场景是最简单的并发场景,但是该场景下是多个事务同时读取同一数据,并不会有任何数据的变化,所以此时如果加锁就显得很不友好,会很降低效率,此时使用共享锁也可以是一个办法,但是加锁和释放锁也是需要

读-写写-读 其实可以看作是一组并发事务场景,无非是哪个事务先写还是先读的问题,这种情况是最容易出现并发问题的,当存在一个事务在进行写操作时,在进行读操作的事务就可能存在一系列问题,如前面提到的 脏读不可重复读幻读 ,因此,数据库才会引入各种机制来尽可能避免这些问题

写-写 场景是非常容易出现 脏写 的问题,如两个事务对同一组数据进行写操作,但是其中一个事务进行的是删除操作,且先于另一个事务提交,那么此时另外一个事务进行的任何操作都是没有意义的,因为数据都不见了,这是在所有数据库和所有隔离级别中都是零容忍的存在

但是有人可能对此有疑惑,上面的几种场景,我是用数据库的 锁机制 都可以解决的,为什么还要引入新的 MVCC 机制,这是因为在实际场景中,读-写 场景中出现的 脏读不可重复读幻读 的问题本身就是一个小概率的事件,而我们为了这种小概率事件使用可能导致事务串行化的 锁机制 是不是有点大材小用了,或者说一刀切是不是不太好(注意:写-写 场景必须要使用锁机制来避免脏写,所以排除该情况)

因此 MySQL 针对 读-写 的场景推出了 MVCC 机制,该机制在线程安全问题(安全)和加锁串行化(效率)之间做了一定取舍,让两者之间达到了很好的平衡,既防止了 脏读不可重复读幻读 问题的出现,又无需对并发 读-写 事务加锁处理

MVCC机制综述

MVCC 机制全称为 Multi-Version Concurrency Control ,即多版本并发控制技术,主要是为了提升数据库并发性能而设计的,其采用了更好的方式处理 读-写 并发冲突,做到了即使有读写冲突时,也可以不加锁解决,从而确保了任何时刻的读操作都是非阻塞的

MVCC 机制原理暂时可以简单理解为数据库中的同一数据存在多个版本,当发生并发的读、写操作事务时,他们操作的是同一数据的不同版本,如写操作走的是新版本,而读操作走的是旧版本,这样子无论写操作的事务做了什么都不会影响到读操作的事务

需要注意的是: MySQL 的四种事务隔离级别:

读未提交(RU)读已提交(RC)可重复读(RR)串行化(Serializable)

中只有 读已提交(RC)可重复读(RR) 用到了 MVCC 机制

其实也很好理解,因为如果是 读未提交(RU) 隔离级别,既然都允许存在脏读问题(允许一个事务读取另一个事务未提交的数据),那自然可以直接读最新版本的数据,因此无需MVCC介入

如果是 串行化(Serializable) 隔离级别,因为会将所有的并发事务串行化处理,此时都不存在所谓的多线程并发问题了,所以也无需 MVCC 机制介入

当然在两种隔离级别下,MVCC机制的实现也有些许的不同,这个后面再介绍

另外只有 InnoDB 引擎实现了 MVCC 机制,类似 MyISAMMemory 等引擎中都未曾实现,所以接下来介绍 MVCC 机制都是基于InnoDB 引擎

MVCC机制原理

MVCC 机制主要借助三样东西,即隐藏字段、Undo-log日志、ReadView 来实现其功能,现在分别来介绍一下这三样东西

隐藏字段

实际开发中,每当我们在 MySQL 中创建表格时,MySQL 不仅会构建我们显式声明的字段,还会构建一些隐藏字段,在 InnoDB 引擎中主要有 DB_ROW_IDDB_Deleted_BitDB_TRX_IDDB_ROLL_PTR 这四个隐藏字段,接下来依次介绍一下这些隐藏字段

隐藏主键 -- ROW_ID(6Bytes)

对于InnoDB引擎的表来说,其表数据是按照 聚簇索引 的格式存储,因此通常都会选择主键作为聚簇索引列,然后基于主键字段构建索引树,但如若表中未定义主键,则会选择一个具备唯一非空属性的字段,作为聚簇索引的字段来构建树

当两者都不存在时,InnoDB 会隐式定义一个顺序递增的列 ROW_ID 来作为聚簇索引列

删除标识 -- DELETE_BIT(1Bytes)

MySQL 中执行 delete 语句后并不会立马删除表中对应的数据,而是将这条数据的Deleted_Bit删除标识改为1/true(和我们实际开发中对表中数据进行逻辑删除的有点像),后续的查询SQL检索数据时,如果检索到了这条数据,但看到隐藏字段Deleted_Bit=1时,就知道该数据已经被其他事务delete了,因此不会将这条数据纳入结果集

那为什么要设计这么一个隐藏字段呢,那我们先做一个假设:即删除时,直接进行物理删除,那么会出现什么情况呢?

  1. 直接删除表数据时,可能会破坏聚簇索引树的结构
  2. 当事务回滚时,需要恢复被删除的数据,而且此前被破坏的聚簇索引树结构也需要恢复到数据删除之前的结构

可以看到,一次数据的删除可能会引起两次聚簇索引树结构的调整,而且这两次调整还是没有意义且极其耗时的,所以实际在 MySQL 中,它是借助 DELETE_BIT 字段去标记被删除的数据,后续事务再回滚时,再将该字段的值改回 0/false,这样也避免的聚簇索引树的反复调整

那么是不是就是说直接就将数据不删除,直接使用 DELETE_BIT 标记即可,那这么干,磁盘可就要遭老罪咯 ~

实际上 MySQL 是这么干的,当我们执行 DELETE 语句删除数据时,会将目标数据通过 DELETE_BIT 字段标记为 1/true,而不是立即删除数据且释放磁盘空间。另外 MySQL 内部会有一个存储删除记录的地方,称为 undo log,以便在需要回滚事务时使用

另外 MySQL 有一个后台线程,称为 purge 线程,它负责清理已删除的数据。purge 线程会定期检查 undo log,将已删除的数据清理掉,释放磁盘空间。MySQLpurge 线程会根据不同的情况来进行清理,例如判断是否有事务需要使用 undo log,以及 undo log 是否被其他事务使用等

最近更新的事务ID - TRX_ID(6Bytes)

TRX_ID 全称是 Transaction ID (事务ID),它是MVCC实现中的核心字段之一,用于标识创建或最后修改某行数据的事务

每当有事务对数据进行修改时,无论是插入还是更新,系统都会在该记录的隐藏字段中标记当前事务的TRX_ID

另外 TRX_ID 与回滚指针 ROLL_PTR 共同构成了数据行的版本链。当一行数据被多次更新时,每个版本都会保留自己的 TRX_ID,并通过指针连接形成一条版本历史链。这种设计不仅支持事务的回滚操作,还为实现各种隔离级别提供了基础。在事务回滚时,系统可以根据 TRX_ID 定位到需要恢复的数据版本;在事务隔离时,系统可以根据 TRX_ID 过滤掉不应该看到的中间版本。TRX_ID 的这种双重角色使得 MySQL 能够在保证数据一致性的同时,提供灵活的并发控制能力

但是有一个细节需要注意一下:InnoDB 对自动提交模式下的简单SELECT查询会优化掉事务ID分配,但在显式事务中或需要版本控制的场景下,即使只有SELECT也会分配事务ID。即事务ID的分配是惰性的

回滚指针 - ROLL_PTR(7Bytes)

ROLL_PTR全称为rollback_pointer,也就是回滚指针的意思,这个也是表中每条数据都会存在的一个隐藏字段,当一个事务对一条数据做了改动后,Undo-log 日志都会记录旧版本的数据,而 ROLL_PTR 就是一个指向Undo-log 日志中记录的旧版本数据的指针,当事务回滚时,就可以通过 ROLL_PTR 来找到改动之前的旧版本数据,而 MVCC 机制也利用这点,实现了行数据的多版本

InnoDB引擎的 Undo-log

在上面有提到 Undo-log 日志中会存储旧版本的数据,而且记录的不仅是一条旧数据,而是数据行的版本链,可能这听起来有点抽象,还是直接上例子比较好:

  1. 首先我们有一个银行账户表:
CREATE TABLE account (
    id INT PRIMARY KEY,
    name VARCHAR(20),
    balance DECIMAL(10,2)
);
  1. 事务1TRX_ID = 1001 ) 对该表进行了 insert 操作
-- 用户 Alice 存入 1000 元
INSERT INTO account VALUES(1, 'Alice', 1000.00);

此时 InnoDB 内部操作为:

  1. 聚簇索引记录
R1: [id=1, name='Alice', balance=1000.00, TRX_ID=1001, ROLL_PTR=NULL]
  1. Undo log记录
Undo1: [TRX_ID=1001, 操作类型=INSERT, 主键=1, 反操作SQL="DELETE FROM account WHERE id=1"]
  1. 事务2TRX_ID = 1002 )更新数据
-- 用户 Alice 消费了 100 元
UPDATE account SET balance=900.00 WHERE id=1;

此时 InnoDB 内部操作为:

  1. 将当前记录R1拷贝到Undo log:
Undo2: [TRX_ID=1002, 操作类型=UPDATE, 主键=1, 旧数据: {name='Alice', balance=1000.00},反操作SQL="UPDATE account SET name='Alice', balance=1000.00 WHERE id=1"]
  1. 修改聚簇索引记录
R1': [id=1, name='Alice', balance=900.00, TRX_ID=1002, ROLL_PTR→Undo2]

ROLL_PTR 指向 Undo2,形成版本链:R1' → Undo2 → R1

  1. 事务3(TRX_ID=1003)再次更新数据
-- 用户 Alice 又消费了 100 元并且修改用户名为 Alice Smith
UPDATE account SET name='Alice Smith', balance=800.00 WHERE id=1;

此时 InnoDB 内部操作为:

  1. 将当前记录 R1' 拷贝到Undo log:
Undo3: [TRX_ID=1003, 操作类型=UPDATE, 主键=1,旧数据: {name='Alice', balance=900.00},反操作SQL="UPDATE account SET name='Alice', balance=900.00 WHERE id=1"]
  1. 修改聚簇索引记录
R1'': [id=1, name='Alice Smith', balance=800.00, TRX_ID=1003, ROLL_PTR→Undo3]

现在版本链是:R1'' → Undo3 → R1' → Undo2 → R1

  1. 版本链的可视化
当前记录:
R1'' → [id=1, name='Alice Smith', balance=800.00, TRX_ID=1003, ROLL_PTR→Undo3]

Undo Log链:
Undo3 → [TRX_ID=1003, 反操作: "UPDATE account SET name='Alice', balance=900.00 WHERE id=1"]
Undo2 → [TRX_ID=1002, 反操作: "UPDATE account SET name='Alice', balance=1000.00 WHERE id=1"]
Undo1 → [TRX_ID=1001, 反操作: "DELETE FROM account WHERE id=1"]

MVCC核心 - ReadView

在上面已经介绍了 MVCC 会借助 undo-log 来实现数据多版本,但是此时会面临一个关键问题:当 事务A 需要查询一条正在被 事务B 修改的数据时,如何确定应该读取哪个版本的数据?(因为被修改的数据肯定存在多版本)这时 ReadView 就发挥了重要作用。作为实现并发控制的核心机制,ReadView 会根据事务启动的时机选择对该事务可见的合适数据版本来进行读取,从而保证事务隔离性

那什么是 ReadView 呢?ReadView 其实是当事务在尝试读取一条数据时,MVCC 基于当前 MySQL 的数据状态生成的快照,也被称为读视图。这个快照中记录着当前所有活跃事务的ID(活跃事务是指还在执行的事务,即还没有结束(提交或者回滚)的事务

当一个事务启动后并且是首次执行 select 操作时,MVCC 就会生成一个数据库当前的ReadView,通常而言,事务与 ReadView 属于一对一的关系(RCRR 隔离级别下会存在细微差异),ReadView一般包含四个核心内容:

  • creator_trx_id:创建当前这个ReadView的事务ID(只有执行写操作的事务才会被分配唯一 ID,只读事务的 creator_trx_id 为 0)
  • trx_ids:生成当前ReadView时,系统内活跃的读写事务ID列表
  • up_limit_id:活跃的事务列表中,最小的事务ID(即最早开始的活跃事务)
  • low_limit_id:表示在生成当前ReadView时,系统中要给下一个事务分配的ID

上面四个值很简单,值得一提的是low_limit_id,它并不是目前系统中活跃事务的最大ID,因为之前讲到过,MySQL的事务ID是按序递增的,因此当启动一个新的事务时,都会为其分配事务ID,而这个low_limit_id则是整个MySQL中,要为下一个事务分配的ID

MVCC 机制实现原理

前面依次介绍了undo-log、隐藏字段、ReadView,从其中了解到:

  • 当一个事务尝试修改数据时,数据库会将旧数据写入 undo-log 日志,并借助回滚指针形成数据的版本链
  • 当一个事务尝试查询某条数据时,MVCC 会生成一个ReadView 快照,根据该事务的查询时机决定该事务读取哪个版本的数据

继续用前面的银行账户例子来说明:

事务一  trx_id = 1
update account set name = 'bob' where id = 1;
update account set balance = 80  where id = 1;
事务二 trx_id = 0
select * from account where id = 1 

存在T1T2两个并发事务,其中T1在尝试修改 id=1 的这条数据,而T2则在尝试查询这条数据,对于事务T2,其执行时具体过程如下:

① 当事务中出现select语句时,会先根据MySQL的当前情况生成一个ReadView,且其ReadView构成为:

{
    "creator_trx_id" : "0",
    "trx_ids" : "[1]",
    "up_limit_id" : "1",
    "low_limit_id" : "2"
}

② 判断行数据中的隐藏列 trx_idReadViewcreator_trx_id 是否相同:

  • 相同:代表创建ReadView和修改行数据的事务是同一个,自然可以读取最新版数据
  • 不相同:代表目前要查询的数据,是被其他事务修改过的,继续往下执行

③ 判断隐藏列 trx_id 是否小于 ReadViewup_limit_id 最小活跃事务ID

  • 小于:代表改动行数据的事务在创建快照前就已结束,可以读取最新版本的数据
  • 不小于:则代表改动行数据的事务还在执行,因此需要继续往下判断

④ 判断隐藏列trx_id是否小于ReadViewlow_limit_id这个值:

  • 大于或等于:代表改动行数据的事务是生成快照后才开启的,因此不能访问最新版数据
  • 小于:表示改动行数据的事务IDup_limit_idlow_limit_id之间,需要进一步判断

⑤ 如果隐藏列trx_id小于low_limit_id,继续判断trx_id是否在trx_ids中:

  • 在:表示改动行数据的事务目前依旧在执行,不能访问最新版数据
  • 不在:表示改动行数据的事务已经结束,可以访问最新版的数据

总结下来就是当查询的事务执行时生成 ReadView 后首先会去获取表中行数据的隐藏列(主要是 trx_id),然后经过上述一系列判断后,可以得知:目前查询数据的事务到底能不能访问最新版的数据。如果能,就直接拿到表中的数据并返回,反之,不能则去Undo-log日志中获取旧版本的数据返回

但是如果去Undo-log日志中获取旧版本的数据,又存在一个问题,因为前面提到,Undo-log中可能存在多个版本的数据,那么怎么知道要获取哪个版本的数据呢?其判断条件为:旧版本的数据,其隐藏列 trx_id 不能在 ReadView 的 trx_ids 活跃事务列表中,原因:因为如果旧版本的数据,其trx_id依旧在ReadViewtrx_ids中,就代表着产生这条旧数据的事务还未提交,自然不能读取这个版本的数据