likes
comments
collection
share

手把手教你解数据库死锁

作者站长头像
站长
· 阅读数 11

手把手教你解数据库死锁

相信大家在使用数据库过程中难免会因为书写不当的问题遇到死锁问题。怎么预防死锁?以及,死锁问题发生之后,我们应该如何一步步分析去解开死锁呢?本文将与大家一起探讨这个话题。

什么是死锁

对于死锁的定义,百度百科是这样定义的:

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

手把手教你解数据库死锁

但是具有死锁风险的程序也不一定会发生死锁,死锁并非100%必现。死锁的发生需要满足以下条件:

  1. 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
  2. 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
  3. 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  4. 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

解死锁实战

在了解了死锁之后,接下来,我将以我最近遇到的一个数据库死锁case为例,带大家一步步去分析死锁的产生。为避免业务场景增大大家的理解成本,我会对此case做相应的简化处理。

相关背景

有这么一个明细表detail

CREATE TABLE `detail` (
	`id` BIGINT ( 20 ) NOT NULL AUTO_INCREMENT COMMENT '主键id',
	`code` VARCHAR ( 10 ) NOT NULL DEFAULT '' COMMENT 'code',
	`desc` VARCHAR ( 50 ) NOT NULL DEFAULT '' COMMENT '描述',
	`status` TINYINT ( 1 ) NOT NULL DEFAULT '1' COMMENT '状态,1-有效,0-无效',
	PRIMARY KEY ( `id` ),
        KEY `idx_code` ( `code` ) USING BTREE COMMENT 'code' 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4

索引情况:主键索引id、非唯一的二级索引idx_code

数据库的隔离级别为RR

业务上有这么一段处理逻辑

    @Transactional(rollbackFor = Exception.class)
    public void updateDetail(Detail newDetail) {
        detailMapper.invalidByCode(newDetail.getCode())
        detailMapper.insertSelective(newDetail);
    }

比较简单的一段处理逻辑,事务中,先根据code无效掉已有的数据,然后再插入该code对应的新的数据

数据库已经存在的数据:

手把手教你解数据库死锁

此时,detail表的索引情况为:

手把手教你解数据库死锁

supremum这条记录是啥?

我们可以简单理解为是数据页中的一条“伪记录”。mysql的数据页中,不管有多少自己的记录,始终会存在两条虚拟的记录,也就是伪记录,分别是“Infimum”(最小记录)和“Supremum”(最大记录)。要知道,一个数据页中多条记录存储类似于链表,Infimum->1->2->3->...->Supremum,这种。

死锁发生:

2021-03-22 20:15:44.067 [- | xxxxxxThread2] ERROR c.l.s.s.t.t.e.xxxxx - 执行任务出现异常
org.springframework.dao.DeadlockLoserDataAccessException: 
### Error updating database.  Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
### The error may exist in class path resource [mybatis/mappers/xxxxxMapper.xml]
### The error may involve com.xxx.xxxxxMapper.insertSelective-Inline
### The error occurred while setting parameters
### SQL: insert into detail xxxx
### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
; Deadlock found when trying to get lock; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
	at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:267)
	at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:72)
	at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:88)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:440)
	at com.sun.proxy.$Proxy149.insert(Unknown Source)
	at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:271)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:62)
	at org.apache.ibatis.binding.MapperProxy$PlainMethodInvoker.invoke(MapperProxy.java:144)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:85)
	....
        ....
        ....

日志查看: 在死锁发生后,查看了数据库的死锁日志

查看mysql死锁日志的命令为:

show engine innodb status

我截取了死锁部分的日志,如下:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2021-03-26 19:35:39 0x70000c70f000
*** (1) TRANSACTION:
TRANSACTION 16632, ACTIVE 8 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 15, OS thread handle 123145510760448, query id 2824 localhost 127.0.0.1 root update
INSERT INTO `test`.`detail`( `code`, `desc`, `status`) VALUES ( 'w', 'white', 1)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 67 page no 4 n bits 80 index idx_code of table `test`.`detail` trx id 16632 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) TRANSACTION:
TRANSACTION 16633, ACTIVE 6 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 16, OS thread handle 123145511038976, query id 2828 localhost 127.0.0.1 root update
INSERT INTO `test`.`detail`( `code`, `desc`, `status`) VALUES ( 'y', 'yellow', 1)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 67 page no 4 n bits 80 index idx_code of table `test`.`detail` trx id 16633 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 67 page no 4 n bits 80 index idx_code of table `test`.`detail` trx id 16633 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (2)

死锁日志解读

以上相关背景中,我罗列了数据库的表结构、索引、数据情况,以及死锁发生的代码片段,并对mysql的死锁日志进行了查看。接下来,就是解读得到的日志,结合已有知识,一步步分析死锁的发生原因。

事务一相关信息

TRANSACTION 16632, ACTIVE 8 sec inserting

TRANSACTION 16632 表示事务的id

ACTIVE 8 sec 表示事务活动时间

inserting 为事务当前正在运行的状态,可能的事务状态有:fetching rows,updating,deleting,inserting 等。

mysql tables in use 1, locked 1

tables in use 1 表示有一个表被使用

locked 1 表示有一个表锁

LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1

LOCK WAIT 表示事务正在等待锁

3 lock struct(s) 表示该事务的锁链表的长度为 3,每个链表节点代表该事务持有的一个锁结构,包括表锁,记录锁以及 autoinc 锁等

heap size 1136 为事务分配的锁堆内存大小

2 row lock(s) 表示当前事务持有的行锁个数,通过遍历上面提到的 3 个锁结构,找出其中类型为 LOCK_REC 的记录数

undo log entries 1 表示当前事务有 1 个 undo log 记录

MySQL thread id 15, OS thread handle 123145510760448, query id 2824 localhost 127.0.0.1 root update

事务的相关线程信息,没有太大用处

INSERT INTO test.detail( code, desc, status) VALUES ( 'w', 'white', 1)

这里显示的是正在等待锁的 SQL 语句,可以看到,在执行insert时发生了锁等待,处于阻塞

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:

字面意思,即表明当前事务正在等待的锁

RECORD LOCKS space id 67 page no 4 n bits 80 index idx_code of table test.detail trx id 16632 lock_mode X insert intention waiting

解析此句日志时,先来补充下mysql中的锁相关知识。 我们都知道,mysql中的锁按照不同的维度可以分为很多种,行锁、表锁;共享锁、排他锁;记录锁、间隙锁、邻键锁等等。那么InnoDB中的锁到底是什么样的?

手把手教你解数据库死锁

RECORD LOCKS:事务在等待的锁为记录锁

space id 67:表空间号为67

page no 4:数据页的页号为4

n bits 80:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。

index idx_code of table test.detail:索引信息,表明是二级索引idx_code上的锁

lock_mode X insert intention:插入意向锁

waiting:等待中

如何根据日志去辨别究竟是什么锁呢?

记录锁 - lock_mode X locks rec but not gap

间隙锁 - lock_mode X locks gap before rec

邻键锁(Next-key锁) - lock_mode X

插入意向锁 - lock_mode X locks gap before rec insert intention

supremum记录上的锁的特殊性

记录锁 - lock_mode X locks rec but not gap

间隙锁 - lock_mode X

邻键锁(Next-key锁) - lock_mode X

插入意向锁 - lock_mode X insert intention

插入意向锁是个啥玩意呢?

在InnoDB中,一个事务在插入一条记录时,需要判断一下插入的位置是不是被别的事务加了间隙锁(临键锁也包含了间隙锁),如果有的话,当前事务的插入操作就需要等待,直至拥有间隙锁的事务提交。插入意向锁本身也是一个特殊的间隙锁。

用间隙锁代替插入意向锁行吗?

既然插入意向锁本身是一个特殊的间隙锁,用间隙锁代替插入意向锁不行吗?为啥还得再整一个插入意向锁?我们知道,间隙锁的作用仅仅是为了防止插入幻影记录而存在,间隙锁之间并不是互斥的。且间隙锁本身并不会产生堵塞,也就是唤起等待,而插入意向锁是具有堵塞功能的,间隙锁和插入意向锁共同作用,才是解决幻读的根本。

Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0 0: len 8; hex 73757072656d756d; asc supremum;;

以上我们知道了事务阻塞时所在等待的锁类型,那么,这个在等待的锁究竟是加在了哪条记录上呢?

数据页的结构中有这么一个属性,heap_no,表示当前记录在本页中的位置。Infimum的heap_no值为0,Supremum的heap_no值为1。

supremum:表明是在等待的锁是“supremum”这条记录上的锁

上述的一坨解析,我们现在应该弄清楚了死锁发生时,事务一的行为:

事务一在等待idx_code索引上,supremum这条记录的插入意向锁。

事务二相关信息

事务二的日志信息与事务一基本类似,我们就不重复分析了。只不过事务二多了一部分,即*** (2) HOLDS THE LOCK(S):,它表明了死锁发生时事务二持有的锁情况,我们简要看一下。

RECORD LOCKS space id 67 page no 4 n bits 80 index idx_code of table test.detail trx id 16633 lock_mode X

表明事务二正持有一把索引idx_code上的邻键锁

Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0 0: len 8; hex 73757072656d756d; asc supremum;;

表明事务二持有的锁是“supremum”这条记录上的锁

结合其余日志信息,我们可以知道死锁发生时,事务二的行为:

事务二持有idx_code索引上,supremum这条记录的邻键锁。且正在等待idx_code索引上,supremum这条记录的插入意向锁。

死锁推理

到目前为止,我们已经通过日志获得了死锁发生时,事务一和事务二的很多关键信息。接下来就是推理并解开死锁的时候了。

之前玩过的一个侦探游戏中有这么一句话,我很喜欢,“所谓推理,不过是模仿凶手的过程罢了”。对于我们解死锁而言,也是一样的道理,要想解开死锁,就要模拟出死锁发生的过程。

那么根据以上的各种分析,我们能模拟出死锁的发生吗?实际上这是非常困难的。如果每个事务只有一条SQL语句,我们可以仅通过死锁日志就能分析出死锁的成因。但是,大多数情况下,每个事务都不止一条 SQL 语句。上面的undo log entries 1 表明了执行insert时还执行了其他的 SQL 语句,但是具体是什么并未在日志中体现。另外,从日志中,我们只知道了事务二在发生死锁是持有的锁情况,并不知道事务一的锁持有情况。所以,对死锁的诊断不能仅仅靠死锁日志,还应该结合应用程序的代码来进行分析。当然,如果你看不到具体的应用代码,你也可以通过数据库的 binlog来分析。当然,我本身就是一个研发人员,所以,接下来,就站在研发的角度,结合应用程序代码去推理死锁的发生。

我们再把开头所说到的业务代码拎出来:

    @Transactional(rollbackFor = Exception.class)
    public void updateDetail(Detail newDetail) {
        detailMapper.invalidByCode(newDetail.getCode())
        detailMapper.insertSelective(newDetail);
    }

也就是在insert一条新的详情之前,会先根据新的详情的code进行一次invalid操作。以上的两行代码对应的sql为:

UPDATE detail set `status`=0 where `code`= ? ;
INSERT INTO `test`.`detail`( `code`, `desc`, `status`) VALUES ( ?, ?, ?);

日志中显示,事务一死锁时执行的insert的sql为: INSERT INTO test.detail( code, desc, status) VALUES ( 'w', 'white', 1) 因此,我们可以知道,整个事务一执行的具体sql为:

UPDATE detail set `status`=0 where `code`='w' ;
INSERT INTO `test`.`detail`( `code`, `desc`, `status`) VALUES ( 'w', 'white', 1);

同理,整个事务二执行的具体sql为:

UPDATE detail set `status`=0 where `code`='y' ;
INSERT INTO `test`.`detail`( `code`, `desc`, `status`) VALUES ( 'y', 'yellow', 1);

我们知道,为了防止幻读的发生,在RR级别下,事务中执行update语句会加间隙锁/邻键锁(取决于需要update的记录是否存在)。事务一需要更新的记录为code='w',code='w'的记录并不存在,为了防止其他事务插入code='w'的记录,因此会使用间隙锁。而code='w'的插入位置是“supremum”之前,因此会对“supremum”加上间隙锁。而执行insert语句时,判断需要插入的位置是“supremum”之前,因此会生成一个“supremum”记录的插入意向锁。事务二同理。

手把手教你解数据库死锁

我们用示意图来恢复下当时的死锁现场(简化了锁结构):

手把手教你解数据库死锁

细心的读者肯定会发现,前文不是说对“supremum”记录加间隙锁吗?怎么图上就变成了邻键锁呢?这里先卖个关子,后续如果有时间,会专门写一篇数据库的锁的文章。邻键锁本身就包含了间隙锁,在此可以当做间隙锁看待,不影响。

从上图可以很清楚地推理出死锁的产生原因了。

事务一和事务二执行update语句时,会产生“supremum”记录的的邻键锁。当事务一和事务二执行insert语句时,会产生“supremum”记录的插入意向锁。间隙锁和插入意向锁是不能同时存在的。事务一等待事务二释放“supremum”记录的的邻键锁。事务二等待事务一释放“supremum”记录的的邻键锁。互相等待的时候,死锁也就形成了!时间线如下:

事务一事务二
UPDATE detail set status=0 where code='w'
UPDATE detail set status=0 where code='y'
INSERT INTO test.detail( code, desc, status) VALUES ( 'w', 'white', 1)
INSERT INTO test.detail( code, desc, status) VALUES ( 'y', 'yellow', 1)

解死锁

找到死锁的形成原因之后,就是解死锁了,破坏死锁的产生条件即可。

因为我们需要update的字段是status,该字段并不在索引idx_code中,因此,我们可以采用by id的形式进行update,由于idx_code索引中并不包含status字段,因为by id的方式,并不会对idx_code进行加锁操作,也就不会形成死锁啦。

总结

解死锁,不过是模仿死锁形成的过程罢了。

在我们日常的开发过程中,可能不经意间就写出了存在死锁可能的代码。而毕竟死锁也不是100%必现的,很有可能具有死锁风险的代码在极长一段时间内都不会有死锁发生。但我们需要做的就是,加强自己对数据库锁的理解,理解锁的类型及作用仅仅是第一步,更多需要做的是理解锁的结构,理解mysql大佬们创建各种各样的锁的真正思想。

转载自:https://juejin.cn/post/6944615453700390919
评论
请登录