起因是线上报了一个错误信息,Deadlock found when trying to get lock; try restarting transaction,这是Mysql检测到死锁后,自动回滚了事务引发的异常。spring框架里打印了异常堆栈,所以很快就定位到哪一行代码出了问题。
业务的伪代码如下:
val result = query("select * from game where user_id = xx and game_id = xx")
if (result == null) {
excute("Insert Into game_player(game_id,user_id) Values(?,?)")
}
-- table schema
create table game (
id bigint,
game_id bigint,
user_id bigint,
primary key(id),
constraint unique_game_id_user_id unique (game_id, user_id)
)
看了代码逻辑,只有insert的时候会加一个行锁,死锁要有两个锁被多个线程账户等待,才会发生。就算有并发的情况,同时插入两条一样的记录,第二个事务也是处于等待锁的状态,等待锁超时后就报获取锁超时,也不会发生死锁。
然后通过show engine innodb status命令看一下死锁的相关信息。
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-12-29 05:43:26 22399693092608
*** (1) TRANSACTION:
TRANSACTION 3701497, ACTIVE 29 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 43921, OS thread handle 22399139387136, query id 225853288 122.225.228.122 admin update
Insert Into game(game_id,user_id) Values(1,1)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 77 page no 18 n bits 624 index unique_gameId_userId of table `demo`.`game` trx id 3701497 lock mode S locks gap before rec
Record lock, heap no 237 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 8f5b6f9141c00000; asc [o A ;;
1: len 8; hex 8026440340000040; asc &D @ @;;
2: len 8; hex 8f981c8273000000; asc s ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 77 page no 18 n bits 624 index unique_gameId_userId of table `demo`.`game` trx id 3701497 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 237 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 8f5b6f9141c00000; asc [o A ;;
1: len 8; hex 8026440340000040; asc &D @ @;;
2: len 8; hex 8f981c8273000000; asc s ;;
*** (2) TRANSACTION:
TRANSACTION 3701499, ACTIVE 21 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 43945, OS thread handle 22399490520832, query id 225853305 122.225.228.122 admin update
Insert Into game(game_id,user_id) Values(1,1)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 77 page no 18 n bits 624 index unique_gameId_userId of table `demo`.`game` trx id 3701499 lock mode S locks gap before rec
Record lock, heap no 237 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 8f5b6f9141c00000; asc [o A ;;
1: len 8; hex 8026440340000040; asc &D @ @;;
2: len 8; hex 8f981c8273000000; asc s ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 77 page no 18 n bits 624 index unique_gameId_userId of table `demo`.`game` trx id 3701499 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 237 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 8f5b6f9141c00000; asc [o A ;;
1: len 8; hex 8026440340000040; asc &D @ @;;
2: len 8; hex 8f981c8273000000; asc s ;;
*** WE ROLL BACK TRANSACTION (2)
从错误信息里,发现是事务3701497和3701499产生了死锁,我们简称事务1和事务2。
事务1里持有了unique_game_id_user_id这个索引上624这个位置的记录的共享间隙锁。它在等待unique_game_id_user_id索引同一个位置的插入意向锁,这是排他间隙锁的一种类型。
事务2的锁状态和事务1一样,也是获取了同个索引位置共享间隙锁,在等待这个位置的排他间隙锁,所以它们陷入了锁死。
从死锁日志描述里存在一个共享间隙锁,但代码里的查询语句:select * from game where user_id = xx and game_id = xx。没有加任何锁,也不会占用共享间隙锁。
既然问题不是出在select上,那就是在insert上了。
在Mysql官方文档里描述,insert语句插入的加锁过程是这样:
再回到上面的死锁日志,里面描述的共享间隙锁其实是insert语句执行时,发现有重复记录,那么当前事务会尝试获得的共享锁。
完整的逻辑是这样的,有三个事务同时执行insert。事务1执行成功后获得了该记录的行锁,但是事务没提交,所以行锁没释放。事务2执行插入,发现记录冲突了,会尝试获取共享临键锁,因为事务1行锁未释放,事务2只能获取临键锁中的间隙锁,无法获得行锁。所以当前事务要排队等待获取共享行锁。事务3也是一样的逻辑。
这时,事务1没有提交,而是因为某些原因回滚了事务。接着事务2和3排队等待的共享行锁都不需要了,有之前获得的间隙锁就行。然后它们又一起去获取排它的插入意向锁,插入意向锁和间隙锁是互斥的,其他事务未释放间隙锁,当前事务就无法获得插入意向锁。这样,事务2个3的间隙锁和插入意向锁形成了死锁。
至此,死锁的原因是找到了,因为并发原因导致同一个请求被多次触发。多个事务同时执行同样的insert,在第一个事务回滚时,导致出现死锁。
在官方文档的描述里,除了执行三个insert会有死锁,先执行delete再执行三个insert也会出现死锁。原因是一样的,第一个事务执行delete后,其他事务因为锁冲突,也会先拿到共享锁。等第一个事务回滚后,就产生死锁。
当出现死锁时,我们可以通过show engine innodb statue命令查看死锁信息。当然也可以打开全局的死锁日志,将每次死锁信息都记录下来。