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

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

一、问题现象

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

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

二、排查与定位

按代价从低到高:

  1. 先排除事务语义层:异常是否被 catch 吞掉,或是否是 checked 异常而未配 rollbackFor。这一层事务是存在的。
  2. 在出问题的方法入口打印 TransactionSynchronizationManager.isActualTransactionActive(),返回 false 即事务没建立,问题在代理层。
  3. 看调用点写法:同一类里直接写 deduct(...) 或 this.deduct(...) 就是自调用。
  4. 查库确认:数据已在表里,说明各写操作独立提交,与"部分提交"吻合。

一条隐性线索: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:

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:

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:

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。

预期输出:

text 复制代码
>>> 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),调用回到代理链:

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。多线程场景即使修好自调用,也必须在新线程内自己建事务。单条写入且不需要原子性、或本来就是只读查询,不必引入事务边界。

参考资料

相关推荐
小蒜学长1 小时前
基于SpringBoot的佳新超市管理系统设计与实现系统(代码+数据库+LW)
java·数据库·spring boot·后端·佳新超市管理系统
vx_Biye_Design3 小时前
springboot高校选课系统82776-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·课程设计·express
写后端的胖头鱼5 小时前
【高频面试题】FullText 全文索引(MySQL)
数据库·mysql·索引·全文索引
专业程序开发源6 小时前
SSM校园拍摄交流服务平台36936-计算机课程设计、毕业设计
java·spring boot·后端·python·elasticsearch·php·课程设计
AI人工智能+电脑小能手6 小时前
Java 企业级工具类全家桶-74-JSON序列化工具类
spring boot·jackson·jdk 17·objectmapper·java工具类·json序列化·dto互转
code_whiter7 小时前
01-MySQL数据库基础
数据库·mysql
ZealSinger8 小时前
Boot4升级PostConstruct不跑了
java·spring boot·spring·升级迁移
专业程序开发源8 小时前
django新闻推荐系统70655-计算机课程设计、毕业设计
java·javascript·spring boot·后端·python·django·课程设计
vx_Biye_Design8 小时前
springboot一站式旅游管理平台81037-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·课程设计·express·旅游