事务
什么是事务
MySQL的常用的存储引擎有InnoDB,MyISAM。InnoDB支持事务,MyISAM不支持事务。那事务是什么呢?
事务就是一组操作,这一组操作我们希望它是一个不可分割的整体,这组操作要么全部都执行,要么全部都不执行。在很多环境中都有事务,数据库中有事务,Spring中也有事务。
那数据库的什么样的场景下,会用到事务呢?
- 最典型的莫过于转账了。
比如说,我有一百万,我要转给你一百万,那在数据库中会涉及到以下几个步骤:
假如说当执行了前三个步骤后,服务器断电了,后面的三个步骤没有执行,那这时候我的100万没有了,你的账号上也没有加上100万,这100万就这么没了,是不是很离谱,那所以为了避免这种情况发生,我们希望通过事务让上面6个操作,作为一个整体,要么6个操作都执行,要么6个操作都没执行。不过在这里并不是6个操作真的都没执行,意思就是:假如执行了前三个步骤之后,真的断电了,那MySQL在执行前三个步骤时可以将执行了哪些操作记录到日志中,后续当通电后,可以根据日志中记录的信息,执行一些操作来抵消前三个操作(说白了就是给你的账号上加100万,让你的账号余额不变)。根据日志回退的这个过程就叫做回滚。
以上的案例解释了事务的用途,最基本的思想是让一组操作作为一个整体,要么都执行,要么都不执行,不能出现执行了一部分的情况。
那事务具体是怎么使用的呢?
- 就拿上面转账操作来说,我们在开始转账前,先开启事务,然后等转账的六个操作全部成功执行后,再提交事务,如果中途发生错误,那该事务期间对数据库所做的所有修改都会回滚到没有发生修改之前的状态。
事务四大特性
事务的四大特性:ACID
- 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成。如果这些操作执行的中间过程中发生错误或者中断,那就回滚到事务开始前的状态。即事务是一个不可分割的原子。没有中间状态。
- 一致性:一致性是指事务发生前和发生后,数据库保持一致性状态。拿转账来说,张三和李四总共有五千元,不管中间转账几次,每次转了多少钱,最后总金额还得5000元
- 隔离性:一个MySQL服务器可以连接多个客户端,多个客户端的事务操作是在不同线程中执行的,线程可以并行执行,也就意味着事务可以并行执行。隔离性就可以保证多个事务并行执行时对同一份数据读写,多个事务之间互相不会产生干扰。
- 持久性:事务执行过程中对数据的修改是持久的,即事务一旦提交,对数据库中数据的修改是持久性的
持久性是通过:重做日志(redolog)来保证的,
原子性是通过:回滚日志(undolog)来保证的,
隔离性是通过:MVCC来保证的或锁机制来保证的,
一致性是通过持久性+原子性+隔离性来保证的。
并行事务
并行事务产生的问题
MySQL服务器是允许多个客户端连接的,而服务器会为每个客户端分配一个线程,来执行用户的操作。线程可以并行执行,那么事务自然也就可以并行执行。因为事务是在线程中的
并行事务会产生什么问题?
当多个数据对同一份数据产生读写操作的时候,就有可能会产生诸如以下问题:脏读,不可重复读,幻读。
1️⃣脏读:
事务A在执行过程中对数据进行了修改,在事务A未提交之前,事务B读取了对应的数据,此时事务B的读取行为就是脏读。因为事务B读到的数据可能一会事务A就修改了。那事务B就是读到了一个脏数据。(说的简单点就是:一个事务读到了另一个未提交事务修改过的数据)事务A读取到了事务B中间状态的数据
事务A和事务B同时执行,事务A先从账号中读取余额为100万,然后随即更新了余额为200万,此时事务A还没有提交,然后事务B这时候读取了余额200万。因为事务A没有提交,所以事务A有可能发生回滚,假如发生了回滚,将余额更新为100万。那么就相当于事务B读取了一个过期的数据。这个现象称为脏读。
2️⃣不可重复读:
在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一致的情况,就意味着发生了不可重复读现象。事务A并没有读取事务B中间状态的数据,而是事务A的两次查询中插入了事务B的修改,这一点要和脏读区分开,脏读是读到了另一个事务的中间状态的值。事务A在读取的时候,事务B要么是未开启,要么是已提交
事务A在进行多次读取数据,在事务A多次读取数据的过程中,事务B修改了数据,导致事务A前后读取的数据不一致,就称作不可重复读现象。
3️⃣幻读:
在一个事务内,多次查询符合某个查询条件的记录,如果出现了前后读取的记录数不一致(或者说结果集不一致),则代表产生了幻读现象
事务B读取余额大于100万的数量为5条,然后事务A插入一条余额大于100万的账号,然后提交事务,等事务B再读取的时候,余额大于100万的数量变为了6条。
⭕️脏读是读到了另一事务中间状态值,不可重复读是前后两次读到的数据不一致(侧重点是数据),幻读是前后两次读到的结果集不一致(侧重点是结果的数量)
事务的隔离级别
针对事务并发时,可能产生的脏读,不可重复读,幻读等现象,SQL标准提出了四个隔离级别,来解决上述现象。分别是读未提交,读提交,可重复读,串行化。
- 读未提交是指一个事务还没有提交时,他做的变更就能被其他事务看到。每次读到的都是最新的数据。
- 读提交是指一个事务只有提交之后,他做的变更才能被其他事务看到。
- 可重复读是指指事务在事务执行过程中看到的数据跟这个事物刚启动时看到的数据是保持一致的。
- 串行化会对事务加上读写锁,当多个事务对同一条记录发生了读写操作,如果发生了读写冲突,后访问的事务必须等前一个事务执行完才能继续执行。
在不同隔离级别下,可能会发生的现象:
从左到右隔离级别越来越高,并发性越来越低
读未提交三种现象都可能发生,读已提交解决了脏读问题,可重复读解决了脏读和不可重复读问题,串行化解决了所有问题。
这四种隔离级别是SQL标准中规定的,不过不同的数据库厂商对这四种隔离级别的实现还和标准不太一样。比如:在MySQL中,在可重复度隔离级别可以很大程度上避免幻读(并不是完全避免),所以一般不会使用串行化来解决幻读,因为串行化效率太低了。
下面举例来说明这四种隔离级别下的读取行为:
==在读未提交隔离级别下:==每次读取到的都是最新的数据,所以V1为200万,V2为200万,V3为200万
==在读提交隔离级别下:==事务只有提交后,更改的数据才能被其他事务看到。在读取V1时由于事务B还没提交,所以事务B做出的更改,事务A看不到,所以V1为100万,读取V2时,事务B已经提交了,所以V2为200万,V3为200万
==在可重复读隔离级别下:==事务执行过程中看到的数据永远和事务启动时看到的数据是一致的。所以在事务A执行过程中的两次读取V1,V2都是100万,而提交事务后,再读取到的V3是200万
==在串行化隔离级别下:==当事务B要将100万更改为200万时,由于此前事务A已经发生了一次读取,那事务B的写操作就和事务A的读操作发生了读写冲突,那事务B就会被锁住,直到事务A提交后,所以V1,V2都是100万,读取V3时,如果事务B已经将数据更改为了200万,那V3就是200万。
这四种隔离级别具体是如何实现的呢?
- 对于读未提交隔离级别的事务来说,不需要用到锁,也不需要用到MVCC,直接从数据库读取最新的数据就好了
- 对于串行化隔离级别的事务来说,通过加锁的方式来避免并行事务
- 对于读提交和可重复读隔离级别下的事务来说,是通过创建Read View来实现的,它们的区别在于创建Read View的时机不同,读提交隔离级别下,是在每个语句执行前都会重新生成一个Read View,而可重复读隔离级别下是启动事务时生成一个Read View,然后整个事务期间都在用这个Read View
执行开启事务命令,并不意味着启动了事务:
这两种开启事务的命令,事务的启动时机是不同的:
- 执行了start/begin transaction命令,并不代表事务启动了,只有在执行了这个命令,然后执行增删查改的SQL语句的时候,才真正启动了事务,才会真正创建read view。
- 执行了start transaction with consistent snapshot命令后,就会马上启动事务,就会马上创建read view
所以以上两种命令真正开启事务的时机并不相同,自然创建read view的时机也不同。
MySQL的InnoDB存储引擎默认使用的是可重复读隔离级别,不过在该级别下可以在很大程度上解决幻读。解决的方案有以下两种:
- 针对快照读(普通select语句):通过MVCC方式解决了幻读,因为在可重复度隔离级别下,事务执行过程中看到的数据和事务刚启动时看到的数据是一致的,所以即使中间有其他事务插入了一条数据,也是查询不出来这条数据的,所以就避免了幻读。
- 针对当前读:(select…for update,update,insert,delete等语句),是通过next-key lock(记录锁+间隙锁)解决的幻读,当执行select…for update时,会加上next-key lock,如果有其他事务要在next-key lock锁的范围内要插入一条记录,则该插入操作会被阻塞,无法插入。所以就解决了幻读问题。
MVCC
MVCC使用场景
数据库并发场景:
- 读-读:不会发生线程安全问题
- 读-写:会发生线程安全问题,比如脏读,不可重复读,幻读,
- 写-写:会发生线程安全问题:可能会存在更新丢失问题。
那如何解决上面的线程安全问题呢?
我们先讨论读-写场景下产生的线程安全问题。
针对读-写产生的线程安全问题的解决一种方式是通过加锁,还有一种方式是使用MVCC。在读取数据的时候也有两种方式,一种是当前读,一种是快照读。分别与上面解决线程安全问题的两种方式相对应。
- 当前读:每次读取的是数据库中最新的记录,会对读取的数据进行加锁,防止其他事务修改。以下读取方式都是当读:select for update insert update delete
- 快照读:是基于MVCC实现的,读取到的数据不一定是最新版本,可能是历史版本。读取时不会对数据加锁。普通的select语句是快照读(在可重复读隔离级别下)
MVCC:多版本并发控制。是通过维持数据的多个版本,来解决读-写冲突的一种无锁机制。
解决读-写冲突,可以加锁,也可以使用MVCC,不过MVCC没有用锁,所以不会涉及到线程阻塞,所以MVCC的并发性自然就更好。
MVCC的实现原理
MVCC的实现是通过版本链,undo日志,Read View来实现的。
1️⃣版本链:
在了解版本链之前,我们先了解一些前置知识:
实际上在聚簇索引的B+树中的每个用户记录,除了本身的字段之外,还有两个隐藏字段:trx_id,db_roll_pointer:
- trx_id:当一个事务对聚簇索引中的记录进行修改时,会把该事务的id写到这条记录的trx_id列
- 当在事务中对聚簇索引的记录进行修改时,会先把旧版本的记录写到undo日志中,这个roll_pointer就相当于一个指针,指向上一个版本的记录
-
每次在事务中修改当前记录前,都会将旧版本的记录放到undo日志中,然后修改当前记录,t事务id,roll_pointer相当于是一个指针,指向undo日志中的上一个版本的记录。
-
所以聚簇索引树中的记录和undo日志中的记录就可以通过roll_pointer以链表的形式串联了起来,这个链表就称为版本链。
2️⃣undo日志
用途:
- 当事务进行回滚时,需要用到undo日志中记录的历史数据进行恢复
- 用于MVCC进行快照读的数据,在MVCC的多版本控制中,通过读取undo log的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本。
3️⃣Read View(读视图)
Read View是用来实现记录对于事务的可见性的,即哪些记录是当前事务可见的,哪些事务是当前事务不可见的。
对于读提交隔离级别下的事务,每个语句执行前都会重新生成一个read view,对于可重复读隔离级别下的事务,会在启动事务时,创建一个 read view,整个事务期间都会用这一个read view。
read view:读视图: read view 中有四个字段,分别解释一下这四个字段的含义:
- m_ids:指的是创建read view时,当前数据库中活跃事务的事务id列表,活跃事务指的是启动了,但还没有提交的事务。
- min_trx_id:指的是创建read view时,当前数据库中活跃事务的最小事务id。
- max_trx_id:指的是创建read view时当前数据库中应该给下一个事务的事务id(也就是全局事务中最大的事务id值+1)(默认事务的id是按事务的开启顺序递增的)。
- creator_trx_id:创建该read view事务的事务id
在创建出Read View之后,可以将每条记录的trx_id字段的值划分到以下几个区间内,然后针对不同的区间,再确定出该记录对于当前事务是否可见。
以下几种情况,记录对于事务是可见的:
- trx_id==creator_trx_id,说明该记录是当前事务修改的,自然是可见的。
- 如果记录的trx_id值小于read view中的min_trx_id,表明当前版本的记录在创建read view前已经提交的事务生成的,所以该版本记录对于当前事务可见。
- 如果记录的trx_id值大于等于read view中max_trx_id,则说明该版本的记录是在创建read view后才启动的事务生成的,所以该版本记录对于当前事务不可见。
- 如果trx_id值在min_trx_id和max_trx_id之间,还需要判断trx_id是否在m_ids列表中,如果在这个列表中,说明生成该记录的活跃事务还活跃着(还没有提交),所以该记录对于当前事务不可见。如果不在该列表中,表明生成该版本记录的活跃事务已经被提交,所以该版本记录对于当前事务可见。
MVCC是通过版本链和Read View来判断聚簇索引树中的记录对于当前事务是否可见的。
下面就分别演示一下可重复读和读提交的隔离级别下,是怎么通过MVCC解决并发冲突的:
不同隔离级别下MVCC的使用
事务的四个隔离级别:读未提交,读提交,可重复读,串行化。
- 读未提交,每次都从数据库中读取最新的数据,不需要锁,也不需要MVCC。
- 读提交,只能看到其他事务提交的数据,未提交的数据看不到,解决了脏读
- 可重复读,事务执行过程中看到的数据和事务启动时看到的数据是一致的,解决了脏读,不可重复读
- 串行化,事务发生读写冲突时就要加锁。解决了脏读,不可重复读幻读问题。
在InnoDB存储引擎中,读提交和可重复读会使用到MVCC来解决读-写冲突。不过只有这两个隔离级别下的快照读(普通select语句)会使用MVCC来解决读写冲突,而这两个隔离级别下的当前读(select for update,insert,update,delete)是使用的加锁的这种方式。
下面我们来具体分析一下,在读提交和可重复读隔离级别下,MVCC是如何工作的。
⭕️可重复读隔离级别下:事务启动时创建一个Read View,然后事务执行期间都用的这一个Read View。
事务A先启动,紧接着事务B启动,则这两个事务创建的Read View如下:
- 事务A先启动,启动时直接创建Read View,给事务A分配的事务id为51
- 事务B随后启动,给事务B分配的事务id为52
事务A,B执行前的记录字段信息:
在事务A和事务B开启后,在事务A和事务B中按时间顺序执行如下操作:
- 事务B读取余额为100万
- 事务A修改余额为200万,并没有提交事务
- 事务B读取余额,读到的余额依然是100万
- 事务A提交
- 事务B读取余额,还是100万
然后,我们通过MVCC来分析一下,为啥事务B三次读取到的都是100万
1️⃣首先,事务B第一次读取余额时,通过比较trx_id和min_trx_id,发现trx_id(50)比min_trx_id(51)小,那也就意味着id为50的事务在事务B启动前已经提交了,那该记录对于事务B是可见的。所以直接读取该记录就好,读取到的自然就是100万。
2️⃣然后事务A通过update修改了余额为200万,这时,MySQL会将旧版本的记录(余额为100万)存到undo log日志中,将聚簇索引树中的记录修改为余额200万,并且旧版本记录和新版本记录通过链表的形式连接,形成版本链:
3️⃣然后事务B第二次读取余额,发现记录的trx_id为51,在min_trx_id和max_trx_id之间,这时候就需要判断trx_id是否在m_ids范围内,结果发现在这个范围内,那就说明启动事务B时,id为51的事务还没有提交,那这时候就不能读取该记录,需要沿着版本链向下继续找对于事务B可见的第一条记录,然后就找到了undo log中的trx_id为50的这条记录,然后读取余额为100万。
4️⃣然后事务A提交,然后事务B第三次读取数据,依然是通过read view和记录的trx_id字段去比较,根据比较规则,读取到的依然是undo log日志中的余额为100万的记录。
通过以上验证了在可重复读隔离级别下,读到的数据和事务启动时看到的数据是一致的。
⭕️读提交隔离级别下,事务每次执行快照读,都会生成一个新的Read View,来判断记录的可见性。
读提交隔离级别下,事务只能读到其他已提交的事务修改过的数据,并不能读取未提交事务修改过的数据,因此解决了脏读现象。
假设事务A(事务id为51)启动后,紧接着事务B(事务id为52)也启动了。
然后按时间顺序执行如下操作:
- 事务B读取余额(创建Read View)为100万
- 事务A修改余额数据为200万,此时事务A并没有提交
- 事务B读取余额(创建Read View)为100万
- 事务A提交
- 事务B读取余额(创建Read View)为200万。
1️⃣第一步:事务B读取数据
- 通过Read View和记录的trx_id字段比较,trx_id为50小于min_trx_id所以该记录对于事务B是可见的,直接读取该记录就好了,余额为100万
2️⃣事务A修改记录为200万(并没有提交事务),此时会将历史记录存于undo log中,然后修改聚簇索引树中的记录的余额为200万,trx_id为事务A的id(51),然后回滚指针指向上一个版本的记录,形成版本链
3️⃣事务B再次读取余额,此时创建的Read View如下:
- 记录的trx_id(51)在min_trx_id和max_trx_id之间,而且在m_ids中,说明创建Read View时,上一个修改记录的事务(也就是事务A还没有提交),这时候不能读取该记录,应该沿着版本链去undo log日志中找对于事务B可见的记录,然后找到了trx_id为50的这条记录,50<min_trx_id,所以这条记录对于事务B是可见的,所以直接读取该记录,余额为100万
4️⃣事务A提交
5️⃣事务A提交后,事务B接着读取余额,由于在读提交隔离级别下会创建新的Read View:
- 由于事务A已经提交了,所以在创建的新的Read View中没有了事务A的事务id(51),这是和事务A没有提交的时候的区别。
当前的版本链为:
- 通过比较发现,记录的trx_id(51)小于Read View中的min_trx_id,所以该记录对于事务B是可见的,直接读取该记录,余额为200万。
🎃正是因为在读提交隔离级别下,每次进行快照读时都会生成新的Read View,所以对于未提交的事务做出的修改是看不见的,这就解决了脏读问题,但是当其他事务提交后,其他事务做出的修改仍然是可以看见的,所以还是没有解决不可重复读问题。
⭕️总结:
数据库事务并发的场景:
- 读-读(不会产生线程安全问题)
- 读-写(会产生诸如脏读,不可重复读,幻读等问题)
- 写-写(会产生数据更新丢失问题)
针对读-写场景产生脏读,不可重复读,幻读等问题,SQL标准引入了四个隔离级别:读未提交,读已提交,可重复读,串行化。隔离级别越来越高,并发性越来越低。
在InnoDB引擎中:
对于读未提交隔离级别来说,每次从数据库中读取最新的数据就好了,不用MVCC,也不用锁,产生的读-写冲突问题为:脏读,不可重复读,幻读。
对于串行化来说,会加锁,来解决并发冲突。
对于读提交和可重复读隔离级别下,对于快照读,是使用的MVCC来解决读-写冲突,对于当前读,是通过加锁的方式来解决的读-写冲突。
对于读提交和可重复读隔离级别下的快照读,是使用MVCC的方式来解决的读-写冲突,不过对于读提交隔离级别来说,是每次执行select语句都会创建一个read view,对于可重复读隔离级别下,是事务启动时创建一个read view,在以后的事务执行过程中,都使用的这一个read view。
可重复读隔离级别没有完全解决幻读
MySQL的InnoDB存储引擎默认的是可重复读隔离级别,在可重复读隔离级别下,可以很大程度上解决幻读,不过并没有完全解决幻读,下面我们就来说一说是怎么解决幻读的。
- 对于快照读(普通select语句),是通过mvcc的方式解决了幻读,在可重复读隔离级别下,事务执行过程中看到的数据,事务启动时看到的数据是保持一致的,所以即使中间有其他事务插入了一条记录,那也是看不到这条记录的,所以结果集并不会变化。这就解决了幻读
- 对于当前读(select for update,insert,update,delete),是通过next-key lock(记录锁+间隙锁)的方式解决的幻读,因为当执行select for update 语句时,会加上next-key lock,如果有其他事务在next-key lock锁的范围内插入了一条记录,则该插入语句会被阻塞,无法成功插入,这就解决了幻读问题
下面,我们通过具体的例子,来演示一下,快照读和当前读这两种情况下,会不会产生幻读。
快照读:
1️⃣事务A先开启事务,查询id>2的学生:
2️⃣事务B开启事务,插入id为6的学生,然后提交事务
3️⃣事务A再次查询id>2的学生,结果依然不变:
4️⃣然后事务A提价事务,该客户端再次执行相同的命令,查询到了id为6的学生:
🎃在事务A的执行期间,两次查询id>2的学生期间,事务B插入了一条id为6的记录,但是事务A的查询结果并没有发生变化,并没有查询到id为6的记录。
- 因为当事务A执行了第一次查询的时候,就真正开启了事务,并且创建了read view,而事务B是在事务A的read view创建之后开启的,所以对于事务A来说,事务B插入的记录对于事务A都是不可见的,那自然就查询不到id为6的记录。
- 当提交事务之后,再查询,自然就能查询到id为6的记录了。
当前读:
- 事务A执行先执行当前读,然后就会对表中记录加id范围是(2,+∞]的next-key lock (记录锁+间隙锁),然后事务B再想在id>2的范围内插入一条记录,那事务B就会生成一个插入意向锁,同时进入阻塞状态,直到事务A提交了事务,这样就避免了幻读问题。
1️⃣事务A先通过当前读查询id>2的学生,同时会对表中记录加id范围是(2,+∞]的next-key lock (记录锁+间隙锁)
2️⃣事务B插入id为7的学生,就会被阻塞,可以看到执行时间为24.23秒,这是事务A提交之后,这条插入语句才能成功执行
3️⃣事务A第二次查询记录数依然不变,因为事务B的插入操作已经被阻塞了。
🎃当前读通过加next-key lock的方式解决了幻读
但是我们一开始就提到了可重复读隔离级别下并没有完全解决幻读,那下面就来举两个会出现幻读现象的例子:
可重复读隔离级别下产生幻读的第一个例子:
1️⃣事务A先查询id>2的学生
2️⃣事务B插入一条id为8,name为‘猫小猫’的记录,并提交事务
3️⃣事务A更改id为8的记录的name字段为张飞
4️⃣事务A第二次查询id>2的学生:
⭕️在第一次和第二次查询中,发现结果集不一样了,第二次查询多出来id为8的学生,在可重复读隔离级别下出现了幻读现象。这是因为第三步,事务A对id为8的这条记录做出了更改,这一更改就会导致该记录的trx_id字段更改为事务A的id,这就导致了第四步事务A可以看到这条记录(原本id为8的这条记录的trx_id字段为事务B的id,对事务A是不可见的)。而之所以事务A可以对id为8的这条记录做出更改,是因为update是当前读,读取的永远是数据库中最新的数据。
可重复读隔离级别下产生幻读的第二个例子:
1️⃣事务A执行快照读(查询id>2的记录):
2️⃣事务B删除id为8的这条记录
3️⃣事务A第二次读取id>2的记录(当前读):
⭕️第二次查询id>2的记录和第一次不一致,是因为第二次不是快照读,没有使用MVCC,是当前读,读取的是数据库中最新版本的数据。
🎃🎯总结:在InnoDB的可重复读隔离级别下,还是会产生幻读问题,一般产生幻读问题,是因为在事务中快照读和当前读都用到了。因为当前读每次读的都是数据库中最新版本的数据,快照读每次读有可能读取历史版本数据,就导致前后两次读取结果集不一样,也就是幻读。