死锁-Redis 锁与数据库事务的时序问题

背景:

  某一数据表用于记录sku的多次业务操作,要求同一sku在不同状态下是唯一的,数据库针对sku_status建立唯一索引。链路的大致流程如下:

   在生产端,同一sku会并发的产生多个消息,但同一时刻只允许有一个状态位数据存在,故在insert和update都加了同一个redis锁,并在执行完成后释放锁,伪代码如下:

   从代码上一看,已经加了redis锁,似乎不会发生同时执行insert和update的情况。但在高并发下确实出现了,死锁告警如下:

  从图中可以看出,是S锁和X锁的互相等待造成了死锁。

锁失效后,死锁情况分析:

  假设SKU-A,并发了两次库存扣减,我们将唯一索引缩减为sku+status来举例:status为2处理中,1推送成功。

  1、事务1:insert message记录,redis判断A+2的锁,此时无锁,执行insert,然后释放redis锁。
  2、事务1执行到去发消息前,判断没有A+1的redis锁,准备0将A+2更新为A+1。此时事务2进来
  3、事务2:insert message记录,redis判断A+2的锁,此时事务1已经释放了这个redis锁,所以事务2可以获取到redis锁,并执行insert A+2。这个场景下,事务1和事务2并发下产生锁资源竞争。竞争原理如下:  

  • 事务1执行完insert准备执行update 时,MySQL 会:
    • 加 X 锁:修改记录必须独占,X 锁会直接阻塞所有S锁、X 锁。
    • 加间隙锁:在可重复读隔离级别下,update 操作对索引间隙加锁(防止并发插入相同唯一键记录)。
    • 间隙锁本身不直接阻塞事务2的 S 锁,但X锁已经和S锁冲突 ,所以事务1的X锁是冲突点  
  • 事务2执行insert时,MySQL会:
    • 唯一性检查:验证 skuId+status 唯一索引是否冲突,此时隐式申请S锁,用于读索引、检查冲突。
    • 等待原因:事务1已经对该索引记录加了X 锁+间隙锁
    • 由于S锁(事务2)和 X 锁(事务1)冲突 ,事务2无法获取S锁,进入waiting状态。
    • 事务2未获取到 S 锁,但 申请S锁的行为本身会被事务1的X锁阻塞,间隙锁更多是扩大了阻塞范围,核心冲突还是X锁与S锁

  从报错图片可以看出也是这样的情况:事务1的insert的lockRequest,是申请S锁,最终是等待。事务2的update的lockRequest申请X锁

原因分析:

  明明已经加了同一把redis锁,为何会出现两个事务的同时执行呢?核心原因是Redis 锁的生命周期与数据库事务的生命周期不同步,Redis 锁的释放时机早于数据库事务的最终确认(提交 / 回滚),导致临界区保护失效。finally块中释放 Redis 锁的逻辑,执行时机是应用层代码执行完毕,但此时数据库事务可能尚未真正提交;数据库事务的提交是一个独立的过程,发生在应用层代码执行完成后(由数据库连接池或事务管理器异步处理)。当finally块释放锁时,数据库可能还未完成事务提交,此时其他线程获取锁后执行操作,会与未提交的事务产生并发冲突。

解决方案:

  1、真实解决方案是根据梳理业务流程后,确定下游处理是取sku最新的流水信息,所以上游同个sku的多次请求,只需要保证有一个请求处理并产生消息即可,后续都可丢弃,故将redis锁处理调整,在流程开始时,判断有锁后续就不会执行,也不会存在并发的执行insert与update。

  2、通用解决方案:利用事务同步管理器,方法调用层级分离了锁的控制与事务的执行

   原理:

  • 锁的生命周期完全覆盖事务周期
    • 外层方法:负责获取和释放 Redis 锁,锁的持有范围从获取锁开始,直到整个业务流程(包括内层事务)完成后才释放。
    • 内层方法: 仅负责数据库操作并被@Transactional注解标记,事务的提交 / 回滚发生在该方法执行完毕后。
  • Spring 事务的传播特性保障了事务边界
    • Spring 的@Transactional注解默认传播级别为REQUIRED,即:当内层方法被外层方法调用时,会加入外层方法的执行上下文所关联的事务中。内层方法的事务提交 / 回滚会在外层方法的finally块执行前完成。
  • 无论内层事务是正常提交、抛出异常回滚,还是因其他原因中断,外层方法的finally块都会确保锁被释放。避免了因事务异常导致的锁泄漏,同时保证异常发生时,锁的释放仍在事务回滚之后。
posted @ 2025-07-21 16:10  难得  阅读(15)  评论(0)    收藏  举报