Mysql事务和锁特性
一、事务的ACID特性
什么是事务?
在数据库系统里而言,事务是代表一个或者一系列操作的最小逻辑单元,所有在这个逻辑单元内的操作要么全部成功,要么就全部失败,不存在任何中间状态,一旦事务失败那么所有的更改都会被撤消,一旦事务成功所有的操作结果都会被保存。
“一致性是事务的最终目的,而原子性、隔离性、持久性其实都是为了实现一致性的手段”。
1、原子性(Atomicity)
概念:一个事务必须是一系列操作的最小单元,这系列操作的过程中,要么整个执行,要么整个回滚,不存在只执行了其中某一个或者某几个步骤。
对应到转账操作中,原子性就代表(检查余额、转账、到账)三个步骤就是一个整体,少了任何一个都不能称为一次转账,整个过程中检查余额、转账、到账要么整体都执行,要么一个失败就整体失败,绝对不会出现某一个执行成功其它的都执行失败,或者某一个执行失败其它的操作执行成功的情况。
2、隔离性(Isolation)
概念:隔离性是说两个事务的执行都是独立隔离开来的,事务之前不会相互影响,多个事务操作一个对象时会以串行等待的方式保证事务相互之间是隔离的:
小明和小芳各自有一本作业本,如果他们同时去写作业,这时他们都可以在各自作业本上写作业是相互不影响的。但是如果他们两个人只有一本作业本,但是他们都想去写作业怎么办,那么就这个时候就只能等一个人先写完作业后,另外一个人才能写,要不然两个人同时在同一个作业本上写作业,那么肯定会乱套。所以这种两个事物操作同一个对象必须隔离开来不能相互影响的特性称为事务的隔离性。
3、一致性(Consistency)
概念:事务要保证数据库整体数据的完整性和业务的数据的一致性,事务成功提交整体数据修改,事务错误则回滚到数据回到原来的状态;
如上面转账的案例,如果事务提交成功则A账户减金额,B账户则加对应的金额,数据库总体金额不变只是载体变了。如果事务出错则整体回滚,无论到了上面的哪个步骤A和B的数据都会回到最事务开启前的状态保证数据的始终一致;
4、持久性 (Durability)
概念:持久性是指一旦事务成功提交后,只要修改的数据都会进行持久化,不会因为异常、宕机而造成数据错误或丢失。
二、事务并发与事务隔离
用什么方式实现事务的隔离性
通常数据库里都是采用锁的机制,保证事务之间的隔离性,在一个事务对数据进行修改的时候,首先会对该数据进行加锁,在当前事务没有释放锁之前,后续的事务是无法对该数据再次进行加锁的,所以其它事务只能等待,只有前一个事务释放了锁之后,后面的事务才能进行加锁。通过加锁的方式来保证这种先来后到的顺序,以隔离多个事务对数据的操作,从而实现事务的隔离性。
事务并发问题与事务隔离级别
在事务并发执行的时候,如果不进行事务隔离,那么就会产生脏写、脏读、重复读、幻读的问题。为了解决这些问题,数据库也针对不同的场景通过加锁的形式进行了不同程度的隔离,下面我们了解下产生这些问题的根源,然后不同的事务隔离级别是通过什么方式解决这些问题的。
1、脏写问题
在事务并发的时候,一个事务可以修改另外一个正在进行中的事务的数据,这可能会导致一个写的事务会覆盖另外一个写的事务数据,这也就是脏写问题。
如何解决脏写问题
在事务隔离级别READ_UNCOMMITTED 解决了脏写的问题,其原理是在READ_UNCOMMITTED事务隔离级别下,当事务对数据进行修改时,首先会对数据加写锁,加写锁成功后只有等事务提交或者回滚后才会释放,所以已经有一个事务对数据加了写锁,那么其他事务就会因为无法获取对应数据的锁而阻塞,所以在READ_UNCOMMITTED事务隔离级别下,多个事务是无法对同一个数据同时进行修改。
2、脏读问题
在事务并发的时候,一个事务可以读取到另外一个正在进行中的事务数据,这产生了脏读问题。
如何解决脏读问题
在事务隔离级别READ_COMMITTED(读提交)解决了脏读的问题,其原理是在READ_COMMITTED的事务隔离级别下,当事务对数据进行修改时,得先要对数据加写锁,当事务读取数据时,首先需要对数据加读锁。因为写锁与读锁不能共存,所以在修改数据的时候,其它事务会因为无法成功加读锁而阻塞,所以READ_COMMITTED 的事务隔离级别下,一个事务就无法读取另外一个未完成的事务所修改的数据了。
3、不可重复读问题
在事务并发的时候,一个事务里多次对同一个数据进行读取,但是读取到的结果是不一样的,这种问题称为不可重复读问题。
如何解决不可重复读
不可重复读产生的核心问题是,在一个事务第1次读取和第2次读取数据的间隔过程中可以被另外一个事务修改,因为在READ_COMMITTED的事务隔离级别下,事务中每次读取数据结束后(事务未结束)就会释放读锁,而一旦读锁释放后另外一个事务就可以加写锁,最终导致事务中多次读取该数据的间隙中可以被其它事务修改。
而REPEATABLE_READ (可重复读)的事务隔离级别下,一个事务中的读取操作会对数据加读锁(并且在当前事务结束之前不会释放),此时另外一个事务对该数据修改之前会尝试加写锁(此时不会成功,因为读写锁冲突),所以就避免了一个事务多次读取的数据的间隔可以被另外一个事务修改。
不过实际实现的过程中,数据库解决不可重复读的方式会有所不同,在Mysql innodb引擎中,解决不可重复读的问题并不是通过加锁实现,而是通过MVCC机制实现,使用MVCC后读取数据的时候不会加读锁,而是读取的历史版本数据,在RR事务隔离级别里,MVCC保证了在一个事务里多次读取的数据历史版本是一致的,所以就无法看到最新修改的数据,这样也就保证了一个事务里多次读取到的数据肯定是一致的。
4、幻读问题
在事务并发的时候,一个事务可以往另外一个正在读取的事务查询范围内插入新数据,导致另外一个事务在第二次查询数据里,要比前一次查询的数据要多,同样的SQL后面一次查询凭空多出了数据,像幻觉一样所以称为幻读。
解决幻读的两种方式
1、设置事务隔离级别为SERIALIZABLE
在SERIALIZABLE事务隔离级别下,所有的事务都串行化执行,一个事务的执行必须等前面的事务结束,这样的话查询的时候就无法有其他事务查询新的数据,所以不会产生幻读问题。
2、加间隙锁
幻读问题的本质在于,没有对查询范围内的所有数据进 (包括不存在的数据)进行加锁,而导致改查询范围内可以被插入新的数据,所以使用间隙锁,对查询的范围进行加锁,此时新插入的数据的事务会因为无法加锁成功而阻塞,所以就避免了幻读。比如表数据如下图, 那么此时如果执行select * from user where id>2 时 ,间隙锁会对id>2的空间加锁,所以此时我们另外一个事务插入ID为3 、4、6、7....... 都会因为锁阻塞而无法成功。
总结:
为解决不同场景的并发事务问题,事务定义了四种隔离级别,每个隔离级别都针对事务并发问题中的一种或几种进行解决,事务级别越高,解决的并发事务问题也就越多,同时也意味着加的锁就越多,所以性能也会越差。不同的隔离级别下它们加锁的情况如下:
READ_UNCOMMITTED
事务读取:不加锁
事务写入:加写锁
解决问题:脏写
存在问题:脏读,不可重复读、幻读。
READ_COMMITTED
事务读取:加读锁(每次select完成都会释放读锁)
事务写入:加写锁
解决问题:脏写、脏读
存在问题:不可重复读、幻读。
REPEATABLE_READ
事务读取:加读锁(每次select完不会释放锁,而是事务结束后才释放)(如果是Mysql的innodb还会加间隙锁)。
事务写入:加写锁
解决问题:脏写、脏读、不可重复读,幻读(如果是Mysql的innodb则已解决)
存在问题:幻读 (如果是Mysql的innodb则不存在)。
SERIALIZABLE
不管读取还是修改所有的事务串行化执行,一个事务的执行必须等其他事务结束。
三、Mysql里的锁
数据库的锁是为了解决事务的隔离性问题,为了让事务之间相互不影响,每个事务进行操作的时候都会对数据加上一把特有的锁,防止其他事务同时操作数据。
Mysql里的锁是基于什么实现的?
数据库里面的锁是基于索引实现的,在Innodb中我们的锁都是作用在索引上面的,当我们的SQL命中索引时,那么锁住的就是命中条件内的索引节点(行锁),如果没有命中索引的话,那我们锁的就是整个索引树(表锁),如下图一下锁住的是整棵树还是某几个节点,完全取决于你的条件是否有命中到对应的索引节点。
innodb索引结构图(B+ tree):
锁的分类
-
基于锁的属性分类:共享锁、排他锁。
-
基于锁的粒度分类:表锁、行锁、记录锁、间隙锁、临键锁。
-
基于锁的状态分类:意向共享锁、意向排它锁。
1、属性锁
共享锁(Share Lock)
共享锁又称读锁,简称S锁;当一个事务为数据加上读锁之后,其他事务只能对该数据加读锁,而不能对数据加写锁,直到所有的读锁释放之后其他事务才能对其进行加持写锁。
共享锁的特性主要是为了支持并发的读取数据,读取数据的时候不支持修改,避免出现重复读的问题。
排他锁(eXclusive Lock)
排他锁的目的是在数据修改时候,不允许其他人同时修改,也不允许其他人读取。避免了出现脏数据和脏读的问题。
2、粒度锁
表锁
表锁是指上锁的时候锁住的是整个表,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问;
特点:粒度大,加锁简单,容易冲突;
行锁
行锁是指上锁的时候锁住的是表的某一行或多行记录,其他事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问;
特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高;
记录锁(Record Lock)
记录锁也属于行锁中的一种,只不过记录锁的范围只是表中的某一条记录,记录锁是说事务在加锁后锁住的只是表的某一条记录。
触发条件:精准条件命中,并且命中的条件字段是唯一索引;
例如:update user_info set name=’张三’ where id=1 ,这里的id是唯一索引。
记录锁的作用:加了记录锁之后数据可以避免数据在查询的时候被修改的重复读问题,也避免了在修改的事务未提交前被其他事务读取的脏读问题。
间隙锁(Gap Lock)
间隙锁属于行锁中的一种,间隙锁是在事务加锁后其锁住的是表记录的某一个区间,当表的相邻ID之间出现空隙则会形成一个区间,遵循左开右闭原则。
比如下面的表里面的数据ID 为 1,4,5,7,10 ,那么会形成以下几个间隙区间,-n-1区间,1-4区间,7-10区间,10-n区间 (-n代表负无穷大,n代表正无穷大)
触发条件:范围查询并且查询未命中记录,查询条件必须命中索引、间隙锁只会出现在REPEATABLE_READ(重复读)的事务级别中。
例如:对应上图的表执行select * from user_info where id>1 and id<4(这里的id是唯一索引) ,这个SQL查询不到对应的记录,那么此时会使用间隙锁。
间隙锁作用:防止幻读问题,事务并发的时候,如果没有间隙锁,就会发生如下图的问题,在同一个事务里,A事务的两次查询出的结果会不一样。
临键锁(Next-Key Lock)
临键锁也属于行锁的一种,并且它是INNODB的行锁默认算法,总结来说它就是记录锁和间隙锁的组合,临键锁会把查询出来的记录锁住,同时也会把该范围查询内的所有间隙空间也会锁住,再之它会把相邻的下一个区间也会锁住。
例如:下面表的数据执行 select * from user_info where id>1 and id<=13 for update ;
会锁住ID为5,10的记录;同时会锁住,1至5,5至10,10至15的区间。
触发条件:范围查询并命中,查询命中了索引。
临键锁的作用:结合记录锁和间隙锁的特性,临键锁避免了在范围查询时出现脏读、重复读、幻读问题。加了临键锁之后,在范围区间内数据不允许被修改和插入。
3、状态锁
状态锁包括意向共享锁和意向排它锁,把他们区分为状态锁的一个核心逻辑,是因为这两个锁都是都是描述是否可以对某一个表进行加表锁的状态。
意向锁的解释:当一个事务试图对整个表进行加锁(共享锁或排它锁)之前,首先需要获得对应类型的意向锁(意向共享锁或意向共享锁)
意向共享锁
当一个事务试图对整个表进行加共享锁之前,首先需要获得这个表的意向共享锁。
意向排他锁
当一个事务试图对整个表进行加排它锁之前,首先需要获得这个表的意向排它锁。
为什么我们需要意向锁?
意向锁光从概念上可能有点难理解,所以我们有必要从一个案例来分析其作用,这里首先我们先要有一个概念那就是innodb加锁的方式是基于索引,并且加锁粒度是行锁,然后我们来看下面的案例。
第一步:
事务A对user_info表执行一个SQL:update user_info set name =”张三” where id=6 加锁情况如下图;
第二步:
与此同时数据库又接收到事务B修改数据的请求:SQL: update user_info set name =”李四”;
1、因为事务B是对整个表进行修改操作,那么此SQL是需要对整个表进行加排它锁的(update加锁类型为排他锁);
2、我们首先做的第一件事是先检查这个表有没有被别的事务锁住,只要有事务对表里的任何一行数据加了共享锁或排他锁我们就无法对整个表加锁(排他锁不能与任何属性的锁兼容)。
3、因为INNODB锁的机制是基于行锁,那么这个时候我们会对整个索引每个节点一个个检查,我们需要检查每个节点是否被别的事务加了共享锁或排它锁。
4、最后检查到索引ID为6的节点被事务A锁住了,最后导致事务B只能等待事务A锁的释放才能进行加锁操作。
思考:
在A事务的操作过程中,后面的每个需要对user_info加持表锁的事务都需要遍历整个索引树才能知道自己是否能够进行加锁,这种方式是不是太浪费时间和损耗数据库性能了?
所以就有了意向锁的概念:如果当事务A加锁成功之后就设置一个状态告诉后面的人,已经有人对表里的行加了一个排他锁了,你们不能对整个表加共享锁或排它锁了,那么后面需要对整个表加锁的人只需要获取这个状态就知道自己是不是可以对表加锁,避免了对整个索引树的每个节点扫描是否加锁,而这个状态就是我们的意向锁。
在测试锁的实操过程中需要注意的问题。
看了这么多理论,相信很多人都会去实际操作一下,一是验证逻辑,而是加深自己的理解,为了避免大家少踩坑,在这里提醒几个需要注意的地方。
1、关闭自动提交事务功能,自己手动begin commit rollback。
show variables like 'autocommit' Set autocommit = 0
begin select * from tb_user commit
2、查看当前会话隔离级别是否为REPEATABLE-READ(一般默认都是此级别)
SELECT @@tx_isolation
3、最重要的一点,查询数据的时候要使用当前读(因为Mysql 有MVCC的机制所以很多情况下都不会进行加锁,使用当前读就不会使用MVCC) 比如使用下面这个 for update 就是使用当前读。
BEGIN select * from tb_user where id>3 and id<10 for updateCOMMIT