说到死锁,很多人脑子里蹦出来的就是“系统卡死了”、“数据库没响应”、“服务挂了”。但死锁这事儿,其实离我们非常近,甚至你可以把它想象成两个人在走廊里迎面走来,都想让对方先过,结果两个人都僵在原地,谁也不动,最后谁也过不去。这就是死锁最直观的样子。
今天咱们不整那些晦涩的学术定义,就用两个最接地气的例子——“夫妻争抢物品”和“银行跨行转账”,把悲观锁导致死锁的根因扒得干干净净,然后再给你一套实用的防身技巧。读完这篇,你不仅能理解死锁,还能在写代码的时候避开这些坑。
一、 夫妻锁:一个关于“争抢”的生活隐喻
咱们先抛开技术术语,讲个家里的故事。
假设有一对夫妻,丈夫叫阿强,妻子叫阿珍。家里有两样珍贵的东西:阿强的游戏机,和阿珍的化妆品。这两样东西都很贵重,而且都需要被“占用”才能使用。阿强想玩一会儿游戏,同时阿珍也想用一会儿化妆品。
这个场景,在操作系统或者数据库眼里,就是一把“游戏机锁”和一把“化妆品锁”。
1. 没有死锁的情况:有序争夺
如果阿强和阿珍都很有默契,约定了一个规则:“先抢游戏机,再抢化妆品”。
- 早上,阿强醒了,他先去拿游戏机(获取游戏机锁)。拿到后,再去拿化妆品(获取化妆品锁)。然后他玩游戏,顺便闻闻香水。
- 晚上,阿珍醒了,她也先去拿游戏机(获取游戏机锁)。这时候游戏机被阿强用着,她只能等着。等阿强用完走了,她拿到游戏机,再去拿化妆品。
这个过程很顺畅,没有人卡住。因为他们的加锁顺序是一致的:游戏机 -> 化妆品。
2. 死锁的发生:反向争夺
现在,假设阿强习惯不好,他想“先抢化妆品,再抢游戏机”。而阿珍依然习惯“先抢游戏机,再抢化妆品”。
时间点来到周六下午:
- 时刻 T1:阿强醒了,他第一步去拿化妆品。成功!阿强手里握着“化妆品锁”。
- 时刻 T2:阿珍醒了,她第一步去拿游戏机。成功!阿珍手里握着“游戏机锁”。
- 时刻 T3:阿强想玩会儿游戏,需要拿游戏机。可是游戏机在阿珍手里。阿强不撒手化妆品锁,等着阿珍给他。
- 时刻 T4:阿珍想用化妆品,需要拿化妆品。可是化妆品在阿强手里。阿珍不撒手游戏机锁,等着阿强给她。
这时候,奇迹(或者说悲剧)发生了:
- 阿强拿着化妆品,等游戏机。
- 阿珍拿着游戏机,等化妆品。
- 阿强不会放下化妆品,因为没玩成游戏,他不爽。
- 阿珍不会放下游戏机,因为没玩成游戏,她也不爽。
两个人就这样僵持下去,谁也不让谁。这就是死锁。
在数据库术语里,这叫“循环等待”(Circular Wait)。阿强等阿珍释放资源A,阿珍等阿强释放资源B,资源A和B又分别被对方持有。这是一个完美的闭环,系统如果没有外力强介入(比如爸妈来吼一声),他们可能永远僵持下去。
3. 死锁的四个必要条件
通过这个夫妻案例,我们可以提炼出死锁发生的四个必要条件。只要这四个条件同时满足,死锁就有可能发生。这也是后续我们要打破的点。
- 互斥条件(Mutual Exclusion):资源只能被一个事务(人)占用。阿强拿着化妆品时,阿珍就不能用。这是悲观锁的核心假设。
- 请求与保持条件(Hold and Wait):持有资源的同时,还在请求其他资源。阿强拿着化妆品,还想要游戏机。
- 不剥夺条件(No Preemption):资源不能被强制夺走。你不能强行从阿强手里把化妆品抢过来给阿珍,除非阿强自愿放下。在数据库里,除非事务自己提交或回滚,否则锁不会释放。
- 循环等待条件(Circular Wait):存在一个事务等待链,形成一个环。阿强等阿珍,阿珍等阿强。
重点来了: 悲观锁(Pessimistic Locking)默认就是开启“互斥”和“请求与保持”的。它假设冲突一定会发生,所以每次操作都先加锁。如果再加上不一致的加锁顺序,就会触发“循环等待”,死锁就诞生了。
二、 银行取款:跨行转账中的死锁陷阱
夫妻案例太生活化了,咱们来点硬核的。假设你在银行工作,系统需要支持跨行转账。
场景:用户A从银行甲转账1000元到用户B在银行乙的账户。
为了数据一致性,银行甲需要扣A的钱,银行乙需要加B的钱。在分布式系统或复杂的数据库事务中,这涉及到两个数据库的锁操作。
1. 悲观锁在转账中的应用
假设银行甲和银行乙都使用悲观锁来保证账户余额不被并发修改。
交易1:用户1从账户X(在甲行)转账给账户Y(在乙行)。
- 事务T1先在甲行对账户X加锁(SELECT … FOR UPDATE)。
- 然后事务T1去乙行对账户Y加锁。
- 逻辑:先锁汇出行,再锁汇入行。顺序是:X锁 -> Y锁。
交易2:用户2从账户Y(在乙行)转账给账户X(在甲行)。
- 事务T2先在乙行对账户Y加锁。
- 然后事务T2去甲行对账户X加锁。
- 逻辑:先锁汇出行,再锁汇入行。顺序是:Y锁 -> X锁。
2. 死锁现场直播
现在,这两个交易同时发生,且时间非常接近:
- T1开始:T1在甲行获取了账户X的锁。
- T2开始:T2在乙行获取了账户Y的锁。
- T1继续:T1要去乙行获取账户Y的锁。但是Y已经被T2锁住了!T1进入阻塞状态,等待Y锁释放。注意,T1依然持有X锁。
- T2继续:T2要去甲行获取账户X的锁。但是X已经被T1锁住了!T2进入阻塞状态,等待X锁释放。注意,T2依然持有Y锁。
看,这就是死锁:
- T1持有X,等待Y。
- T2持有Y,等待X。
- X和Y是相互依赖的闭环。
在数据库层面,如果没有任何防范机制,这两个事务就会永远挂起,占用连接资源,直到达到某个超时时间(如果有的话),或者直到管理员手动杀掉进程。
根因分析: 这里的根因就是加锁顺序不一致。 T1的顺序是 X -> Y。 T2的顺序是 Y -> X。 当并发请求以相反的顺序获取锁时,死锁的概率极高。
三、 防止死锁的实用技巧
既然知道了根因,我们就有针对性地去打破那四个必要条件。但在实际工程中,我们主要聚焦在打破循环等待和缩短持锁时间上。以下是几个经过实战检验的实用技巧。
技巧一:固定加锁顺序(打破循环等待)
这是最有效、最根本的方法。既然死锁是因为顺序不一致导致的,那我们就规定:所有事务在访问多个资源时,必须按照统一的顺序加锁。
在银行转账的例子中,我们可以规定:无论转账方向如何,总是先锁金额较小的账户,或者先锁账户ID较小的那个。
- 规则:如果账户ID X < Y,则先锁X,再锁Y。
- 应用:
- T1(X->Y):X < Y,先锁X,再锁Y。符合规则。
- T2(Y->X):Y > X,规则要求先锁X,再锁Y。所以T2在乙行拿到Y锁后,不能马上去甲行锁X,而是应该先释放Y锁,去甲行锁X,拿到X锁后,再回来锁Y。
虽然这听起来有点麻烦(需要释放锁再重新获取),但这能彻底消除死锁的可能性。
代码示意(伪代码):
def transfer(user_from_id, user_to_id, amount):
# 保证加锁顺序:总是先锁ID小的,再锁ID大的
if user_from_id < user_to_id:
first_lock_id = user_from_id
second_lock_id = user_to_id
else:
first_lock_id = user_to_id
second_lock_id = user_from_id
# 第一步:获取第一个锁
lock_account(first_lock_id)
# 第二步:获取第二个锁
lock_account(second_lock_id)
try:
# 执行转账逻辑
deduct(first_lock_id, amount)
add(second_lock_id, amount)
commit()
except Exception as e:
rollback()
finally:
# 释放锁(顺序不重要,因为已经持有两者)
unlock_account(first_lock_id)
unlock_account(second_lock_id)
技巧二:超时机制(Timeout)
虽然固定顺序很好,但在分布式系统中,有时很难保证全局的统一顺序,或者系统过于复杂。这时候,超时就是一个很好的“兜底”策略。
数据库(如MySQL InnoDB)通常都有死锁检测机制,但检测本身也有开销。更常见的做法是,在应用层设置锁等待超时时间。
- 原理:如果一个事务在等待获取锁时,超过了设定的时间(比如5秒),就自动放弃等待,回滚事务,并抛出异常。
- 效果:虽然不能100%防止死锁发生,但可以防止事务永远阻塞。死锁发生时,至少有一个事务会因为超时而被终止,从而打破僵局。
- 注意:超时时间短了,可能导致正常业务被误杀;时间长了,系统响应会变慢。需要权衡。
代码示意(Java/Spring):
@Transactional(timeout = 5) // 设置事务超时时间为5秒
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepository.findByIdForUpdate(fromId); // 悲观锁查询
Account to = accountRepository.findByIdForUpdate(toId); // 悲观锁查询
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountRepository.save(from);
accountRepository.save(to);
}
如果第3行 findByIdForUpdate 等待超过5秒还没拿到锁,整个事务就会超时回滚。
技巧三:重试机制(Retry)
超时机制会导致事务失败,这时候就需要重试。
- 原理:当事务因为死锁(或超时)失败时,不要直接报错给用户,而是让程序稍等一下,然后重新执行这个事务。
- 配合:重试 + 超时 是最佳拍档。超时保证不会无限等待,重试保证业务最终能完成。
- 实现:可以使用指数退避(Exponential Backoff)策略,即第一次重试等1秒,第二次等2秒,第三次等4秒,避免瞬间的高压重试导致系统雪崩。
代码示意(Python装饰器模式):
import time
import functools
def retry_on_deadlock(max_retries=3, delay=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i in range(max_retries):
try:
return func(*args, **kwargs)
except DeadlockException as e:
if i == max_retries - 1:
raise e
print(f"Deadlock detected, retrying in {delay} seconds...")
time.sleep(delay)
delay *= 2 # 指数退避
return wrapper
return decorator
@retry_on_deadlock(max_retries=3, delay=1)
def transfer(from_id, to_id, amount):
# 执行转账逻辑
pass
技巧四:缩小锁粒度,缩短持锁时间
悲观锁的代价就是锁竞争。如果锁的时间越长,死锁的概率就越大。所以,能早解锁就早解锁,能少锁就少锁。
- 不要在整个事务开始时就锁住所有资源。
- 先查询,再加锁:比如转账,可以先查询余额,确认有钱后,再在提交前加锁扣款。
- 使用行锁而不是表锁:行锁粒度小,冲突概率低。
- 批量操作拆分:不要一次性锁住成千上万行数据,分批次处理。
对比示例:
差的做法(长事务):
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 拿到锁
-- 做一些耗时的网络请求,或者业务逻辑计算
-- ... 耗时操作 ...
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
这把锁持有了整整一段“耗时操作”的时间,极大地增加了死锁风险。
好的做法(短事务):
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 不加锁,只读查询
-- 业务逻辑计算
-- ... 耗时操作 ...
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 直到真正修改时才加锁
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
这样,锁只在最后修改数据的瞬间持有,大大减少了与其他事务冲突的机会。
技巧五:死锁检测与自动回滚(数据库层面)
大多数现代数据库(MySQL InnoDB, PostgreSQL等)都有内置的死锁检测机制。
- InnoDB:默认开启死锁检测。当一个事务等待锁时,InnoDB会 periodically 检查是否有死锁发生。一旦检测到,它会选择牺牲一个“成本最低”的事务(通常是修改行数最少的事务),强制回滚它,从而释放锁,让其他事务继续执行。
- 应用层处理:作为开发者,你需要捕获
DeadlockException(或类似的错误码,如MySQL的ER_LOCK_DEADLOCK1213),然后应用上面的重试机制。
不要忽视数据库的配置! 确保 innodb_deadlock_detect 是开启的(MySQL默认是ON)。在某些高并发场景下,死锁检测本身也有CPU开销,如果死锁极其频繁,可以考虑关闭检测,改用超时机制(通过设置 innodb_lock_wait_timeout),让事务自己超时退出,这样更轻量。
四、 总结:从认知到实践
回顾一下我们今天聊的内容:
- 夫妻锁案例让我们理解了死锁的本质:循环等待。两个事务互相持有对方需要的锁,谁也不让谁。
- 银行转账案例展示了现实系统中死锁的高发场景:并发操作共享资源且加锁顺序不一致。
- 防止技巧我们可以记成口诀:“顺序要统一,超时做兜底,失败要重试,锁要尽量短。”
- 顺序要统一:全局约定加锁顺序,从根本上打破循环等待。
- 超时做兜底:设置合理的锁等待超时时间,防止永久阻塞。
- 失败要重试:配合重试机制,提高系统的可用性。
- 锁要尽量短:缩小锁粒度,缩短持锁时间,减少竞争窗口。
最后,我想说的是,死锁在并发系统中几乎无法完全避免,尤其是当系统复杂度很高时。我们的目标不是消灭死锁(那是不可能的),而是优雅地处理死锁——让它发生得少,发生了能很快恢复,不影响用户体验。
希望这篇文章能帮你理清思路。下次再看到死锁报错,别慌,想想阿强和阿珍,想想那个僵持在走廊里的场景,你就知道该怎么解决了。
