# MySQL InnoDB 的 MVCC 实现机制
MVCC,全程:Multi Version Concurrency Control,即多版本并发控制
MVCC 在 InnoDB 中的实现是为了提高数据库并发能力,用更好的方式去处理读写冲突,做到即使有读写冲突时,也不需要加锁,非阻塞并发读。
# 什么是当前读和快照读?
- 当前读:像 select lock in share mode(共享锁),select for update、update、insert、delete(排他锁)这些操作都是一种当前读,顾名思义,就是它读取的记录是最新版本,读取时还要保证其它并发事务不能修改当前记录,会对读取的记录进行加锁。
可以通过 MySQL 来测试一下
- 快照读:像不加锁的 select 操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行化的,串行化下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即 MVCC,可以认为 MVCC 是行锁的一个变种,在很多情况下,避免了加锁操作,降低开销;既然是基于多版本,那么快照读可能读取到的数据不一定是最新版本,很可能是之前的历史版本。
MVCC 其实就是为了实现读写冲突不加锁,而这个读指的就是快照读,而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现
# 当前读、快照读和 MVCC 的关系
- MVCC 多版本并发控制是指:“维持一个数据的多个版本,使得读写操作没有冲突”
- 快照读本身也是一个抽象的概念,MVCC 模型在 MySQL 中的具体实现则是由四个隐式字段,undo 日志、Read View,事务 ID、回滚指针去完成的
# MVCC 能解决什么问题,好处是?
# 数据库并发场景
有三种,分别为:
- 读 - 读:不存在任何问题,也不需要并发控制
- 读 - 写:有线程安全问题,可能会造成事务隔离性问题,可能会遇到脏读、幻读、不可重复读
- 写 - 写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失、第二类更新丢失
# MVCC 带来的好处
MVCC 是用来解决读写冲突不加锁的并发控制,为事务分配单向增长的时间戳,为每一个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据快照。
在并发读写数据库时,可以做到读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题。
- MVCC + 悲观锁:MVCC 解决读写冲突,悲观锁解决写写冲突
- MVCC + 乐观锁:MVCC 解决读写冲突,乐观锁解决写写冲突
# MVCC 的实现原理
# 隐式字段
- DB_ROW_ID:6 byte,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引
- DB_TRX_ID:6 byte,最近修改(插入 / 修改)事务 ID,记录创建这条记录 / 最后一次修改该记录的事务 ID
- DB_ROLL_PTR:7 byte,回滚指针,指向这条记录的上一个版本(存储在 rollback segment 里)
- DELETE_BIT:1 byte,记录被更新或删除并不代表真的删除,而是删除 flag 变了
# undo log
InnoDB 把这些为了回滚而记录的东西称为 undo log。这里需要注意,由于查询操作(select)并不会修改任何记录,所以在查询操作时,并不会记录对应的 undo log,undo log 主要分为 3 种:
- insert undo log:插入一条记录时,至少把这条记录的主键记录下来,之后回滚时只需要把这个主键对应的记录删除就好了
- update undo log:修改一条记录时,至少要把修改这条记录的旧值都记录下来,之后回滚时再把这条记录更新为旧值就好了
- delete undo log:删除一条记录时,至少要把这条记录种的内容记录下来,之后回滚时再把这些内容组成的记录插入到表中就好了
- 删除操作都只是设置一下老记录的 DELETE_BIT,并不真正将过时的记录删掉
- 为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 DELETE_BIT 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个 read view(系统中最老活跃的事务的 read view);如果某个记录的 DELETE_BIT 为 true,并且 DB_TRX_ID 相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的
对 MVCC 有帮助的实际上是 update undo log,undo log 实际上就是存在 rollback segment 中旧记录链,它是执行流程如下:
比如有一个事务插入 person 表一条新记录,name 为 Jerry,age 为 24 岁,主键为 1,事务 ID 和回滚指针为 NULL
现在来一个事务对该记录的 name 做出修改,改成 Tom
- 在事务 1 修改该行(记录)数据时,数据库会先对该行加排他锁
- 然后把该行的数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本
- 拷贝完毕后,修改该行 Jerry 为 Tom,并且修改隐藏字段的事务 ID 为当前事务 1 的 ID,假设从 1 开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是它
- 事务提交后,释放锁
又来一个事务修改同一个记录,age 修改为 30
- 在事务 2 修改该行数据时,先为该行加锁
- 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行已经有 undo log 了,那么最新的旧记录作为链表的表头,插在该行记录的 undo log 最前面
- 修改该行 age 为 30,并修改隐藏字段的事务 ID 为当前事务 2 的 ID,也就是 2,回滚指针指向刚刚拷贝到 undo log 的副本记录
- 事务提交,释放锁
可以看到,不同的事务或相同的事务对同一行记录修改,会导致该记录的 undo log 形成一条版本线性表,undo log 的链首就是最新的旧记录,链尾就是最早的旧记录
# Read View(读视图)
什么是 read view,read view 就是事务进行快照读操作时产生的读视图(read view),在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID(当每个事务开启时,都会被分配一个 ID,ID 是递增的,所以最新的事务,ID 值越大)
read view 主要是用来做可见性判断的,即当我们某个事务执行快照读时,对改记录创建一个 read view 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,即可能是最新的数据,也可能是该行记录的 undo log 中某个版本的数据。
read view 遵循一个可见性算法,主要是将要修改的数据的最新记录中的 DB_TRX_ID 取出,与系统当前其它活跃事务的 ID 去比对,如果 DB_TRX_ID 跟 read view 的属性做了某些比较,不满足,那么就通过 DB_ROLL_PTR 回滚指针去取出 undo log 中的 DB_TRX_ID 再比较,即遍历链表(从最近的一次修改开始),直到找到满足条件的 DB_TRX_ID,那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本。
判断条件的部分源码如下
/** Check whether the changes by id are visible. | |
@param[in] id transaction id to check against the view | |
@param[in] name table name | |
@return whether the view sees the modifications of id. */ | |
bool changes_visible( | |
trx_id_t id, | |
const table_name_t& name) const | |
MY_ATTRIBUTE((warn_unused_result)) | |
{ | |
ut_ad(id > 0); | |
if (id < m_up_limit_id || id == m_creator_trx_id) { | |
return(true); | |
} | |
check_trx_id_sanity(id, name); | |
if (id >= m_low_limit_id) { | |
return(false); | |
} else if (m_ids.empty()) { | |
return(true); | |
} | |
const ids_t::value_type* p = m_ids.data(); | |
return(!std::binary_search(p, p + m_ids.size(), id)); | |
} |
- trx_list:未提交事务 ID 列表,用来维护 read view 生成时刻系统正活跃的事务 ID
- up_limit_id:记录 trx_list 列表中事务 ID 最小的 ID
- low_limit_id:read view 生成时刻系统尚未分配的下一个事务 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 是否在活跃事务之中,trx_list.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,维护在一个列表上,我们成为 trx_list
事务 1 | 事务 2 | 事务 3 | 事务 4 |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且提交 |
进行中 | 快照读 | 进行中 | |
… | … | … |
read view 不仅仅会通过一个列表 trx_list 来维护事务 2 执行快照读那一刻系统正在活跃的事务 ID,还会有两个属性 up_limit_id(记录 trx_list 中事务最小的 ID),low_limit_id(记录 trx_list 中下一个事务 ID,也就是目前已出现过的事务 ID 的最大值 + 1);所以例子中,up_limit_id 就是 1,low_limit_id 就是 4 + 1 = 5,trx_list 包含 1、3
在例子中,只有事务 4 修改过该行数据,并在事务 2 执行快照读之前,就提交了事务,所以当前该行数据的 undo log 如下图;事务 2 在快照读该行记录时,就会用该行记录的 DB_TRX_ID 去跟 up_limit_id、low_limit_id 和活跃事务列表进行比较,判断当前事务 2 能看到哪个版本的记录。
首先拿该记录的 DB_TRX_ID 与 up_limit_id 比较,4 不小于 up_limit_id (1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id (5),也不符合条件,最后判断 4 是否处于 trx_list 活跃事务列表中,发现 4 也不在当前活跃事务列表中,符合可见性,所以事务 4 修改后提交的最新结果对事务 2 快照读时是可见的,所以事务 2 读取的最新的数据记录是事务 4 所提交的版本。
也正在 read view 生成时机不同,从而造成 RC、RR 级别下快照读的结果不同
# MVCC 相关问题
# RR 是如何在 RC 级别的基础上解决不可重复读的?
当前读和快照读在 RR 级别下的区别:
表 1
事务 A | 事务 B |
---|---|
开启事务 | 开启事务 |
快照读(无影响)查询金额为 500 | 快照读查询金额为 500 |
更新金额为 400 | |
提交事务 | |
select 快照读 金额为 500 | |
select lock in share mode 当前读 金额为 400 |
表 2
事务 A | 事务 B |
---|---|
开启事务 | 开启事务 |
快照读(无影响)查询金额为 500 | |
更新金额为 400 | |
提交事务 | |
select 快照读 金额为 400 | |
select lock in share mode 当前读 金额为 400 |
在表 2 的顺序中,事务 B 在事务 A 提交后的快照读和当前读都是实时的新数据 400,这是为什么?
这里与上表的唯一区别仅仅是在表 1 的事务 B 在事务 A 修改金额前快照读过一次金额数据,而表 2 的事务 B 在事务 A 修改金额钱没有进行过快照读。
# RC、RR 级别下的 InnoDB 快照读有什么不同?
正是 read view 生成时机的不同,从而造成 RC、RR 级别下快照读的结果不同
我们可以测试一下,我们开启两个事务,一个事务查询并更新值,另一个事务只查询
- 在 RR 级别下的某个事务对某条记录的第一次快照会创建一个快照及 read view,将当前系统活跃的其它事务记录起来,此后在调用快照读的时候,还是使用同一个 read view,所以只要当前事务在其它事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个 read view,所以对之后的修改不可见
- 即 RR 级别下,快照读生成 read view 时,read view 会记录此时所有其它活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于 read view 创建的事务所做的修改均是可见的
- 而在 RC 级别下,事务中,每次快照都会新生成一个快照和 read view,这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因
总之在 RC 隔离级别下,是每个快照读都会生成并获取最新的 read view;而在 RR 隔离级别下,则是同一个事务中的第一个快照读才会创建 read view,之后的快照读获取的都是同一个 read view。
- 参考:https://blog.csdn.net/SnailMann/article/details/94724197