死锁是指两个或多个事务在执行过程中,因争夺锁资源而相互等待,导致所有事务都无法继续推进的状态。数据库通常通过等待图检测死锁,一旦发现环路,就会主动回滚其中一个事务(牺牲者),让其余事务继续执行。因此死锁并不是“数据库坏了”,而是并发访问下的一种必然可能,关键在于我们能否在代码层面降低发生概率。
死锁的四个必要条件可以简单概括为:互斥、持有并等待、不可剥夺、循环等待。数据库无法消除互斥,但应用代码可以通过统一加锁顺序来打破“循环等待”,这是最有效的规避手段。
下面用两张账户表模拟转账,两个事务以相反顺序更新账户,这是最经典的死锁模型。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
-- 事务 A BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 此处暂停,等待事务 B 执行 UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;
-- 事务 B(与 A 并发执行) BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance + 100 WHERE id = 1; COMMIT; |
执行后数据库会返回类似 Deadlock found when trying to get lock; try restarting transaction 的错误,说明检测到环路并回滚了其中一个事务。
转账时始终按账户 ID 升序加锁,让所有事务的加锁路径一致,从根本上消除循环等待。
|
1 2 3 4 5 6 7 8 9 |
public void transfer(long fromId, long toId, BigDecimal amount) { long first = Math.min(fromId, toId); long second = Math.max(fromId, toId); // 固定顺序加锁,避免交叉等待 accountMapper.lockById(first); accountMapper.lockById(second); accountMapper.decrease(fromId, amount); accountMapper.increase(toId, amount); } |
把远程调用、消息发送等非数据库操作移出事务边界,事务内只保留必要的 SQL,同时确保更新条件命中索引。
|
1 2 3 4 5 6 7 |
@Transactional(timeout = 3) public void settleOrder(Long orderId) { // 事务内只做数据库操作 orderMapper.updateStatus(orderId, "PAID"); stockMapper.deductByOrderId(orderId); // 通知逻辑放到事务提交后执行 } |
|
1 2 |
-- 为更新条件建立索引,避免全表扫描扩大锁范围 ALTER TABLE orders ADD INDEX idx_user_status (user_id, status); |
死锁被数据库回滚后,业务侧应识别该异常并进行有限次重试,而不是直接抛给用户。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public void executeWithRetry(Runnable action, int maxRetry) { for (int i = 0; i < maxRetry; i++) { try { action.run(); return; } catch (DeadlockLoserDataAccessException e) { if (i == maxRetry - 1) { throw e; } // 退避后重试,降低再次冲突概率 sleepQuietly(50L * (i + 1)); } } } |
线上出现死锁时,优先查看数据库的死锁日志(如 MySQL 的 SHOW ENGINE INNODB STATUS),定位具体 SQL 与加锁顺序。再结合慢查询日志确认是否存在长事务或缺失索引。建议在应用层对死锁异常做埋点统计,当某条链路死锁频率上升时及时告警。
总结来说,死锁无法彻底消除,但通过统一加锁顺序、缩短事务、补齐索引和合理重试,可以把发生概率压到很低,并让系统在偶发死锁时具备自愈能力。