🔔@Transactional 中 finally 块抛异常后,事务到底回不回滚?

📌 前言

在 Spring 开发中,@Transactional 是我们最常用的声明式事务注解。但当 try-catch-finally 与 @Transactional 结合使用时,很多开发者对事务的回滚行为产生了困惑——如果 finally 块中抛出了异常,事务会回滚吗?原来 try 块中的异常还能触发回滚吗?

本文将从原理到实验,彻底讲清这个问题。


🔄 一、@Transactional 回滚机制回顾

在深入 finally 之前,先明确 @Transactional 的回滚规则:

场景 默认行为
方法正常返回 提交
抛出 RuntimeException 或 Error 回滚
抛出受检异常(checked exception) 提交(不回滚)

Spring 的事务代理(AOP Proxy)包裹在方法外层,它只关心方法最终抛出了什么异常(或没有异常),以此决定提交还是回滚。

核心源码在 TransactionAspectSupport#invokeWithinTransaction 中:

// 简化后的伪代码
try {
    // 调用目标方法(即我们的业务方法)
    Object result = invocation.proceed();
    // 正常返回 → 提交事务
    commitTransactionAfterReturning(txInfo);
    return result;
} catch (Throwable ex) {
    // 异常抛出 → 判断是否需要回滚
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
}

关键结论:Spring 代理只能看到方法最终向外抛出的那个异常。 方法内部 try-catch-finally 的流转过程,代理是无感知的。


⚠️ 二、finally 块抛异常的 Java 语义

在分析事务行为之前,必须先理解一个纯 Java 层面的规则:

如果 finally 块中抛出了异常,它会覆盖(替换)try/catch 块中原本要抛出的异常。原始异常将丢失。

public void demo() {
    try {
        throw new RuntimeException("原始异常"); // ① 抛出
    } finally {
        throw new IllegalStateException("finally异常"); // ② 覆盖①
    }
    // 方法最终抛出的是 IllegalStateException("finally异常")
    // RuntimeException("原始异常") 被静默吞掉了!
}

这个 Java 语义是理解后续所有场景的基础。


🔍 三、六大场景全解析

场景 1:try 正常执行,finally 也正常

@Transactional
public void scenario1() {
    try {
        userMapper.insert(new User("张三"));  // 插入数据
    } finally {
        log.info("清理资源");  // 正常执行,无异常
    }
}

结果:事务提交。 方法正常返回,代理执行 commit。


场景 2:try 抛出 RuntimeException,finally 正常

@Transactional
public void scenario2() {
    try {
        userMapper.insert(new User("张三"));
        throw new RuntimeException("业务异常");  // ① 抛出
    } finally {
        log.info("清理资源");  // finally 正常执行
    }
}

结果:事务回滚。 finally 没有抛异常,try 的 RuntimeException 正常传播到代理层,触发回滚。


场景 3:try 正常,finally 抛出 RuntimeException

@Transactional
public void scenario3() {
    try {
        userMapper.insert(new User("张三"));  // 正常插入
    } finally {
        throw new RuntimeException("finally爆炸了");  // ① 抛出
    }
}

结果:事务回滚。 方法最终抛出 RuntimeException,代理判定需要回滚。张三的数据不会入库。

这是最容易被忽视的场景。try 里的业务逻辑明明成功了,但 finally 的异常导致整个事务回滚。


场景 4:try 抛 RuntimeException,finally 也抛 RuntimeException

@Transactional
public void scenario4() {
    try {
        userMapper.insert(new User("张三"));
        throw new RuntimeException("业务异常");  // ① 原始异常
    } finally {
        throw new RuntimeException("finally异常");  // ② 覆盖①
    }
}

结果:事务回滚。 但注意:

  • 代理层捕获的是 "finally异常",不是 "业务异常"
  • 原始的业务异常被丢失了,日志中看不到真正的错误原因
  • 这在排查问题时极为致命

场景 5:try 抛 RuntimeException,finally 抛 checked exception

@Transactional
public void scenario5() throws IOException {
    try {
        userMapper.insert(new User("张三"));
        throw new RuntimeException("业务异常");  // ① 原始异常(unchecked)
    } finally {
        throw new IOException("finally IO异常");  // ② 覆盖①(checked)
    }
}

结果:事务提交!数据不会回滚!

这是最反直觉的场景:

  1. try 块明确抛出了 RuntimeException,按理应该回滚
  2. 但 finally 的 IOException(checked exception)覆盖了原始异常
  3. 代理层只看到 IOException,默认不回滚 checked exception
  4. 结果:张三的数据被提交了,而且原始的 RuntimeException 丢失了

场景 6:try 抛异常,finally 中 catch 吞掉了 finally 自身的异常

@Transactional
public void scenario6() {
    try {
        userMapper.insert(new User("张三"));
        throw new RuntimeException("业务异常");
    } finally {
        try {
            riskyCleanup();  // 可能抛异常的清理操作
        } catch (Exception e) {
            log.error("finally清理失败", e);  // 吞掉finally中的异常
        }
    }
}

结果:事务回滚。 finally 内部的异常被 catch 了,不影响原始异常的传播。try 的 RuntimeException 正常到达代理层,触发回滚。

这是推荐的写法。


📊 四、场景汇总表

场景 try 块 finally 块 方法最终抛出 事务结果
1 正常 正常 无 提交
2 RuntimeException 正常 RuntimeException 回滚
3 正常 RuntimeException RuntimeException 回滚
4 RuntimeException RuntimeException finally 的异常 回滚(原始异常丢失)
5 RuntimeException CheckedException CheckedException 提交(最危险!)
6 RuntimeException catch 吞掉异常 RuntimeException 回滚(推荐写法)

🧪 五、验证实验

下面通过一个完整的 Spring Boot 单元测试来验证上述结论:

@SpringBootTest
@Slf4j
public class TransactionalFinallyTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserMapper userMapper;

    /**
     * 场景5验证:try抛RuntimeException,finally抛checked exception
     * 预期:事务不回滚(最危险的场景)
     */
    @Test
    public void testTryRuntimeExceptionFinallyCheckedException() {
        try {
            userService.riskyMethod();
        } catch (Exception e) {
            log.info("捕获到异常类型: {}", e.getClass().getSimpleName());
            log.info("异常信息: {}", e.getMessage());
        }

        // 验证数据是否入库
        User user = userMapper.selectByName("张三");
        // 数据存在!事务没有回滚!
        assertNotNull(user, "场景5验证:数据应该存在,因为事务未回滚");
    }
}

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Transactional
    public void riskyMethod() throws IOException {
        try {
            userMapper.insert(new User("张三"));
            throw new RuntimeException("业务异常");
        } finally {
            throw new IOException("finally中的IO异常");
        }
    }
}

运行结果:

捕获到异常类型: IOException
异常信息: finally中的IO异常
// 断言通过 —— 张三的数据确实被提交了!

🧩 六、原理图解

方法调用链路:

Caller
  └─→ Spring AOP Proxy(事务拦截器)
        │
        │  try {
        │      Object result = targetMethod.invoke();  ←── 调用真实方法
        │      tx.commit();                             ←── 正常就提交
        │  } catch (Throwable ex) {
        │      if (isRollbackException(ex))             ←── 判断异常类型
        │          tx.rollback();                       ←── unchecked → 回滚
        │      else
        │          tx.commit();                         ←── checked → 提交
        │  }
        │
        └─→ 目标方法内部
              │
              │  try {
              │      // 业务代码 → 可能抛异常A
              │  } finally {
              │      // 清理代码 → 可能抛异常B
              │  }
              │
              │  如果A和B都抛了 → 方法向外抛B(A丢失)
              │  代理只看到B → 根据B的类型决定回滚/提交

✅ 七、最佳实践

1. finally 中永远不要裸抛异常

// 错误写法
@Transactional
public void bad() {
    try {
        doBusiness();
    } finally {
        cleanup();  // 如果这里抛异常,可能覆盖业务异常
    }
}

// 正确写法
@Transactional
public void good() {
    try {
        doBusiness();
    } finally {
        try {
            cleanup();
        } catch (Exception e) {
            log.error("清理操作失败", e);  // 记录日志但不向外抛
        }
    }
}

2. 使用 try-with-resources 代替手动 finally

@Transactional
public void better() {
    try (Connection conn = dataSource.getConnection()) {
        // 业务操作
    }
    // try-with-resources 的 close 异常不会覆盖 try 中的异常
    // 而是作为 suppressed exception 附加上去
}

3. 如果必须在 finally 抛异常,保留原始异常

@Transactional
public void preserveOriginal() {
    Throwable primaryException = null;
    try {
        doBusiness();
    } catch (Throwable t) {
        primaryException = t;
        throw t;
    } finally {
        try {
            cleanup();
        } catch (Exception e) {
            if (primaryException != null) {
                primaryException.addSuppressed(e);  // 保留两个异常
            } else {
                throw e;
            }
        }
    }
}

4. 明确指定 rollbackFor

// 防止 checked exception 导致不回滚
@Transactional(rollbackFor = Exception.class)
public void safe() throws Exception {
    try {
        doBusiness();
    } finally {
        cleanup();  // 即使抛 checked exception 也会回滚
    }
}

这是最简单有效的防御手段。建议团队规范中强制要求所有 @Transactional 都加上 rollbackFor = Exception.class。


💡 八、总结

要点 说明
Spring 代理的视角 只看方法最终抛出的异常类型,不感知内部 try-finally 流转
finally 异常会覆盖 try 异常 这是 Java 语言层面的行为,与 Spring 无关
最危险的场景 try 抛 RuntimeException + finally 抛 CheckedException = 不回滚
核心防御 @Transactional(rollbackFor = Exception.class)
finally 块铁律 永远在 finally 内部 try-catch,不要让异常逃逸

记住一句话:@Transactional 的回滚与否,取决于方法最终向外抛出的异常类型,而不是方法内部曾经发生过什么异常。 finally 块如果抛出异常,就会改变"最终向外抛出的异常",从而可能改变事务的回滚行为。


作者注:本文基于 Spring Framework 5.x / Spring Boot 2.x+ 验证,Spring 6.x 行为一致。如有疑问欢迎讨论。

posted @ 2026-04-17 09:29  丿似锦  阅读(29)  评论(0)    收藏  举报