@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 的结果差异,理解"异常感知"对事务回滚的影响。

相关推荐
whyfail13 分钟前
前端学 Spring Boot(2):一次点击,如何穿过整个后端?
前端·spring boot·后端
程序员cxuan2 小时前
Loop 还没玩明白,Graph Engineering 又火了。
人工智能·后端·程序员
uzong3 小时前
把一面面试总结做成一个 Skill:重复的事,就别每次都重写
后端·面试
程序员海军3 小时前
一个 AI 应用开发程序员的一天,都在屏幕前忙些什么?
前端·后端·程序员
不能放弃治疗3 小时前
MCP
后端
江湖十年4 小时前
Go 语言中 YAML to JSON 踩坑笔记
后端·go·json
你驴我5 小时前
WhatsApp 消息撤回与编辑的幂等性设计实践
java·服务器·前端·后端·python
一缕清烟在人间5 小时前
HarmonyOS开发实战:小分享-TextEditPage文字编辑器——Header+TextArea+工具栏
后端·华为·harmonyos·鸿蒙
人间凡尔赛5 小时前
K8s 十年之变:从 Cloud Native 到 AI Native,2026 年后端架构的范式革命
后端·云原生·架构
程序员cxuan5 小时前
Opus 5 深夜炸场,价格还挺香。。。
人工智能·后端·程序员