MVCC(Multi Version Concurrency Control)即多版本并发控制,MySQL 中InnoDB中实现了事务(多版本并发控制MVCC+锁), 通过MVCC解决隔离性问题。
具体来说:
隔离性:MySQL 中使用两种锁机制,分别是行级锁和表级锁。在事务中,当一个事务对某个数据进行修改时,MySQL 会对这个数据进行加锁,其他事务就不能对这个数据进行修改,从而保证隔离性。
原子性:MySQL 还会将事务的操作记录到日志中,如果事务执行失败,可以通过日志进行回滚,保证了原子性;
持久性:通过日志进行恢复,保证了持久性。
首先明白两个概念
- 当前读:读取的是记录最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。像
select lock in share mode(共享锁)
,select for update ;
update
,insert
,delete
(排他锁)这些操作都是一种当前读。 - 快照读:像
不加锁的select操作
就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;
那么 当前读、快照读和MVCC的关系?
- MVCC多版本并发控制指的是 “维持一个数据的多个版本,使得读写操作没有冲突” 这么一个概念,仅仅是一个理想概念
- 快照读就是MySQL为我们实现MVCC理想模型的其中一个具体非阻塞读功能。而相对而言,当前读就是悲观锁的具体功能实现
- 要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。MVCC模型在MySQL中的具体实现则是由 4个隐式字段,undo日志 ,Read View 等去完成的,具体可以看下面的MVCC实现原理
在 MVCC 中事务的所有写操作(INSERT、UPDATE、DELETE)为数据行新增一个最新的版本快照,而读操作是去读旧版本的快照,也就是说,读操作和写操作是分离的,二者之间没有依赖、互斥关系
MVCC是由MySQL数据库InnoDB存储引擎实现的,并非是由MySQL本身实现的,不同的存储引擎,对MVCC都有不同的实现标准
MVCC 解决了什么问题,有什么好处?
数据库的并发场景有三种, 分别为:
- 读-读:不存在任何问题,也不需要并发控制
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
MVCC是为了解决数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案。在数据库中,因为MVCC可以:
- MVCC + 悲观锁 MVCC解决读写冲突,悲观锁解决写写冲突
- MVCC + 乐观锁 MVCC解决读写冲突,乐观锁解决写写冲突
这种组合的方式就可以最大程度的提高数据库并发性能,并解决读写冲突,和写写冲突导致的问题
实现原理
MVCC模型在MySQL中的具体实现则是由 4个隐式字段,undo日志 ,Read View 等去完成的,每行记录除了自定义的字段外,还有数据库隐式定义的四个字段
- DB_ROW_ID 6byte, 隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以
DB_ROW_ID
产生一个聚簇索引 - DB_TRX_ID 6byte, 最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
- DB_ROLL_PTR 7byte, 回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
- DELETED_BIT 1byte, 记录被更新或删除并不代表真的删除,而是删除flag变了

这四个字段是记录在 InnoDB 存储引擎的聚簇索引中的数据行中。InnoDB 存储引擎使用聚簇索引来组织数据,并将数据行按照聚簇索引的顺序存储在磁盘上。在聚簇索引中,每个数据行包含了完整的记录信息,包括字段和相关的事务信息
需要明确两点
- 读操作对数据库也是一项事务,但它的事务id不是 根据
max_trx_id
生成的,而是根据trx_id
地址计算而来的 - 如果事务B对于事务A来说是不可见的,就需要顺着修改记录的版本链,从回滚指针开始往前遍历,直到找到第一个对于事务A来说是可见的事务ID,或者遍历完版本链也未找到(表示这条记录对事务A不可见)
undo log
undo log
在 一条sql语句 中会在更新前产生一条 undo log
记录,作用就是保护事务失败之后回滚到历史版本,它一共有三种类型
- Insert undo log :插入一条记录时,至少把这条记录的主键值记下来,回滚的时候只需要把这个主键值对应的记录删掉
- Update undo log:修改一条记录时,至少把修改这条记录前的旧值都记录下来,回滚时再把这条记录更新为旧值
- Delete undo log:删除一条记录时,至少把这条记录中的内容都记下来,回滚时把由这些内容组成的记录插入到表中
- 删除操作都只是设置一下老记录的
DELETED_BIT
,并不真正将过时的记录删除。 - 为了节省磁盘空间,InnoDB有专门的purge线程来清理
DELETED_BIT = true
的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view
(这个read view相当于系统中最老活跃事务的read view);如果某个记录的DELETED_BIT为true,并且DB_TRX_ID
相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
- 删除操作都只是设置一下老记录的
查询操作(
SELECT
) 并不会修改任何用户记录,所以并不会记录相应的undo log
执行流程:
-
一个有个事务插入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修改同一条记录,将age改为30
-
在事务2修改该行数据时,数据库也先为该行加锁
-
然后把该行数据拷贝到
undo log
中,作为旧记录,发现该行记录已经有undo log
了,那么最新的旧数据作为链表的表头,插在该行记录的undo log
最前面 -
修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到
undo log
的副本记录 -
事务提交,释放锁
-
不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log
成为一条记录版本线性表,即链表,undo log
的链首就是最新的旧记录,链尾就是最早的旧记录(向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,该undo log的节点可能是会purge线程清除掉)
Read View
Read View
就是事务进行快照读操作的时候生产的,每个事务都有自己的 Read View(读视图)。每个事务的读视图是独立的,用于确保事务在读取数据时能够看到一致的数据快照。虽然每个事务有自己的读视图,但是在相同的事务隔离级别下,所有事务的读视图都是基于相同的全局一致性视图(Global Consistent View)。全局一致性视图是当前系统中所有已提交的事务形成的一个一致的数据快照
在
读已提交
和可重复读
的隔离级别中,事务在启动的时候会创建一个读视图(Read View),用它来记录当前系统的活跃事务信息,通过读视图来进行本事务之间的可见性判断。
其中有四个重要的字段:
-
creator_trx_id:表示生成读视图的事务的事务ID
-
m_ids:表示在生成读视图时,当前系统中活跃着的事务ID列表(未提交的事务ID列表)
-
min_trx_id:表示在生成读视图时,当前系统中活跃着的最小事务ID
-
max_trx_id:表示在生成读视图时,系统应该分配给下一个事务的事务ID(事务 ID 是累计递增分配的,所以后面分配的事务ID一定会比前面的大)
需要注意下一个事务ID的值,并不是事务ID列表中的最大值+1,而是当前系统中已存在过的事务的最大值+1。例如当前数据库中活跃的事务有(1,2),此时事务2提交,同时又开启了新事务,在生成的读视图中,下一个事务ID的值为3
通过将版本链与读视图两者结合起来,来进行并发事务间可见性的判断,判断规则如下(假设现在要判断事务A是否可以访问到事务B的修改记录)

- 若 事务B的
当前事务ID DB_TRX_ID
小于 事务A中最小事务ID min_trx_id
,代表事务B是在事务A生成读视图之前就已经提交了的,所以事务B对于事务A来说是可见的。 - 若事务B的
当前事务ID DB_TRX_ID
大于或等于 事务A下一个事务ID
,代表事务B是在事务A生成读视图之后才开启,所以事务B对于事务A来说是不可见的。 - 若事务B的
当前事务ID DB_TRX_ID
在事务A的最小事务ID
和下一个事务ID
之间**(左闭右开,[最小事务ID, 下一个事务ID))**,需要分两种情况讨论:- 若事务B的
当前事务ID
在事务A的事务ID列表
中,代表创建事务A时事务B还是活跃的,未提交,所以事务B对于事务A来说是不可见的。 - 若事务B的
当前事务ID
不在事务A的事务ID列表
中,代表创建事务A时事务B已经提交,所以事务B对于事务A来说是可见的。
- 若事务B的
读视图的创建时机,事务在启动时会创建一个读视图(Read View),而开启一个事务有两种方式,通过这两种方式开启事务,创建读视图的时机也是不同的:
- 如果是以
begin/start transaction
方式开启事务,读视图会在执行第一个快照读语句时创建 - 如果以
start transaction with consistent snapshot
方式开启事务,同时便会创建读视图
整体流程
- 当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为
m_ids
事务1 | 事务2 | 事务3 | 事务4 |
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
… | … | … |
-
Read View
不仅仅会通过一个列表m_ids
来维护事务2执行快照读那刻系统正活跃的事务ID(事务4已经提交,所以不活跃)min_trx_id
= 1,max_trx_id
= 4 + 1 = 5m_ids
集合的值是1,3
-
事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,当前该行当前数据的undo log如下图所示;
-
事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟
min_trx_id
,max_trx_id
和活跃事务ID列表(m_ids
)进行比较,判断当前事务2能看到该记录的版本是哪个DB_TRX_ID
= 4 >min_trx_id
= 1 且DB_TRX_ID
= 4 <min_trx_id
= 5,也就是可能在范围内,可能是活跃的,也可能不活跃DB_TRX_ID
= 4 不在m_ids
= [1, 3] 中,所以符合可见性条件
-
所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
也正是Read View生成时机的不同,从而造成RC(read commited),RR(Repeatable read)级别下快照读的结果的不同
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 |
表1与表2的区别是:
- 表1的事务B在事务A修改金额前快照读过一次金额数据
- 表2的事务B在事务A修改金额前没有进行过快照读
事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力
删除和更新是一样的,如果事务B的快照读是在事务A操作之后进行的,事务B的快照读也是能读取到最新的数据的
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。