如何规避死锁

死锁本质是:多个线程 / 事务 / 进程互相等待对方释放资源,结果谁也无法继续执行

在 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 = ?;

这类写法天然防止重复支付、重复关闭、重复退款,适合真实业务系统。✅

相关推荐
该用户已不存在1 小时前
用 Claude Code Agents 与 CI/CD 搭建自动化研发团队(Part 3)
后端·ai编程·claude
豹哥学前端1 小时前
agent智能体经典范式构建
人工智能·后端
胡志辉2 小时前
邮件中点击“加载图片”,你的IP地址已经被泄漏
前端·后端·安全
拽着尾巴的鱼儿2 小时前
spring 动态代理
java·后端·spring
Rust研习社3 小时前
Rust 的 move 语义,一次讲透
后端·rust·编程语言
IT_陈寒3 小时前
用了Vue的动态组件之后,我被坑得找不着北
前端·人工智能·后端
undefinedType3 小时前
深入理解 Rails includes:为什么一个 order(users.xxx) 会导致超级 JOIN 性能问题
后端
baviya3 小时前
用 Spring AI Alibaba JManus 构建零售智能客服工单系统:从 0 到日处理 10 万单
后端·ai编程
叫我少年3 小时前
C# 基础数据类型:布尔类型
后端