这个问题问得特别实际,是把理论知识变成生产级代码的关键一步。
我用最直白的方式给你讲清楚。
1. 一句话核心定义
应用层重试 :当数据库操作因为死锁 或锁等待超时 而失败时,应用程序自动捕获异常,重新执行整个事务,直到成功或超过重试次数。
说白了就是:失败了没关系,自动再试一次。
2. 为什么要做应用层重试?
因为死锁在 MySQL 里是"正常的意外":
- MySQL 检测到死锁后,一定会回滚其中一个事务(牺牲者)。
- 被回滚的事务会抛出异常(如
Deadlock found when trying to get lock)。 - 你的程序如果不处理这个异常,用户就会看到"系统错误"或"操作失败"。
应用层重试的目的 :把这种"偶尔的失败"对用户透明化,让用户感觉不到。
3. 重试什么?------ 是整个事务,不是单条 SQL!
这是最容易踩的坑:
| ❌ 错误做法 | ✅ 正确做法 |
|---|---|
| 只重试那一条失败的 SQL | 重试整个事务(从 BEGIN 开始) |
| 原因:事务已经回滚了,之前的所有操作都失效了 | 原因:必须重新开始一个新事务 |
错误示例:
java
// ❌ 错误:只重试 UPDATE
try {
jdbc.update("UPDATE account SET balance = ..."); // 失败,抛出死锁异常
} catch (DeadlockException e) {
jdbc.update("UPDATE account SET balance = ..."); // 再试一次(但事务已回滚,上下文丢失)
}
正确示例:
java
// ✅ 正确:重试整个事务
public void transfer(int fromId, int toId, int amount) {
int retries = 3;
while (retries-- > 0) {
try {
doTransfer(fromId, toId, amount); // 整个事务在里面
return; // 成功,退出
} catch (DeadlockException | LockWaitTimeoutException e) {
// 重试前可以短暂等待(可选)
if (retries == 0) {
throw new RuntimeException("转账失败,请稍后重试", e);
}
// 继续下一次循环
}
}
}
private void doTransfer(int fromId, int toId, int amount) {
// 注意:这里用 Spring 的 @Transactional 或手动管理事务
// 整个方法是一个事务
accountDao.decreaseBalance(fromId, amount);
accountDao.increaseBalance(toId, amount);
}
4. 完整的重试实现(代码示例)
方式一:手动重试(Java 示例)
java
public void transferWithRetry(int fromId, int toId, int amount) {
int maxRetries = 3;
int retryCount = 0;
long backoffMs = 50; // 初始等待时间(毫秒)
while (retryCount < maxRetries) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 开启事务
// 1. 扣减转出账户
updateBalance(conn, fromId, -amount);
// 2. 增加转入账户
updateBalance(conn, toId, amount);
conn.commit(); // 提交事务
return; // 成功,退出
} catch (SQLException e) {
// 判断是否是死锁或锁超时
if (isDeadlockOrLockTimeout(e)) {
// 回滚事务
if (conn != null) {
try { conn.rollback(); } catch (SQLException ignored) {}
}
retryCount++;
if (retryCount < maxRetries) {
// 指数退避:等待时间递增
Thread.sleep(backoffMs * (1 << retryCount));
continue;
}
}
throw new RuntimeException("转账失败", e);
} finally {
if (conn != null) {
try { conn.close(); } catch (SQLException ignored) {}
}
}
}
throw new RuntimeException("转账失败,超过最大重试次数");
}
private boolean isDeadlockOrLockTimeout(SQLException e) {
// MySQL 死锁错误码:40001
// 锁等待超时错误码:1205
return e.getErrorCode() == 40001 || e.getErrorCode() == 1205;
}
方式二:Spring 注解(推荐)
Spring 提供了 @Retryable 注解,可以更优雅地实现重试:
xml
<!-- 引入 spring-retry 依赖 -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
java
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
@Retryable(
value = { DeadlockException.class, LockWaitTimeoutException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2) // 指数退避:50ms → 100ms → 200ms
)
@Transactional // 整个方法在一个事务中
public void transfer(int fromId, int toId, int amount) {
accountDao.decreaseBalance(fromId, amount);
accountDao.increaseBalance(toId, amount);
}
}
注意 :需要在启动类或配置类上加上
@EnableRetry。
5. 重试时的"指数退避"是什么意思?
指数退避(Exponential Backoff):每次重试等待时间递增,避免"惊群效应"。
| 重试次数 | 等待时间 | 说明 |
|---|---|---|
| 第1次失败 | 等待 50ms | 短暂等待后重试 |
| 第2次失败 | 等待 100ms | 等待时间翻倍 |
| 第3次失败 | 等待 200ms | 继续翻倍 |
为什么要这样做?
- 如果所有线程都同时重试,可能再次死锁。
- 错开重试时间,减少竞争。
6. 哪些异常需要重试?(精确判断)
| 异常类型 | 是否重试 | 说明 |
|---|---|---|
| 死锁异常(DeadlockException) | ✅ 重试 | MySQL 错误码 40001 |
| 锁等待超时(LockWaitTimeoutException) | ✅ 重试 | MySQL 错误码 1205 |
| 唯一键冲突(DuplicateKeyException) | ❌ 不重试 | 业务数据问题,重试也没用 |
| 网络超时 | ⚠️ 看情况 | 可能是网络问题,可重试 |
| 参数错误(SQL 语法错误) | ❌ 不重试 | 代码 bug,重试也没用 |
7. 重试的副作用和注意事项
⚠️ 风险1:幂等性
重试可能导致同一操作执行多次!
sql
-- ❌ 非幂等操作
INSERT INTO transfer_log (order_id, amount) VALUES (123, 100);
-- 如果第一次执行成功,但响应超时,客户端重试 → 插入两条日志
解决方案:
sql
-- ✅ 使用唯一键约束
CREATE TABLE transfer_log (
id INT PRIMARY KEY,
order_id VARCHAR(50) UNIQUE, -- 唯一键,防止重复插入
amount INT
);
-- 重试时如果 order_id 重复,会报唯一键冲突,不会插入重复数据
⚠️ 风险2:重试次数限制
| 场景 | 建议重试次数 |
|---|---|
| 高并发下死锁较多 | 3-5 次 |
| 偶尔死锁 | 2-3 次 |
| 金融系统(严格) | 3 次,超过则人工介入 |
8. 重试的完整流程图
markdown
用户发起请求
│
▼
┌─────────────────┐
│ 第1次尝试 │
│ 执行事务 │
└────────┬────────┘
│
▼
是否成功?
│ │
是 否
│ │
▼ ▼
返回成功 判断是否是死锁/锁超时?
│ │
是 否
│ │
▼ ▼
是否超过 直接抛异常
重试次数? (业务或系统错误)
│ │
是 否
│ │
▼ ▼
抛异常 等待(指数退避)
│
▼
第2次尝试
执行事务
│
└── 循环直到成功或超时
9. 终极总结
| 问题 | 答案 |
|---|---|
| 什么是应用层重试? | 死锁失败后,自动重试整个事务 |
| 为什么要重试? | 让用户无感知,提高成功率 |
| 重试什么? | 整个事务(从 BEGIN 到 COMMIT) |
| 重试多少次? | 通常 3 次 |
| 重试间隔怎么定? | 指数退避(50ms→100ms→200ms) |
| 必须注意什么? | 幂等性(防止重复执行) |
10. 一句话记住
应用层重试就是:死锁失败不要慌,自动重试整个事务,用指数退避错开竞争,用幂等设计防止重复,让用户感觉不到"偶尔的失败"。