一、MVCC概述
MVCC
,全称Multi-Version Concurrency Control
,即多版本并发控制。整个MVCC多并发控制的目的就是为了实现读-写冲突不加锁,提高并发读写性能,而这个读指的就是快照度, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现。
- 当前读
读取的是记录数据的最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发的修改这条记录
- 快照读
读取的是记录数据的可见版本(可能是过期的数据),不用加锁。
总结来说MVCC的好处:
-
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
-
同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
-
MVCC解决读写冲突,悲观锁或者乐观锁解决写写冲突
二、mvcc原理
MVCC的目的就是多版本并发控制,目的是为了解决读写冲突
,总的来说MVCC通过保存数据在某个时间点的快照来实现的,意味着在同一个时刻不同事务看到的相同表里的数据可能是不同的(即多版本)。如下示例:事务1和事务3可读到不同的数据快照。
时间点 | 事务1 | 事务2 | 事务3 |
T1 | 开始事务 | 开始事务 | 开始事务 |
T2 | 查询A的账户,金额为100 | ||
T3 | 修改A的账户,金额从100改为200 | ||
T4 | 提交事务 | ||
T5 | 查询A的账户,金额为100 | 查询A的账户,金额为200 |
MVCC最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,它的实现原理主要是依赖记录中的 3个隐式字段、
undo日志
和Read View
来实现的。
1、隐藏字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,
DB_ROLL_PTR,
DB_ROW_ID
等字段
DB_TRX_ID
6byte,最近操作(修改/插入
)事务ID:记录创建这条记录或者最后一次修改该记录的事务ID
DB_ROLL_PTR
7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
DB_ROW_ID
6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID
产生一个聚簇索引
如上图,DB_ROW_ID
是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID
是当前操作该记录的事务ID,而DB_ROLL_PTR
是一个回滚指针,用于配合undo日志,指向上一个旧版本。
2、undo日志
undo log主要分为两种:
- insert undo log
代表事务在insert
新记录时产生的undo log
, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃 - update undo log
事务在进行update
或delete
时产生的undo log
; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge
线程统一清除
因此,对MVCC有帮助的实质是update undo log
,undo log
实际上就是存在rollback segment
中旧记录链,它的执行流程如下:
比如事务0插入person表一条新记录,name为Jerry, age为24,隐式主键
是1,事务ID
和回滚指针
,我们假设为NULL,如下图
现在来了一个事务1
对该记录的name
做出了修改,改为Tom
- 在
事务1
修改该行数据时,数据库会先对该行加排他锁
- 然后把该行数据拷贝到
undo log
中,作为旧记录,既在undo log
中有当前行的拷贝副本 - 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前
事务1
的ID, 我们默认从1
开始,之后递增,回滚指针指向拷贝到undo log
的副本记录,既表示我的上一个版本就是它 - 事务提交后,释放锁
又来了个事务2
修改person表
的同一个记录,将age
修改为30岁
- 在
事务2
修改该行数据时,数据库也先为该行加锁 - 然后把该行数据拷贝到
undo log
中,作为旧记录,发现该行记录已经有undo log
了,那么最新的旧数据作为链表的表头,插在该行记录的undo log
最前面 - 修改该行
age
为30岁,并且修改隐藏字段的事务ID为当前事务2
的ID, 那就是2
,回滚指针指向刚刚拷贝到undo log
的副本记录 - 事务提交,释放锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log
成为一条记录版本线性表,既链表,undo log
的链首就是最新的旧记录,链尾就是最早的旧记录(该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
3、Read View(读视图)
Read View就是事务进行快照读操作的时候生产的读视图
(Read View),记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大),所以我们知道 Read View
主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View
读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log
里面的某个版本的数据。
Read View
遵循一个可见性算法,主要是将要被修改的数据
的最新记录中的DB_TRX_ID
(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID
跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR
回滚指针去取出Undo Log
中的DB_TRX_ID
再比较,即遍历链表的DB_TRX_ID
(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID
, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。
我先简化一下Read View,我们可以把Read View简单的理解成有三个全局属性
- rw_trx_ids
一个数值列表,用来维护Read View生成时刻系统正活跃的事务IDup_limit_id
记录rw_trx_ids列表中事务ID最小的IDlow_limit_id
ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
判断逻辑如下:
-
首先比较
DB_TRX_ID < up_limit_id
, 如果小于,则当前事务能看到DB_TRX_ID
所在的记录,如果大于等于进入下一个判断 -
接下来判断
DB_TRX_ID 大于等于 low_limit_id
, 如果大于等于则代表DB_TRX_ID
所在的记录在Read View
生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断 -
判断
DB_TRX_ID
是否在活跃事务之中,rw_trx_ids.contains(DB_TRX_ID)
,如果在,则代表我Read View
生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View
生成之前就已经Commit了,你修改的结果,我当前事务是能看见的
四、整体流程
我们在了解了隐式字段
,undo log
, 以及Read View
的概念之后,就可以来看看MVCC实现的整体流程是怎么样了。
- 当
事务2
对某行数据执行了快照读
,数据库为该行数据生成一个Read View
读视图,假设当前事务ID为2
,此时还有事务1
和事务3
在活跃中,事务4
在事务2
快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表rw_trx_ids上
事务1 | 事务2 | 事务3 | 事务4 |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
… | … | … |
- Read View不仅仅会通过一个列表rw_trx_ids来维护
事务2
执行快照读
那刻系统正活跃的事务ID,还会有两个属性up_limit_id
(记录rw_trx_ids列表中事务ID最小的ID),low_limit_id
(记录rw_trx_ids列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1
,我更倾向于后者;所以在这里例子中up_limit_id
就是1,low_limit_id
就是4 + 1 = 5,rw_trx_ids集合的值是1,3。
- 我们的例子中,只有
事务4
修改过该行记录,并在事务2
执行快照读
前,就提交了事务,所以当前该行数据的undo log
如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID
去跟up_limit_id
,low_limit_id
和rw_trx_ids
进行比较,判断当前事务2
能看到该记录的版本是哪个。
- 所以先拿该记录
DB_TRX_ID
字段记录的事务ID4
去跟Read View
的的up_limit_id
比较,看4
是否小于up_limit_id
(1),所以不符合条件,继续判断4
是否大于等于low_limit_id
(5),也不符合条件,最后判断4
是否处于rw_trx_ids中的活跃事务, 最后发现事务ID为4
的事务不在当前活跃事务列表中, 符合可见性条件,所以事务4
修改后提交的最新结果对事务2
快照读时是可见的,所以事务2
能读到的最新数据记录是事务4
所提交的版本,而事务4提交的版本也是全局角度上最新的版本
- 也正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同
五、mvcc示例解析
下面以RR隔离级别为例,结合前文提到的几个问题分别说明。
(1)脏读
时间点 | 事务1 | 事务2 |
T1 | 开始事务 | 开始事务 |
T2 | 修改A的金额,将金额从100改为200 | |
T3 | 查询A的金额,为100 | |
T4 | 提交事务 |
当事务1在T3时刻读取A的余额前,会生成ReadView,由于此时事务2没有提交仍然活跃,因此其事务id一定在ReadView的rw_trx_ids中,因此根据前面介绍的规则,事务B的修改对ReadView不可见。接下来,事务A根据指针指向的undo log查询上一版本的数据,得到A的余额为100。这样事务1就避免了脏读。
(2)不可重复读
时间点 | 事务1 | 事务2 |
T1 | 开始事务 | 开始事务 |
T2 | 快照读A账户,为100 | |
T3 | 修改A的金额,将金额从100改为200 | |
T4 | 提交事务 | |
T5 | 快照读A的金额,为100 |
|
当事务1在T2时刻读取A的金额前,会生成ReadView。此时事务2分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;一种是事务2还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务2的修改对ReadView都不可见。当事务1在T5时刻再次读取A的余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务2的修改不可见;因此事务1根据指针指向的undo log查询上一版本的数据,得到A的余额为100,从而避免了不可重复读。
(3)幻读
时间点 | 事务1 | 事务2 |
T1 | 开始事务 | 开始事务 |
T2 | 快照读A账户,为100 | |
T3 | 修改A的金额,将金额从100改为200 | |
T4 | 提交事务 | |
T5 | 快照读A的金额,为100 |
|
MVCC避免幻读的机制与避免不可重复读非常类似。
当事务A在T2时刻读取0<id<5的用户余额前,会生成ReadView。此时事务B分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;一种是事务B还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务B的修改对ReadView都不可见。
当事务A在T5时刻再次读取0<id<5的用户余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务B的修改不可见。因此对于新插入的数据lisi(id=2),事务A根据其指针指向的undo log查询上一版本的数据,发现该数据并不存在,从而避免了幻读。