本文摘要:同类内方法互调绕过代理,事务注解静默失效,异常抛出后数据已提交不回滚。修复是拆出独立类让调用经过代理,或改用编程式事务重建边界。该结论只在代理被绕过时成立,多线程与非公开方法的事务边界需另行配置。
一、问题现象
转账接口抛出 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:
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。多线程场景即使修好自调用,也必须在新线程内自己建事务。单条写入且不需要原子性、或本来就是只读查询,不必引入事务边界。