先别慌,深呼吸。我见过太多人在生产环境看到那条卡住的请求时,心率直接飙到 120。上周我的一个开发者朋友就遇到这事儿,他的用户反馈“提交订单”按钮点了没反应,后台一看,数据库连接池瞬间被打满,整个服务直接雪崩。原因很简单——又是经典的死锁(Deadlock)。
很多人听到“锁”就紧张,仿佛这是性能杀手。但其实,死锁往往不是因为加了锁,而是因为锁的顺序乱来或者该用的悲观锁没用对地方。今天我们就来聊聊,如何用悲观锁(Pessimistic Locking) 来优雅地避开死锁陷阱,顺便看看那些真正能落地的实战技巧。
一、先搞清楚:死锁到底是谁在搞鬼?
在谈解决方案前,咱们得先看懂“敌人”。死锁有四个必要条件,缺一不可:
- 互斥条件:资源一次只能被一个线程占用。
- 请求与保持:线程已经持有了至少一个资源,但又去请求新的资源,而这个资源被别的线程占了,于是它边持有边等待。
- 不剥夺条件:线程持有的资源不能被强制抢走。
- 循环等待:若干线程形成一条头尾相接的循环等待链。
举个最常见的例子:
线程 A 持有资源 X,等待资源 Y;
线程 B 持有资源 Y,等待资源 X。
两人互相死盯,谁也不让,永久僵持。
数据库里的死锁,通常发生在两个事务以不同的顺序访问多个表或行的时候。比如:
- 事务1先锁用户表,再锁订单表;
- 事务2先锁订单表,再锁用户表。
一旦并发进来,死锁立马发生。数据库引擎会检测出循环等待,然后随机选一个事务作为牺牲品,回滚它,释放锁。这就是为什么有时候你会看到报错:“Deadlock found when trying to get lock; try restarting transaction”。
关键点来了:数据库已经帮你做了检测,但频繁的自动回滚会拖慢系统,增加延迟,甚至影响用户体验。所以,最好的策略是——让死锁根本不要发生。
二、悲观锁是什么?为什么它能防死锁?
2.1 乐观锁 vs 悲观锁:别再混淆了
首先澄清一个常见误区:
- 乐观锁:假设不会冲突,提交时再检查。比如用版本号(version)或时间戳。适合读多写少、冲突少的场景。
- 悲观锁:假设一定会冲突,操作前先加锁。比如
SELECT ... FOR UPDATE。适合写多、并发高、资源争用激烈的场景。
悲观锁防死锁的核心思路:通过强制锁定顺序,打破“循环等待”这个必要条件。
2.2 悲观锁的常见实现方式
在 MySQL 中,悲观锁主要通过:
SELECT ... FOR UPDATE(行锁)LOCK TABLES(表级锁)innodb_lock_wait_timeout(控制等待超时)- 业务层加分布式锁(如 Redis 的 SETNX)
其中,SELECT ... FOR UPDATE 是最常用、最精细的手段,因为它只锁住需要的行,而不是整张表,能最大程度减少锁竞争。
三、实战:用悲观锁彻底杜绝死锁
场景还原:电商下单系统
假设我们有一个典型的下单流程:
- 检查用户账户余额
- 锁定商品库存
- 创建订单
- 扣减余额
- 扣减库存
如果两个用户同时购买同一件商品,且我们的代码没有严格锁定顺序,就容易死锁。
❌ 错误示范:无锁定或锁顺序不一致
-- 事务1:先查用户余额,再查库存
START TRANSACTION;
SELECT balance FROM user WHERE user_id = 1 FOR UPDATE; -- 锁用户行
SELECT stock FROM product WHERE product_id = 100 FOR UPDATE; -- 锁商品行
-- 然后更新...
COMMIT;
-- 事务2:先查库存,再查用户余额
START TRANSACTION;
SELECT stock FROM product WHERE product_id = 100 FOR UPDATE; -- 锁商品行
SELECT balance FROM user WHERE user_id = 2 FOR UPDATE; -- 锁用户行
-- 然后更新...
COMMIT;
注意看:事务1先锁用户再锁商品,事务2先锁商品再锁用户。这就是典型的循环等待,必死无疑。
✅ 正确示范:统一锁顺序 + 悲观锁
原则:所有事务必须按照相同的顺序加锁。
比如,我们规定:永远先锁商品,再锁用户。
-- 事务1:统一先锁商品,再锁用户
START TRANSACTION;
SELECT stock FROM product WHERE product_id = 100 FOR UPDATE; -- 先锁商品
SELECT balance FROM user WHERE user_id = 1 FOR UPDATE; -- 再锁用户
-- 然后执行业务逻辑
UPDATE user SET balance = balance - 100 WHERE user_id = 1;
UPDATE product SET stock = stock - 1 WHERE product_id = 100;
COMMIT;
-- 事务2:同样先锁商品,再锁用户
START TRANSACTION;
SELECT stock FROM product WHERE product_id = 100 FOR UPDATE; -- 先锁商品(此时事务1已持有,事务2等待)
SELECT balance FROM user WHERE user_id = 2 FOR UPDATE; -- 再锁用户
UPDATE user SET balance = balance - 100 WHERE user_id = 2;
UPDATE product SET stock = stock - 1 WHERE product_id = 100;
COMMIT;
现在,事务2在申请“商品锁”时,必须等事务1释放。这样就不会形成循环等待,死锁自然消失。
2.3 代码层面怎么实现?
以 Java + Spring + MyBatis 为例:
@Transactional
public void placeOrder(Long userId, Long productId, int quantity) {
// 第一步:先锁商品
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() < quantity) {
throw new InsufficientStockException("库存不足");
}
// 第二步:再锁用户
User user = userMapper.selectForUpdate(userId);
if (user.getBalance() < product.getPrice() * quantity) {
throw new InsufficientBalanceException("余额不足");
}
// 第三步:执行业务更新
userMapper.deductBalance(userId, product.getPrice() * quantity);
productMapper.deductStock(productId, quantity);
orderMapper.insert(buildOrder(userId, productId, quantity));
}
注意:selectForUpdate 方法对应的 SQL 是:
SELECT * FROM product WHERE id = #{id} FOR UPDATE
SELECT * FROM user WHERE id = #{id} FOR UPDATE
关键点:在同一个事务中,锁的顺序必须全局一致。你可以在代码里定义一个常量顺序,比如:
public static final List<String> LOCK_ORDER = Arrays.asList("product", "user", "order");
然后在加锁前校验,确保所有线程都按这个顺序执行。
2.4 如果业务逻辑复杂,无法保证全局顺序怎么办?
有时候,业务逻辑涉及多个表,顺序难以统一。这时候可以用超时机制 + 重试作为兜底:
public void placeOrderWithRetry(Long userId, Long productId, int quantity) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
transactionTemplate.execute(status -> {
placeOrder(userId, productId, quantity);
return null;
});
return; // 成功则退出
} catch (DeadlockLoserDataAccessException e) {
if (i == maxRetries - 1) throw e;
// 记录日志,等待重试
log.warn("Deadlock detected, retrying... attempt {}", i + 1);
try {
Thread.sleep(100 * (i + 1)); // 指数退避
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
Spring 的 DeadlockLoserDataAccessException 会在检测到死锁时被抛出。我们通过捕获它并重试,而不是让系统崩溃。
四、更深层的优化:缩小锁粒度
悲观锁虽然能防死锁,但锁的范围越大,并发性能越差。所以,我们还要尽量缩小锁的粒度。
4.1 用唯一索引避免锁升级
如果你在 SELECT ... FOR UPDATE 时,查询条件没有命中索引,MySQL 可能会锁住整个表(表级锁),而不是行。这会严重降低并发。
解决方案:确保所有 FOR UPDATE 查询都使用唯一索引或主键。
-- 错误:没有索引,可能锁全表
SELECT * FROM product WHERE category_id = 10 FOR UPDATE;
-- 正确:使用主键或唯一索引
SELECT * FROM product WHERE id = 100 FOR UPDATE;
4.2 使用间隙锁(Gap Lock)控制
InnoDB 的默认隔离级别是 REPEATABLE READ,会使用Next-Key Lock(记录锁 + 间隙锁),防止幻读。但这也会导致锁的范围变大。
如果你不需要防止幻读,可以将隔离级别降到 READ COMMITTED:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
这样,SELECT ... FOR UPDATE 只会锁住匹配的行,不会锁间隙,大大减少锁竞争。
但注意:降级隔离级别有风险,必须确认业务逻辑能接受幻读。
4.3 分批次处理,避免长时间持锁
如果一次操作需要锁很多行(比如批量扣减库存),不要一次性锁所有行。可以分批次加锁、提交:
public void batchDeductStock(List<Long> productIds) {
int batchSize = 100;
for (int i = 0; i < productIds.size(); i += batchSize) {
List<Long> batch = productIds.subList(i, Math.min(i + batchSize, productIds.size()));
transactionTemplate.execute(status -> {
for (Long productId : batch) {
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() < 1) {
throw new InsufficientStockException("库存不足: " + productId);
}
productMapper.deductStock(productId, 1);
}
return null;
});
}
}
这样,每次事务只持有少量行的锁,持锁时间短,死锁概率大幅降低。
五、监控与排查:怎么知道死锁还在发生?
即使做了优化,也不能掉以轻心。你需要持续监控死锁情况。
5.1 MySQL 死锁信息查看
-- 查看最近的死锁报告
SHOW ENGINE INNODB STATUS;
-- 关注 LATEST DETECTED DEADLOCK 部分
输出中会明确告诉你:
- 哪个事务被选中作为牺牲品
- 死锁的 SQL 语句
- 锁的结构
5.2 开启死锁日志
在 MySQL 配置文件中启用:
innodb_print_all_deadlocks = 1
这样,所有死锁都会被记录到错误日志,便于后续分析。
5.3 应用层监控
在你的 Java 代码中,捕获 DeadlockLoserDataAccessException 并上报监控(如 Prometheus + Grafana):
@Aspect
@Component
public class DeadlockMetricsAspect {
@Autowired
private MeterRegistry meterRegistry;
@Around("@annotation(Transactional)")
public Object captureDeadlock(ProceedingJoinPoint joinPoint) throws Throwable {
try {
return joinPoint.proceed();
} catch (DeadlockLoserDataAccessException e) {
meterRegistry.counter("db.deadlock.count").increment();
throw e;
}
}
}
当监控显示死锁频率上升时,说明你的锁顺序或锁粒度可能还有问题,需要进一步优化。
六、总结:防死锁的终极心法
- 统一加锁顺序:这是最有效的手段。所有事务必须按照相同的顺序访问资源。
- 缩小锁粒度:用主键或唯一索引锁定,避免表锁;考虑使用 READ COMMITTED 隔离级别。
- 快速释放锁:事务尽量短小,不要在大事务中持有锁。
- 超时与重试:作为兜底机制,捕获死锁异常并重试。
- 持续监控:开启死锁日志,实时监控,及时发现问题。
最后说一句:悲观锁不是银弹,但它能解决大部分因锁顺序混乱导致的死锁问题。真正的关键在于理解锁的本质,精心设计锁的顺序和粒度。
希望这篇分享能帮到你。如果你在实战中遇到具体的死锁问题,欢迎把 SQL 和场景发出来,我们一起分析。记住,死锁不可怕,可怕的是不知道它从哪里来。
