MySQL之MVCC

事务隔离级别

对于一个MySQL服务,可以有多个客户端与其建立连接,并向其发送SQL语句,一条SQL语句可能是一个事务的一部分,MySQL可以同时处理客户端的多个事务。

一次事务对应着一次完成的状态转换,事务执行完毕后,需要保证数据符合客观逻辑上的一致性,数据库自身提供了一些约束保证一致性,例如主键、唯一索引、外键、不允许为NULL值等帮助用户解决部分数据上的一致性要求,

例如唯一索引是不可以重复的,判断是否重复由数据库管理,如果重复则业务抛出错误,并回滚事务,避免出现相同的值导致错乱,

但是数据库仅仅提供了一些通用性的规则,实际业务场景中,还有很多一致性规则需要业务代码来保证,因为数据库无法智能的判定你的业务数据是否是正确一致的,

MySQL通过redo、undo日志保证了数据的原子性,因此业务代码只需要将符合一致性的数据放到一个事务中,事务完成后,必然由一个一致性状态转移到下一个一致性状态(如果失败则回滚到最初的一致性状态)。

举个业务的状态转换例子

例如A向B转账5元,那么大概需要经过以下几步:

  1. 将A的余额读出到变量AA中,简称 read(AA)
  2. 将A的余额减去5,简称 AA = AA - 5
  3. 将A的余额写回磁盘,简称 write(AA)
  4. 将B的余额读出到变量BB中,简称 read(BB)
  5. 将B的余额加上5,简称 BB = BB + 5
  6. 将B的余额写回磁盘,简称 write(BB)

在转账业务中,我们的一致性规则就是,完成转账的情况下,并保证两个账户的总和加起来是不变的,将以上操作放到一个事务中执行,在原子性的保护下,这些操作结束后肯定能满足一致性需求,

如果事务是以单个的形式一个接一个执行,那么一个事务开始时,面对的就是上一个事务结束时的一致性状态,这样循环往复是没什么问题的,

但是,如果多个事务并行,情况会变的复杂,如果多个事务并行时,访问的数据并不相同,例如A向B转账,C向D转账,那么他们之间的数据是错开的,一致性还是可以保证,

但是如果A向B同时发起两笔转账,也就是两个事务,此时两个事务并行,就会引发问题了,我们将两个事务分别命名为T1和T2,假设A账户里初始化有11元,B有2元,他们的账户余额总和是13元,

通过上图可以发现,两个事务一起运行,最终B的余额变成了12元,A的余额还是6元,此时总和变成了18,比之前的总和多出了5元,

这种情况保证正确性,可以强制让事务按照顺序一个一个执行,或者最终执行的效果和单独执行一样,即让多个事务执行互不干涉,也称为事务隔离性。

实现隔离性最直接的手段就是让系统同一时刻只允许一个事务执行,这种方式称为串行执行,这种方式会严重降低系统的吞吐量和资源利用率,并增加其他事务的等待时间,

可以进一步优化为,如果多个事务访问不同的数据,可以同时并行,一旦需要访问相同的数据的时候,就变为串行执行,这种执行方式称为可串行化执行,

需要使用可串行化执行的场景有三种,写-读、读-写、写-写,因为读-读并不会影响结果,可串行化执行通过锁来实现事务的排队等待。

事务并发执行的一致性问题

可串行化执行会导致性能上有一些损失,但是可以通过控制的事物隔离级别,在不同的场景下应用不同的隔离级别来提升性能,

不同的隔离级别可以解决事务并发执行下的不同的一致性问题,一致性问题共有以下几种:

1. 脏写

  1. 事务A先对X值进行修改,此时并未提交
  2. 事务B读取到A对X修改后的值,进行进一步的修改并提交
  3. 事务A回滚

上面三个步骤就发生了脏写现象,因为A的事务并未提交,B读取到并做了修改。

脏写对一致性的影响

假设我们让两个事务A和B同时对X和Y进行修改,并且对一致性的要求是事务结束时X和Y的值是相同的,步骤如下:

  1. 事务A将X改为1
  2. 事务B将X改为2,将Y改为2并提交
  3. 事务A将Y改为1,并提交

此时最终的状态是X=2,Y=1,这并不符合我们对一致性的要求。

脏写对原子性和持久性的影响

假设X、Y初始值为0:

  1. 事务A将X改为1
  2. 事务B将X、Y改为2,并提交
  3. 事务A回滚

此时事务A回滚会带来两个问题:

一是回滚的话,就要将X的值回滚为0,但是当前X的值已经被B改为2并提交了,如果要将事务A回滚,

那么事务B的部分提交也要被回滚,也就是X回滚,Y不回滚,这就不符合事务的原子性了,因为一个事务要么全部提交,要么全部回滚,不存在部分回滚的,这就是脏写对原子性的影响。

另一个是事务B已经提交,它对数据的修改应该具备持久性,如果事务A回滚为了保证原子性将事务B的修改也都回滚,那么一个未提交的事务将已提交的事务造成了持久性影响,这也是不合理的。

2. 脏读

脏读即事务A对X做了修改,事务B读取到了事务A对X的修改,之后事务A又回滚了,但是事务B提交了,因此事务B读取到的X是一个无效事务所作处理的值。

脏读现象可能也会引起一致性的问题,例如事务A和B要访问X和Y两个数据,一致性的需求是X和Y值始终相同,X、Y初始值是0:

  1. 事务A将X改为1
  2. 事务B读取A改的X=1,然后读取到Y=0,并提交
  3. 事务A将Y改为1,并提交

虽然最后数据库里的X、Y值都是1,但是B读到的一个是1,一个是0,因此不符合一致性要求。

3. 不可重复读

不可重复读指的是事务A读取到X的值,此时并未提交,然后事务B修改了X的值,之后事务A再次读取X的值,读到了修改后的值,和第一次读到的不一样,不可重复读也被称为模糊读(Fuzzy Read)。

不可重复读也会引发一致性问题,X、Y初始化值都是0:

  1. 事务A读取X、Y值为0
  2. 事务B修改X、Y值为1,并提交
  3. 事务A读取到Y值为1

因为事务B已经提交了,所以并不算是脏读,但是事务A之后读取的Y和之前不一样,也是不符合一致性状态的要求。

4. 幻读

幻读指的是事务A先根据一些搜索条件得到一个结果集,事务B写入一些符合搜索条件的记录并提交,之后事务A再次搜索,得到了和之前不一样的结果集。

幻读也会引发一致性问题,例如符合X > 0的数据开始有3条:

  1. 事务A搜索X > 0,得到3条记录
  2. 事务B插入X=4、5、6三条数据并提交
  3. 事务A再次搜索X > 0,得到6条

事务A两次读到结果集数量不一样,不符合一致性的要求。

SQL标准中的4种隔离级别

事务并发执行的一致性问题的严重性排序如下:

脏写 > 脏读 > 不可重复读 > 幻读

SQL标准中定义了4个隔离级别,不同的隔离级别通过损失一定的隔离性来提升一定的性能:

  1. 未提交读 (read uncommited):会发生脏读、不可重复读、幻读的现象
  2. 已提交读 (read commited):会发生不可重复读、幻读的现象
  3. 可重复读 (repeatable read):会发生幻读的现象
  4. 可串行化 (serializable):以上现象都不会发生

可以看到,上面4种都没有提交脏写,意味着不管什么隔离级别,都不会发生脏写的情况。

标题脏读不可重复读幻读
read uncommited可能可能可能
read commited不可能可能可能
repeatable read不可能不可能可能
serializable不可能不可能不可能

MySQL支持的4种隔离级别

不同的数据库对SQL隔离级别的标准支持不一样,Oracle仅支持 read commited 和 serializable。

mysql支持4种,但是 repeatable read 与SQL标准有些出入,mysql会在一定程度上禁止幻读的发生。

mysql默认的隔离级别是 repeatable read 可重复读。

修改事务隔离级别

通过下面的语句可修改事务隔离级别:

set [global | session] transaction isolation Level level;

level的取值如下:

  1. repeatable read
  2. read committed
  3. read uncommitted
  4. serializable

set后面的关键字可以是global或session,两种设置会对不同范围的事务产生不同的影响:

global

  • 在全局范围内产生影响
  • 只对执行完该语句后的事务生效,当前已创建的事物无效

session

set session transaction isolation Level serializable;

  • 对当前会话所有的后续事务有效
  • 语句执行不影响正在执行的事务,仅对后续事务有效

set transaction isolation Level serializable;

  • 仅对当前会话下一个开启的事务生效,下一个事务执行完毕后,恢复到之前的隔离级别
  • 该语句不能在正在运行的事务中执行,否则会报错

启动时设置隔离级别

在MySQL启动项中增加 --transaction_isolation=level,即可修改默认的隔离级别。

通过下面几种方式查看事务隔离级别:

  1. show variables like 'transaction_isolation'
  2. select @@transaction_isolation

另一种修改隔离级别的方式

上面的 set transaction 最终修改的就是transaction_isolation系统变量的值,系统变量一般只有global和session两个作用范围,

但是 transaction_isolation 有三个(全局,当前会话,下一个事务),通过修改transaction_isolation的语法如下:

语法作用范围
set global transaction_isolation = level全局
set @@global.transaction_isolation = level全局
set session transaction_isolation = level会话
set @@session.transaction_isolation = level会话
set transaction_isolation = level会话
set @@transaction_isolation = level下一个事务

transaction_isolation是MySQL5.7.20后引入的,之前的叫做tx_isolation。

PS,需要注意的地方:

set @@transaction_isolation = level 这种语法,因为是在下一个事务中生效,但是你会在下一个事务中查询当前隔离级别并非设置的隔离级别,如下图:

你会发现有点奇怪,这是因为这句话的意思是下一个事务的隔离级别是啥,而不是把变量给改掉,所以你要去innodb_trx表中查看当前事务的隔离级别:

MVCC

MVCC用于解决不可重复读,幻读(一定程度上)的问题。

版本链

Innodb表中的行记录中有两个必要的隐藏列:

  1. trx_id: 一个事务每次对聚簇索引记录进行改动,都会将当前事务id赋值给该隐藏列

  2. roll_pointer: 每次修改聚簇索引记录时,将旧值写入到undo日志中,这个隐藏列用于指向该undo日志,通过这个隐藏列就可以找到上一个版本的记录

    roll_pointer占用7字节,第一个bit表示其undo日志的类型,如果该值为1,表示其指向的undo日志是 trx_undo_insert 类型的,即insert undo日志(insert类型实际上只有在事务回滚时发生作用)。

每条undo日志也有一个roll_pointer属性,insert是没有的,因为insert记录没有更早的版本,通过这个属性,可以形成一个版本链,

每次更新该记录,都会将旧值生成一个undo日志,undo日志的roll_pointer指向更旧的undo日志,以此形成一个链表,版本链的头节点就是最新的记录值,并且每个版本还记录生成该版本时的事务id,

通过版本链可以控制多事务并发访问相同记录时的行为,这种机制被称为 多版本并发控制(Multi-Version Concurrency Control, MVCC)

在update的undo日志中,只会记录被更新的列值和索引,如果undo日志中并没有我们要查询的列,那么就会去undo版本的上一个版本去找,直到找到,如果到最后也没找到,说明从头到尾没有改过,直接用聚簇索引中的那个就可以。

ReadView

对于read uncommited,是读未提交,所以直接读取记录的最新版本即可,而serializable则是串行化执行,直接用锁排队等待即可。

核心要解决读已提交和可重复读两个隔离级别的问题,这两个级别都必须读取事务已提交记录,如果一个事务修改了该记录,但是没有提交,那么是不能读到的,

所以要解决的问题是:需要判断版本链中的哪个版本是当前事务可见的,Innodb提供了ReadView(一致性视图)技术来解决这个问题,ReadView中包含了如下4个重要内容:

  1. m_ids:生成ReadView时,当前系统中活跃的读写事务的id列表
  2. min_trx_id:生成ReadView时,当前系统中活跃的读写事务中最小的事务id,即m_ids中最小的那个
  3. max_trx_id:生成ReadView时,系统应该分配给下一个事务的id,m_ids中最大的值+1
  4. creator_trx_id:生成该ReadView的事物id(一个事务仅在执行增删改的时候才分配事务id,否则是0)

通过ReadView可以很方便的实现版本是否可见,步骤如下:

  1. 如果被访问的记录版本的trx_id值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问
  2. 如果被访问的记录版本的trx_id小于ReadView中的min_trx_id值,说明该版本在生成ReadView前就已经提交,也是可以被访问的
  3. 如果被访问的记录版本的trx_id大于等于ReadView中的max_trx_id值,说明该版本在ReadView生成之后才出现,不可被访问
  4. 如果被访问的记录版本的trx_id在ReadView中的min_trx_id和max_trx_id之间,那么判断其是否在m_ids列表中,如果在,说明该版本在ReadView生成时,处于活跃状态,不可访问,如果不在说明生成ReadView时,该事务已经提交,可以访问。

根据上面的步骤,在版本链中依次找到符合条件的记录,如果最后还是没有找到,说明该记录不符合条件,即不用加入结果集中。

ReadView开启时机

读已提交和可重复读都是通过ReadView实现的,他们之间最大的区别是生成ReadView的时机不同,所以得到的效果不同。

  1. read committed在每次执行select查询时生成一个ReadView,然后根据上面所说的步骤去找,因为每次查询都有一个ReadView所以可以判断哪些版本是已提交的,每次读取最新提交版本的记录就好了。

  2. repeatable read在第一次读取数据时生成一个ReadView,后续读取数据不会重复生成ReadView,因为只有一次才生成ReadView,所以后续根据上面的步骤去找,永远用的都是第一次ReadView中的数据,可达到一个快照读取的效果,只读取符合第一次ReadView条件的版本记录,形成可重复读。

    repeatable read有一个特殊的情况,如果在开启事务时,使用的是 start transaction with consistent snapshot 语句开启事务,那么执行该语句后会立刻生成一个ReadView,而不是等到第一次执行select的时候生成。

二级索引与MVCC

二级索引中是没有trx_id和roll_pointer隐藏列的,如果查询语句使用二级索引查询的话,判断记录是否可见的步骤如下:

  1. 每个二级索引页面的page header部分有一个page_max_trx_id的属性,每次对该页面中的记录进行增删改的操作时,如果当前事务id大于该属性的值,则将当前事务id赋值给该属性,即该值保存着操作该索引页最大的事务id,当select语句访问该页面的记录时,判断ReadView中的min_trx_id是否大于页面中属性的值,如果大于,说名该页面中的记录对当前事务可见,否则就要进入第2步

  2. 通过二级索引记录中的主键值进行回表操作,找到聚簇索引后,根据上面提到的方式找到第一个对当前事务可见的版本,判断该版本中对应二级索引列的值是否和利用二级索引查询的值相同,是则返回,不是则跳过该记录。

PS:之所以删除一条记录时给该记录标记上delete_mark,就是因为MVCC版本中,其他的事务可能还需要查询该记录,如果是直接删除而不是打上删除标记,那么其他事务就无法搜到该记录了。

purge

为了实现MVCC多版本,undo日志做了下面两件事:

  1. insert undo log在事务提交之后可直接删除,update undo log需要支持MVCC,不可以直接删除。

    一个事务对应的一组undo日志中有一个Undo Log Header部分,该Header中存在一个 trx_undo_history_node的属性,表示一个名为history的链表节点,一个事务提交后,其对应的update undo日志组将插入到history链表头部。

    每个回滚段都对应一个名为Rollback Segment Header页面,页面中有下面两个属性:

    • trx_rseg_history: 表示history链表的基节点
    • trx_rseg_history_size: 表示history链表占用的页面数量

    即每个回滚段都有一个history链表,一个事务在某个回滚段中写入一组update undo日志在事务提交之后,就会加入到这个回滚段的history链表中,因此这些history链表中的update undo日志需要在合适的时机释放,否则会浪费大量的存储空间。

  2. 为了支持MVCC,delete mark操作仅仅在记录上打上标记,没有真正删除。

    在一组undo日志中,undo log header 部分有一个名为trx_undo_del_marks属性,用来标记本组undo日志中是否包含引delete_mark产生的undo日志,这些被标记为删除的日志也需要在合适的时机彻底删除。

为了节约存储空间,在合适的时机将update undo log以及被标记为删除的记录彻底删除,这个删除操作被称为purge。

合适的删除时机

update undo 与 delete mark是为了支持MVCC存在,只要系统中最早产生的ReadView不再访问它们,就是最合适的删除时机,即生成ReadView时,某个事务已经提交,那么该ReadView肯定不需要访问该事务的undo日志。

Innodb做了下面两个设计,来判断删除时机:

  1. 在一个事务提交时,为这个事务生成名为事务no的值,该值表示事务提交顺序,先提交的no值小,后提交的no值大

    一组undo日志的Undo Log Header中有一个名为trx_undo_trx_no的属性,事务提交时,会把其事务no赋值给这个属性,history链表中的各组undo日志就是按照对应的事务no排序的。

  2. 一个ReadView结构除了之前说的几个属性,还包含一个事务no属性,这个属性值是在生成ReadView时,将当前系统中最大的事务no +1 赋值的。

purge过程

Innodb将所有的ReadView按照创建时间生成一个链表,purge操作由后台线程执行,执行purge操作时,将系统中最早生成的ReadView取出来,如果不存在ReadView就直接创建一个(新创建的事务no肯定比已提交的事务no大),

然后从各个回滚段的history链表中取出事务no值较小的各组undo日志,如果一组undo日志的事务no小于当前系统中最早生成的ReadView的事务no属性值,

则说明该undo日志不会再被访问可以直接从history链表中移除,并彻底释放存储空间,如果改组undo日志中包含delete_mark产生的undo日志,则将标记为删除的记录也彻底释放。

系统中最早产生的ReadView决定purge操作中可以清理哪些update undo log 和 delete mark,因此如果某个可重复读的事务一直复用最初产生的ReadView,并不提交事务,

那么系统中的update undo log 和 delete mark记录会越来越多,表空间的文件越来越大,一条记录的版本链越来越长,将影响系统性能。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值