@Transactional 失效的 7 种场景:第 5 种最难排查

@Transactional 失效的 7 种场景:第 5 种最难排查

加了 @Transactional 事务还是没回滚?不是注解的问题,是你的用法有问题。7 种失效场景,你至少踩过 3 个。


一、事故现场

线上有个工单状态更新接口,先更新工单状态,再写操作日志。两个操作要在同一个事务里,要么都成功,要么都回滚。

java 复制代码
@Service
public class TicketService {

    @Autowired
    private TicketMapper ticketMapper;
    
    @Autowired
    private OperationLogMapper logMapper;

    @Transactional
    public void updateTicketStatus(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);      // 更新工单状态
        logMapper.insert(new OperationLog(ticketId, status));  // 写操作日志
    }
}

测试环境跑得好好的。上线后某天,工单状态更新了,但操作日志没写进去。事务没回滚,@Transactional 失效了。

排查发现:这个方法被同一个类的另一个方法调用了(自调用),@Transactional 的 AOP 代理没生效。


二、@Transactional 为什么会失效

@Transactional 的原理是 Spring AOP 动态代理。Spring 在 Bean 初始化时生成一个代理对象,代理对象在方法执行前后管理事务的开启和提交。

凡是绕过代理对象的调用,@Transactional 都不生效。

这个原理决定了 7 种失效场景,下面逐个讲。


三、7 种失效场景

场景 1:自调用(最常见)

java 复制代码
@Service
public class TicketService {

    @Transactional
    public void updateTicketStatus(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);
        logMapper.insert(new OperationLog(ticketId, status));
    }

    public void doUpdate(Long ticketId, String status) {
        // 自调用:this 调用,走的是原始对象不是代理对象
        updateTicketStatus(ticketId, status);  // @Transactional 失效!
    }
}

doUpdate 调用 this.updateTicketStatus()this 是原始对象不是 Spring 代理对象。代理拦截不到,事务不生效。

解决:

java 复制代码
// 方案 1:拆到另一个 Service
@Service
public class TicketTxService {
    @Transactional
    public void updateTicketStatus(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);
        logMapper.insert(new OperationLog(ticketId, status));
    }
}

@Service
public class TicketService {
    @Autowired
    private TicketTxService ticketTxService;

    public void doUpdate(Long ticketId, String status) {
        ticketTxService.updateTicketStatus(ticketId, status);  // 走代理,事务生效
    }
}

// 方案 2:注入自己的代理
@Service
public class TicketService {
    @Autowired
    @Lazy
    private TicketService self;

    public void doUpdate(Long ticketId, String status) {
        self.updateTicketStatus(ticketId, status);  // 走代理
    }
}

场景 2:方法不是 public

java 复制代码
@Service
public class TicketService {

    @Transactional
    void updateTicketStatus(Long ticketId, String status) {  // 包级私有!
        ticketMapper.updateStatus(ticketId, status);
        logMapper.insert(new OperationLog(ticketId, status));
    }
}

Spring AOP 默认只代理 public 方法。protected、private、包级私有方法上的 @Transactional 不生效。

解决: 改成 public。

java 复制代码
@Transactional
public void updateTicketStatus(Long ticketId, String status) {  // ✅ public
    // ...
}

场景 3:异常被 catch 了

java 复制代码
@Service
public class TicketService {

    @Transactional
    public void updateTicketStatus(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);
        try {
            logMapper.insert(new OperationLog(ticketId, status));
        } catch (Exception e) {
            log.error("写日志失败", e);  // 异常被吞了!
            // 事务不会回滚,因为 Spring 没看到异常
        }
    }
}

Spring 通过捕获方法抛出的异常来判断是否回滚。异常被 catch 了,Spring 看不到异常,认为方法正常执行完,提交事务。

解决:

java 复制代码
@Transactional
public void updateTicketStatus(Long ticketId, String status) {
    ticketMapper.updateStatus(ticketId, status);
    try {
        logMapper.insert(new OperationLog(ticketId, status));
    } catch (Exception e) {
        log.error("写日志失败", e);
        throw e;  // ✅ 重新抛出,让 Spring 感知到异常
    }
}

场景 4:异常类型不对(最难排查)

java 复制代码
@Service
public class TicketService {

    @Transactional  // 默认只回滚 RuntimeException 和 Error
    public void updateTicketStatus(Long ticketId, String status) throws Exception {
        ticketMapper.updateStatus(ticketId, status);
        if (!status.equals("VALID")) {
            throw new Exception("状态不合法");  // 受检异常,不回滚!
        }
        logMapper.insert(new OperationLog(ticketId, status));
    }
}

@Transactional 默认只回滚 RuntimeException 和 Error,不回滚受检异常(checked exception)。 throw new Exception() 是受检异常,Spring 默认不会回滚。

这是最坑的场景。代码看起来没问题,异常也抛了,但事务就是不回滚。而且不会报错,只在数据层面出问题,排查极难。

解决:

java 复制代码
// 方案 1:指定 rollbackFor
@Transactional(rollbackFor = Exception.class)  // ✅ 所有异常都回滚
public void updateTicketStatus(Long ticketId, String status) throws Exception {
    // ...
}

// 方案 2:抛 RuntimeException
@Transactional
public void updateTicketStatus(Long ticketId, String status) {
    ticketMapper.updateStatus(ticketId, status);
    if (!status.equals("VALID")) {
        throw new RuntimeException("状态不合法");  // ✅ RuntimeException 会回滚
    }
    // ...
}

建议养成习惯:所有 @Transactional 都加 rollbackFor = Exception.class。宁可多回滚,不可漏回滚。

场景 5:事务传播行为不对

java 复制代码
@Service
public class TicketService {

    @Autowired
    private LogService logService;

    @Transactional
    public void updateTicketStatus(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);
        logService.writeLog(ticketId, status);  // REQUIRES_NEW,独立事务提交
        throw new RuntimeException("更新失败");   // 外层事务回滚
    }
}

@Service
public class LogService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)  // 新开事务
    public void writeLog(Long ticketId, String status) {
        logMapper.insert(new OperationLog(ticketId, status));
    }
}

LogService.writeLog 用了 REQUIRES_NEW,会挂起当前事务,新开一个独立事务。writeLog 执行完,内层事务就提交了。随后外层抛异常回滚,但内层事务已经提交了,不会跟着回滚。工单状态更新被回滚,操作日志却留下来了。

如果这里的日志不是"必须留痕"而是"必须跟主业务一致",就会出现数据不一致。这就是 REQUIRES_NEW 的陷阱:内外层事务独立,一个回滚不影响另一个。

反过来也一样:如果内层事务抛异常回滚,异常会传播到外层,外层如果不 catch 也会回滚。别以为用了 REQUIRES_NEW 就互不影响了。

解决: 理解传播行为,按业务需要选择。

传播行为 行为 什么时候用
REQUIRED(默认) 有事务就加入,没有就新建 99% 的场景
REQUIRES_NEW 挂起当前事务,新建独立事务 日志记录(即使主事务回滚日志也要留)
NESTED 嵌套事务(基于 savepoint) 部分回滚场景
SUPPORTS 有事务就加入,没有就非事务执行 查询方法
NOT_SUPPORTED 非事务执行,挂起当前事务 不需要事务的操作
MANDATORY 必须在事务中,否则报错 强制要求调用方有事务
NEVER 非事务执行,有事务则报错 不允许在事务中执行

场景 6:数据库引擎不支持事务

sql 复制代码
-- 建表时用了 MyISAM 引擎
CREATE TABLE ticket (
    id BIGINT PRIMARY KEY,
    status VARCHAR(20)
) ENGINE = MyISAM;  -- ❌ MyISAM 不支持事务

MyISAM 引擎不支持事务,InnoDB 才支持。即使代码层面 @Transactional 配置正确,数据库引擎不支持,事务也不生效。

解决:

sql 复制代码
-- 改成 InnoDB
ALTER TABLE ticket ENGINE = InnoDB;

MySQL 5.5+ 默认引擎已经是 InnoDB,但老项目或手动建表可能还是 MyISAM。检查一下:SHOW TABLE STATUS FROM your_db;

场景 7:Bean 没有被 Spring 管理

java 复制代码
// 没有 @Service 注解,Spring 不会管理这个 Bean
public class TicketService {

    @Transactional  // 没用,Spring 根本不知道这个类
    public void updateTicketStatus(Long ticketId, String status) {
        // ...
    }
}

// 手动 new 出来的也不行
TicketService service = new TicketService();
service.updateTicketStatus(1L, "VALID");  // @Transactional 失效

@Transactional 依赖 Spring 容器创建代理对象。如果类没有被 Spring 管理(没加 @Service/@Component),或者手动 new 的,Spring 没机会生成代理。

解决: 加上 @Service 或 @Component,通过依赖注入使用。


四、一张图总结

java 复制代码
@Transactional 生效的前提:
  │
  ├─ 1. 方法是 public ──→ 非 public 不代理
  │
  ├─ 2. 通过代理对象调用 ──→ 自调用 / 手动 new 失效
  │
  ├─ 3. 异常抛到代理层 ──→ catch 吞异常失效
  │
  ├─ 4. 异常类型匹配 ──→ 受检异常默认不回滚(加 rollbackFor)
  │
  ├─ 5. 传播行为正确 ──→ REQUIRES_NEW 会独立提交/回滚
  │
  ├─ 6. 数据库支持事务 ──→ MyISAM 不支持
  │
  └─ 7. Bean 被 Spring 管理 ──→ 没注解 / 手动 new 失效

五、CheckList:@Transactional 上线前排查

# 检查项 风险点 正确做法
1 自调用 this 调用绕过代理 拆到另一个 Service 或注入代理
2 方法非 public AOP 不代理非 public 改成 public
3 异常被 catch Spring 感知不到异常 catch 后重新 throw
4 抛受检异常 默认不回滚 加 rollbackFor = Exception.class
5 传播行为 REQUIRES_NEW 独立事务 按业务需要选择传播行为
6 数据库引擎 MyISAM 不支持事务 用 InnoDB
7 Bean 未被管理 没有代理对象 加 @Service,通过注入使用

六、总结

7 种失效场景,记住核心原理:

@Transactional 依赖 Spring AOP 代理。凡是不经过代理的调用、代理拦截不到的异常、不匹配的回滚条件,都会让事务失效。

养成三个习惯:

  • 所有 @Transactional 加 rollbackFor = Exception.class
  • 事务方法不要自调用,拆到不同的 Service
  • 异常别吞,catch 了就 throw 出去

附录:本地复现完整代码

java 复制代码
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

@SpringBootApplication
public class TransactionalFailApp {

    public static void main(String[] args) {
        ConfigurableApplicationContext ctx = SpringApplication.run(TransactionalFailApp.class, args);
        
        TicketService service = ctx.getBean(TicketService.class);

        // 测试 1:自调用失效
        System.out.println("\n=== 场景1:自调用 ===");
        try {
            service.testSelfInvoke(1L, "CLOSED");
        } catch (Exception e) {
            System.out.println("异常: " + e.getMessage());
        }
        System.out.println("工单状态: " + service.getTicketStatus(1L));
        // 如果事务生效,状态不应该被更新
        // 如果事务失效,状态被更新了(因为自调用绕过了代理)

        // 测试 2:异常被 catch
        System.out.println("\n=== 场景3:异常被catch ===");
        try {
            service.testCatchException(1L, "CLOSED");
        } catch (Exception e) {
            System.out.println("异常: " + e.getMessage());
        }
        System.out.println("工单状态: " + service.getTicketStatus(1L));
        // 异常被吞,事务不回滚,状态被更新

        // 测试 3:受检异常不回滚
        System.out.println("\n=== 场景4:受检异常 ===");
        try {
            service.testCheckedException(1L, "CLOSED");
        } catch (Exception e) {
            System.out.println("异常: " + e.getMessage());
        }
        System.out.println("工单状态: " + service.getTicketStatus(1L));
        // 受检异常默认不回滚,状态被更新

        // 测试 4:加 rollbackFor 后回滚
        System.out.println("\n=== 场景4修复:rollbackFor ===");
        try {
            service.testCheckedExceptionFixed(1L, "CLOSED");
        } catch (Exception e) {
            System.out.println("异常: " + e.getMessage());
        }
        System.out.println("工单状态: " + service.getTicketStatus(1L));
        // 加了 rollbackFor,事务回滚,状态没变

        ctx.close();
    }
}

@Service
class TicketService {

    @Autowired
    private TicketMapper ticketMapper;

    @Autowired
    private LogMapper logMapper;

    // 场景 1:自调用
    @Transactional
    public void updateTicketStatus(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);
        logMapper.insert(new OperationLog(ticketId, status));
    }

    public void testSelfInvoke(Long ticketId, String status) {
        updateTicketStatus(ticketId, status);  // 自调用,事务失效
    }

    // 场景 3:异常被 catch
    @Transactional
    public void testCatchException(Long ticketId, String status) {
        ticketMapper.updateStatus(ticketId, status);
        try {
            throw new RuntimeException("故意抛异常");
        } catch (Exception e) {
            System.out.println("异常被吞了: " + e.getMessage());
            // 没有 throw,事务不回滚
        }
    }

    // 场景 4:受检异常,默认不回滚
    @Transactional
    public void testCheckedException(Long ticketId, String status) throws Exception {
        ticketMapper.updateStatus(ticketId, status);
        throw new Exception("受检异常,默认不回滚");
    }

    // 场景 4 修复:加 rollbackFor
    @Transactional(rollbackFor = Exception.class)
    public void testCheckedExceptionFixed(Long ticketId, String status) throws Exception {
        ticketMapper.updateStatus(ticketId, status);
        throw new Exception("受检异常,加了 rollbackFor 会回滚");
    }

    public String getTicketStatus(Long ticketId) {
        return ticketMapper.selectStatus(ticketId);
    }
}

运行前需要配置数据库(MySQL + InnoDB 引擎)和对应的 Mapper。

复现要点:

  1. 自调用:testSelfInvokethis.updateTicketStatus,事务失效,工单状态被更新
  2. 异常被 catch:事务不回滚,工单状态被更新
  3. 受检异常:默认不回滚,工单状态被更新
  4. 加 rollbackFor:事务回滚,工单状态没变

对比场景 3 和场景 4 的结果差异,理解"异常感知"对事务回滚的影响。

相关推荐
字节跳动数据库1 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
用户6757049885021 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan2 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885022 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia2 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530142 小时前
Java 实现 Word 文档加密与权限解除
java·后端
vanuan2 小时前
给你的A2A-Agent加把锁-认证鉴权实战指南
后端
Yeats_Liao2 小时前
14:Servlet中的页面跳转-Java Web
java·后端·架构