前言
本文将讲述InnoDB引擎下MVCC机制在可重复读和读已提交级别的一个实现;当前读如何解决幻读问题;MVCC机制到底能不能完全避免幻读问题?
什么是MVCC
MVCC全称是多版本并发控制 (Multi-Version Concurrency Control),只有在InnoDB引擎下存在。MVCC机制的作用其实就是避免同一个数据在不同事务之间的竞争,提高系统的并发性能。
它的特点如下:
- 允许多个版本(指的是不同版本的数据)同时存在。
- 不依赖锁机制,性能高。
- 只在读已提交和可重复读的事务隔离级别下工作。
为什么需要MVCC
并发性能:
在早期的数据库中,只有读读之间的操作才可以并发执行,读写,写读,写写操作都要阻塞,这样就会导致MySQL的并发性能极差。
采用了MVCC机制后,只有写写之间相互阻塞,其他三种操作都可以并行,这样就可以提高了MySQL的并发性能。
防止读写冲突:
MVCC通过为每个事务创建一个独立的数据版本(也就是ReadView),使得读操作不会受到其他事务写操作的干扰。这样可以避免脏读、不可重复读和幻读等问题。
InnoDB 对 MVCC 的实现
MVCC
的实现依赖于:隐藏字段、ReadView、undo log。在内部实现中,通过数据行的DB_TRX_ID
和ReadView
判断数据对当前事务的可见性,如果不可见,则会通过数据行的DB_ROLL_PTR
回滚指针找到版本链中的历史数据,重复刚刚的操作,在同一个事务当中,用户只能看到该事务创建ReadView之前其他事务已经提交的数据和自己修改的数据。
隐藏字段
在内部,InnoDB
存储引擎为每行数据添加了三个隐藏字段:
DB_TRX_ID(6字节)
:表示最后一次插入或更新该行的事务 id。此外,delete
操作在内部被视为更新,只不过会在记录头Record header
中的deleted_flag
字段将其标记为已删除DB_ROLL_PTR(7字节)
回滚指针,指向该行的undo log
。如果该行未被更新,则为空DB_ROW_ID(6字节)
:如果没有设置主键且该表没有唯一非空索引时,InnoDB
会使用该 id 来生成聚簇索引
ReadView
class ReadView {
/* ... */
private:
trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */
trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */
trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */
trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 清除 */
ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */
m_closed; /* 标记 Read View 是否 close */
}
Read View
主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”
主要有以下字段:
m_low_limit_id
:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见m_up_limit_id
:活跃事务列表m_ids
中最小的事务 ID,如果m_ids
为空,则m_up_limit_id
为m_low_limit_id
。小于这个 ID 的数据版本均可见m_ids
:Read View
创建时其他未提交的活跃事务 ID 列表。创建Read View
时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids
不包括当前事务自己和已提交的事务(正在内存中)m_creator_trx_id
:创建该Read View
的事务 ID
undo log
该日志主要有两个作用:
在 InnoDB
存储引擎中 undo log
分为两种:insert undo log
和 update undo log
:
insert undo log
:指在 insert
操作中产生的 undo log
。因为 insert
操作的记录只对事务本身可见,对其他事务不可见,并且也不需要提供支持给MVCC,故该 undo log
可以在事务提交后直接删除。不需要交给 purge
线程删除
insert
时的数据初始状态:
update undo log
:update
或 delete
操作中产生的 undo log
。该 undo log
需要提供支持给 MVCC
机制,因此不能在事务提交时就进行删除。提交时放入 undo log
链表,等待 purge线程
进行最后的删除
数据第一次被修改时:
数据第二次被修改时:
不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log
成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。
在可重复读隔离级别下如何实现
当开启一个事务时,第一次执行普通select语句前会创建一个ReadView,该快照中存储的数据,简单点说就是当前事务不可见和可见的事务id信息。
该事务之后的普通select语句在查找数据时,就会通过数据行的DB_TRX_ID
跟ReadView
中存储的不可见和可见的事务id就行比对,如果数据对当前事务不可见,则会通过数据行的DB_ROLL_PTR
回滚指针找到undolog中的历史数据重复刚刚的操作。
以上方的两张图为例:
select * from user
时,发现当前数据的DB_TRX_ID
为102,对我不可见,就会通过DB_ROLL_PTR
找到undolog中的历史版本数据继续比对DB_TRX_ID
对我是否可见。name
为菜花的这条数据。这就是在可重复读隔离级别中MVCC的实现。
在读已提交隔离级别下的实现
该级别下MVCC的实现与可重复读的唯一差异就在于创建ReadView的时机不同,该级别是在每次执行普通select
语句都会创建出ReadView,每次都会创建,所以就无法避免不可重复读问题。其他则没有差异
快照读,当前读
说到这了,你应该知道了什么是快照读,快照读就是读取数据时,如果数据不可见,或者因为其他事务的写操作数据被加锁了,不需要等待锁的释放,而是去读取数据的历史版本数据,这个就是快照读。
当前读指的是读取最新的数据。
间隙锁加记录锁解决当前读的幻读问题
我们前面说的selete
语句都有强调普通两个字,当如果我们执行的是select...for update/lock in share mode、insert、update、delete这样的语句时,是不会创建ReadView
的,这类语句属于当前读。
普通select
语句是属于快照读,而这类语句它属于当前读,当前读也就是会读取最新的数据,此时如果有事务在我读取的范围内做了一个插入操作那么就会出现幻读问题,针对当前读这个情况InnoDB会为我们读取的数据加上间隙锁跟记录锁来防止幻读。
MVCC机制到底能不能完全避免幻读问题?
答案是不能的。
我们假设有一张user表
id | name |
---|---|
1 | Linda |
4 | Jack |
5 | Smith |
select * from user
时,会创建一个ReadView,读取出了id为1、4、5三个人insert into user values(2,'Anne')
并且提交了事务。DB_ROLL_PTR
为null。select * from user
时,就读到了id为2的数据,此时就产生了幻读问题。还有第二种情况
select * from user
。insert into user values(2,'Anne')
,并提交。select * from user for update
当前读。这两种情况都是因为在创建出快照后,执行了当前读导致的幻读问题,所以说MVCC机制并不会完全避免幻读,又或者说是MVCC机制不能避免快照读后又执行当前读导致的幻读问题。
想要彻底解决幻读,可以在一开始事务时就执行select ... for update
这样的select语句,通过next-key-lock(记录和间隙锁的结合)这样的锁,拒绝其他事务对我读取的数据进行写操作,从而完全避免幻读问题。