数据库死锁-应用层重试

这个问题问得特别实际,是把理论知识变成生产级代码的关键一步。

我用最直白的方式给你讲清楚。


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. 一句话记住

应用层重试就是:死锁失败不要慌,自动重试整个事务,用指数退避错开竞争,用幂等设计防止重复,让用户感觉不到"偶尔的失败"。


相关推荐
宠友信息1 天前
消息序号如何保证即时通讯源码聊天记录稳定加载
java·spring boot·redis·python·mysql·uni-app
笨蛋不要掉眼泪1 天前
MySQL架构揭秘:慢查询日志详解
数据库·mysql·架构
️学习的小王1 天前
MySQL 实战:从建表到索引管理的完整指南
数据库·mysql·oracle
宠友信息2 天前
MySQL复合索引与Druid优化仿小红书源码个人主页查询链路
数据库·spring boot·websocket·mysql·uni-app
minji...2 天前
MySQL数据库 (十九) MySQL图像化界面,MySQL连接池
数据库·mysql·navicat·mysql workbench·mysql连接池·数据库图形化界面
文档搬运工2 天前
Innodb Cluster安装
mysql
想躺平的小羊2 天前
MySQL中LAST_DAY函数用法
数据库·mysql
光影6272 天前
MySQL基础入门
数据库·笔记·sql·学习·mysql·学习方法
光影6272 天前
MySQL 进阶篇 —— 事务 / 外键 / 索引 / 高级查询
数据库·笔记·sql·学习·mysql