引言
mysql 在我们平常项目中是最常用的数据库,我们也经常对mysql数据库进行各种优化,比如索引、隔离级别,从而让不同的数据库参数满足不同的项目需求。了解mysql事务的同学都知道,不同的事务隔离级别会带来各种不同的问题,最严格的隔离级别就是串行化,但是这种隔离级别我们平常却是非常少用的,因为这对数据库的性能有非常大的影响。那么我们在采用默认事务隔离基本的时候,mysql是怎么解决并发问题的呢?这就是我们需要详细了解的myql的一种多版本并发控制机制。
基本概念
网上有很多的官方定义,其实在我们看来 就是一种数据的多版本控制机制,也就说一行数据在mysql 内部维护了多个不同的版本,用来解决 读-写并发带来的各种问题,根据不同的隔离级别,不同的事务读取到同一行数据的不同版本
当前读和快照读
在这里我们在定义两个概念,这两个名词分别对应InnoDB引擎中两种读操作
当前读:像 select lock in share mode ,select for update,这些操作都是当前读,也就是读取当前数据的最新记录。
快照读:和当前读对比,不加锁的读就是快照读,也就说我们读取可能是某行数据的一个快照版本,并不是最新数据,这是MVCC实现的核心,这种快照读可以在不加锁的情况下解决并发操作带来的各种影响。
组成元素
在innoDB中实现MVCC依靠三部分:三个隐藏字段、undo日志、Read View,每个组成部分的含义和作用我们会在下文继续介绍。
为什么要引入MVCC机制?
mysql并发场景:
读-读:无所谓
读-写:存在并发安全问题,可能造成事务隔离性问题,可能出现脏读、幻读、不可重复读
写-写:存在并发完全问题,可能出现两类数据更新丢失
第一类:事务A撤销时,把已经提交了的事务B的更新数据覆盖了
第二类:事务A覆盖了事务B已经提交的数据。
而MVCC机制就是在不加锁的情况下,解决读-写带来的问题,也就是为事务分配一个单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳有关系,读操作只读该事务开始前的数据库快照。所以MVCC机制,可以做到下面这样
在并发读写数据的时候,读写数据互相不阻塞,并且不会出现脏读、幻读、不可重复读等问题。但是不能解决更新丢失问题。
MVCC实现原理
隐藏字段
其实在我们平常定义的数据表中,除了 我们自己定义的业务字段意外,mysql 还内置了三个隐藏字段:
DB_TRX_ID
6byte,最近修改事务ID:记录创建这条记录/最后一次修改该记录的事务ID
DB_ROLL_PTR
7byte,回滚指针,指向这条记录的上一个版本
DB_ROW_ID
6byte,隐藏自增ID,如果表中没有指定主键,则innoDB引擎会默认使用该字段来创建聚集索引。
undo日志
insert undo log
代表事务在insert新记录时产生的undo log,只在事务回滚时需要,并且在事务提交后会被立即删除
update undo log
事务在进行update或delete时产生的undo log 日志,不仅在事务回滚时需要,在快照读时也需要,所以不能随便删除,只有再快速读或者事务回滚不涉及到该日志时,对应的日志才会被purge线程统一删除。因为删除也是更新一个dele_flag标志位,所以这里的更新和删除是一种操作。
update undo log 产生流程:
一、 比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL
二、 现在来了一个事务1对该记录的name做出了修改,改为Tom
在事务1修改该行(记录)数据时,数据库会先对该行加排他锁
然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它
事务提交后,释放锁
三、 又来了个事务2修改person表的同一个记录,将age修改为30岁
在事务2修改该行数据时,数据库也先为该行加锁
然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
事务提交,释放锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
Read View(读视图)
Read View 在我看来就是当时事务发生 快照读的时候 会对当前数据库生成一个快照,记录并维护当前所有的活跃的事务ID,数据库的事务ID是单边增加的,所以事务ID越大代表事务最新。
而之所以要维护一个这样的视图,主要是用来进行可见性判断的,也就说我们要根据这个视图来判断当前发生的快照读,是读取最新的数据还是读取undo log中某一个快照版本数据。
可见性算法
该算法的核心就是,将要被修改的数据的最新记录中的DB_TRX_ID(当前事务ID)取出来,与当前系统其它事务ID对比(这些事务ID就是在发生快照读的时候生成的Read View维护的),如果DB_TRX_ID根据Read View的属性进行比较以后,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出undo log日志中的DB_TRX_ID再次进行比较,一次遍历整个undo log日志版本链,直到找到满足条件的DB_TRX_ID,那么这个事务id就是当前事务能看到的快照数据。
比较过程
先定义三个全局变量
trx_list
事务id列表,其中存放的是当前系统中所有活跃的事务id
min_id
trx_list中最小值
max_id
Read View生成时刻系统中尚未分配的下一个事务ID,也就是目前当前系统最大事务ID+1
版本链比对规则:
1. 如果 DB_TRX_ID 落在绿色部分( DB_TRX_ID<min_id ),表示这个版本是已提交的事务生成的,这个数据是可见的;
2. 如果DB_TRX_ID 落在红色部分( DB_TRX_ID>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若 DB_TRX_ID 就是当前自己的事务是可见的);
3. 如果DB_TRX_ID 落在黄色部分(min_id <=DB_TRX_ID<= max_id),那就包括两种情况
a. 若 DB_TRX_ID 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若DB_TRX_ID 就是当前自己的事务是可见的);
b. 若 DB_TRX_ID不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。
举一个例子
整体的流程是怎么样的呢?我们可以模拟一下
1)当事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为trx_list
事务1 | 事务2 | 事务3 | 事务4 |
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且已提交 |
进行中 | 快照读 | 进行中 |
2)Read View不仅仅会通过一个列表trx_list来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性up_limit_id(记录trx_list列表中事务ID最小的ID),low_limit_id(记录trx_list列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,我更倾向于后者 >>>资料传送门 | 呵呵一笑百媚生的回答) ;所以在这里例子中up_limit_id就是1,low_limit_id就是4 + 1 = 5,trx_list集合的值是1,3,Read View如下图
3)我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行当前数据的undo log如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟up_limit_id,low_limit_id和活跃事务ID列表(trx_list)进行比较,判断当前事务2能看到该记录的版本是哪个。
4)所以先拿该记录DB_TRX_ID字段记录的事务ID 4去跟Read View的的up_limit_id比较,看4是否小于up_limit_id(1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id(5),也不符合条件,最后判断4是否处于trx_list中的活跃事务, 最后发现事务ID为4的事务不在当前活跃事务列表中, 符合可见性条件,所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
不同事务隔离级别下的Read View
先来一个列子,在可重复读 Repeatable read RR 隔离级别下
表1:
事务A | 事务B |
开启事务 | 开启事务 |
快照读(无影响)查询金额为500 | 快照读查询金额为500 |
更新金额为400 | |
提交事务 | |
select 快照读 金额为500 |
|
select lock in share mode当前读 金额为400 |
在上表的顺序下,事务B的在事务A提交修改后的快照读是旧版本数据,而当前读是实时新数据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,可重复读 级别下的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。