死锁不是 MySQL 的异常行为,而是并发系统的正常风险。只要多个事务以不同顺序持有资源并等待对方,就可能死锁。InnoDB 会自动检测死锁,并回滚其中一个事务。业务系统需要做的是降低死锁概率,并在发生死锁时能快速定位和恢复。
供应链系统里,入库上架、库存转移、订单扣减都容易出现死锁,因为这些流程通常会同时更新库存主表、库存流水表、批次表和单据状态。
入库上架死锁案例 入库上架时,系统要把待上架数量转成可用库存:
1 2 3 4 5 6 7 8 9 10 UPDATE scm_inventorySET available_qty = available_qty + 10 WHERE warehouse_id = 8 AND sku_id = 1001 ;INSERT INTO scm_inventory_log(...)VALUES (...);UPDATE scm_receipt_detailSET putaway_qty = putaway_qty + 10 WHERE receipt_id = 5001 AND sku_id = 1001 ;
另一个任务可能先更新入库明细,再更新库存:
1 2 3 4 5 6 7 UPDATE scm_receipt_detailSET checked_qty = checked_qty + 10 WHERE receipt_id = 5001 AND sku_id = 1001 ;UPDATE scm_inventorySET available_qty = available_qty + 10 WHERE warehouse_id = 8 AND sku_id = 1001 ;
事务 A 持有库存记录,等待入库明细。事务 B 持有入库明细,等待库存记录。两边互相等待,就形成死锁。
死锁的四个条件 死锁通常满足四个条件:
互斥:资源一次只能被一个事务持有。
持有并等待:事务持有一个锁,同时等待另一个锁。
不可抢占:锁只能由持有者提交或回滚后释放。
循环等待:多个事务形成等待环。
业务上最容易控制的是循环等待。只要固定加锁顺序,就能显著降低死锁概率。
固定加锁顺序 对库存转移来说,要从 A 库位转到 B 库位。两个事务方向相反时容易死锁:
1 2 事务 1:锁 A,再锁 B 事务 2:锁 B,再锁 A
解决办法是按稳定规则排序,比如按库存记录 ID 升序加锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Transactional public void transfer (long fromInventoryId, long toInventoryId, int qty) { List<Long> ids = Stream.of(fromInventoryId, toInventoryId) .sorted() .toList(); inventoryMapper.selectForUpdate(ids.get(0 )); inventoryMapper.selectForUpdate(ids.get(1 )); inventoryMapper.decrease(fromInventoryId, qty); inventoryMapper.increase(toInventoryId, qty); inventoryLogMapper.insertTransferLog(fromInventoryId, toInventoryId, qty); }
SQL:
1 2 3 4 SELECT idFROM scm_inventoryWHERE id = #{id}FOR UPDATE ;
即使业务方向不同,底层锁顺序一致,死锁概率会低很多。
缩短事务范围 错误做法:
1 2 3 4 5 6 7 @Transactional public void putaway (PutawayCommand command) { inventoryMapper.lockInventory(command.skuId()); supplierClient.checkQuality(command.supplierId()); wmsClient.notifyPutaway(command.taskId()); inventoryMapper.increase(command.skuId(), command.qty()); }
这段代码在事务里做远程调用。远程调用期间数据库锁一直不释放,任何并发库存操作都可能等待。
改进方式是把远程调用放在事务外,事务里只做核心数据变更:
1 2 3 4 5 6 7 8 9 10 11 12 public void putaway (PutawayCommand command) { supplierClient.checkQuality(command.supplierId()); doPutawayInTransaction(command); eventPublisher.publishPutawayFinished(command.taskId()); } @Transactional public void doPutawayInTransaction (PutawayCommand command) { inventoryMapper.increase(command.skuId(), command.qty()); receiptMapper.markPutaway(command.receiptDetailId(), command.qty()); inventoryLogMapper.insertPutawayLog(command); }
如何排查锁等待 第一步看当前连接:
第二步看 InnoDB 状态:
1 SHOW ENGINE INNODB STATUS;
重点找:
LATEST DETECTED DEADLOCK
等待的锁类型。
等待的索引。
涉及的 SQL。
哪个事务被回滚。
MySQL 8 可以查询:
1 2 3 4 5 SELECT * FROM performance_schema.data_locks;SELECT * FROM performance_schema.data_lock_waits;
结合事务表可以定位阻塞链:
1 2 3 4 5 6 7 8 9 SELECT r.trx_id waiting_trx, r.trx_mysql_thread_id waiting_thread, b.trx_id blocking_trx, b.trx_mysql_thread_id blocking_thread FROM information_schema.innodb_lock_waits wJOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
不同版本系统表略有差异,核心思路是找到等待者、阻塞者和对应 SQL。
业务层重试 死锁发生时,InnoDB 会回滚一个事务。对于库存扣减、上架、状态流转这类短事务,可以做有限重试:
1 2 3 4 5 6 7 8 9 10 11 public void putawayWithRetry (PutawayCommand command) { for (int i = 0 ; i < 3 ; i++) { try { putawayService.doPutawayInTransaction(command); return ; } catch (DeadlockLoserDataAccessException e) { sleep(50L * (i + 1 )); } } throw new BizException ("上架繁忙,请稍后重试" ); }
重试必须满足幂等。比如上架流水要有业务唯一键,避免第一次事务部分成功但应用误判后重复写入。通常建议给业务流水加唯一键:
1 UNIQUE KEY uk_biz_event (biz_type, biz_id)
小结 死锁排查不能只看异常栈,要回到事务里的 SQL 顺序、索引和持锁时间。供应链系统降低死锁的关键措施是:固定加锁顺序,缩短事务范围,避免事务内远程调用,保证 SQL 命中索引,对可重试短事务做有限幂等重试。