欢迎光临
我们一直在努力

数据没回滚也不报错:@Transactional 自调用失效的 3 种复现与修复

本文摘要:同类内方法互调绕过代理,事务注解静默失效,异常抛出后数据已提交不回滚。修复是拆出独立类让调用经过代理,或改用编程式事务重建边界。该结论只在代理被绕过时成立,多线程与非公开方法的事务边界需另行配置。

一、问题现象

转账接口抛出 RuntimeException("Insufficient balance"),控制台只有异常栈,没有事务警告;查库发现 alice 的 balance 已是 -100,bob 一行未动——扣款写入了,加款没执行。

这不是"建了事务但回滚规则不对",而是 @Transactional 从未被解析,事务根本没创建。三类表现同根:同类内 this.deduct() 互调,注解被绕过;同类内调用 @Transactional(propagation = Propagation.REQUIRES_NEW),独立事务没开出来;@Async 或新线程里再调同类方法,代理绕过与 ThreadLocal 上下文缺失叠加。排查方向因此不是调 rollbackFor,而是确认调用点是否经过代理。

二、排查与定位

按代价从低到高:

  • 先排除事务语义层:异常是否被 catch 吞掉,或是否是 checked 异常而未配 rollbackFor。这一层事务是存在的。
  • 在出问题的方法入口打印 TransactionSynchronizationManager.isActualTransactionActive(),返回 false 即事务没建立,问题在代理层。
  • 看调用点写法:同一类里直接写 deduct(…) 或 this.deduct(…) 就是自调用。
  • 查库确认:数据已在表里,说明各写操作独立提交,与"部分提交"吻合。
  • 一条隐性线索:SimpleJpaRepository.save() 自带 @Transactional。外层没有事务时 save() 仍会独立建事务并立即提交,异常抛出时该行早已落库。

    三、关键原理

    Spring AOP 基于代理。Spring Framework 6.x 文档原话是 “Self-invocation does not lead to an advice getting a chance to run.”——同类内 this.method() 直接落到 target 对象,不进拦截器链,事务 advice 没机会执行。

    正文技术图

    静默性是同一策略:文档写明 @Transactional 标在 protected、private 或 package-private 方法上 “no error is raised”。自调用失效行为一致——方法照常执行,只是没有事务语义。

    传播属性失效是同一根因的另一面。属性由代理建事务时解析,代理没走到就不被读取:REQUIRES_NEW 预期挂起外层事务、独立提交,实际与业务共用上下文,业务回滚时审计记录一起消失;NESTED 预期按 savepoint 子事务语义执行,实际退化为外层事务内的普通写入,回滚范围扩大到整体。

    多线程是另一层:事务上下文绑定在 TransactionSynchronizationManager 的 ThreadLocal,新线程不继承。@Async 里即使修好自调用,也要在新线程自己的调用链上建事务。CGLIB 与 JDK 动态代理两种模式下自调用问题都存在,Spring Framework 5.x(Spring Boot 2.x)与 6.x(Spring Boot 3.x)行为一致。

    四、复现与修复

    环境:Java 17+、Spring Boot 3.x,依赖 spring-boot-starter-data-jpa;测试用 H2(spring-boot-starter-test 与 com.h2database:h2 均为 test scope),生产 MySQL InnoDB。Account 实体含 id、username、balance,AccountRepository extends JpaRepository<Account, Long>。

    实体 src/main/java/com/example/entity/Account.java:

    package com.example.entity;

    import jakarta.persistence.*;
    import java.math.BigDecimal;

    @Entity
    @Table(name = "t_account")
    public class Account {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true) private String username;
    @Column(nullable = false) private BigDecimal balance;

    public Account() {}
    public Account(String username, BigDecimal balance) {
    this.username = username;
    this.balance = balance;
    }
    public Long getId() { return id; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
    }

    Bug 版服务 src/main/java/com/example/service/TransferService.java:

    package com.example.service;

    import com.example.entity.Account;
    import com.example.repository.AccountRepository;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;

    import java.math.BigDecimal;

    @Service
    public class TransferService {

    private final AccountRepository repo;

    public TransferService(AccountRepository repo) {
    this.repo = repo;
    }

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
    deduct(fromId, amount); // 自调用,不经过代理
    add(toId, amount); // 自调用,不经过代理
    }

    @Transactional
    public void deduct(Long id, BigDecimal amount) {
    Account acc = repo.findById(id).orElseThrow();
    acc.setBalance(acc.getBalance().subtract(amount));
    repo.save(acc);
    if (acc.getBalance().signum() < 0) {
    throw new RuntimeException("Insufficient balance");
    }
    }

    @Transactional
    public void add(Long id, BigDecimal amount) {
    Account acc = repo.findById(id).orElseThrow();
    acc.setBalance(acc.getBalance().add(amount));
    repo.save(acc);
    }
    }

    复现测试 src/test/java/com/example/SelfInvocationTest.java:

    package com.example;

    import com.example.entity.Account;
    import com.example.repository.AccountRepository;
    import com.example.service.TransferService;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;

    import java.math.BigDecimal;

    import static org.junit.jupiter.api.Assertions.*;

    @SpringBootTest
    class SelfInvocationTest {

    @Autowired private TransferService transferService;
    @Autowired private AccountRepository repo;

    @Test
    void selfInvocation_rollbackNotApplied() {
    repo.deleteAll();
    Long aliceId = repo.save(new Account("alice", new BigDecimal("100"))).getId();
    Long bobId = repo.save(new Account("bob", new BigDecimal("50"))).getId();

    assertThrows(RuntimeException.class,
    () -> transferService.transfer(aliceId, bobId, new BigDecimal("200")));

    Account alice = repo.findById(aliceId).orElseThrow();
    System.out.println(">>> Alice balance after failed transfer: " + alice.getBalance());
    assertEquals(–100, alice.getBalance().intValue());
    }
    }

    执行 mvn -Dtest=SelfInvocationTest#selfInvocation_rollbackNotApplied test。

    预期输出:

    >>> Alice balance after failed transfer: -100

    实际输出:断言通过即证明数据已提交、未回滚;再执行 SELECT username, balance FROM t_account WHERE username = 'alice'; 复查,balance 仍为 -100(撰写环境未重新执行,数值按用例断言给出)。

    失败处理:测试类或测试方法上加了 @Transactional,用例结束自动回滚,Bug 被测试框架掩盖,断言永远是 100。去掉测试上的 @Transactional,让它真实读写数据库。

    修复版把 deduct、add 挪进独立 Bean AccountOps(src/main/java/com/example/service/AccountOps.java),调用回到代理链:

    package com.example.service;

    import com.example.entity.Account;
    import com.example.repository.AccountRepository;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;

    import java.math.BigDecimal;

    @Service
    public class AccountOps {

    private final AccountRepository repo;

    public AccountOps(AccountRepository repo) {
    this.repo = repo;
    }

    @Transactional
    public void deduct(Long id, BigDecimal amount) {
    Account acc = repo.findById(id).orElseThrow();
    acc.setBalance(acc.getBalance().subtract(amount));
    repo.save(acc);
    if (acc.getBalance().signum() < 0) {
    throw new RuntimeException("Insufficient balance");
    }
    }
    }

    add 方法原样搬入,同样标注 @Transactional;TransferService 改为注入 AccountOps 后调用 accountOps.deduct(…)、accountOps.add(…)。同一用例的 assertEquals 改为 100 即通过。

    五、替代方案与取舍

    方案选择条件代价边界
    拆分到独立 Service Bean 大多数业务场景,职责本可拆分 多一个类,调用链变长 不适合把原子流程硬拆成多个类、接口数暴涨
    TransactionTemplate 编程式事务 事务内有分支判断、需要细粒度边界 边界从注解变代码块,风格不统一 团队统一声明式事务、想一眼看清单方法语义时
    @Lazy 自注入 self 只想改一行,暂不动结构 self.xxx() 与 this.xxx() 并存易误读 调用点很多时容易继续踩坑,不宜长期使用
    AopContext.currentProxy() 需要精确拿到当前代理 要配 exposeProxy = true,业务代码耦合 AOP 内部 API 不希望业务层依赖 Spring AOP 内部 API 时
    AspectJ 编译期或加载期织入 无法拆类、必须同类调用、需织入非 public 方法 构建链路与团队学习成本高 中小项目只为修一处自调用时不值得上

    倾向:优先拆 Bean,其次 TransactionTemplate;确实改不动类结构才考虑 AspectJ。

    六、验证结果与边界

    在 MySQL 上用 SET GLOBAL general_log = 'ON'; 复核事务语义:

    场景修复前(自调用)修复后(经代理)
    普通 @Transactional 只见 save() 的独立 COMMIT,异常后数据仍在 业务异常触发 ROLLBACK,数据回到 100
    REQUIRES_NEW 审计 审计与业务同事务,一起消失 预期输出:独立 COMMIT,审计行保留
    NESTED 子流程 无 savepoint,回滚扩大到整体 预期输出:SAVEPOINT 与 ROLLBACK TO SAVEPOINT

    上表日志形态按 Spring 事务语义给出,具体语句以 general query log 为准,未逐字核对。H2 与 MySQL 在 savepoint 支持上一致,但并发隔离级别表现不同,涉及 NESTED 时建议在 MySQL 上复验。

    适用边界:结论只在代理被绕过时成立。经代理调用的 public 方法、rollbackFor 配置正确的场景不适用。非 public 方法即使经代理调用也不生效且不报错,拆 Bean 救不了,只能调整可见性或改用 AspectJ。多线程场景即使修好自调用,也必须在新线程内自己建事务。单条写入且不需要原子性、或本来就是只读查询,不必引入事务边界。

    参考资料

    • Spring Framework Reference — AOP Proxying
    • Spring Framework Reference — @Transactional Annotation
    赞(0)
    未经允许不得转载:171主机测评 » 数据没回滚也不报错:@Transactional 自调用失效的 3 种复现与修复
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址