MySQL事务及MVCC

欢迎关注我的公众号:"阿东编程之路"!

在我们日常开发中事务是很常用的,今天我们一起来了解下MySQL的事务及实现原理。

一. 什么是事务?

事务在计算机术语中指访问或更新数据库中各数据项的一个程序执行单元,事务里面可能只有一个操作,也可能有多个操作。而事务的作用就是要保证事务内的所有操作要么全部成功,要么全部失败。

举个关于事务最经典的例子-转账:假设阿东现在有200元,张三最近手头有点紧,阿东就给张三转100元支援一下;那么,这个转账你认为会有哪些操作?

  • 第一步:查询阿东账户余额是否大于等于100,如果小于就直接返回余额不足,大于就继续后面的操作;

  • 第二步:将张三的账户余额+100;

  • 第三步:将阿东的账户余额-100;

  • 第三步:完成转账。

如果没有事务,上面的操作会有什么问题?

  • 假如操作2执行成功,操作3因为数据库或者网络原因导致操作失败了,阿东的账户还是100元而张三的账户也有100元,这么搞银行就亏死了。所以我们需要在操作2和操作3外面加一层事务,让操作2和操作3要么都成功,要么都失败。

二. 事务四大特性

事务的四大特性:原子性、一致性、隔离性、持久性。

  • 原子性(atomicity):组成一个事务的多个数据库操作是不可分割的工作单元。只有所有操作都成功才会提交事务,否则会撤销所有操作,让数据库恢复事务执行前的状态。

  • 一致性(consistency):不管事务是否操作成功,数据库所处的状态和业务规则是一致的。(比如上面提到的转账,阿东给张三转账100,不管操作是否成功,总额不会变)。

  • 隔离性(isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

  • 持久性(durability):指事务一旦提交,对数据库中数据的改变就是永久性的。

MySQL通过undo log来实现事务失败的回滚,保证了事务的原子性和一致性。而写操作在MySQL中是先写到缓存中,并且写入到redo log保证内存中还没刷盘的脏页不会丢失,最终都会根据一些场景刷盘持久化到磁盘保证了事务的持久性(redo log的知识可以看下我之前的文章《MySQL日志之redo log与binlog》)。

三. 事务的隔离级别

当数据库中有多个事务同时执行且事务内的操作资源相同或相关时,就可能出现脏读,不可重复读,幻读这些问题,为了解决这些问题,就有了“隔离级别”。

  • 我们知道,隔离级别肯定是通过一些手段去实现的,隔离级别越高,性能肯定越差,所以很多时候都要有一个选择。SQL的标准隔离级别由低到高是以下四种:读未提交(read uncommitted)、读已提交(read  committed)、可重复读(repeatable read)和串行化(serializable)。

怎么理解这四个隔离级别呢?

  • 读未提交:一个事务还没提交,它的变更就可以被其他事务看到。会出现脏读、不可重复读、幻读的问题。

  • 读已提交:一个事务提交后,它的变更才可以被其他事务看到。解决了脏读,但是有不可重复读和幻觉读的问题。

  • 可重复读:一个事务整个执行过程中看到的数据,总是跟在这个事务中在第一次看到的数据是一致的。只有幻读的问题。

  • 串行化:所有对记录的操作都会加锁,读加读锁,写加写锁。除了并发读不会串行,读写和写写都会有冲突互斥;对同一条记录的操作,后访问的事务会等前一个事务提交释放记录锁后才能操作,否则会阻塞。

在这里给大家解释下脏读就是读的是事务还未提交的数据,不可重复读是同一个事务内多次读的数据内容不一致(更新导致),幻读是同一个事务内多次读到的数据数量不一致(增删导致)。

了解了事务隔离级别的概念,我们来看下MySQL是怎么实现这些隔离级别的(MySQL只有InnoDB引擎的支持事务,所以我们下面的实现都是在InnoDB引擎的前提下)。

  • 在读未提交中,读操作都不会去加锁,读的数据都是最新的。写操作会写锁,但是写完就会立刻释放,不等事务提交;串行化我们刚才讲了是通过将所有的读写操作加锁来实现的。下面我们重点来讲下读未提交和可重复读的隔离级别在MySQL中是怎么实现的。

  • MySQL读未提交和可重复读的隔离级别下的写操作也是会加锁的,同时会去记录一条更新前的数据在undo log里来保证失败事务回滚,然后直到事务提交才会释放锁。

那读也是加锁吗?

  • 其实目前我们很多应用都是读多写少的场景,读操作要远远大于写操作,所以读操作之间的互斥锁就没必要了,所以就有了读写锁,读锁和读锁之间不会阻塞,但是读锁和写锁之间会互斥;如果在一个事务更新一批数据的同时,别的所有查询该批数据的事务都阻塞不可用,肯定会对业务影响比较大;那有没有什么办法让读和写之间不互斥,再提升一下性能呢?

可以通过MVCC来解决读锁和写锁之间的互斥

  • MVCC(Multi-Version Concurrency Control)多版本并发控制,MVCC的原理简单来说就是通过一种类似快照的形式将数据保存下来,不同的事务通过一些规则限定只能看到特定的快照版本,来实现并发读写(快照一种抽象的概念,可以简单理解为不同版本的数据,这就对应着MVCC的MV-多版本)。

那在MySQL中是怎么实现MVCC的呢?

MySQL是通过undo log的版本链和创建一致性视图来实现MVCC的。

undo log

  • MySQL的增删改操作(rc和rr隔离级别下)同时会记录undo log,它记录了反向操作,比如删除就记录新增,新增就记录删除,更新就记录更新前的数据。在InnoDB里,UPDATE和DELETE操作产生的Undo日志被归成一类,即update_undo在回滚段中的undo logs分为: insert undo log 和 update undo log

  • insert undo log : 事务对insert新记录时产生的undo log,只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。

  • update undo log : 事务对记录进行delete和update操作时产生的undo log,事务回滚和一致性读(快照读创建的一致性视图)也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除(undo log的记录不会立即删除,而是标记一下deleted_bit,purge线程去定时清理标记了deleted_bit的记录,类似Java垃圾回收中的标记清除算法)。

所以我们所说的版本链只存在更新和删除操作。

InnoDB存储引擎在数据库每行数据的后面隐式添加了三个字段:

  • DB_TRX_ID:事务id,用来记录最近一次对本行记录做修改的事务id。

  • DB_ROW_ID:这个字段就是主键id,如果建表没有设置主键,InnoDB会默认生成一个DB_ROW_ID来隐式作为表的主键。

  • DB_ROLL_PTR:回滚指针,指向undo log中的回滚记录。

所以数据表中的记录会有多个版本,这个多版本就是存在undo log中,组成版本链;

每个记录形成的版本链的结构大概是这样:

(借用思否平台上@大佬的图)

那么创建一致性视图会做哪些事情呢?

1. trx_ids(活跃事务id集合):将当前所有未提交的事务id(DB_TRX_ID)存进视图的单独维护的活跃事务id数组trx_ids里;

2. up_limit_id(低水位):记录下trx_ids(活跃事务id集合)最小的值为up_limit_id;

3. low_limit_id(高水位):当前系统里已经创建过的事务id的最大值+1(也就是下一个生成的事务id)。

那么在什么时机会触发创建一致性视图呢?

在不同的隔离级别下,创建一致性视图的时机不同,但切记创建一致性视图的时机并不是在事务开启时。

  • 在读已提交(read  committed)隔离级别下,事务内的每次快照读(快照读的概念后面再讲,这里可以先理解为简单的select查询)都会触发创建一致性视图,从而防止幻读,但是正因为每次快照读都会创建新的视图,所以不可重复读的问题还是会发生。

  • 在可重复读(repeatable read)隔离级别下,事务内的第一次快照读且只有一次时,会触发创建一致性视图。因为在整个事务内只创建一次视图,整个事务内多次读都会在这个唯一的视图上进行查找,所以能防止不可重复读的发生。

什么是快照读和当前读?

  • 快照读(snapshot read):普通的 select 语句(不包括 select ... lock in share mode, select ... for update)

  • 当前读(current read) :select ... lock in share mode,select ... for update,insert,update,delete 语句(这些语句获取的是数据库中的最新数据,和写操作互斥)

下面就讲一下一致性视图是怎么保证事务隔离的:

select * from t where id = 1;

当执行这个语句时会创建一致性视图,包括生成好trx_ids(活跃事务id集合),up_limit_id(低水位),low_limit_id(高水位)。假设现在id = 1的这条记录内的DB_TRX_ID是trx_current:

创建完视图后,首先会去将trx_current与up_limit_id(低水位)和low_limit_id(高水位)进行比较,分为三种情况:

1. trx_current < up_limit_id:当前记录的事务id小于低水位,意味着最新修改该行的事务已经提交或者是当前事务自己生成的,数据可见,直接返回。

2. trx_current >= low_limit_id:当前记录的事务id大于等于高水位,意味最新修改该行的事务在“当前事务”创建视图之后才修改该行,数据不可见,根据该记录行的 DB_ROLL_PTR 指针所指向的undo log回滚段中,取出最新的的旧事务号DB_TRX_ID, 将它赋给trx_current,然后重新进行水位的比较。

3. up_limit_id <= trx_current< low_limit_id:当前记录的事务id大于等于低水位且小于高水位,这个时候就有两种情况:

    a.  第一种情况:trx_current在活跃事务集合trx_ids中:最新修改该行的事务还未提交,数据不可见,和上述2一样,去版本链中找上一个版本的DB_TRX_ID去继续判断操作。

    b.  第二种情况:trx_current不在活跃事务集合trx_ids中:最新修改该行的事务已经提交,数据可见,直接返回数据。

上述的逻辑就可以实现不同事务更新时数据的隔离性,同时又能保证读和写的并发。

  • 我们在上面说了RR隔离级别可以防止脏读和可重复读,防止不了幻读,确实,在只有MVCC的情况下确实防止不了幻读,但是在RR隔离级别下InnoDB引擎通过next-key lock(record lock和gap lock)来防止幻读的产生,就是在当前读时,会锁住匹配到的记录和记录左开右闭的间隙(间隙锁-gap lock)来防止幻读(锁这块知识后面的文章会详细讲),所以RR隔离级别下MySQL是可以根据MVCC+next-key lock来解决脏读、不可重复读及幻读等一系列问题的

并且MySQL的默认隔离级别是可重复读(RR):

show variables like "transaction_isolation";

但是一般线上不会去用可重复读(RR)隔离级别,为什么呢?

上面我们也讲了,RR隔离级别会加更多的锁,首先加锁会带来额外的开销,其次性能下降,最后并且很容易发生死锁的情况!所以一般线上业务都会去设置读已提交隔离级别(RC)。

看到这里,你是不是觉得MVCC多版本并发控制设计的非常巧妙呢?

MVCC多版本并发控制真的对MySQL的性能提升巨大,我理解就是一种乐观锁的实现,我们在开发中也可以多用用乐观锁的思想来减少使用悲观锁带来的开销

四. 小结

今天我们讲了事务的四大特性、事务的四个隔离级别、以及MySQL对隔离级别的实现、MySQL中MVCC多版本控制的实现等。MVCC多版本控制的设计确实巧妙,可以给我们日常开发中遇到的并发问题带来新思路!

如果觉得文章不错可以给个关注和赞,欢迎关注我的公众号:"阿东编程之路"!

参考书籍及文章

1. 《MySQL实战45讲》 作者:林晓斌

2. 《深入理解MySQL核心技术》 作者:Sasha Pachev

3.   https://segmentfault.com/a/1190000012650596

4.   MySQL官方文档:https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_consistent_read

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值