事务中不为所知的异常致命细节,重要内部原理与核心结构

摘要:从一次"明明加了@Transactional却没回滚"的诡异bug出发,深度剖析Spring事务的5大致命细节。通过事务传播行为的7种场景、异常类型导致的回滚失效、以及事务失效的8种原因,揭秘为什么private方法事务不生效、为什么try-catch会吞掉事务、以及自调用为什么绕过AOP代理。配合时序图展示事务代理流程,手写简易版事务管理器,给出事务使用的最佳实践。


💥 翻车现场

周三上午,测试同学报了一个bug。

测试同学:@哈吉米 转账功能有问题!扣款失败了,但钱还是被扣了!
哈吉米:不可能啊,我加了@Transactional,出错应该回滚的!

查看代码:

@Service
public class AccountService {
    
    @Autowired
    private AccountMapper accountMapper;
    
    @Transactional
    public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
        // 1. 扣减转出方余额
        accountMapper.decreaseBalance(fromUserId, amount);
        
        // 2. 增加转入方余额
        accountMapper.increaseBalance(toUserId, amount);
        
        // 3. 调用外部接口(可能失败)
        try {
            externalService.notify(fromUserId, toUserId, amount);
        } catch (Exception e) {
            log.error("通知失败", e);
            // 吞掉了异常,事务不会回滚 ❌
        }
    }
}

测试场景

操作:AB转账100元
结果:
- A余额:1000900(扣了)
- B余额:500500(没加)
- 外部接口调用失败(抛异常)
- 但异常被catch了,事务没回滚 ❌

问题:A的钱扣了,B没收到,钱丢了!

哈吉米:"卧槽,try-catch会导致事务不回滚?"

南北绿豆和阿西噶阿西来了。

南北绿豆:"这是Spring事务的第一个坑:异常被catch后,事务不会回滚!"
哈吉米:"那怎么办?"
阿西噶阿西:"有很多细节要注意,我给你讲讲Spring事务的5大致命细节。"


🕳️ 致命细节1:异常被catch后不回滚

问题原因

南北绿豆:"Spring事务的回滚机制是:捕获未处理的异常,触发回滚。"

// Spring事务的伪代码
try {
    // 开启事务
    beginTransaction();
    
    // 执行业务方法
    transfer(...);
    
    // 提交事务
    commitTransaction();
    
} catch (Exception e) {
    // 捕获到异常,回滚
    rollbackTransaction();
    throw e;
}

如果你catch了异常

@Transactional
public void transfer(...) {
    try {
        decreaseBalance(...);
        increaseBalance(...);
        externalService.notify(...);  // 抛异常
    } catch (Exception e) {
        log.error("异常", e);  // 异常被吞了
        // Spring看不到异常,认为执行成功,提交事务 ❌
    }
}

解决方案

方案1:不要catch,让异常抛出

@Transactional
public void transfer(...) {
    decreaseBalance(...);
    increaseBalance(...);
    externalService.notify(...);  // 让异常抛出
}

方案2:catch后手动回滚

@Transactional
public void transfer(...) {
    try {
        decreaseBalance(...);
        increaseBalance(...);
        externalService.notify(...);
    } catch (Exception e) {
        log.error("异常", e);
        // 手动标记回滚
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw e;  // 必须重新抛出
    }
}

方案3:拆分事务(推荐)

@Transactional
public void transfer(...) {
    // 核心事务逻辑
    decreaseBalance(...);
    increaseBalance(...);
}

// 外部调用(事务外)
public void transferAndNotify(...) {
    transfer(...);  // 事务方法
    
    // 事务提交后,再调用外部接口
    try {
        externalService.notify(...);
    } catch (Exception e) {
        log.error("通知失败", e);
        // 这里catch不影响事务(事务已提交)
    }
}

🕳️ 致命细节2:只有RuntimeException才回滚

默认行为

@Transactional
public void transfer(...) {
    decreaseBalance(...);
    throw new Exception("checked异常");  // checked异常,不会回滚 ❌
}

@Transactional
public void transfer(...) {
    decreaseBalance(...);
    throw new RuntimeException("运行时异常");  // RuntimeException,会回滚 ✅
}

Spring事务的默认规则

异常类型是否回滚
RuntimeException(运行时异常)✅ 回滚
Error✅ 回滚
Exception(checked异常)❌ 不回滚

为什么这样设计?

南北绿豆:"因为checked异常通常是可预期的,如文件不存在、网络超时,不一定需要回滚。"

// 示例:文件上传
@Transactional
public void uploadFile(File file) throws IOException {
    // 保存文件记录
    fileMapper.insert(fileRecord);
    
    // 上传文件(可能抛IOException)
    ossService.upload(file);  // 抛IOException(checked异常)
    
    // 如果上传失败,是否要回滚文件记录?
    // 不一定,可能只需要标记"上传失败"
}

解决方案:指定回滚的异常

// 方案1:回滚所有异常
@Transactional(rollbackFor = Exception.class)
public void transfer(...) throws Exception {
    decreaseBalance(...);
    externalService.notify(...);  // 抛checked异常也会回滚
}

// 方案2:指定特定异常回滚
@Transactional(rollbackFor = {BusinessException.class, IOException.class})
public void upload(...) {
    // ...
}

// 方案3:指定不回滚的异常
@Transactional(noRollbackFor = {FileNotFoundException.class})
public void process(...) {
    // FileNotFoundException不回滚,其他异常回滚
}

最佳实践

// 推荐写法
@Transactional(rollbackFor = Exception.class)
public void businessMethod() {
    // ...
}

🕳️ 致命细节3:事务传播行为的7种场景

什么是事务传播?

@Transactional
public void methodA() {
    // 事务A
    methodB();  // methodB也有@Transactional,怎么办?
}

@Transactional
public void methodB() {
    // 用事务A?还是新建事务B?
}

7种传播行为

传播行为含义场景
REQUIRED(默认)有事务就用,没有就新建⭐⭐⭐⭐⭐ 最常用
REQUIRES_NEW总是新建事务,挂起当前事务独立事务(日志记录)
SUPPORTS有事务就用,没有就不用查询方法
NOT_SUPPORTED总是非事务执行,挂起当前事务非事务方法
MANDATORY必须在事务中,否则抛异常强制事务
NEVER不能在事务中,否则抛异常强制非事务
NESTED嵌套事务(子事务)部分回滚

场景1:REQUIRED(默认)

@Transactional
public void methodA() {
    // 事务A
    methodB();
}

@Transactional(propagation = Propagation.REQUIRED)  // 默认
public void methodB() {
    // 用事务A(不新建)
}

// 结果:methodA和methodB在同一个事务中
// methodB抛异常 → methodA也回滚

场景2:REQUIRES_NEW(新建事务)

@Transactional
public void methodA() {
    // 事务A
    insertOrder();  // 插入订单
    
    methodB();  // 新建事务B
    
    // 如果这里抛异常,订单回滚,但日志不回滚
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB() {
    // 新建事务B(独立于事务A)
    insertLog();  // 插入日志
}

// 结果:
// - methodB新建事务B,独立提交
// - 即使methodA回滚,methodB的日志仍然保存

时序图

sequenceDiagram
    participant MethodA as methodA(事务A)
    participant TxnA as 事务A
    participant MethodB as methodB(REQUIRES_NEW)
    participant TxnB as 事务B

    MethodA->>TxnA: 1. 开启事务A
    MethodA->>MethodA: 2. insertOrder()
    
    MethodA->>MethodB: 3. 调用methodB()
    MethodB->>TxnA: 4. 挂起事务A
    MethodB->>TxnB: 5. 新建事务B
    MethodB->>MethodB: 6. insertLog()
    MethodB->>TxnB: 7. 提交事务B ✅
    MethodB->>TxnA: 8. 恢复事务A
    
    MethodA->>MethodA: 9. 抛异常
    MethodA->>TxnA: 10. 回滚事务A ❌
    
    Note over TxnB: 事务B已提交,日志保存 ✅
    Note over TxnA: 事务A回滚,订单删除 ❌

应用场景:记录操作日志(无论业务成功失败,日志都要记录)


场景3:NESTED(嵌套事务)

@Transactional
public void methodA() {
    insertOrder();
    
    try {
        methodB();  // 嵌套事务
    } catch (Exception e) {
        // methodB回滚,但methodA可以继续
    }
    
    insertLog();
}

@Transactional(propagation = Propagation.NESTED)
public void methodB() {
    insertDetail();
    throw new RuntimeException();  // 抛异常
}

// 结果:
// - methodB回滚(明细不插入)
// - methodA不回滚(订单和日志插入)

NESTED vs REQUIRES_NEW

特性NESTEDREQUIRES_NEW
是否新建事务否(用Savepoint)
子事务回滚不影响外层不影响外层
外层回滚子事务也回滚子事务不回滚(已提交)

🕳️ 致命细节4:事务失效的8种原因

原因1:方法不是public

@Service
public class UserService {
    
    // ❌ private方法,事务不生效
    @Transactional
    private void updateUser(User user) {
        userMapper.updateById(user);
    }
    
    // ✅ public方法,事务生效
    @Transactional
    public void updateUserPublic(User user) {
        userMapper.updateById(user);
    }
}

原因:Spring事务基于AOP代理,只能代理public方法。


原因2:自调用(同一个类内部调用)

@Service
public class UserService {
    
    public void methodA() {
        // 自调用methodB
        this.methodB();  // 不走代理,事务不生效 ❌
    }
    
    @Transactional
    public void methodB() {
        userMapper.updateById(user);
    }
}

原理图

graph LR
    A[Controller] --> B[UserService代理对象]
    B --> C{调用methodA}
    C --> D[methodA 不走代理]
    D --> E[this.methodB 直接调用]
    E --> F[methodB 不走代理]
    
    G[正确调用] --> H[UserService代理对象]
    H --> I[methodB 走代理]
    I --> J[开启事务]
    J --> K[执行方法]
    K --> L[提交/回滚]
    
    style F fill:#FFB6C1
    style L fill:#90EE90

解决方案

// 方案1:注入自己(走代理)
@Service
public class UserService {
    
    @Autowired
    private UserService self;  // 注入自己
    
    public void methodA() {
        self.methodB();  // 走代理,事务生效 ✅
    }
    
    @Transactional
    public void methodB() {
        userMapper.updateById(user);
    }
}

// 方案2:通过AopContext获取代理对象
public void methodA() {
    UserService proxy = (UserService) AopContext.currentProxy();
    proxy.methodB();  // 走代理
}

// 方案3:拆成两个Service
@Service
public class UserServiceA {
    @Autowired
    private UserServiceB serviceB;
    
    public void methodA() {
        serviceB.methodB();  // 走代理 ✅
    }
}

@Service
public class UserServiceB {
    @Transactional
    public void methodB() {
        userMapper.updateById(user);
    }
}

原因3:数据库引擎不支持事务

-- MyISAM不支持事务
CREATE TABLE user (
  id BIGINT PRIMARY KEY,
  ...
) ENGINE=MyISAM;  -- ❌ 不支持事务

-- InnoDB支持事务
CREATE TABLE user (
  id BIGINT PRIMARY KEY,
  ...
) ENGINE=InnoDB;  -- ✅ 支持事务

原因4:没有被Spring管理

// ❌ 错误(没有@Service)
public class UserService {
    
    @Transactional
    public void updateUser(User user) {
        // 不是Spring Bean,事务不生效
    }
}

// ✅ 正确
@Service
public class UserService {
    
    @Transactional
    public void updateUser(User user) {
        // ...
    }
}

原因5-8(快速总结)

原因说明
5. 方法被final修饰无法被代理
6. 异步方法@Async新线程,事务传播失效
7. 传播行为设置错误如NEVER、NOT_SUPPORTED
8. 多数据源未正确配置事务管理器配置错误

🎯 Spring事务的内部原理

事务代理的流程

阿西噶阿西:"Spring事务是基于AOP动态代理实现的。"

// 原始Service
@Service
public class UserService {
    
    @Transactional
    public void updateUser(User user) {
        userMapper.updateById(user);
    }
}

// Spring生成的代理类(伪代码)
public class UserService$$EnhancerBySpringCGLIB {
    
    private UserService target;  // 原始对象
    
    public void updateUser(User user) {
        TransactionInfo txInfo = null;
        try {
            // 1. 开启事务
            txInfo = createTransactionIfNecessary();
            
            // 2. 调用原始方法
            target.updateUser(user);
            
            // 3. 提交事务
            commitTransactionAfterReturning(txInfo);
            
        } catch (Throwable ex) {
            // 4. 回滚事务
            completeTransactionAfterThrowing(txInfo, ex);
            throw ex;
        } finally {
            // 5. 清理事务信息
            cleanupTransactionInfo(txInfo);
        }
    }
}

完整时序图

sequenceDiagram
    participant Controller
    participant Proxy as Service代理对象
    participant TxManager as 事务管理器
    participant Target as Service原始对象
    participant DB as 数据库

    Controller->>Proxy: 1. 调用updateUser()
    Proxy->>TxManager: 2. 开启事务
    TxManager->>DB: 3. BEGIN TRANSACTION
    
    Proxy->>Target: 4. 调用原始方法
    Target->>DB: 5. UPDATE user ...
    
    alt 方法执行成功
        Target->>Proxy: 6. 返回
        Proxy->>TxManager: 7. 提交事务
        TxManager->>DB: 8. COMMIT
        Proxy->>Controller: 9. 返回结果
    else 方法抛异常
        Target->>Proxy: 6. 抛异常
        Proxy->>TxManager: 7. 回滚事务
        TxManager->>DB: 8. ROLLBACK
        Proxy->>Controller: 9. 抛异常
    end

哈吉米:"所以事务是由代理对象控制的,自调用绕过了代理,事务就失效了!"


🛠️ 使用ThreadLocal管理事务

事务信息的存储

// Spring事务管理器(简化)
public class TransactionManager {
    
    // 用ThreadLocal存储事务信息
    private static final ThreadLocal<TransactionInfo> TX_INFO = new ThreadLocal<>();
    
    public void begin() {
        // 开启事务
        Connection conn = dataSource.getConnection();
        conn.setAutoCommit(false);
        
        // 存储到ThreadLocal
        TransactionInfo info = new TransactionInfo(conn);
        TX_INFO.set(info);
    }
    
    public void commit() {
        TransactionInfo info = TX_INFO.get();
        if (info != null) {
            info.getConnection().commit();
        }
    }
    
    public void rollback() {
        TransactionInfo info = TX_INFO.get();
        if (info != null) {
            info.getConnection().rollback();
        }
    }
    
    public void cleanup() {
        TransactionInfo info = TX_INFO.get();
        if (info != null) {
            info.getConnection().close();
            TX_INFO.remove();  // 清理ThreadLocal
        }
    }
}

南北绿豆:"事务信息存在ThreadLocal中,保证同一个线程内的多个方法共享同一个事务。"


🎓 面试标准答案

题目:@Transactional什么时候会失效?

答案

8种失效场景

  1. 方法不是public → AOP只能代理public方法
  2. 自调用 → 绕过代理对象
  3. 异常被catch → Spring看不到异常
  4. 异常类型不匹配 → checked异常默认不回滚
  5. 数据库引擎不支持 → MyISAM不支持事务
  6. 没有被Spring管理 → 不是Bean
  7. 方法被final修饰 → 无法被代理
  8. 多数据源配置错误 → 事务管理器不对

解决方案

  • 方法改public
  • 注入自己或拆Service
  • 不要catch或手动setRollbackOnly
  • 设置rollbackFor = Exception.class
  • 用InnoDB引擎
  • 加@Service注解

题目:事务传播行为有哪些?

答案(见7种传播行为表格)

常用的3种

  • REQUIRED:默认,有事务就用
  • REQUIRES_NEW:新建独立事务
  • NESTED:嵌套事务(Savepoint)

🎉 结束语

晚上10点,哈吉米把所有事务的坑都填完了。

哈吉米:"原来@Transactional有这么多坑!try-catch、自调用、方法访问权限……"

南北绿豆:"对,事务看起来简单,但细节很多。"

阿西噶阿西:"记住:public方法、不要自调用、不要catch异常、设置rollbackFor。"

哈吉米:"还有事务传播行为,REQUIRED和REQUIRES_NEW的区别要搞清楚。"

南北绿豆:"对,理解了原理和代理机制,就知道为什么会失效了!"


记忆口诀

事务基于AOP代理,public方法才生效
自调用绕过代理,异常catch不回滚
RuntimeException默回滚,checked异常要指定
传播行为七种姿势,REQUIRED最常用
用完记得cleanup,ThreadLocal别泄漏


希望这篇文章能帮你避开Spring事务的所有坑!记住:理解代理机制,才能理解事务的行为!💪