一,什么是 MVCC?
MVCC即Multi-Version Concurrency Control(多版本并发控制) 。是一种事务隔离级别无锁的一种实现方式,用于提高数据库的并发性能。做到即使有读写冲突时,也能以非阻塞的方式处理。它通过在读写操作期间保存多个数据版本,以提供并发事务间的隔离性,从而避免了传统的锁机制所带来的资源争用和阻塞问题。
注意:MVCC只在 READ COMMITTED (读已提交)和 REPEATABLE READ(可重复读) 两个隔离级别下工作。其他两个隔离级别和MVCC不兼容, 因为 READ UNCOMMITTED (读未提交)总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 SERIALIZABLE (串行化)则会对所有读取的行都加锁。
二. MVCC实现的关键知识点
2.1 当前读
当前读指的是读取记录的最新版本数据,读取的时候要保证当前记录不被其它事务进行修改,所以对读取的记录是需要加锁的。
> select ... for update; > select ... lock in share mode; > delete update insert语句 --本身就自带锁,所以这些操作在读取数据的时候也是当前读。
2.2 快照读
快照读指的是读取数据记录的历史版本,也就是事务开始时的数据快照。这种读取方式不会对记录加锁,因此可以提高数据库的并发性能。
Read Committed(读已提交):每一次
select,都会产生一个快照读。
Repeatable Read(可重复读):仅仅是开启事务后的第一个select语句才会产生一个快照读。之后的每次读取都是读的是这个快照。
Serializable(串行化):快照读变为当前读。
注意:生成快照读的时候,就会生成Read View;
2.3 隐式字段
对于InnoDB存储引擎,每一行记录都有两个隐藏列trx_id、roll_pointer,如果表中没有主键和非NULL唯一键时,则还会有第三个隐藏的主键列row_id。
| 列名 | 是否必须 | 描述 |
|---|---|---|
| row_id(行ID) | 否 | 单调递增的行ID,不是必需的,占用6个字节。 |
| trx_id(事务ID) | 是 | 记录操作该数据事务的事务ID |
| roll_pointer(回滚指针) | 是 | 这个隐藏列就相当于一个指针,指向这条记录的上一个版本(undo log)。 |
2.4 事务版本号
每一个事务每次开启前,都会从数据库获得一个自增长的事务ID,可以从事务ID判断事务的执行先后顺序。这就是事务版本号。
2.5 undo log
1.
undo log记录了每个操作的逆操作,实现事务的原子性和一致性。如果事务回滚,即可以通过undo log来还原之前数据。undo log可以这样认为,当delete一条记录时,undo log中会记录一条对应的insert记录,当update一条记录时,它记录一条对应相反的update记录。
2.undo log提供多版本控制(MVCC),在表数据被修改之前,会先把该数据拷贝到undo log里一份。当用户读取表中一行数据时,若该数据已经被其他事务占用,当前事务可以通过undo log读取之前的行版本信息,以此实现非锁定读取。
2.5.1 undo log 分类
insert undo log : 事务对数据进行insert产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
update undo log : 事务对数据进行delete和update操作时产生的undo log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被删除。
2.5.2 undo log用途
- 事务回滚时,保证原子性和一致性。
- 如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本(用于MVCC快照读)。
2.5.3 举例undo log作用
- 假设我们有一个事务A,它要更新一条记录,将字段value从10改为20。
- 在这个操作发生之前,系统会在undo log中记录这条记录的原始状态,即value为10。然后,事务A将value更新为20。
- 此时,如果有另一个事务B要读取这条记录,根据MVCC的规则,事务B应该看到的是事务A操作之前的数据,即value为10。
- 系统此时就会利用undo log中的信息,为事务B提供一个value为10的“旧版本”数据。
- 如果事务A在之后发生了错误,需要回滚,那么系统也会利用undo log中的信息,将value恢复为10。
2.5.4 undo log 版本链
多个事务并行操作某一行数据时,不同事务对该行数据的修改会产生多个版本,然后通过roll_pointer回滚指针,连成一个链表,这个链表就称为版本链。如下:
其实,通过版本链,我们就可以看出事务trx_id、roll_pointer和undo log它们之间的关系。我们再来小分析一下。
- 假设现在有一张
core_user表,表里面有一条数据 ,id为1,名字为孙权:
- 现在开启一个事务A: 对
core_user表执行update core_user set name ="曹操" where id=1,会进行如下流程操作
- 首先获得一个事务ID=100。
- 把
core_user表修改前的数据,拷贝到undo log。 - 修改
core_user表中,id=1的数据,名字改为曹操。 - 把修改后的数据事务Id=101改成当前事务版本号,并把
roll_pointer指向undo log数据地址。
2.6 Read View(读视图)
Read View就是事务进行 快照读 操作时产生的Read View(读视图)。不同的事务隔离级别产生快照读的时机是不同的,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID。Read View主要是根据可见性算法判断当前事务可见哪个版本的数据。
2.6.1 关于Read View与可见性算法的解释
Read View和可见性算法其实就是记录了SQL查询那个时刻数据库里提交和未提交所有事务的状态。
- 要实现
Read Committed(读已提交)隔离级别,事务里面每次执行查询操作Read View都会按照数据库当前状态重新生成Read View,也就是每次查询都是跟数据库里所有事务提交状态来比对数据是否可见,来实现每次查询的可重复读的效果。- 要实现
Repeated Read(可重复读)隔离级别,事务里面每次执行查询操作Read View都是第一次查询时生成的Read View,也就是都是以第一次查询时当时数据库里所有事务提交状态来比对数据是否可见,来实现每次查询的已提交的最新数据效果。总之在
Read Committed(读已提交)隔离级别下,是每个快照读都会生成并获取最新的Read View;而在Repeated Read(可重复读)隔离级别下,则是同一个事务中的第一个快照读才会创建Read View之后的快照读获取的都是同一个Read View。
2.6.2 组成Read View重要属性
m_ids:当前系统中那些活跃(事务开始但是未提交)的读写事务ID, 它数据结构为一个List。min_limit_id:表示在生成Read View时,当前系统中活跃(事务开始但是未提交)的读写事务中最小的事务ID值,即m_ids中的最小值。max_limit_id:表示生成Read View时,系统中应该分配给下一个事务的ID值,(当前已出现最大事务ID + 1)creator_trx_id: 创建当前Read View的事务ID
2.6.3 Read View是如何保证可见性判断的呢?
Read View 匹配条件规则如下:
trx_id(当前事务ID) ==creator_trx_id(创建Read View事务ID):可以访问该版本,说明数据是当前事务修改的。trx_id(当前事务ID) <min_limit_id(最小活跃事务ID):可以访问该版本,说明数据已经提提交了。trx_id(当前事务ID)>=max_limit_id(最大活跃事务ID):不可以访问该版本,该版本事务是在Read View生成后才开启的。min_limit_id(最小活跃事务ID) =<trx_id(当前事务ID) <max_limit_id(最大活跃事务ID)and (trx_id(当前事务ID) not in m_ids(活跃事务ID List)):可以访问该版本,说明数据已经提交。
三. MVCC原理分析
InnoDB实现MVCC是通过Read View + Undo Log版本链根据可见性算法共同完成的,Undo Log保存了历史快照,Read View可见性算法帮助判断当前版本的数据是否可见。
3.1 查询一条记录,基于MVCC的流程
- 获取该事务自己当前的事务版本号,即事务ID。
- 获取
Read View(读视图)。- 将该事务ID根据
Read View(读视图)的可见性规则去匹配。- 如果不符合
Read View(读视图)的可见性规则, 就需要去比对Undo log中历史版本。- 最后返回符合规则的数据。
四.MVCC在RC与RR不同隔离级别下的工作方式
MVCC在不同事务隔离级别下的Read View工作方式是不一样的,RR可以解决不可重复读问题,就是跟Read View工作方式有关。
- 在读已提交(RC)隔离级别下,同一个事务里面,每一次查询都会产生一个新的Read View,这样就可能造成同一个事务里前后读取数据可能不一致的问题(不可重复读并发问题)。
| begin | |
|---|---|
| select * from core_user where id =1 | 生成一个新的Read View |
| select * from core_user where id =1 | 生成一个新的Read View |
- 在可重复读(RR)隔离级别下,一个事务里只会获取一次Read View,都是副本共用的,从而保证每次查询的数据都是一样的。
| begin | |
|---|---|
| select * from core_user where id =1 | 生成一个Read View |
| select * from core_user where id =1 | 共用一个Read View副本 |
4.1 读已提交(RC)隔离级别不可重复读问题的分析历程
-
创建
core_user表,插入一条初始化数据,如下: -
隔离级别设置为读已提交(RC),事务A和事务B同时对
core_user表进行查询和修改操作。
事务A: select * fom core_user where id = 1;
事务B: update core_user set name = "曹操";
执行流程如下:
最后事务A查询到的结果是,name=曹操的记录,我们基于MVCC,来分析一下执行流程:
- A开启事务,首先得到一个事务ID为100。
- B开启事务,得到事务ID为101。
- 事务A生成一个
Read View,Read View对应的值如下。
| 变量 | 值 |
|---|---|
| m_ids | 100,101 |
| max_limit_id | 102 |
| min_limit_id | 100 |
| creator_trx_id | 100 |
然后回到版本链:开始从版本链中挑选可见的记录:
由图可以看出,最新版本的列name的内容是孙权,该版本的trx_id值为100。开始执行Read View可见性规进行校验,满足min_limit_id =< trx_id 或 creator_trx_id = trx_id校验规则。由此可得,trx_id = 100的这个记录,当前事务是可见的。所以查到是name为孙权的记录。
-
事务B进行修改操作,把名字改为曹操。把原数据拷贝到
Undo Log,然后对数据进行修改,标记事务ID和上一个数据版本在Undo Log的地址。 -
提交事务
-
事务A再次执行查询操作,新生成一个
Read View,Read View对应的值如下
| 变量 | 值 |
|---|---|
| m_ids | 100 |
| max_limit_id | 102 |
| min_limit_id | 100 |
| creator_trx_id | 100 |
然后再次回到版本链:从版本链中挑选可见的记录:
从图可得,最新版本的列name的内容是曹操,该版本的trx_id值为101。开始执行Read View可见性规则校验满足(min_limit_id =< trx_id < max_limit_id) and (trx_id not in m_ids),因此,trx_id=101这个记录,对于当前事务是可见的。所以SQL查询到的是name为曹操的记录。
综上所述,在读已提交(RC)隔离级别下,同一个事务里,两个相同的查询,读取同一条记录(id=1),却返回了不同的数据(第一次查出来是孙权,第二次查出来是曹操那条记录),因此RC隔离级别,存在不可重复读并发问题。
4.2 可重复读(RR)隔离级别,解决不可重复读问题的分析
回到上面的例子,然后执行第2个查询的时候:事务A再次执行查询操作,复用老的
Read View副本,Read View对应的值如下
| 变量 | 值 |
|---|---|
| m_ids | 100,101 |
| max_limit_id | 102 |
| min_limit_id | 100 |
| creator_trx_id | 100 |
然后再次回到版本链:从版本链中挑选可见的记录:
从图可得,最新版本的列name的内容是曹操,该版本的trx_id值为101。开始执行read view可见性规则校验不满足(min_limit_id =< trx_id < max_limit_id) and (trx_id not in m_ids),所以,trx_id=101这个记录,对于当前事务是不可见的。此时版本链roll_pointer跳到下一个版本,trx_id=100这个记录,再次校验且满足(min_limit_id =< trx_id < max_limit_id) and (trx_id not in m_ids)所以,trx_id=100这个记录,对于当前事务是可见的。即在可重复读(RR)隔离级别下,复用老的Read View副本,解决了不可重复读的问题。
五. MVCC是否解决了幻读问题呢?
5.1 RR级别下,一个快照读的例子,不存在幻读问题
由图可得,步骤2和步骤6查询结果集没有变化,看起来RR级别是已经解决幻读问题啦~
5.2 RR级别下,一个当前读的例子
假设现在有个account表,表中有4条数据,RR级别。
- 开启事务A,执行当前读,查询 id > 2 的所有记录。
- 再开启事务B,插入id=5的一条数据。
流程如下:
显然,事务B执行插入操作时,阻塞了~因为事务A在执行select ... lock in share mode(当前读)的时候,不仅在id = 3,4 这2条记录上加了锁,而且在id > 2 这个范围上也加了间隙锁。
因此,我们可以发现,RR隔离级别下,加锁的select, update, delete等语句,会使用间隙锁+ 临键锁,锁住索引记录之间的范围,避免范围间插入记录,以避免产生幻影行记录,那就是说RR隔离级别解决了幻读问题???
5.3 这种特殊场景,似乎有幻读问题
其实,上图事务A中,多加了update account set balance=200 where id=5;这步操作,同一个事务,相同的sql,查出的结果集不同了,这个结果,就符合了幻读的定义.
5.4 结论
使用MVCC机制解决了REPEATABLE READ(可重复读)隔离级别中部分幻读问题,但又没把全部幻读问题都解决。
MVCC解决了REPEATABLE READ(可重复读)隔离级别中,快照读的幻读问题。多次查询快照读时,因为REPEATABLE READ级别是复用Read View(读视图),所以没有幻读问题。- 但
MVCC解决不了REPEATABLE READ(可重复读)隔离级别中,如果遇到快照读和当前读(读取当前最新的数据)中间发生过新增操作,那么Read View不能复用,就出现了幻读的问题。