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

MySQL死锁了,怎么办?_0

时间:2023-03-12 17:21:37 科技观察

之前分享了MySQL死锁文章,很多读者对“插入意向锁”感到困惑。人们误以为“insertintentlock”是意向锁,即表锁。确实,这个名字非常具有误导性。但实际上,“插入意向锁”并不是意向锁,而是一种特殊的间隙锁,属于行级锁。注意是“特殊”的间隙锁,不是我们常说的间隙锁。因此,我在原文章的基础上增加了两个知识点:什么是插入意向锁?插入语句是如何锁定的?大纲如下:有一个主要的业务逻辑,包括新增订单、修改订单、查询订单等操作。然后因为订单不能重复,所以我在加单的时候做了一次幂等校验。方法是在添加订单记录前通过select...forupdate语句检查订单是否存在。如果不存在插入订单记录。正是因为这样的操作,在业务量大的时候可能会出现死锁。下面就和大家说说为什么会出现死锁,以及如何避免死锁。死锁的发生本例使用存储引擎Innodb,隔离级别为RepeatableRead(RR)。接下来,我将以实际的方式向您展示死锁是如何发生的。我建了一张order表,其中id字段为主键索引,order_no字段为普通索引,即非唯一索引:CREATETABLE`t_order`(`id`intNOTNULLAUTO_INCREMENT,`order_no`intDEFAULTNULL,`create_date`datetimeDEFAULTNULL,PRIMARYKEY(`id`),KEY`index_order`(`order_no`)USINGBTREE)ENGINE=InnoDB;那么,t_order表中现在有6条记录:假设此时有两笔交易,一笔交易需要插入订单1007,另一笔交易需要插入订单1008。因为订单需要检查幂等性,两个事务首先要检查订单是否存在,如果不存在则插入记录。过程如下:可以看到,两个事务都处于等待状态(前提是没有开启死锁检测),也就是因为互相等待对方释放锁而产生了死锁。这里在查询记录是否存在时,使用select...forupdate语句来防止事务执行过程中其他插入记录的事务产生幻读。如果不使用select...forupdate语句,而是使用简单的select语句,如果同时进来两个相同订单号的请求,会出现两个重复的订单,可能出现幻读,如图下图中:为什么会出现死锁?在可重复读隔离级别下,存在幻读问题。为了解决“可重复读”隔离级别下的幻读问题,Innodb引擎引入了next-key锁,它是记录锁和间隙锁的组合。RecordLoc,记录锁,锁定记录本身;GapLock,间隙锁,锁定两个值之间的间隙,防止其他事务在这个间隙插入新的数据,从而避免幻读。普通的select语句不会对记录加锁,因为它是通过MVCC机制读取的快照。如果想在查询的时候给记录加行锁,可以使用下面两种方法:begin;//read给取到的记录加共享锁select...lockinsharemode;commit;//lockreleasebegin;//给读记录加独占锁select...forupdate;commit;//lockreleaserowlockrelease时机是在事务提交(commit)后,会释放锁,执行完一条语句后不会释放行锁。比如下面的事务A查询语句会锁定(2,+∞]范围内的记录,然后如果其他事务在此期间向这个锁定范围内插入数据,就会被阻塞。next-key的锁定规则锁其实挺复杂的,在某些场景下会退化成记录锁或者间隙锁,需要注意的是,如果update语句的where条件没有使用索引列,那么会扫描全表。在逐行扫描的过程中,不仅对行加了Row锁,还在行两边的间隙加了gap锁,相当于给整个表加锁,然后加锁就不会了直到事务结束才释放。因此,线上不要执行没有索引条件的update语句,否则会导致业务停滞。我的一位读者因此被老板教育。回到之前的死锁例子,当执行f以下语句:selectidfromt_orderwhereorder_no=1008forupdate;因为order_no不是唯一索引,行锁的类型是间隙锁,所以间隙锁的范围是(1006,+∞)。那么,当事务B将id=1008的记录插入间隙锁时,就会被锁住。因为当我们执行下面的insert语句时,会在insertgap上再次获取insertintentlock。插入t_order(order_no,create_date)values(1008,now());插入意向锁与间隙锁冲突,所以当其他事务持有间隙的间隙锁时,需要等待其他事务释放间隙锁,才能获取插入意向锁。间隙锁兼容间隙锁,所以两个事务中的select...forupdate语句不会互相影响。案例中,事务A和事务B在select...forupdate语句执行后都持有范围为(1006,+∞)的间隙锁,接下来的插入操作就是获取插入意向锁。等待对方事务的间隙锁释放,从而造成循环等待,导致死锁。为什么间隙锁与间隙锁兼容?MySQL官网上还有一个非常挑剔的描述:InnoDB中的间隙锁是“纯抑制性的”,也就是说它们的唯一目的就是防止其他事务插入到间隙中。间隙锁可以共存。一个事务获取的间隙锁不会阻止另一个事务在同一间隙上获取间隙锁。共享和排他间隙锁之间没有区别。它们彼此不冲突,并且它们执行相同的功能。这段话说明,间隙锁本质上没有共享间隙锁和互斥间隙锁的区别,间隙锁不是互斥的,即两个事务可以同时持有包含公共间隙的间隙锁。这里常见的gap包括两种场景:一种是两个gap锁的gap间隔完全一样;另一种是一个gaplock包含的gapinterval是另一个gaplock包含的gapinterval的子集。间隙锁本质上是用来防止其他事务在间隙中插入新的记录,而自己的事务则允许在间隙中插入数据。也就是说,间隙锁的应用场景包括并发读、并发更新、并发删除、并发插入。什么是插入意向锁?注意!插入意向锁虽然名字里有意向锁,但它不是意向锁,是一种特殊的间隙锁。MySQL官方文档中有一个重要的描述:Insert意向锁是Insert操作在rowInsertion之前设置的一种gaplock。这个锁以这样一种方式发出插入意图的信号,即如果多个事务插入到同一索引间隙中,如果它们不在间隙内的同一位置插入,则无需等待彼此。假设有值为4和7的索引记录,分别尝试Insert值为5和6的事务,在获得排他锁之前,分别用Insert意向锁锁定4和7之间的间隙Inserted行,但不要互相阻塞,因为这些行是不冲突的。这段话说明,插入意向锁虽然是一种特殊的间隙锁,但它与间隙锁的区别在于,该锁只用于并发的插入操作。如果间隙锁锁定一个范围,那么“插入意向锁”锁定一个点。所以从这个角度来看,插入意向锁确实是一种特殊的间隙锁。insertintentlocks和gaplock还有一个很重要的区别,就是“insertintentlocks”虽然也属于gaplocks,但是两个事务不能同时存在,一个持有gaplock,另一个拥有区间内的gap。插入意向锁(当然,如果不在gaplock区间内,也可以插入意向锁)。另外补充一下,插入意向锁的时机:每插入一条新记录,需要检查要插入的记录的下一条记录是否加了间隙锁。如果加了间隙锁,Insert语句应该是Blocks,生成insertintentlock。Insert语句是如何加行级锁的?Insert语句在正常执行期间不会生成锁结构。它依靠聚集索引记录自带的trx_id隐藏列作为隐式锁来保护记录。什么是隐式锁?当一个事务需要加锁时,如果锁不太可能发生冲突,InnoDB将跳过加锁过程。这种机制称为隐式锁定。隐式锁是InnoDB实现的一种延迟锁机制。它的特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统的整体性能。隐式锁在Insert过程中不加锁。只有在特殊情况下才会将隐式锁转换为显式锁。这里我们列出两种情况。如果记录之间存在间隙锁,为了避免幻读,此时不能插入记录;如果Insert记录与已有记录存在唯一键冲突,则此时无法插入该记录;1.记录之间存在间隙锁每次插入新记录时,需要检查要插入的记录的下一条记录是否加了间隙锁。如果加了间隙锁,应该阻塞Insert语句,产生插入意向锁。比如t_order表中,只有这些数据,order_no是二级索引。现在,事务A执行以下语句。#TransactionAmysql>begin;QueryOK,0rowsaffected(0.01sec)mysql>select*fromt_orderwhereorder_no=1006forupdate;Emptyset(0.01sec)接下来执行select*fromperformance_schema.data_locks\G;statement,判断事务A加的是什么类型的锁。这里只关注记录上的锁类型。可以看到加了X型锁,但是记录锁、间隙锁、next-key锁呢?注意这里LOCK_TYPE中的RECORD是指行级锁,不是记录锁。首先可以通过LOCK_MODE确认是“next-keylockorgaplock”还是“recordlock”:如果LOCK_MODE为X,则表示next-keylock或gaplock;如果LOCK_MODE是X,REC_NOT_GAP,表示记录锁。是next-key锁还是gap锁取决于LOCK_DATA信息。如果LOCK_DATA信息为supremum,说明是间隙锁;如果LOCK_DATA信息是一个具体的记录值,则表示它是一个next-key;因此,本例增加了一个间隙锁,间隙锁的范围为(1005,+∞)。然后,事务B在这个间隙锁中插入一条记录,此时事务B会被阻塞:#事务B插入一条记录mysql>begin;QueryOK,0rowsaffected(0.01sec)mysql>insertintot_order(order_no,create_date)values(1010,now());###阻塞状态。...接下来,我们执行select*fromperformance_schema.data_locks\G;语句来判断事务B加的是什么类型的锁。这里我们只关注记录上的锁类型。可以看出事务B的状态是等待,因为在事务A产生的间隙锁(1005,+∞)中插入了一条记录,所以事务B的插入操作产生了插入意向锁(LOCK_MODE:X,插入意图)。2.遇到唯一键冲突如果在插入新记录时,插入一条与现有记录具有相同主键或唯一二级索引列值的记录”(但是可以有多个唯一二级索引记录的索引列的值同时为NULL,这里不考虑这种情况),此时插入会失败,然后给这条记录加一个S型锁,至于行级锁的类型是不是记录锁还是next-key锁,跟是主键冲突还是唯一二级索引冲突有关系,如果主键值重复:当隔离级别为readcommitted时,插入新记录的事务会对已有的主键值重复的聚簇索引记录加S型记录锁,当隔离级别为可重复读(默认隔离级别)时,插入新记录的事务会在已有的记录上加S型next-key锁产业集群ex具有重复主键值的记录。如果唯一的二级索引列重复:无论隔离级别如何,插入新记录的事务都会对现有二级索引列值重复二级索引记录添加S型next-key锁。没错,即使是read-committed隔离级别也会添加next-key锁。这是read-committed隔离级别中为记录添加间隙锁的少数场景之一。因为如果不加间隙锁,唯一二级索引列值相同的多条记录会出现在唯一二级索引中,违反了UNIQUE约束。以下是唯一二级索引冲突的示例。在MySQL8.0版本中,事务隔离级别为可重复读(默认隔离级别)。t_order表中的order_no字段是唯一二级索引,已经有order_no值为1001的记录,此时事务A插入一条order_no值为1001的记录,出现错误。但是除了报错之外,很重要的一点就是给order_no值为1001的记录加一个S型next-key锁,我们可以执行select*fromperformance_schema.data_locks\G;语句来确定将哪种类型的锁添加到事务中。这里我们只关注记录上的锁类型。可以看出index_order二级索引中1001(LOCK_DATA)记录的锁类型是S型next-key锁。注意这里LOCK_TYPE中的RECORD是指行级锁,不是记录锁。如果是记录锁,LOCK_MODE会显示S,REC_NOT_GAP。此时事务B执行select*fromt_orderwhereorder_no=1001forupdate;而且会阻塞,因为这个语句要加X型锁,和S型锁冲突,所以会阻塞。从performance_schema.data_locks表我们也可以看出,事务B的状态(LOCK_STATUS)是等待状态,锁的类型是X类型的记录锁(LOCK_MODE:X,REC_NOT_GAP)。上面的案例是由于唯一二级索引重复导致插入失败的场景。接下来分析两个事务执行过程中执行同一条insert语句的场景。现在t_order表中,只有这些数据,order_no是唯一的二级索引。在隔离级别可重复读的情况下,开启两个事务,前后执行相同的Insert语句。这时候事务B的Insert语句就会被阻塞。两个事务的加锁过程:事务A先插入order_no为1006的记录,插入成功。此时对应的唯一二级索引记录被“隐式锁”保护,此时没有实际的锁结构;然后,事务B也插入了一条order_no值为1006的记录。由于事务A已经插入了一条order_no值为1006的记录,事务B在插入二级索引记录时会遇到重复的唯一二级索引列值。此时事务B想要获取一把S型next-key锁,但是事务A还没有提交。事务A插入的order_no值为1006的记录上的“隐式锁”会变成“显式锁”,锁类型为X型记录锁。因此,当事务B获取到S型next-key锁时,会遇到锁冲突,事务B进入阻塞状态。我们可以执行select*fromperformance_schema.data_locks\G;语句来确定将哪种类型的锁添加到事务中。这里我们只关注记录上的锁类型。先看事务A给order_no为1006的记录加了什么锁?从下图可以看出,事务A给order_no为1006的记录加了一个X类型的记录锁(注意这个锁是在事务B执行后产生的,在执行事务B之前,记录还是隐式锁)。然后看事务B要给order_no为1006的记录加什么锁?从下图可以看出,事务B想对order_no为1006的记录加一个S型next-key锁,但是由于事务A对该记录持有一个X型记录锁,所以这两个锁在冲突。所以导致事务B处于等待状态。通过这个实验可以知道,当多个事务并发时,第一个事务插入的记录不会被加锁,而是使用隐式锁来保护唯一二级索引的记录。但是当第一个事务还没有提交,其他事务插入和第一个事务相同的记录时,第二个事务就会被阻塞,因为此时第一个事务插入的记录中的隐式锁会被阻塞。变成显示锁,类型是X型记录锁,第二个事务想给记录加S型next-key锁。X型和S型锁冲突,所以第二个事务会等到第一个事务提交后锁被释放。如果order_no不是唯一的二级索引,那么前后执行同一条Insert语句的两个事务就不会被阻塞,就像前面的例子一样。如何避免死锁?死锁的四个必要条件:互斥、占有等待、不占有、循环等待。只要系统死锁,这些条件就必须成立,但只要其中任何一个条件被打破,死锁就不会成立。在数据库层面,通过“打破循环等待条件”解除死锁状态的策略有两种:为等待锁的事务设置超时时间。当一个事务的等待时间超过这个值时,事务被回滚,于是锁被释放,另一个事务可以继续执行。在InnoDB中,参数innodb_lock_wait_timeout用于设置超时时间,默认值为50秒。当发生超时时,会出现如下提示:Enableactivedeadlockdetection。主动死锁检测发现死锁后,主动回滚死锁链中的某个事务,让其他事务继续执行。将参数innodb_deadlock_detect设置为on表示开启这个逻辑,默认是开启的。当检测到死锁时,会出现如下提示:以上两种策略是避免“当死锁发生时”的方法。我们可以回到业务的角度来防止死锁。对订单进行幂等性检查的目的是保证不会出现重复订单。那么我们可以直接将order_no字段设置为唯一索引列,利用它的唯一性来保证order表中不会有重复的订单,但是不好的是当我们插入一条已经存在的订单记录时会抛出异常。参考资料:《MySQL 是怎样运行的?》http://mysql.taobao.org/monthly/2020/09/06/