从银行转账死锁到电商订单卡单:悲观锁常见陷阱与防死锁实战技巧
一把锁引发的”血案”
先讲个真实发生过的故事。
2018年,某大型电商平台的支付高峰期,后台监控报警:大量订单卡在”处理中”状态,用户付了钱,订单却不更新。排查三天,最后发现是一个看似无害的悲观锁把整个支付链路冻住了。
这和我们今天聊的主题直接相关——悲观锁,用对了是护身符,用错了就是定时炸弹。
一、死锁是什么?先从银行转账说起
1.1 为什么转账最容易触发死锁?
想象一下这个场景:
用户A向用户B转账100元。
在数据库层面,这个操作大致是这样:
-- 第一步:扣A的钱
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
-- 第二步:加B的钱
UPDATE account SET balance = balance + 100 WHERE user_id = 'B';
听起来很简单对吧?但如果两个用户同时交叉转账呢?
线程1:A → B 转账
线程2:B → A 转账
时间线是这样的:
时间 线程1 线程2
────────────────────────────────────────────
T1 加锁A的记录
T2 加锁B的记录
T3 尝试加锁B → 等待 ──
T4 尝试加锁A → 等待
死锁就这样发生了: 线程1拿着A的锁等B的锁,线程2拿着B的锁等A的锁,谁也不让谁,系统直接卡死。
MySQL的InnoDB引擎会在检测到死锁后,自动选择一个事务回滚(通常选择代价最小的那个),但这期间会产生大量无用计算,还可能导致业务数据不一致。
1.2 真实案例:某支付平台的死锁风暴
某支付平台曾在双11期间遭遇死锁高峰,峰值达到每秒200+次死锁检测。
原因很简单:
- 多笔订单并发处理同一个用户账户
- 加锁顺序不统一
- 没有设置合理的锁超时时间
结果:系统频繁回滚,用户体验极差,部分用户重复扣款。
二、电商订单卡单:悲观锁的”翻车现场”
2.1 订单处理的经典场景
电商订单有一个典型状态流转:
待支付 → 已支付 → 发货中 → 已发货 → 已完成
在”待支付 → 已支付”这个环节,如果用户快速点击两次支付按钮,或者两个设备同时发起支付,就会出现竞态条件。
2.2 悲观锁的”正确”写法
很多开发者会这样写:
// 伪代码
@Transactional
public void payOrder(String orderId, String userId) {
// 1. 加悲观锁查询订单
Order order = orderMapper.selectForUpdate(orderId);
// 2. 检查状态
if (!"PENDING".equals(order.getStatus())) {
throw new BusinessException("订单状态异常");
}
// 3. 更新状态
order.setStatus("PAID");
orderMapper.updateById(order);
}
看起来没问题?问题大了。
2.3 为什么悲观锁会卡单?
陷阱一:锁粒度太大
上面的代码锁住的是整行记录。如果多个用户同时操作不同订单但同一个用户,锁之间没有依赖关系,却会因为select for update的排他性互相阻塞。
陷阱二:锁持有时间过长
如果查询到订单后,中间还做了其他业务逻辑(比如调用风控接口、发送短信通知等),锁的持有时间会大大延长,其他事务只能排队等待。
陷阱三:锁顺序不一致
// 线程1:处理订单A,再处理订单B
orderAMapper.selectForUpdate(orderAId);
orderBMapper.selectForUpdate(orderBId);
// 线程2:处理订单B,再处理订单A
orderBMapper.selectForUpdate(orderBId);
orderAMapper.selectForUpdate(orderAId);
这和银行转账的死锁如出一辙。
三、悲观锁的常见陷阱
3.1 陷阱一:以为锁住了就万事大吉
很多开发者认为,加了select for update就高枕无忧了。但实际上:
- 锁是在事务内有效的,事务提交后锁释放
- 如果事务超时或者异常回滚,锁的行为可能不符合预期
- 不同隔离级别下,锁的行为不同
-- 在READ COMMITTED隔离级别下
-- 每次查询都会加锁,但锁立即释放
-- 可能导致"不可重复读"
-- 在REPEATABLE READ隔离级别下(MySQL默认)
-- 锁持有到事务结束
-- 可能导致死锁概率增加
3.2 陷阱二:锁升级导致性能雪崩
InnoDB有一个特性叫锁升级:当一行记录被锁定时,如果后续操作需要扫描更多行,InnoDB可能会将行锁升级为表锁。
// 这段代码很危险
Order order = orderMapper.selectForUpdate(
"SELECT * FROM orders WHERE user_id = ? AND status = ?",
userId
);
// 如果user_id对应大量订单,可能触发锁升级!
后果: 一个用户的大量订单被锁住,其他用户根本无法操作自己的订单。
3.3 陷阱三:忽略了乐观锁的存在
悲观锁的核心思想是”假设最坏情况”。但在很多电商场景下,冲突概率并不高,使用乐观锁反而更合适:
// 乐观锁版本
@Transactional
public void payOrder(String orderId, String userId) {
Order order = orderMapper.selectById(orderId);
if (!"PENDING".equals(order.getStatus())) {
throw new BusinessException("订单状态异常");
}
// 使用版本号控制
int rows = orderMapper.updateByIdAndVersion(
orderId, userId, order.getVersion() + 1
);
if (rows == 0) {
// 并发冲突,重试或返回错误
throw new BusinessException("订单已被处理");
}
}
-- 对应的SQL
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE id = ? AND version = ? AND user_id = ?
3.4 陷阱四:没有设置合理的锁超时
// 不设置超时 = 等待 indefinitely
SET lock_wait_timeout = 30; -- 30秒后放弃等待
// 或者在代码中设置
@Transactional(timeout = 30)
public void payOrder(String orderId) { ... }
如果超时时间太长,会导致线程积压;如果太短,又可能误杀正常事务。
四、防死锁实战技巧
4.1 技巧一:统一加锁顺序
这是防止死锁最简单也最有效的方法。
// 错误做法:加锁顺序不统一
public void transfer(Account from, Account to, BigDecimal amount) {
synchronized (from) { // 先锁from
synchronized (to) { // 再锁to
// 转账逻辑
}
}
}
// 正确做法:统一按ID排序后加锁
public void transfer(Account from, Account to, BigDecimal amount) {
Account first, second;
if (from.getId().compareTo(to.getId()) < 0) {
first = from;
second = to;
} else {
first = to;
second = from;
}
synchronized (first) {
synchronized (second) {
// 转账逻辑
}
}
}
在SQL层面也一样:
-- 始终按照主键顺序加锁
-- 先锁user_id小的,再锁user_id大的
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
UPDATE account SET balance = balance + 100 WHERE user_id = 'B';
-- 保证所有事务都按 A → B 的顺序执行
4.2 技巧二:最小化锁持有时间
原则:能不加锁的地方就不加,能尽快释放锁就尽快释放。
// 错误:锁持有时间过长
@Transactional
public void processOrder(String orderId) {
Order order = orderMapper.selectForUpdate(orderId);
// 这些操作不应该在锁内!
callRiskControlService(order); // 外部调用
sendNotification(order); // 网络IO
updateInventory(order); // 其他表操作
order.setStatus("PAID");
orderMapper.updateById(order);
}
// 正确:只锁必要的部分
Order order = orderMapper.selectForUpdate(orderId); // 锁住查询
// 锁已经释放(如果没有开启事务)
callRiskControlService(order); // 外部调用
sendNotification(order); // 网络IO
// 需要再次确认状态
order = orderMapper.selectForUpdate(orderId);
order.setStatus("PAID");
orderMapper.updateById(order);
注意: 如果使用了@Transactional,整个方法都在事务内,锁会一直持有到方法结束。此时应该把锁定的范围缩小到最小。
4.3 技巧三:使用短事务
长事务是死锁的温床。
// 错误:长事务
@Transactional
public void longTransaction() {
// 查询
Order order = orderMapper.selectForUpdate(orderId);
// 中间做了很多无关操作
Thread.sleep(1000); // 模拟耗时操作
doSomethingElse();
// 最后才更新
order.setStatus("PAID");
orderMapper.updateById(order);
}
// 正确:短事务
public void shortTransaction() {
// 在事务外做耗时操作
Thread.sleep(1000);
doSomethingElse();
// 只在更新时开启短事务
transactionTemplate.execute(status -> {
Order order = orderMapper.selectForUpdate(orderId);
order.setStatus("PAID");
orderMapper.updateById(order);
return null;
});
}
4.4 技巧四:合理设置锁超时和重试
// 设置锁超时时间
@Transactional(timeout = 5) // 5秒超时
public void payOrder(String orderId) { ... }
// 配合重试机制
public void payOrderWithRetry(String orderId, int maxRetries) {
for (int i = 0; i < maxRetries; i++) {
try {
transactionTemplate.execute(status -> {
Order order = orderMapper.selectForUpdate(orderId);
if (!"PENDING".equals(order.getStatus())) {
throw new BusinessException("订单状态异常");
}
order.setStatus("PAID");
orderMapper.updateById(order);
return null;
});
return; // 成功退出
} catch (DeadlockLoserDataAccessException e) {
if (i == maxRetries - 1) {
throw e; // 最后一次重试仍失败,抛出异常
}
log.warn("死锁检测,第{}次重试", i + 1);
try {
Thread.sleep(100 * (i + 1)); // 指数退避
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
4.5 技巧五:分库分表 + 分布式锁
对于高并发场景,单库的悲观锁已经不够用了。
// 使用Redis分布式锁
public void payOrderDistributed(String orderId, String userId) {
String lockKey = "order_lock:" + orderId;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等待10秒,锁15秒后自动释放
if (lock.tryLock(10, 15, TimeUnit.SECONDS)) {
try {
// 业务逻辑
Order order = orderMapper.selectById(orderId);
if (!"PENDING".equals(order.getStatus())) {
throw new BusinessException("订单状态异常");
}
order.setStatus("PAID");
orderMapper.updateById(order);
} finally {
lock.unlock();
}
} else {
throw new BusinessException("系统繁忙,请稍后重试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("系统错误");
}
}
4.6 技巧六:状态机设计替代锁
有些场景下,与其加锁,不如设计一个状态机:
// 使用状态机处理订单流转
public class OrderStateMachine {
private static final Map<String, Set<String>> VALID_TRANSITIONS =
new HashMap<>();
static {
VALID_TRANSITIONS.put("PENDING", Set.of("PAID", "CANCELLED"));
VALID_TRANSITIONS.put("PAID", Set.of("SHIPPING", "REFUNDING"));
VALID_TRANSITIONS.put("SHIPPING", Set.of("SHIPPED"));
VALID_TRANSITIONS.put("SHIPPED", Set.of("COMPLETED"));
}
public void transition(String orderId, String fromStatus, String toStatus) {
Set<String> allowed = VALID_TRANSITIONS.get(fromStatus);
if (allowed == null || !allowed.contains(toStatus)) {
throw new BusinessException("非法状态转换");
}
// 使用CAS更新
int rows = orderMapper.updateStatus(
orderId, fromStatus, toStatus
);
if (rows == 0) {
throw new BusinessException("订单状态已被修改");
}
}
}
-- 利用数据库的原子性,不需要显式加锁
UPDATE orders
SET status = 'PAID'
WHERE id = ? AND status = 'PENDING';
五、从银行到电商:统一的防死锁原则
5.1 核心原则总结
- 统一加锁顺序 — 所有事务按相同顺序获取锁
- 最小化锁范围 — 锁粒度越小越好,持有时间越短越好
- 短事务优先 — 避免长事务持有锁
- 设置超时 — 防止无限等待
- 重试机制 — 死锁发生后自动重试
- 考虑无锁方案 — 乐观锁、状态机、CAS等
5.2 不同场景的推荐方案
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 银行转账 | 统一加锁顺序 + 短事务 | 资金安全,不允许丢失 |
| 库存扣减 | 乐观锁 + CAS | 冲突概率低,性能好 |
| 订单支付 | 分布式锁 + 重试 | 高并发,需要全局一致性 |
| 秒杀抢购 | 预扣库存 + 异步下单 | 避免热点数据竞争 |
| 用户余额 | 悲观锁 + 状态机 | 严格一致性要求 |
六、一个完整的实战案例
下面是一个电商订单支付的完整实现,结合了前面提到的各种技巧:
@Service
public class OrderPayService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RedissonClient redissonClient;
@Autowired
private PlatformTransactionManager transactionManager;
private static final int MAX_RETRY = 3;
private static final long LOCK_WAIT_TIMEOUT = 3;
private static final long LOCK_LEASE_TIME = 10;
/**
* 订单支付 - 带重试的分布式锁方案
*/
public PayResult payOrder(String orderId, String userId, BigDecimal amount) {
for (int attempt = 0; attempt < MAX_RETRY; attempt++) {
String lockKey = "order:pay:" + orderId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁
if (!lock.tryLock(LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIME, TimeUnit.SECONDS)) {
if (attempt == MAX_RETRY - 1) {
return PayResult.fail("系统繁忙,请稍后重试");
}
Thread.sleep(100 * (attempt + 1));
continue;
}
// 在锁内执行事务
return transactionTemplate.execute(status -> {
try {
return doPayOrder(orderId, userId, amount);
} catch (Exception e) {
status.setRollbackOnly();
throw e;
}
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return PayResult.fail("系统错误");
} catch (Exception e) {
log.warn("支付尝试 {} 失败: {}", attempt + 1, e.getMessage());
if (attempt == MAX_RETRY - 1) {
return PayResult.fail("支付失败,请稍后重试");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
return PayResult.fail("达到最大重试次数");
}
/**
* 实际支付逻辑
*/
private PayResult doPayOrder(String orderId, String userId, BigDecimal amount) {
// 1. 查询订单(带版本号)
Order order = orderMapper.selectByVersion(orderId);
if (order == null) {
return PayResult.fail("订单不存在");
}
// 2. 校验订单状态
if (!"PENDING".equals(order.getStatus())) {
return PayResult.fail("订单状态异常: " + order.getStatus());
}
// 3. 校验金额
if (order.getAmount().compareTo(amount) != 0) {
return PayResult.fail("金额不匹配");
}
// 4. 更新订单状态(乐观锁)
int updated = orderMapper.updateStatusByVersion(
orderId, userId, "PAID", order.getVersion()
);
if (updated == 0) {
throw new ConcurrentModificationException("订单已被修改");
}
// 5. 更新用户余额(按用户ID排序,防止死锁)
updateUserBalance(userId, amount.negate());
// 6. 记录支付流水
recordPayment(orderId, userId, amount);
return PayResult.success();
}
/**
* 更新用户余额 - 统一按用户ID排序加锁
*/
@Transactional
public void updateUserBalance(String userId, BigDecimal amount) {
// 先查询再更新,使用悲观锁
UserAccount account = accountMapper.selectForUpdate(userId);
BigDecimal newBalance = account.getBalance().add(amount);
if (newBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new InsufficientBalanceException("余额不足");
}
accountMapper.updateBalance(userId, newBalance, account.getVersion());
}
}
// 对应的Mapper方法
@Mapper
public interface OrderMapper {
/**
* 带版本号的查询
*/
@Select("SELECT * FROM orders WHERE id = #{id} FOR UPDATE")
Order selectByVersion(@Param("id") String orderId);
/**
* CAS更新状态
*/
@Update("UPDATE orders SET status = #{newStatus}, version = version + 1 " +
"WHERE id = #{id} AND version = #{version} AND user_id = #{userId}")
int updateStatusByVersion(
@Param("id") String orderId,
@Param("userId") String userId,
@Param("newStatus") String newStatus,
@Param("version") int version
);
}
七、如何检测和预防死锁
7.1 MySQL死锁检测
-- 查看当前死锁信息
SHOW ENGINE INNODB STATUS;
-- 查看最近的死锁日志
SELECT * FROM information_schema.innodb_trx;
-- 开启死锁日志记录
SET GLOBAL innodb_print_all_deadlocks = ON;
7.2 监控指标
- 死锁频率:每秒死锁次数
- 锁等待时间:平均锁等待时间
- 锁超时率:锁超时占所有请求的比例
- 事务成功率:因死锁回滚的事务占比
7.3 日常排查步骤
1. 查看慢查询日志,找出长时间持锁的SQL
2. 分析锁等待链,找出死锁的循环依赖
3. 检查事务大小,是否有不必要的长事务
4. 审查加锁顺序,是否统一
5. 评估锁粒度,是否可以缩小
八、最后的话
悲观锁本身没有对错,关键在于用得是否合适。
- 对于低并发、强一致性的场景,悲观锁是简单有效的方案
- 对于高并发、冲突较少的场景,乐观锁或无锁方案更合适
- 对于超高并发的场景,需要结合分布式锁、消息队列、异步处理等组合方案
记住:死锁不是代码的bug,而是并发设计的缺失。
解决死锁问题,不是靠”运气”让MySQL自动检测,而是要在设计阶段就考虑到并发场景,从业务逻辑上规避冲突。
希望这篇文章能帮你建立起对悲观锁和死锁的完整认知。如有问题,欢迎交流!
