死锁本质是:多个线程 / 事务 / 进程互相等待对方释放资源,结果谁也无法继续执行 。
在 Java 后端里,常见于 Java 锁、数据库事务锁、Redis 分布式锁、MQ 消费并发更新订单 等场景。
一、死锁为什么会出现?
死锁通常要同时满足 4 个条件:
| 条件 | 说明 |
|---|---|
| 互斥 | 一个资源同一时间只能被一个线程或事务占用 |
| 占有且等待 | 已经拿到一个资源,又去等待另一个资源 |
| 不可抢占 | 别人拿到的锁不能被强制释放 |
| 循环等待 | A 等 B,B 又等 A,形成环 |
只要破坏其中一个条件,就可以规避死锁。
二、Java 代码里的死锁例子
比如两个线程加锁顺序不一致:
ini
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) {
sleep(100);
synchronized (lockB) {
System.out.println("t1 执行完成");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
sleep(100);
synchronized (lockA) {
System.out.println("t2 执行完成");
}
}
});
执行过程:
线程1 拿到 lockA,等待 lockB
线程2 拿到 lockB,等待 lockA
结果:
线程1 等线程2释放 lockB
线程2 等线程1释放 lockA
互相等待,形成死锁
三、数据库死锁例子
假设有两个事务:
事务 A
ini
begin;
update order_info set status = 'PAID' where id = 1;
update user_balance set amount = amount - 100 where user_id = 10;
commit;
事务 B
ini
begin;
update user_balance set amount = amount + 100 where user_id = 10;
update order_info set status = 'REFUND' where id = 1;
commit;
问题在于:
css
事务 A:先锁 order_info,再锁 user_balance
事务 B:先锁 user_balance,再锁 order_info
最终可能变成:
css
事务 A 拿着订单锁,等待余额锁
事务 B 拿着余额锁,等待订单锁
这就是典型的 数据库死锁。
四、如何规避死锁?
1. 固定加锁顺序
这是最重要的原则。
所有业务都按照统一顺序加锁,比如:
先锁用户
再锁订单
再锁支付单
再锁退款单
不要一个地方:
rust
订单 -> 用户
另一个地方:
rust
用户 -> 订单
否则高并发下很容易死锁。
2. 缩小事务范围
错误写法:
scss
@Transactional
public void pay() {
queryOrder();
callWechatPay();
updateOrder();
updatePayRecord();
}
这里把外部接口调用也放在事务里面,非常危险。
推荐写法:
scss
public void pay() {
// 1. 先做参数校验
Order order = queryOrder();
// 2. 外部支付接口调用不要放事务里
PayResult result = callWechatPay(order);
// 3. 只把数据库核心更新放事务里
updatePayStatus(result);
}
@Transactional
public void updatePayStatus(PayResult result) {
updateOrder();
updatePayRecord();
}
事务越长,锁持有时间越久,死锁概率越高。
3. SQL 条件必须走索引
例如:
ini
update order_info set status = 'PAID' where order_no = 'ORD123';
如果 order_no 没有索引,MySQL 可能扫描大量数据,锁范围扩大,死锁概率上升。
建议:
csharp
create unique index uk_order_no on order_info(order_no);
特别是这些字段要重点加索引:
lua
订单号 order_no
支付单号 pay_no
退款单号 refund_no
用户ID user_id
业务ID business_id
状态 status + 创建时间 create_time
4. 避免大批量更新
危险写法:
ini
update order_info
set status = 'CLOSED'
where status = 'WAIT_PAY'
and create_time < '2026-05-14 10:00:00';
如果一次更新几万行,会持有大量行锁。
推荐分页处理:
bash
select id
from order_info
where status = 'WAIT_PAY'
and create_time < ?
limit 100;
然后按 ID 批量更新:
ini
update order_info
set status = 'CLOSED'
where id in (...)
and status = 'WAIT_PAY';
5. 使用乐观锁减少悲观锁冲突
适合订单状态流转。
表结构加版本号:
sql
alter table order_info add column version int default 0;
更新时:
ini
update order_info
set status = 'PAID',
version = version + 1
where id = ?
and status = 'WAIT_PAY'
and version = ?;
如果更新行数为 0,说明数据被别人改过,可以重试或提示失败。
6. 使用 tryLock 设置超时时间
Java 里不要无限等待锁。
csharp
boolean locked = lock.tryLock(3, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
try {
// 执行业务
} finally {
lock.unlock();
}
这样可以避免线程一直卡死。
7. Redis 分布式锁必须设置过期时间
危险写法:
ini
setnx lockKey value;
如果服务宕机,锁永远不释放,就会造成"死锁"。
推荐写法:
sql
SET lockKey uniqueValue NX EX 30
释放锁时必须校验 value,避免误删别人的锁。
Redisson 推荐:
csharp
RLock lock = redissonClient.getLock("order:" + orderId);
boolean success = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!success) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
try {
// 业务逻辑
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
五、数据库死锁不能完全避免,要支持重试
MySQL InnoDB 检测到死锁后,会主动回滚其中一个事务。
常见异常:
csharp
Deadlock found when trying to get lock; try restarting transaction
所以业务上要支持有限次数重试:
ini
for (int i = 0; i < 3; i++) {
try {
doTransaction();
break;
} catch (DeadlockLoserDataAccessException e) {
if (i == 2) {
throw e;
}
Thread.sleep(100);
}
}
注意:只能对 幂等业务 做重试,比如根据订单号、支付流水号、退款单号防重复。
六、后端项目里的规避原则总结
| 场景 | 规避方式 |
|---|---|
| Java 多线程死锁 | 固定加锁顺序,减少嵌套锁,使用 tryLock |
| MySQL 事务死锁 | 固定更新顺序,SQL 走索引,缩短事务 |
| 支付订单并发 | 状态机 + 乐观锁 + 幂等单号 |
| 退款并发 | 根据订单号加锁,校验已退金额,数据库事务兜底 |
| Redis 分布式锁 | 设置 TTL,唯一 value,finally 释放 |
| MQ 并发消费 | 消费幂等,状态判断,失败重试 |
| 批量任务 | 分页处理,避免一次锁大量数据 |
七、推荐的实际落地方案
对于订单、支付、退款这种业务,建议这样设计:
markdown
1. 所有状态流转走状态机
2. 所有更新按固定顺序执行
3. 关键表字段必须有索引
4. 使用乐观锁控制并发
5. Redis 分布式锁只做入口限流,不做最终一致性保障
6. 数据库事务做最终兜底
7. MQ 消费必须幂等
8. 死锁异常做有限重试
比较稳的更新方式:
ini
update order_info
set status = 'PAID',
version = version + 1
where order_no = ?
and status = 'WAIT_PAY'
and version = ?;
这类写法天然防止重复支付、重复关闭、重复退款,适合真实业务系统。✅