当前位置: 首页 > 科技观察

本文阐明了MySQL事务隔离级别和实现原理,开发者必备知识点

时间:2023-03-18 11:55:39 科技观察

经常提到数据库事务。你知道数据库还有事务隔离吗?事务隔离也有隔离级别。什么是交易?隔离,什么是隔离级别?本文将帮你梳理。MySQL事务本文所说的MySQL事务是指InnoDB引擎,MyISAM引擎不支持事务。数据库事务是指一组数据操作。事务中的操作要么全部成功,要么全部失败,什么都不做。其实也不是什么都没有。做一部分是可以的,但是只要一步失败,就得全部回滚。操作有点马不停蹄的意思。假设一个网购支付操作,用户支付后,涉及更新订单状态、扣除库存等一系列动作。这是一个交易。如果一切正常,那就没事了。有必要回滚。无法更新订单状态,但不扣除库存。这是个大问题。事务具有四个特性:原子性、一致性、隔离性和持久性,简称ACID,缺一不可。今天要讲的是隔离。概念描述下面几个概念是事务隔离级别实际应该解决的问题,所以需要搞清楚它们的含义。脏读脏读是指从其他事务中读取未提交的数据。Uncommitted的意思就是这些数据可能会被回滚,也就是最后可能没有存入数据库,也就是不存在的数据。已经读过且最终必然存在的数据就是脏读。可重复读可重复读是指在一个事务内,开始时读取的数据与事务结束前任意时刻读取的同一批数据是一致的。通常用于数据**更新(UPDATE)**操作。不可重复读取与可重复读取。不可重复读是指在同一个事务中,不同时间读取的同一批数据可能不同,可能会受到其他事务的影响,比如其他事务改变了这批数据。并提交。通常用于数据**更新(UPDATE)**操作。幻读幻读是针对数据**插入(INSERT)**操作。假设事务A更改了一些行的内容,但是还没有提交,此时事务B插入与事务A更改前的记录相同的记录行,并在事务A提交之前提交,此时,当你在事务A中查询的时候,你会发现刚才的变化对一些数据没有影响,但实际上只是事务B插入的,这让用户觉得很神奇,很幻觉。这称为幻读。事务隔离级别SQL标准定义了四种隔离级别,MySQL均支持这些级别。这四个隔离级别分别是:READUNCOMMITTEDREADCOMMITTEDREADREPEATABLEREADSERIALIZABLE从上到下,隔离强度逐渐增强,性能逐渐变差。使用哪种隔离级别取决于系统需求的平衡,其中可重复读是MySQL的默认级别。事务隔离其实就是为了解决上面提到的脏读、不可重复读、幻读等问题。下面展示四种隔离级别是如何解决这三个问题的。只有序列化隔离级别解决了所有3个问题,其他3个隔离级别都有缺陷。下面我们一一了解一下这4个隔离级别是什么意思。如何设置隔离级别我们可以通过如下语句查看当前数据库的隔离级别。从下面的语句可以看出,我使用的MySQL隔离级别是REPEATABLE-READ,也就是可重复读,这也是MySQL的默认级别。#showvariableslike'transaction_isolation'后查看事务隔离级别5.7.20;SELECT@@transaction_isolation#5.7.20afterSELECT@@tx_isolationshowvariableslike'tx_isolation'+------------+-----------------+|Variable_name|Value|+----------------+----------------+|tx_isolation|REPEATABLE-READ|+--------------+----------------+后面我们要修改数据库的隔离级别,所以先了解具体修改方法。修改隔离级别的语句为:set[scope]transactionisolationlevel[transactionisolationlevel],SET[SESSION|GLOBAL]事务隔离级别读未提交读已提交|可重复阅读|可序列化。action可以是SESSION也可以是GLOBAL,GLOBAL是全局的,SESSION只针对当前会话窗口。隔离级别是READUNCOMMITTEDREADCOMMITTED|可重复阅读|SERIALIZABLE这四个不区分大小写。例如,下面的语句表示将全局隔离级别设置为read-commit级别。mysql>setglobaltransactionisolationlevelreadcommitted;MySQL中执行一个事务的执行过程是这样的,从begin或者starttransaction开始,然后执行一系列的操作,最后执行commit操作,这个事务就被认为结束了。当然,如果执行回滚操作(rollback),事务也会结束。需要注意的是,begin命令并不代表事务的开始,当执行完begin命令后的第一条语句时,事务就开始了。比如下面的例子中select*fromxxx就是事务的开始,begin;select*fromxxx;commit;--或者rollback;另外可以通过下面的语句查询当前有多少个事务正在运行。选择*frominformation_schema.innodb_trx;好了,重点来了,下面开始分析这些隔离级别。接下来我会用一张表来验证一下。表结构如下:CREATETABLE`user`(`id`int(11)NOTNULLAUTO_INCREMENT,`name`varchar(30)DEFAULTNULL,`age`tinyint(4)DEFAULTNULL,PRIMARYKEY(`id`))ENGINE=InnoDBAUTO_INCREMENT=2DEFAULTCHARSET=utf8一开始只有一条记录:mysql>SELECT*FROMuser;+----+----------------+------+|id|名字|年龄|+----+----------------+-----+|1|远古风筝|1|+----+--------------+------+readuncommittedMySQL事务隔离其实是通过加锁来实现的,加锁自然会造成性能损失。readuncommitted隔离级别没有加锁,所以它的性能是最好的,没有加锁和解锁带来的性能开销。但是有利也有弊,基本就相当于裸奔了,所以连脏读的问题都解决不了。一个事务对数据的任何修改都会立即暴露给其他事务,即使这个事务还没有提交。让我们做一个简单的实验来验证一下。首先,将全局隔离级别设置为未提交读。setglobaltransactionisolationlevelreaduncommitted;设置完成后,只对之后的新会话有效,对已经??启动的会话无效。如果使用shell客户端,需要重新连接MySQL,如果使用Navicat,需要新建一个查询窗口。启动两个事务,即事务A和事务B,在事务A中使用update语句,将age的值修改为10,初始值为1,执行update语句后,查询事务B中的user表,会看到age的值已经是10了,此时事务A还没有commit,此时事务B可能会进行修改age=10的其他操作。在事务B的运行过程中,很可能事务A由于某些原因进行了事务回滚操作。其实事务B拿到的是脏数据,脏数据用来进行其他的计算,结果是一定的,也是有问题的。沿着时间轴表示两个事务中操作的执行顺序,重点关注图中age字段的值。Readuncommitted,其实你可以读取其他事务未提交的数据,但是没办法保证你读到的数据最后一定是提交的数据。如果中间有回滚,就会出现脏数据问题。Readuncommitted没有办法解决脏数据问题。更不用说可重复读和幻读,想都别想。ReadCommit既然readuncommitted解决不了脏数据的问题,那么就有了readcommit。读提交是指一个事务只能读取其他事务已经提交的数据,即其他事务调用提交命令后的数据。脏数据问题解决。读取已提交事务隔离级别是大多数流行数据库(例如Oracle)的默认事务隔离级别,但不适用于MySQL。我们继续做验证,先把事务隔离级别改成读提交级别。setglobaltransactionisolationlevelreadcommitted;之后需要重新打开一个新的会话窗口,即一个新的shell窗口。同样启动两个事务,事务A和事务B,在事务A中使用update语句将id=1的记录行的age字段修改为10,此时使用事务B中的select语句进行查询。我们发现在事务A提交前,事务B中查询到的记录年龄一直为1,直到事务A提交。这时候在事务B中selectquery,发现age的值已经是10了,这就产生了一个问题。在同一个事务中(本例中的事务B),事务不同时刻的相同查询条件会导致不同的记录。事务A的提交影响了事务B的查询结果,是不可重复读,也就是readcommitted隔离级别。每个select语句都有自己的snapshot,不是每个transaction一个snapshot,所以在不同的时间,查询到的数据可能不一致。readcommit解决了脏读问题,但是无法实现可重复读,也无法解决幻读。可重复阅读不同于不可重复阅读。上面的不可重复读是指对于同一个东西在不同时间读到的数据值可能不一致。可重复读是指事务不会读取其他事务对现有数据的修改,即使其他事务已经提交,也就是说事务开始时读取的现有数据是什么,在任何时间之前transactioncommitted,这些数据的值都是一样的。但是其他事务新插入的数据是可以读取的,这也导致了幻读的问题。同样,需要将全局隔离级别更改为可重复读取级别。setglobaltransactionisolationlevelrepeatableread;在这个隔离级别下,启动了两个事务,并且两个事务是同时打开的。先看可重复阅读的效果。事务A启动后,数据先于事务B修改提交,事务B在事务启动和事务A提交后的两个时间节点读取相同的数据。可见,反复阅读的效果。重复读已经做了,只对已有行的改变操作有效,但是对于新插入的行记录,就没那么幸运了,幻读就是这样。我们看一下这个过程:事务A启动后,执行更新操作,将age=1的记录名称改为“Kite2”;事务B启动后,事务执行更新后,执行插入操作,插入记录age=1,name=ancientkite,与事务A修改的记录的值相同,然后提交。事务B提交后,在事务A中执行select查询age=1的数据。这时候你会发现多了一行,你会发现另外一条name=ancientkite,age=1的记录,这其实是事务B刚插入的,这是幻读。需要注意的是,在MySQL中测试幻读时,不会出现上图所示的结果。不会发生幻读。MySQL的可重复读隔离级别实际上解决了幻读的问题,后面会讲内容描述序列化序列化是四种事务隔离级别中隔离效果最好的。解决了脏读、可重复读、幻读的问题,但效果是最差的。它将事务的执行变为顺序执行。与其他三种隔离级别相比,它相当于单线程,后一个事务的执行必须等待前一个事务结束。MySQL中如何实现事务隔离首先,readuncommitted,它的性能是最好的,也可以说是最残暴的一种方式,因为它根本不加锁,所以完全没有隔离效果,可以理解为没有隔离。让我们谈谈序列化。读的时候加共享锁,即其他事务可以并发读,但是不能写。写入时加排它锁,其他事务不能并发写入或读取。最后,读取提交和可重复读取。这两个隔离级别比较复杂,既要允许一定的并发量,又要平衡地解决问题。实现可重复读为了解决不可重复读,或者说实现可重复读,MySQL采用了MVVC(多版本并发控制)方式。我们在数据库表中看到的一行记录,实际上可能有多个版本。每个版本的记录除了数据本身,还有一个表示版本的字段,记录为行trx_id,这个字段是为了让它生成交易的id,交易ID记录为交易id,在交易开始时应用于交易系统,并按时间顺序递增。根据上图,现在一条行记录有3个版本,每个版本记录了它产生的事务ID。比如事务A的transactionid是100,那么版本1的rowtrx_id就是100,版本2和版本3也一样。上面介绍readcommit和repeatableread的时候,提到了一个词叫snapshot,学名叫做一致性视图,这也是可重复读和不可重复读的关键。可重复读是在事务开始时产生一个当前事务。事务的全局快照,readcommit是在每次执行语句时重新生成快照。对于一个快照来说,它能读取那些版本数据,它必须遵循以下规则:当前事务中的更新是可以读取的;版本未提交,无法读取;版本已经提交,但是创建快照后提交,无法读取;版本已提交,在快照创建前提交,可以读取;使用上面的规则,再套用回readcommit和repeatableread这两张图就很清楚了。我还是要强调一下,两者的主要区别是在创建快照时,repeatablereads只在事务开始时创建一次,而readcommits是在每次执行语句时重新创建。这种情况存在并发写入问题,两个事务修改同一条数据。最后的结果应该是哪笔交易的结果,时间上肯定是晚一点的吧?并且您必须在更新之前读取数据。这里所说的阅读与上面所说的阅读不同。更新前的读数称为“当前读数”,始终为当前版本的数据,即多个版本中最新提交的版本。.假设事务A执行更新操作,更新时必须对修改的行加行锁,提交后释放行锁。在事务A提交之前,事务B也想更新这行数据,于是申请了行锁,但是由于已经被事务A占用,事务B无法申请。此时事务B会一直处于等待状态,直到事务A提交后,事务B才能继续执行。如果事务A耗时过长,那么事务B很可能会出现超时异常。如下所示。加锁过程分为两种情况:索引和非索引。比如下面的语句updateusersetage=11whereid=1id就是这张表的主键。如果有索引,MySQL会直接在索引号中查找这一行。数据,然后干净利落地加行锁。但是下面的语句updateusersetage=11whereage=10并没有为表中的age字段设置索引,所以MySQL无法直接定位到这一行数据。那怎么办,当然不是加表锁了。MySQL会为这张表的所有行加行锁,没错,所有行。但是在加了行锁之后,MySQL会进行一次过滤,如果发现不满意的行就释放锁,最后只留下符合条件的行。虽然最后只对符合条件的行加锁,但是释放锁的过程对性能影响很大。因此,如果是大表,建议合理设计索引。如果发生这种情况,就很难保证并发度。解决幻读上面介绍可重复读的时候,图片中标注幻读的地方其实在MySQL中是没有出现的。MySQL已经解决了可重复读隔离级别下的幻读问题。刚才说了解决并发写问题的方法是行锁,锁是用来解决幻读的,叫做间隙锁。MySQL结合了行锁和间隙锁来解决并发写和幻读的问题。这把锁叫做Next-Key锁。假设现在表中有两条记录,age字段已经被索引,两条记录中age的值分别为10和30。这时候数据库中会维护一组B+树作为索引,用于快速定位行记录。B+索引树是有序的,所以这张表的索引会被分成几个区间。如图,分为3个区间,(负无穷大,10],(10,30],(30,正无穷大],这3个区间可以加gaplock,之后我用下面两个A事务演示加锁过程,在事务A提交之前,事务B的插入操作只能等待,这就是间隙锁的作用,当事务A执行updateusersetname='Kite2'whereage=10;时,由于条件whereage=10,数据库不仅在age=10的行上加了行锁,还在这条记录的两边,也就是(negativeinfinity,10]的两个区间,(10,30]加了一个间隙锁,使得事务B的插入操作无法完成,只能等待事务A的提交,不仅age=10的记录的插入需要等待事务A的提交,但是age<10和10