2513 字
约 8 分钟
20
S07-X11 · Spring 事务失效的八种场景

@Transactional 写上去了,日志里也确实走了业务逻辑,偏偏回滚没发生;或者相反,一个无关的异常把整批数据全带走了。这类问题九成不是"事务配置少了参数",而是代理压根没拦到那次调用。

Spring 事务不是数据库特性,是一层 AOP 包装:TransactionInterceptor 在方法前后做 begin / commit / rollback,真正的提交交给 PlatformTransactionManager。理解这条链路,八种失效场景就都是同一个问句的不同答案——这次调用经过代理了吗?事务管理器管到这个连接了吗?

以下按 Boot 3.3.x / Framework 6.1.x 核对。


1. 三个角色

角色 实现 失效时对应的问题
代理 CGLIB(Boot 默认)或 JDK 动态代理 自调用、非 public、注解位置错
拦截器 TransactionInterceptor 异常的传播与回滚规则
事务管理器 DataSourceTransactionManager 等 多数据源选错、跨线程

事务状态挂在 TransactionSynchronizationManager 的 ThreadLocal 上:resources(连接持有者)、synchronizations、readOnly、actualTransactionActive。这个事实直接决定了第 7 种失效。


2. 八种失效一览

# 场景 现象 根因 改法
1 同类内部方法调用 内层方法抛异常,外层照样提交 调用走 this,不经代理 拆到另一个 Bean;或注入自身代理
2 方法非 public 注解完全不生效,无任何日志 代理无法拦截(private/final/static 必失效) 改 public,或把事务边界上移
3 异常被 catch 吞掉 抛了异常但数据已写入 拦截器看不到异常 → 正常提交 重新抛出,或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
4 抛受检异常 IOException 抛出仍提交 默认只回滚 RuntimeException/Error @Transactional(rollbackFor = Exception.class)
5 注解只写在接口上 换代理方式后时灵时不灵 属性查找按"具体方法 → 声明类 → 接口"回退,重载或类级注解会读到另一份 注解统一写实现类
6 多数据源用了错的管理器 操作 A 库却回滚了 B 库 默认取唯一的 PlatformTransactionManager,有多个时取 @Primary @Transactional(transactionManager = "orderTxManager")
7 事务方法里开新线程 子线程的写操作不回滚 事务绑定在 ThreadLocal,新线程是新事务 子线程内用 TransactionTemplate,或改为同步/消息补偿
8 readOnly=true 却写 / 嵌套传播配错 写入被驱动拒绝,或内层失败把外层一起回滚 只读是提示不是强制;REQUIRED 下内层异常标记外层回滚 去掉 readOnly;内层改 REQUIRES_NEW

3. 复现与改正(前三种最常见)

自调用,最小复现:

@Service
public class OrderService {
    public void create(Order o) {
        this.save(o);              // 绕过代理,事务不生效
    }
    @Transactional
    public void save(Order o) { orderMapper.insert(o); }
}

改成拆 Bean 或注入自身代理(@Lazy 打断循环):

@Service
public class OrderService {
    @Lazy @Autowired private OrderService self;

    public void create(Order o) { self.save(o); }   // 走代理
}

catch 吞异常与受检异常:

@Transactional(rollbackFor = Exception.class)
public void importRows(List<Row> rows) {
    for (Row r : rows) {
        try { repo.insert(r); }
        catch (Exception e) {
            log.warn("skip row {}", r.id(), e);
            // 吞掉 = 继续提交;要整体失败就 rethrow
        }
    }
}

部分失败要保留已成功的行,就不该用一个大事务包住循环——这是设计问题,不是注解问题。要整体失败,直接把异常抛出去,别在 catch 里"记录后继续"。

第 5 种的写法收敛:

// 注解写在实现类,类级 + 方法级各一份,避免接口/实现类元数据打架
@Service
@Transactional(rollbackFor = Exception.class)
public class PaymentServiceImpl implements PaymentService {
    @Override
    public void pay(String id) { /* ... */ }
}

多数据源与线程:

@Transactional(transactionManager = "orderTxManager")
public void createOrder(Order o) { orderMapper.insert(o); }

// 子线程必须自己开事务,父事务不会传播过去
public void asyncWrite(Order o) {
    transactionTemplate.executeWithoutResult(s -> orderMapper.insert(o));
}

4. 传播行为七种取值

取值 外层有事务时 外层无事务时 适用面
REQUIRED(默认) 加入 新建 绝大多数写操作
REQUIRES_NEW 挂起外层、新建 新建 日志落库、独立补偿,要求"外层回滚我不回滚"
NESTED 建保存点 新建 局部可回退、但随外层一起回滚(需 JDBC 保存点,JPA 不支持)
SUPPORTS 加入 非事务执行 只读查询,有事务也行没事务也行
NOT_SUPPORTED 挂起外层 非事务执行 事务里不该做的慢操作
MANDATORY 加入 抛 IllegalTransactionStateException 强制要求调用方已开事务
NEVER 抛异常 非事务执行 明确禁止在事务中调用

三者的关键差异在"回滚的传播方向":REQUIRED 下内层抛异常会把整个事务标记为 rollback-only,外层即使 catch 了也提交不了,常见日志是 Transaction rolled back because it has been marked as rollback-only;NESTED 回滚到保存点,外层可以继续;REQUIRES_NEW 是两个独立事务,外层回滚不影响内层已提交的数据。


5. @Transactional 与 synchronized 叠加

@Transactional
public synchronized void deduct(Long id, int qty) {   // 锁的范围 < 事务的范围
    stockMapper.decrease(id, qty);
}

锁在方法体执行完就释放,而提交发生在代理返回之后的拦截器里。于是出现窗口:线程 A 已经释放锁但事务未提交,线程 B 拿到锁读到旧值。并发扣减在这种写法下照样超卖。

要锁就锁在事务外层(分布式锁或数据库行锁),或者在事务内用 SELECT ... FOR UPDATE。synchronized 与事务的作用域不一致,这不是事务失效,而是锁放错了地方。


6. 超时与只读提示

配置 实际作用 别指望它
timeout = 5(秒) 拦截器记 deadline,每次语句执行前检查;超时抛 TransactionTimedOutException 不会中断正在执行的 SQL,长查询仍会跑完
readOnly = true 提示驱动 setReadOnly、Hibernate 置 FlushMode.MANUAL、跳过脏检查 不是数据库层强制,写操作是否报错取决于驱动与连接池

readOnly 在部分驱动上会被翻译成 SET SESSION TRANSACTION READ ONLY,此时写入直接抛 SQLException;换一个驱动或连接池,同样的代码又能悄悄写进去。所以它只能算优化提示,不能当权限控制。给只读查询配上 readOnly=true 的实际收益是省掉脏检查开销,典型量级是查询路径上的一次对象比对,别当成数量级优化。


7. 怎么确认"事务到底有没有生效"

最直接的一句,放在方法体第一行:

log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());

在自调用场景里它打出 false,在正常代理调用里打出 true。另一种办法是先看代理类型:

log.info("proxy = {}", AopUtils.isAopProxy(this) ? this.getClass() : "none");

this.getClass() 打印出 OrderService$$SpringCGLIB$$0 之类的名字说明代理存在;但代理存在不代表当前这次调用经过它——这两个判断要一起看。


8. 常见坑

现象 根因 处理
"加了注解就该有事务" 自调用、非 public、Bean 未被 Spring 管理 用第 7 节的方法先验证
内层失败把外层也回滚了 REQUIRED 下内层标记 rollback-only 内层改 REQUIRES_NEW,外层 catch 才有意义
事务里做远程调用 持锁/持连接时间被拉长,连接池被占满 远程调用挪到事务外,先落库再异步发
回滚了但缓存/消息已发出 事务只管数据库,不管 MQ 与 Redis 事务提交后发(TransactionSynchronization#afterCommit)
@Transactional 在 @Async 方法上 异步线程不继承事务上下文 异步方法内部自己开事务
单元测试里事务不生效 手工 new 了对象,没走容器 用 @SpringBootTest 或直接测 TransactionTemplate

第三条在真实项目里代价最大:一个事务方法里调用第三方 HTTP 接口,连接被占用到接口超时为止,连接池在几十毫秒到几秒的区间内被耗尽,表现却是"偶发获取连接超时"。


9. 边界

场景 更合适的做法 理由
跨服务一致性 Saga / 本地消息表 / TCC 本地事务管不到远程
大批量导入 分批提交 + 幂等键 单事务过大,undo 与锁开销都不可控
需要精确控制提交点 TransactionTemplate 编程式比注解更清楚
读多写少的报表查询 只读连接或从库 事务本身解决不了查询成本
需要在提交后做事 TransactionSynchronization 与事务生命周期对齐

事务的边界应该落在"一次业务不变量变更"上,而不是落在"一个 Controller 方法"或"一个 Service 类的所有 public 方法"上。边界画错时,注解写得再全也只是把错误包得更整齐。


10. 自测

  1. 自调用为什么会让 @Transactional 失效,除了拆 Bean 还有什么办法?
  2. 抛 IOException 默认会回滚吗,怎么改?
  3. REQUIRED 下内层抛异常被外层 catch,为什么仍然提交不了?
  4. NESTED 与 REQUIRES_NEW 在回滚范围上差在哪?
  5. synchronized 与事务叠加时,锁为什么保护不了并发?
  6. 怎么用一行日志判断当前方法真的在事务里?
S07-X11 · Spring 事务失效的八种场景
https://www.clxhxhhr.top/posts/5952/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。