想象一下这个场景:早高峰的订单池像炸开的锅,老张和老李同时点开了同一个待处理工单。老张先拿到了锁,正准备修改状态;老李这边一看“锁定中”,但他没走,而是在原地死等——“我就不信这个邪,我等你改完我再动”。结果,老张等老李确认权限,老李等老张释放锁,两个线程僵持不下,数据库连接池告急,系统直接卡死。
这就是典型的死锁(Deadlock),也是分布式系统和多线程开发中让人头秃的噩梦。很多人第一反应是:“上悲观锁啊,先锁住再说!”但悲观锁用不好,恰恰是制造死锁的温床。今天咱们不聊晦涩的理论,就聊聊怎么用悲观锁这把“双刃剑”,既防止撞车,又避开死锁陷阱。
一、 先搞清楚:悲观锁到底在保护什么?
悲观锁的核心思想是:“我觉得每次操作数据都有人抢,所以我先加锁,操作完再释放。”
在数据库层面,比如 MySQL 的 SELECT ... FOR UPDATE,就是最经典的悲观锁实现。它假设冲突不可避免,所以先发制人。
但问题来了:如果锁的顺序不一致,或者锁的粒度太大、时间太长,死锁就来了。
举个真实例子:
- 订单 A 和订单 B 互相关联。
- 线程 1 先锁 A,再锁 B。
- 线程 2 先锁 B,再锁 A。
- 结果:线程 1 等线程 2 释放 B,线程 2 等线程 1 释放 A。死循环,系统卡死。
这不是假设,是我上周在一个电商项目中亲眼看到的线上事故。所以,避免死锁不是靠“小心点”,而是靠结构化的操作规范。
二、 技巧一:固定加锁顺序——死锁的克星
这是最有效、最简单的一条原则。所有线程在获取多把锁时,必须按照全局统一的顺序进行。
比如,订单 ID 是 1001 和 1002。无论哪个线程处理这两个订单,都规定:先锁 ID 小的,再锁 ID 大的。
// 伪代码示例:固定顺序加锁
public void transferOrder(long fromOrderId, long toOrderId) {
// 强制按 ID 从小到大排序,避免死锁
long firstId = Math.min(fromOrderId, toOrderId);
long secondId = Math.max(fromOrderId, toOrderId);
// 按固定顺序加锁
lock(firstId); // 先锁小的
lock(secondId); // 再锁大的
try {
// 执行业务逻辑
updateOrderStatus(firstId, "TRANSFERRING");
updateOrderStatus(secondId, "TRANSFERRING");
} finally {
unlock(secondId);
unlock(firstId);
}
}
为什么这样能避免死锁?因为无论线程 A 还是线程 B,它们获取锁的顺序都是一样的。就像排队过独木桥,大家都从左边上,就不会撞车。
三、 技巧二:缩短锁的持有时间——别“抱着锁睡觉”
悲观锁最怕的不是冲突,而是锁住太久。如果加锁后去做网络请求、写日志、甚至睡一觉,那死锁概率直线上升。
原则:只把核心数据操作放在锁内,其他工作全部移出。
// 错误示范:锁内做了大量无关操作
lock(orderId);
try {
// 修改状态
db.update("status", "LOCKED");
// ❌ 这里去调外部 API,锁住了 3 秒!
externalService.call();
// ❌ 这里又写日志
log.info("Processing...");
} finally {
unlock(orderId);
}
// 正确示范:锁内只做最小必要操作
lock(orderId);
try {
db.update("status", "LOCKED");
// ✅ 只改数据库,毫秒级完成
} finally {
unlock(orderId);
}
// 之后再做外部调用和日志
externalService.call();
log.info("Processing...");
锁的时间越短,其他线程等待的时间就越少,死锁的可能性就越低。记住:锁是公共资源,别私用太久。
四、 技巧三:设置锁超时——给等待一个“出口”
悲观锁如果一直等不到释放,就会永久阻塞。所以,必须设置超时时间,让线程在等不到锁时主动放弃,而不是死等。
在 MySQL 中,SELECT ... FOR UPDATE 默认是阻塞等待的,但可以通过 innodb_lock_wait_timeout 参数控制超时时间(默认 50 秒)。在应用层,可以用 tryLock(timeout) 实现。
// 使用 ReentrantLock 设置超时
ReentrantLock lock = new ReentrantLock();
boolean gotLock = false;
try {
// 尝试获取锁,最多等 3 秒
gotLock = lock.tryLock(3, TimeUnit.SECONDS);
if (gotLock) {
try {
// 执行业务逻辑
doBusiness();
} finally {
lock.unlock();
}
} else {
// 没拿到锁,记录日志,稍后重试或降级处理
log.warn("Failed to acquire lock for order, retry later.");
handleLockTimeout();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
超时的意义在于:让系统有“退路”。死锁往往是因为两个线程都“不肯让步”,设置超时就是给它们一个台阶下,主动释放,避免僵持。
五、 技巧四:最小化锁粒度——别“一把锁锁整个仓库”
很多新手喜欢用一个大锁保护整个业务模块,比如“订单锁”锁住所有订单操作。这虽然简单,但并发性能极差,而且容易引发死锁。
原则:锁住最小必要单元。
比如,你只需要修改订单状态,那就只锁订单记录,别顺手把关联的用户表、库存表都锁了。锁的粒度越细,冲突概率越低。
-- 错误:锁了太多行
SELECT * FROM orders WHERE user_id = 123 FOR UPDATE; -- 锁住用户所有订单
-- 正确:只锁当前操作的这一行
SELECT * FROM orders WHERE order_id = 456 FOR UPDATE; -- 只锁一个订单
在代码层面,也是一样。别用一个全局锁保护所有操作,拆分成更细粒度的锁,比如“订单锁”、“库存锁”、“支付锁”,各司其职。
六、 技巧五:使用检测机制——让系统自己“发现”死锁
即使你做了以上所有预防措施,死锁还是可能因为复杂场景意外发生。这时候,死锁检测机制就是你的最后一道防线。
数据库层面,MySQL InnoDB 引擎内置了死锁检测。当它发现死锁时,会自动选择一个“牺牲者”线程,回滚它的事务,释放锁,让其他线程继续执行。
但光靠数据库不够,应用层也需要监控和告警。你可以:
- 监控死锁次数:如果某个表的死锁频率突然上升,说明加锁逻辑有问题。
- 设置重试机制:检测到死锁后,自动重试业务逻辑(注意不要无限重试)。
- 记录详细日志:记录死锁发生时的锁顺序、线程 ID、业务参数,方便事后分析。
// 简单重试逻辑示例
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
// 尝试获取锁并执行
executeWithLock();
return; // 成功则退出
} catch (DeadlockLoseDataAccessException e) {
// 发生死锁,记录日志,准备重试
log.warn("Deadlock detected, retrying... attempt {}", i + 1);
if (i == maxRetries - 1) {
throw e; // 最后一次重试还失败,则抛出异常
}
// 短暂等待后重试,避免立即竞争
Thread.sleep(100 * (i + 1));
}
}
七、 别忘了:悲观锁不是万能药
最后提醒一下,悲观锁适合写多读少、冲突频繁的场景。如果你的系统主要是读操作,或者冲突很少,用乐观锁(比如版本号机制)可能更高效,因为不需要加锁,性能更好。
但如果你确实需要强一致性,悲观锁就是你的首选。记住这五条技巧:固定顺序、缩短时间、设置超时、最小粒度、检测重试,就能大幅降低死锁风险。
结语
系统卡死不是玄学,而是逻辑漏洞。悲观锁用得好,是稳如泰山的守护者;用得不好,是拖垮系统的绊脚石。希望这五个技巧能帮你避开死锁陷阱,让你的系统既安全又高效。
如果还有其他并发问题,欢迎随时讨论。毕竟,最好的经验,都是从踩坑里捡回来的。
