MVCC 多版本并发控制机制
文章目录
- MVCC 多版本并发控制机制
- 一:并发事务的场景
- 1:读读场景
- 2:写写场景
- 3:读写 or 写读场景
- 二:MVCC机制综述
- 1:MVCC日常生活的体现
- 2:多版本并发控制
- 三:MVCC实现原理剖析(重点,难点)
- 1:隐藏字段
- 1.1:隐藏主键 - ROW_ID(6Bytes)
- 1.2:删除标识 - Deleted_Bit(1Bytes)
- 1.3:最近更新的事务ID - TRX_ID(6Bytes)
- 1.4:回滚指针 - ROLL_PTR(7Bytes)
- 2:undo_log日志
- 3:readView(核心)
- 3.1:到底什么是readView
- 3.2:机制实现原理和可见性算法
- 3.3:RC和RR的区别
一:并发事务的场景
1:读读场景
读-读场景即是指多个事务/线程在一起读取一个相同的数据,比如事务T1正在读取ID=88的行记录,事务T2也在读取这条记录,两个事务之间是并发执行的。
对于这种情况而言,不需要做任何操作,因为不改变数据就不会引起任何并发问题
2:写写场景
多个事务之间一起对同一数据进行写操作,比如事务T1对ID=88的行记录做修改操作,事务T2则对这条数据做删除操作
事务T1提交事务后想查询看一下,结果连这条数据都不见了,这也是所谓的脏写问题,也被称为更新覆盖问题
对于这个问题在所有数据库、所有隔离级别中都是零容忍的存在,最低的隔离级别也要解决这个问题。【写互斥锁就可以解决脏写问题】
3:读写 or 写读场景
读写 -> 先读后写
写读,先写后读
并发事务中同时存在读、写两类操作时,这是最容易出问题的场景,脏读、不可重复读、幻读都出自于这种场景中
当有一个事务在做写操作时,读的事务中就有可能出现这一系列问题,因此数据库才会引入各种机制解决。
二:MVCC机制综述
MVCC机制的全称为Multi-Version Concurrency Control,即多版本并发控制技术,主要是为了提升数据库并发性能而设计的
其中采用更好的方式处理了读-写并发冲突,做到即使有读写冲突时,也可以不加锁解决,从而确保了任何时刻的读操作都是非阻塞的。
1:MVCC日常生活的体现
文章的发布和审核
假设我发布了一篇关于xxx的文章,发布后有一位观看者比较细心,文中存在两三个错别字,被这人指出来了,因此我去修正错别字后重新发布。
问题来了,对于文章首次发布也好,重新发布也罢,绝对要等审核通过后才会正式发布的,那我修正文章后重新发布,文章又会进入「审核中」这个状态
此时对于其他正在看、准备看的人来说,文章是不是就不见了?毕竟文章还在审核,因此对这个业务需求又该如何实现呢?多版本!
也就是说,对于首次发布后通过审核的文章,在后续重新发布审核时,用户可以看到更新前的文章,也就是看到老版本的文章,当更新后的文章审核通过后,再使用新版本的文章代替老版本的文章即可。
这样就能做到新老版本的兼容,也能够确保文章修正时,其他正在阅读的小伙伴不会受影响,而MySQL-MVCC机制的思想也大致相同。
2:多版本并发控制
MySQL中的多版本并发控制,也和上面给出的例子类似
回想一下,脏读、不可重复读、幻读问题都是由于多个事务并发读写导致的,但这些问题都是基于最新版本的数据并发操作才会出现
如果读、写的事务操作的不是同一个版本呢?这样就可以做到互不影响
⚠️ MySQL中仅在RC、RR才会使用MVCC机制
如果是RU允许存在脏读问题、允许一个事务读取另一个事务未提交的数据,那自然可以直接读最新版本的数据,因此无需MVCC介入。
如果是Serializable串行化级别,因为会将所有的并发事务串行化处理,也就是不论事务是读操作,亦或是写操作,都会被排好队一个个执行,这都不存在所谓的多线程并发问题了,自然也无需MVCC介入。
🖊 MVCC机制在MySQL中,仅有InnoDB引擎支持,而在该引擎中,MVCC机制只对RC、RR两个隔离级别下的事务生效。
当然,RC、RR两个不同的隔离级别中,MVCC的实现也存在些许差异
三:MVCC实现原理剖析(重点,难点)
MVCC由三个部分构成:隐藏字段,undoLog, readview
1:隐藏字段
通常而言,当你基于InnoDB引擎建立一张表后,MySQL除开会构建你显式声明的字段外,通常还会构建一些InnoDB引擎的隐藏字段
在InnoDB引擎中主要有DB_ROW_ID
、DB_Deleted_Bit
、DB_TRX_ID
、DB_ROLL_PTR
这四个隐藏字段
1.1:隐藏主键 - ROW_ID(6Bytes)
对于InnoDB引擎的表而言,由于其表数据是按照聚簇索引的格式存储,因此通常都会选择主键作为聚簇索引列,然后基于主键字段构建索引树
但如若表中未定义主键,则会选择一个具备唯一非空属性的字段,作为聚簇索引的字段来构建树。
而当两者都不存在时,InnoDB就会隐式定义一个顺序递增的列ROW_ID来作为聚簇索引列。
因此要牢记一点,如果你选择的引擎是InnoDB,就算你的表中未定义主键、索引,其实默认也会存在一个聚簇索引
只不过这个索引在上层无法使用,仅提供给InnoDB构建树结构存储表数据。
1.2:删除标识 - Deleted_Bit(1Bytes)
对于一条delete语句而言,当执行后并不会立马删除表的数据,而是将这条数据的Deleted_Bit删除标识改为1/true
后续的查询SQL检索数据时,如果检索到了这条数据,但看到隐藏字段Deleted_Bit=1时,就知道该数据已经被其他事务delete了,因此不会将这条数据纳入结果集。
设计Deleted_Bit这个隐藏字段的好处是什么呢?
主要是能够有利于聚簇索引
比如当一个事务中删除一条数据后,后续又执行了回滚操作,假设此时是真正的删除了表数据,会发生什么情况呢?
- ①删除表数据时,有可能会破坏索引树原本的结构,导致出现叶子节点合并的情况。
- ②事务回滚时,又需重新插入这条数据,再次插入时又会破坏前面的结构,导致叶子节点分裂。
综上所述,如果执行delete语句就删除真实的表数据,由于事务回滚的问题,就很有可能导致聚簇索引树发生两次结构调整,这其中的开销可想而知;而且先删除,再回滚,最终树又变成了原状,那这两次树的结构调整还是无意义的。
所以,当执行delete语句时,只会改变将隐藏字段中的删除标识改为1/true,如果后续事务出现回滚动作,直接将其标识再改回0/false即可
会不会产生过多的deleted = 1的数据使得磁盘爆满呢?
MySQL中存在purger线程的概念,为了防止“已删除”的数据占用过多的磁盘空间,purger线程会自动清理Deleted_Bit=1/true的行数据。
当然,为了确保清理数据时不会影响MVCC的正常工作,purger线程自身也会维护一个ReadView
如果某条数据的Deleted_Bit=true,并且TRX_ID对purger线程的ReadView可见,那么这条数据一定是可以被安全清除的(即不会影响MVCC工作)。
1.3:最近更新的事务ID - TRX_ID(6Bytes)
MySQL对于每一个创建的事务,都会为其分配一个事务ID[TRX_ID],事务ID同样遵循顺序递增的特性,即后来的事务ID绝对会比之前的ID要大,比如:
此时事务T1准备修改表字段的值,MySQL会为其分配一个事务ID=1,当事务T2准备向表中插入一条数据时,又会为这个事务分配一个ID=2…
但有一个细节点需要记住:MySQL对于所有包含写入SQL的事务,会为其分配一个顺序递增的事务ID,但如果是一条select查询语句,则分配的事务ID=0。
不过对于手动开启的事务,MySQL都会为其分配事务ID,就算这个手动开启的事务中仅有select操作。
表中的隐藏字段TRX_ID,记录的就是最近一次改动当前这条数据的事务ID,这个字段是实现MVCC机制的核心之一。
1.4:回滚指针 - ROLL_PTR(7Bytes)
ROLL_PTR全称为rollback_pointer,也就是回滚指针的意思,这个也是表中每条数据都会存在的一个隐藏字段
当一个事务对一条数据做了改动后,会将旧版本的数据放到Undo-log日志中,而rollback_pointer就是一个地址指针,指向Undo-log日志中旧版本的数据
当需要回滚事务时,就可以通过这个隐藏列,来找到改动之前的旧版本数据,而MVCC机制也利用这点,实现了行数据的多版本。
2:undo_log日志
Undo-log中并不仅仅只存储一条旧版本数据,其实在该日志中会有一个版本链
从上图中可明显看出:不同的旧版本数据,会以roll_ptr回滚指针作为链接点,然后将所有的旧版本数据组成一个单向链表。
⚠️ 最新的旧版本数据,都会插入到链表头中,而不是追加到链表尾部。[头插法]
上述update语句的详细过程
- 对要修改的行数据加上排他锁。
- 将原本的旧数据拷贝到Undo-log的rollback Segment区域。
- 对表数据上的记录进行修改,修改完成后将隐藏字段中的trx_id改为当前事务ID。
- 将隐藏字段中的roll_ptr指向Undo-log中对应的旧数据,并在提交事务后释放锁。
为什么Undo-log日志要设计出版本链呢?
- 一方面可以实现事务点回滚(这点回去参考事务篇)
- 另一方面则可以实现MVCC机制(这点后面聊)。
移除机制
与删除标识类似,Undo-log移除的工作同样由purger线程负责
purger线程内部也会维护一个ReadView,它会以此作为判断依据,来决定何时移除Undo记录。
3:readView(核心)
思考一个问题:如果T2事务要查询一条行数据,此时这条行数据正在被T1事务写,那也就代表着这条数据可能存在多个旧版本数据
T2事务在查询时,应该读这条数据的哪个版本呢?
此时就需要用到ReadView,用它来做多版本的并发控制,根据查询的时机来选择一个当前事务可见的旧版本数据读取。
3.1:到底什么是readView
先来说下两个概念,快照读和当前读:
当前读 :在锁定读(使用锁隔离事物)的时候读到的是最新版本的数据
快照读:可重复读下mvcc生效读取的是数据的快照,并不是最新版本的数据(未提交事物的数据)
------- 可以这么区分 ------
快照读:读取的是快照版本。普通的SELECT就是快照读。通过mvcc来进行并发控制的,不用加锁。
当前读:读取的是最新版本。UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE是当前读。
MVCC基于当前MySQL的运行状态生成的快照,也被称之为读视图,即ReadView
在这个快照中记录着当前所有活跃事务的ID(活跃事务是指还在执行的事务,即未结束(提交/回滚)的事务)。
当一个事务启动后,首次执行select操作时,MVCC就会生成一个数据库当前的ReadView,通常而言,一个事务与一个ReadView属于一对一的关系(不同隔离级别下也会存在细微差异),ReadView一般包含四个核心内容:
creator_trx_id
:代表创建当前这个ReadView的事务ID。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值。
假设目前数据库中共有T1~T5这五个事务,T1、T2、T4还在执行,T3已经回滚,T5已经提交
此时当有一条查询语句执行时,就会利用MVCC机制生成一个ReadView
由于单纯由一条select语句组成的事务并不会分配事务ID,因此默认为0,所以目前这个快照的信息如下:
{"creator_trx_id" : "0","trx_ids" : "[1,2,4]","up_limit_id" : "1","low_limit_id" : "6"
}
3.2:机制实现原理和可见性算法
经过前面得知:
- 当一个事务尝试改动某条数据时,会将原本表中的旧数据放入Undo-log日志中。
- 当一个事务尝试查询某条数据时,MVCC会生成一个ReadView快照。
其中Undo-log主要实现数据的多版本,ReadView则主要实现多版本的并发控制
假设现在有两个事务:
# 原始数据如下
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 熊猫 | 女 | 6666 | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+# 事务1 trx_id = 1
UPDATE zz_users SET user_name = "竹子" WHERE user_id = 1;
UPDATE zz_users SET user_sex = "男" WHERE user_id = 1;# 事务2 trx_id = 2
SELECT * FROM zz_users WHERE user_id = 1;
目前存在T1、T2两个并发事务,T1目前在修改ID=1的这条数据,而T2则准备查询这条数据,下面就是T2执行的具体的过程:
说简单一点,就是首先会去获取表中行数据的隐藏列,然后经过上述一系列判断后,可以得知:目前查询数据的事务到底能不能访问最新版的数据。
- 如果能,就直接拿到表中的数据并返回
- 不能则去Undo-log日志中获取旧版本的数据返回。
该获取哪个版本的旧数据呢?
如果Undo-log日志中的旧数据存在一个版本链时,此时会首先根据隐藏列roll_ptr找到链表头,然后依次遍历整个列表,从而检索到最合适的一条数据并返回。合适的条件如下:旧版本的数据,其隐藏列trx_id不能在ReadView.trx_ids活跃事务列表中。
因为如果旧版本的数据,其trx_id依旧在ReadView.trx_ids中,就代表着产生这条旧数据的事务还未提交,自然不能读取这个版本的数据
范围查询时,突然出现新增数据怎么办呢?
SELECT * FROM zz_users;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 熊猫 | 女 | 6666 | 2022-08-14 15:22:01 |
| 2 | 竹子 | 男 | 1234 | 2022-09-14 16:17:44 |
| 3 | 子竹 | 男 | 4321 | 2022-09-16 07:42:21 |
| 4 | 猫熊 | 女 | 8888 | 2022-09-27 17:22:59 |
| 9 | 黑竹 | 男 | 9999 | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+-- T1事务:查询ID >= 3 的所有用户信息
select * from zz_users where user_id >= 3;-- T2事务:新增一条 ID = 6 的用户记录
INSERT INTO zz_users VALUES(6,"棕熊","男","7777","2022-10-02 16:21:33");
此时当T1事务查询数据时,突然蹦出来一条ID=6的数据
经过判断之后会发现新增这条数据的事务还在执行,所以要去查询旧版本数据
但此时由于是新增操作,因此roll_ptr=null,即表示没有旧版本数据,此时不会读取最新版的数据
因为如果查询数据的事务不能读取最新版数据,同时又无法从版本链中找到旧数据,那就意味着这条数据对T1事务完全不可见,因此T1的查询结果中不会包含ID=6的这条新增记录。
3.3:RC和RR的区别
RC工作原理
RR工作原理