一次MySQL死锁记录分析

背景

前段时间在压测业务库存模块的实现,发现偶有db死锁的场景,相应日志上显示是两次insert操作的时候出现的错误,故此基于业务上的操作分析相应死锁的原因。

分析

相应的业务逻辑发生在一段库存扣减的业务操作中,整体的逻辑是每次进行扣减时先查询是否已存在相应的流水记录,若存在则直接返回(相应订单已完成库存扣减),否则则是进行库存扣减,若扣减成功则会插入一条流水记录。

下列分析将基于mock的数据表单与日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 库存
create table resource_stock (
resource_id int(10) not null comment '资源id',
reward_count_limit int(10) not null comment '库存总额',
reward_count int(10) not null comment '已经下发数额',
PRIMARY KEY(`resource_id`)
)

-- 流水表
create table resource_reward_record (
order_id int(10) not null comment '订单id',
target_id int(10) not null comment 'target id',
PRIMARY KEY(`order_id`)
)

相应的死锁日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
* (1) TRANSACTION:
TRANSACTION 6203648473, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
// ...
* (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1247 page no 33 n bits 272 index PRIMARY of table xxx trx id 6203648473 lock_mode X insert intention waiting


* (2) TRANSACTION:
TRANSACTION 6203648472, ACTIVE 0 sec inserting, thread declared inside InnoDB 1
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 3058725, OS thread handle 139938582492928, query id 4957599797 10.231.110.109
// ...
* (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1247 page no 33 n bits 272 index PRIMARY of table xxx trx id 6203648472 lock mode S
* (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1247 page no 33 n bits 272 index PRIMARY of table xxx trx id 6203648472 lock_mode X insert intention waiting
* WE ROLL BACK TRANSACTION (2)

重新整理与分析死锁日志

  • trx id 6203648473 lock_mode X insert intention waiting
  • trx id 6203648472 lock mode S, lock_mode X insert intention waiting

可以看出两个事物,事物(6203648472)持有了共享锁,等待排他锁;事物(6203648473)也是等待排他锁,故发生死锁了。

那么问题来了,为什么在执行插入操作的时候会有S锁?

Insert操作锁机制

在这里主要重新分析insert涉及到的锁,在参考了Insert into 加锁机制中对insert的分析

INSERT sets an exclusive lock on the inserted row. This lock is an index-record lock, not a next-key lock (that is, there is no gap lock) and does not prevent other sessions from inserting into the gap before the inserted row.Prior to inserting the row, a type of gap lock called an insertion intention gap lock is set. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap.If a duplicate-key error occurs, a shared lock on the duplicate index record is set. This use of a shared lock can result in deadlock should there be multiple sessions trying to insert the same row if another session already has an exclusive lock.
大体的意思是:insert会对插入成功的行加上排它锁,这个排它锁是个记录锁,而非next-key锁(当然更不是gap锁了),不会阻止其他并发的事务往这条记录之前插入记录。在插入之前,会先在插入记录所在的间隙加上一个插入意向gap锁(简称I锁吧),并发的事务可以对同一个gap加I锁。如果insert 的事务出现了duplicate-key error ,事务会对duplicate index record加共享锁。这个共享锁在并发的情况下是会产生死锁的,比如有两个并发的insert都对要对同一条记录加共享锁,而此时这条记录又被其他事务加上了排它锁,排它锁的事务提交或者回滚后,两个并发的insert操作是会发生死锁的。

以及MySQL · 引擎特性 · InnoDB隐式锁功能解析中的分析

在Insert语句执行过程中,必须检查两种情况,一种是如果记录之间加有间隙锁,为了避免幻读,此时是不能插入记录的,另一中情况如果Insert的记录和已有记录存在唯一键冲突,此时也不能插入记录。除此之外,insert语句的锁都是隐式锁,但跟踪代码发现,insert时并没有调用lock_rec_add_to_queue函数进行加锁, 其实所谓隐式锁就是在Insert过程中不加锁。
只有在特殊情况下,才会将隐式锁转换为显示锁。这个转换动作并不是加隐式锁的线程自发去做的,而是其他存在行数据冲突的线程去做的。例如事务1插入记录且未提交,此时事务2尝试对该记录加锁,那么事务2必须先判断记录上保存的事务id是否活跃,如果活跃则帮助事务1建立一个锁对象,而事务2自身进入等待事务1的状态。

上述的分析基本得到本次死锁的原因结论了,在我们的业务压测场景中,存在测试同一订单并发扣减库存不会出现超发的场景,此时并发执行insert操作发生了死锁

事物1 事物2 事物3
insert into resource_reward_record(id=1)
insert into resource_reward_record(id=1)(wait,隐式锁转换为显示锁,同时事物加了S锁) insert into resource_reward_record(id=1)(wait,隐式锁转换为显示锁,同时事物加了S锁)
commit/rollback 获得S锁 获得S锁
获取X锁,被事物3阻塞 获取X锁,被事物2阻塞
死锁 死锁

总结

对于上述场景,可以考虑在更前置流程加分布式锁,使得同一订单并发场景下是串行执行insert/update操作,避免了上述类型思索的条件。最后结合具体的业务场景,整个库存扣减QPS不高,且上游奖励模块也保证了同一个订单不会并行执行相应扣减操作,暂时没有新增更多的机制去避免死锁。