背景
前段时间在压测业务库存模块的实现,发现偶有db死锁的场景,相应日志上显示是两次insert
操作的时候出现的错误,故此基于业务上的操作分析相应死锁的原因。
分析
相应的业务逻辑发生在一段库存扣减的业务操作中,整体的逻辑是每次进行扣减时先查询是否已存在相应的流水记录,若存在则直接返回(相应订单已完成库存扣减),否则则是进行库存扣减,若扣减成功则会插入一条流水记录。
下列分析将基于mock的数据表单与日志
1 | -- 库存 |
相应的死锁日志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不高,且上游奖励模块也保证了同一个订单不会并行执行相应扣减操作,暂时没有新增更多的机制去避免死锁。