前提概要
什么是MVCC?
MVCC(Multi-Version Concurrency Control,多版本并发控制)是数据库管理系统用于数据并发访问的技术。它允许读写并发进行,而不用传统的阻塞,加锁保护。
MVCC的核心思想就是记录多个版本,使读写能够看到不同的版本,从而避免读写之间相互影响。当然MVCC主要针对的就是读写。
MVCC能够解决什么问题?
在数据库并发的场景主要有以下三种:
- 读 - 写并发:存在问题,会存在事务隔离性问题。出现幻读、不可重复读、脏读等。
- 读 - 读并发:不存在什么问题。
- 写 - 写并发:存在问题,第一类更新丢失,第二类更新丢失。
其中写写存在的问题主要是:
1)会回滚已经事务,导致已经提交的事务数据丢失。
2)会覆盖数据。导致已经提交的事务数据被另一个事务覆盖。
所以针对写 - 写一般是通过排他锁(X锁)来维护。
而读 - 写是mysql使用频率最高的用法,单纯的加锁是无法满足高频使用。一种无所并发的机制多版本控制MVCC就被人们运用到读 - 写当中。
MVCC有什么好处?
MVCC主要针对的是读 - 写,进一步说是在MySQL InnoDB下主要是针对RR 和 RC这俩种机制。
MVCC的好处:
- 高并发的无所版本,能够大幅度提升读写的效率。
- 解决了脏读、幻读、不可重复读等问题。但是无法解决更新丢失问题。
小结:
MVCC是多版本控制版本,通过线性增长的时间戳记录修改过的历史版本,让读和写看到不同的版本,从而用来解决读 - 写效率低的问题。
MVCC的实现原理
MVCC的实现原理主要是依靠3个隐藏字段、Undo日志、ReadView实现。
三个隐藏字段
在数据记录中,每一份记录不仅包含表中的字段。还有三个隐藏的字段构成。
- DB_TRX_ID:6 byte 记录最近修改(insert/update)事务的ID(会自动为每一个事物生成一个线性增长的ID)。
- DB_ROLL_PTR : 7 byte 回滚指针 记录数据的上一个版本(也就是undo log中)。
- DB_ROW_ID : 6 byte 自增主键,如果表中没有创建主键,会默认生成一个隐藏主键。
实际上,还有一个隐藏字段,就是flag,标记字段是否被删除。
举例:创建一个表,并且插入一条消息。显示当前的结果。
补全隐藏字段:
id | name | DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID |
1 | 张三 | null | null | 1 |
这一条数据的事物ID我们是不知道的就设为null(当然你也可以默认设为1),由于也不知道隐藏自增主键默认设为1,这条数据是最新的记录没有历史版本,所以回滚指针也会null。
undo日志
undo日志:又叫做回滚日志,是MySQL在内存中开辟的一块缓冲区。用于保存历史版本的数据,必要的时候,会将数据刷新到磁盘中。
一、假如有一个事务往student表中插入一条新记录,id为1 ,name 为张三,事务的id为null,隐式主键生成为1,没有历史的版本,回滚指针为null。
二、现在事务1对表中的数据修改,将name 修改为李四
- 事务1修改一条记录时,会先增加排他锁。
- 将旧的记录拷贝到nudo日志上。
- 拷贝完成后,修改隐藏字段,将事务ID修改为当前ID为。回滚指针指向undo日志上刚拷贝的记录地址。
- 最后释放锁。
三、事务2,对表中的数据修改,将id修改为2。
事务对表中的数据修改时,会先加排他锁。
将数据拷贝到undo日志中。
拷贝完毕后,对当前的数据修改。将事务id修改为2 ,回滚指针指向刚拷贝的数据地址。最后将id修改为2。
释放锁。
当前事务或者不同事务对数据修改之后,就会在undo日志中形成一条历史版本,并且最新的数据会链接到历史的版本,这就是版本链。
undo日志的数据什么时候删除?
一般是在事务提交,并且其它事务没有关联到这条数据时,就能删除。
补充说明
- 所谓的数据回滚,实际上就是将版本链中的数据删除并且替换成版本链的起始点。
- 而保存回滚节点,实际上就是标记指定的版本链。
- 这种技术是基于写时拷贝,当涉及CURD时候,就会拷贝一份旧的数据记录到undo日志中。
insert和delete是如何保存在undo日志呢?
insert的对立面操作是delete,delete的对立面操作是insert。
这俩种操作会用到flag删除标记位。
- 删除并没有实际的把数据删掉,而是通过写时候拷贝在undo日志中保存一份记录,然后将数据中的flag设位为1,表示删除字段。如果数据回滚,那么它的标记会就会回到0。
- 插入应该是没有历史版本的,但是为了回滚数据,也会写时拷贝一份插入数据到undo日志中,并且将flag标记位设位1。一旦发生回滚,就会把拷贝flag为1的数据,属于删除标记,不会显示。
快照读vs当前读
上述的实验证明:数据的增加、删除、更新都是会产生版本链的。都是要加锁保护。
但是select读取呢?需要加锁吗??不需要!
因为在undo日志中维护了历史和最近的版本。
什么是当前读?读取到的最新的数据,增加、删除、更新后立马就能读取到
快照读:读取的不是最新的。换句话而言,读取到版本链中的数据就是快照读。
我们不难发现,像增加、删除、更新等都是涉及当前读,是需要加锁保护的。如果读取的是当前最新的消息,就需要串行化。效率是很低的。
但是对于快照读,因为维护了历史版本,是不需要加锁保护的。
换言之 ,快照读是可以进行并发读和写。
Read View读视图
什么决定了select是当前读还是快照读?事务的隔离级别
事务总是分为执行前 执行中(begin -> CURD -> commit) 执行后
先来的事务,先开启的时候,和后来的事务,它们看到的数据有可能是不同的。
比如事务先进行CURD并且提交后,这时候来的事务是不是应该看到。
那应该如何保证事务应该看到什么,不应该看到什么?就是由read view决定的。
什么是Read View?
read view是事务在进行快照读的时候,会生成的一个读视图。
本质上read view是一个类对象,类中会进行当前活跃的事务id与自身事务id。
read view也是事务可见性判断标准。
read view管理的成员
m_ids; //一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务ID最小的ID(没有写错)
low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的
最大值+1(也没有写错)
creator_trx_id //创建该ReadView的事务ID
详细介绍
m_ids:活跃的事务集合
up_limit_id:活跃事务集中最小的事务id
low_limit_id:read view系统尚未分配的事务id编号
creator_trx_id:当前事务的活跃ID
read view判断可见性的规则
来自@呵呵一笑百媚生一张源码图 。
事务的id可以划分为3个部分
比最小事务集合中id还要小的事务、正在执行的事务、 尚未分配的事务id
- 如果当前id比最小的up_limit_id还要小,说明在进行快照的时候,这份事务已经执行完毕,那肯定是可以看到的。
- 如果当前id比尚未分配的事务 low_limit_id还要大,说明在快照读的时候,当前事务还没开始(即在快照读之后,立马进行的插入更新删除),是不能被看到的。
- 如果事务id在[up_limit_id,low_limit_id)之前,就要继续比较。判断是否存在于正在活跃的事务id中,如果存在,就说明我已经进行快照读了,你是和我同时执行的事务,还没有commit不应该被看到。如果不存在于活跃id中,那就就说明快照读的时候,已经提交了,可以被看到。
注意:
- 处于正在活跃的事务id,不一定是连续的,应该如果该事务已经提交,就不是活跃事务。
- 如果正在活跃的事务id是3,4,7,8,9。尚未分配的事务id是11
- 则说明:事务 1 2 是已经commit 的事务id ,事务5 6 10也是已经commit都应该被看到。反之,其它的事务都不应该被看到。
MVCC整体流程
模拟一下快照读下的MVCC实现过程,有利于更深入的理解。
1)假设当前有一条记录
name | age | DB_TRX_ID(创建该记录的事 务ID) | DB_ROW_ID(隐式 主键) | DB_ROLL_PTR(回滚 指针) |
张三 | 28 | null | 1 | null |
2)现有有四个事务分别对这张表数据进行CURD,这些事务在同一时刻启动。
事务1 [id=1] | 事务2 [id=2] | 事务3 [id=3] | 事务4 [id=4] |
事务开始 | 事务开始 | 事务开始 | 事务开始 |
... | ... | ... | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
... | ... | ... |
事务4将张三修改为李四,并且提交了事务。(这个时候,修改的操作会生成版本链,尽管提交事务,但是有其它事务关联,也不会删除版本链)。
事务2进行快照读,数据库会生成read view 读视图
那么read view的成员各为什么呢?
- 活跃的事务集 1 2 3 (因为在快照读之前,事务4已经提交,不属于活跃的事务)
- up_limit_id :活跃事务集中最小的事务id 应该是 1
- low_limit_id:事务中系统尚未分配的最小事务id 5 (4已经被创建过,尽管不在活跃集中)。
- 当前的事务id :2
这时候的版本链:
应该记录历史name为数据记录。
视图read view对象就会拿着版本链的数据和自己记录的id比较。
比较规则:
- DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
- DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
- m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
当前数据事务id为4,比较up_limit 不小于。所以事务4的启动没有早于快照读的事务,进行下一步判断。
比较low_limit 不为尚未分配的事务。说明在快照读的时候,事务4已经存在。
比较是否在活跃集中,活跃集为 1 2 3,不在。说明快照读的时候,事务4 已经提交了记录。
所以这一条记录是应该被快照读所看到的。
而事务4 提交的数据,站在全局的角度上也是最新的版本,事务2 能读到最新的版本。
RR 与 RC的本质区别
做一个实对比RR和RC的差别,用的知识(当前读的操作select lock in share mode)
事务A | 事务B |
begin | begin |
select * from 快照读 name=张三 | select* from 快照读 name=张三 |
更新name=李四 | |
提交事务 | |
select* from 快照读 name=李四 | |
select lock in share mode(当前读)name=李四 |
这里符合我们的预期,因为是RR级别,所以我们认为只有在事务B也提交之后,才能读到最新的消息。
事务A | 事务B |
begin | begin |
select * from 快照读 name=张三 | |
更新name=李四 | |
提交事务 | |
select* from 快照读 name=张三 | |
select lock in share mode(当前读)name=李四 |
为什么事务B在没有提交就能读取到最新的消息呢?相信你如果认真看了MVCC的整体流程一定不会有疑惑的,因为read view的判断决定了能看到事务A提交的结果。
快照读非常依赖读取的时机!即首次出现的数据,决定了后续快照读的内容。
本质区别
在Read View下生成的时机不同,会导致快照读的内容不同。
RR下,首次进行快照读的时候,会生成一份read view对象,MySQL会保存起来,用于后续快照读的比较判断。
所以在后续的时间段内,都是同一份read view。
而RC下,每一次快照读都会生成一份read view对象,并且覆盖成为最新的。MySQL的比较就会拿着最新的read view与版本链比较。
正因为RC下每一次快照读都会生成一个read view 就会导致出现不可重复读的问题。
所以本质的区别就是快照读是否为最新的!