@Transactional 写在 private 方法上,事务根本没生效

叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防

问题现象

凌晨两点,告警响了。订单状态对不上,数据库里出现了半截数据。排查三小时,终于找到一个 private 方法顶上的 @Transactional 注解------它安静地躺在那儿像个摆设。Spring 的声明式事务基于 AOP 代理,而代理对象只能拦截 public 方法调用。对 private 方法 @Transactional 置若罔闻。

排查过程

第一步:验证代理状态

张工在本地启动了一个 Debug 端点,直接打印 BuggyRefundService 和 FixedRefundService 的代理信息。

输出中看到:BuggyRefundService 被 Spring 创建了 CGLIB 代理(Is AOP proxy: true),这说明 Spring 确实为这个 Bean 生成了代理子类。但重点是 @Transactional present on private method: true------注解确实存在。

接着张工加了一个更底层的检查:直接用 TransactionAttributeSource 查询代理对象在那个方法上能不能拿到事务属性。结果------BuggyService 的 private 方法返回 txAttr=null,而 FixedService 的 public 方法正常返回 Propagation.REQUIRED

这就说明了:注解存在但事务拦截器看不到它

为了彻底看清,张工用 javap 反编译了 CGLIB 生成的代理子类字节码。对比发现:BuggyRefundService 的代理类中有 doRefund() 方法,但完全没有 processRefund()------因为它是 private,CGLIB 无法 override。而 FixedRefundService(public + @Transactional)的代理类中就有对应的 processRefund() 方法。

第二步:理解调用链路

下面是 private 方法的调用链路结构图,直观对比「有代理拦截」和「无代理拦截」的差异:

关键路径在图中已标注清楚:外部调用 doRefund() 会经过代理,但代理将请求转发给目标对象后,目标对象内部通过 this.processRefund() 直接调用------这里的 this 是原始对象,不是代理。事务拦截器在这个路径上从未介入,每个 JDBC 操作各自独立提交。

第二步:理解 AOP 代理机制

Spring 声明式事务基于 AOP 代理。当 Spring 容器启动时,它检测到 Bean 上有 @Transactional 注解(在类或方法级别),就会为这个 Bean 创建一个代理对象。但 Spring 的代理有两种实现方式,如何选择取决于目标类是否实现了接口:

左侧是 JDK 动态代理的工作原理:代理对象实现目标类的接口,通过 InvocationHandler 拦截接口方法调用。这种方式要求目标类必须实现某个接口,且事务方法在接口中声明。右侧是 CGLIB 代理:代理对象继承目标类,通过 override 父类方法实现拦截。对没有接口的类,Spring 默认使用 CGLIB。

但无论是哪种方式,它们共同的前提是:被拦截的方法必须能被代理访问到 。JDK 代理只能看到接口中的 public 方法,CGLIB 只能看到父类中可 override 的方法(public 或 protected)。private 方法在两种代理中都是不可见的

下面的决策树可以帮你快速判断 @Transactional 是否生效:

对于没有实现接口的 Service 类(大多数 Spring Boot 项目),CGLIB 是默认选择。CGLIB 通过生成子类来 override public 方法------但 Java 语言规范规定 private 方法在编译时静态绑定,子类中不可见、不可 override。CGLIB 生成的代理子类根本不知道目标类有 processRefund 这个 private 方法。所以当 doRefund 内部调用 processRefund() 时,调用链是这样的:

  1. 外部调用 buggyRefundService.doRefund() → 经过代理
  2. 代理把调用转发给目标对象的 doRefund() 方法
  3. 目标对象的 doRefund() 内部调用 this.processRefund()this 是目标对象本身,不是代理
  4. this.processRefund() 就是普通的 Java 方法调用,没有事务拦截

事务拦截器从未介入 。每个 jdbcTemplate.update() 各自独立提交。

第三步:分析失效影响

每个 update 语句都在自己的事务中运行(默认自动提交模式):

  • UPDATE refund_order → 提交成功
  • UPDATE member_order → 如果成功,提交;如果失败,回滚但 refund_order 的更新已经提交了
  • INSERT INTO operation_log → 同理独立

下面这张时序图可以直观看到「部分更新」是怎么发生的:

3 个操作各自在自己的事务中独立提交。第 1 步成功提交后,第 2 步因为行锁超时而回滚------但第 1 步的 refund_order 已经写死了,无法撤回。第 3 步则因为异常抛出而从未执行。最终三张表状态不一致。

所以 27.8% 的「部分更新」率就说得通了------member_order 的更新在并发写同一行时碰到行锁超时,或者 operation_log 在特定条件下插入失败,这些失败不影响已经提交的 refund_order 更新。

根因分析

下面这张图的对比可以一目了然地看到修复前后的事务边界变化:

左侧是修复前的状态:3 个数据库操作各自独立提交,一旦中间某一步失败,前面已提交的操作无法回滚。右侧是修复后的状态:3 个操作在同一个事务中,全部成功才提交,任一步失败则全部回滚。

子原因 1:AOP 代理机制限制

Spring 声明式事务的核心机制是 AOP 代理。无论是 JDK 动态代理还是 CGLIB 代理,都有一个共同的前提:被拦截的方法必须是 public 的

JDK 动态代理基于接口------代理对象实现接口,调用时通过 InvocationHandler 对接口方法做拦截增强。接口中的方法天然是 public abstract,所以 JDK 代理只能拦截接口方法的调用。如果目标类没有实现接口,或者事务方法不在接口中声明,JDK 代理就无能为力了。

CGLIB 基于继承------代理对象是目标类的一个子类实例。子类通过 override 父类的方法来实现方法拦截增强。但 Java 的子类只能 override 访问权限为 public 或 protected 的方法。private 方法在 JVM 字节码层面就是 final 的------所有的 invokevirtual 指令都会直接定位到目标类最具体的实现,代理类的 override 版本永远不会被执行。

当 TransactionInterceptor(Spring 事务拦截器)在代理对象的方法调用链中寻找 @Transactional 注解时,它通过 TransactionAttributeSource 查询当前被调用的 Method 对象上是否有事务属性。对于 private 方法,CGLIB 生成的代理类中压根儿就没有对应的 Method------代理类没有 override 这个方法。拦截器找不到任何事务配置,就直接跳过事务创建,按「无事务」处理每一个 JDBC 操作。

问题不在 @Transactional 注解是否存在------通过反射检查目标类原始 Class 上的 Method,确实能看到 @Transactional。问题在于代理层面的方法匹配机制只检查代理类暴露的 public 方法,private 方法在代理层是完全透明的。注解写了但代理看不见,等于没写。

子原因 2:同类自调用放大问题

即使将 processRefund 改为 public,如果还在同一个类中通过 this.processRefund() 调用,事务仍然不生效。这是 Spring 事务失效的另一个经典场景。

原理与 private 方法一脉相承------关键在 Java 的 this 引用:

上图中,上方是同类自调用的调用链:doRefund() 内部调 this.processRefund()------this 是目标对象本身,不是代理,事务拦截器压根儿看不到这个调用。下方是正确的做法:从外部注入另一个 Bean 的代理对象,通过 txService.processRefund() 调用,这样调用路径走了代理,事务拦截器得以介入。

当 doRefund 调用 this.processRefund() 时,Java 的 this 引用指向的是目标对象(原始 Bean),不是代理对象。要从代理对象发起调用,必须从外部注入当前 Bean:

java 复制代码
@Service
public class RefundFacade {
    @Autowired
    private TransactionalRefundService txService;  // 注入的是代理对象

    public void doRefund(String refundId, String orderId) {
        txService.processRefund(refundId, orderId);  // 走代理
    }
}

private 方法的问题其实和自调用问题是同一类根源------事务边界只存在于代理对象的外部入口。一旦进入目标对象的内部方法链,所有不加代理拦截的调用都脱离事务控制。

子原因 3:测试环境系统性掩盖

代码上线前通过了单元测试,为什么测试没发现数据不一致?

原因在于 Spring Boot 测试的默认行为。如果在测试类或测试方法上加了 @Transactional(或使用 @DataJpaTest 这种自带事务的切片测试),每个测试方法在执行前后会自动开启和回滚事务。测试中调用 BuggyRefundService 时,测试框架的事务包裹了整个测试方法的外部边界,把 private 方法失效的内部漏洞给「糊」住了。

换句话说,测试里的每笔退款操作都被测试事务包着,三个 update 语句恰好落在了同一个测试事务中------看起来像是 @Transactional 在生效。而生产中没有这层测试事务包裹,private 方法上的 @Transactional 又根本没被代理拦截,就直接退化成 auto-commit 模式了。

这是事务失效类 Bug 最危险的特征:功能正常(测试通过)、数据不一致(生产累积)。等财务对账发现时,不一致数据已经累计了 47 笔。

测试事务与生产事务的差异可以用下图来理解:

测试框架的事务包裹了整个测试方法的外部边界,把 private @Transactional 失效的内部漏洞给「糊」住了。而生产中没有这层包裹,问题立刻暴露。

子原因 4:积累效应与业务影响

169 笔退款中 47 笔不一致,27.8% 的比例说明这不是简单的并发冲突------每次调用大概率都会丢一部分更新。按一天 1000+ 笔退款的量级,一周就能积累 1400+ 笔不一致数据,等到月底财务对账时将是一个灾难。

更严重的是业务影响------用户看到退款处理成功,但订单金额未更新,客服系统会收到大量咨询。如果这种不一致影响到后续的自动退款审核流程,还可能导致错误的业务决策。

修复方案

第一步:评估现状

修复方案的架构变化如下------将事务逻辑从 BuggyRefundService 中剥离,独立成 TransactionalRefundService,外部通过 RefundFacade 注入代理对象调用:

张工检查了所有 Service 中的 @Transactional 用法,发现除了 BuggyRefundService 外,还有两个地方也存在同类问题:

  1. CouponRefundService 中也有 private + @Transactional 的方法
  2. OrderCancelService 中同类自调用事务方法

总计涉及 3 个 Service、6 个需要事务的方法。同时需要回刷已经不一致的 47 笔数据。

第二步:确定方向

修复策略分三个层面:

  1. 立即修复:将 private @Transactional 改为 public,把事务逻辑抽取到独立 Service Bean 中,确保调用链路走代理
  2. 验证补全:在测试中增加事务代理验证。核心逻辑不是测试业务功能,而是验证 CGLIB 代理类能否正确识别 @Transactional。用 AopUtils.isAopProxy 确认代理状态,用 TransactionAttributeSource 确认方法上有正确的事务属性
  3. CI 防护:通过 ArchUnit 规则禁止 @Transactional 出现在非 public 方法上。IntelliJ IDEA 也有「@Transactional on private method」的检查,可以设为 Error 级别

第三步:代码修改

旧代码的问题一目了然------@Transactional 放在 private 方法上,同类 doRefund 直接 this 调用:

修复方案是将事务逻辑抽取到独立的 TransactionalRefundService 中,通过外部注入调用:

同时增加的代理验证测试,确保 CGLIB 代理类上能正确识别 @Transactional 注解:

完整的 Git diff 对比:

第四步:增加 CI 防护

在 CI 中增加 ArchUnit 测试,自动拦截 @Transactional 出现在非 public 方法上的情况:

第五步:上线部署

修正后的代码经过编译验证(mvn clean compile),通过新增的 ProxyVerificationTest 测试用例(验证代理类可见性),部署到生产环境。

验证结果

即时指标

上线后立即验证数据一致性:

mismatch 计数从 47 降到 0------所有新增退款的三张表数据完全一致。连接池活跃连接数稳定在 2,没有连接泄漏。

张工接着用 FixedRefundService 回刷了之前不一致的 47 笔数据,全部在同一个事务中完成更新,一致性恢复。

持续观察

上线后 30 分钟内处理了 87 笔退款,日志中所有 processRefund 调用均正常完成,0 错误。三个表(refund_order、member_order、operation_log)的关联查询全部一致。

团队复盘

复盘时总结的几个要点:

  • private 方法对 Spring AOP 代理不可见是根本原因
  • @Async、@Cacheable 等其他声明式注解同理
  • 测试事务掩盖了生产环境的问题
  • 需要 CI 层防护

避坑建议

  1. @Transactional 只放在 public 方法上:这是 Spring 官方文档明确规定的。private、protected、package-private 都不行。IDE 可以配置检查规则来拦截这类问题。

  2. 事务方法必须通过代理调用:同一类内不要用 this.xxx() 调用事务方法,要通过注入的外部 Bean 调用。或者使用 AopContext.currentProxy()(需要 exposeProxy=true)。

  3. 测试事务不等于生产事务:@DataJpaTest 和 @Transactional 测试自带事务边界,会掩盖事务注解失效的问题。测试中应该显式验证事务代理状态。

  4. 增加 ArchUnit 或 IDE 检查:在 CI 中加入规则,禁止 @Transactional 修饰非 public 方法。IntelliJ IDEA 有「@Transactional on private method」的检查,可以开启为 Error 级别。

  5. 声明式注解的代理机制是通用的:@Transactional、@Async、@Cacheable、@Retryable 等基于 Spring AOP 的注解都受相同限制。理解了一种注解的代理原理,就等于理解了全部。判断一个注解是否受代理限制:看它是不是通过 AbstractPointcutAdvisor + MethodInterceptor 实现的。如果是,那么 public + 外部调用就是铁律。

  6. 验证代理边界的手段:用 AopUtils.isAopProxy() 检查 Bean 是否被代理,用 TransactionAttributeSource 查询方法上真正生效的事务属性,不要只看源码上的注解。

  7. 数据一致性告警要尽早配置:这次 47 笔不一致才发现,是因为没有配置数据对账告警。如果有一致性检查规则(refund_order.status=processed 但 member_order.total_refund=0 就告警),问题会提前暴露。

  8. 代码 Review 要 Check 注解可见性:Review 事务相关的代码时,除了检查逻辑正确性,还应该检查 @Transactional 方法的访问权限和调用路径。

附:完整命令清单

bash 复制代码
# 查看告警指标
kubectl get pods -n prod | grep refund
kubectl logs refund-service-6b8c9d7f8-xyz01 -n prod --tail=50 | grep -i 'error\|exception\|rollback'

# 排查日志中的部分更新
tail -100 refund-service.log | grep -A 3 'processRefund'
zgrep '2025-11-15 14:22' refund-service.log.1.gz | grep -oE '(REF-[0-9]+).*(processed|failed)'

# 数据库一致性查询
mysql -u root -h db-prod -e "SELECT r.id, r.status, o.total_refund FROM refund_order r JOIN member_order o ON r.order_id = o.id WHERE r.status='processed' AND o.total_refund=0;"

# Arthas 代理验证
java -jar arthas-boot.jar --attach-only

# 验证修复
mysql -u root -h db-prod -e "SELECT count(*) AS mismatch FROM refund_order r JOIN member_order o ON r.order_id = o.id WHERE r.status='processed' AND o.total_refund=0;"
kubectl logs --tail=20 -l app=refund-service -n prod | grep 'processRefund.*error'
相关推荐
钟智强1 小时前
AWS CloudGoat 实战:一个 SSRF 如何撬动整个云账户
后端
云技纵横1 小时前
@Async 默认执行器又炸了:SimpleAsyncTaskExecutor 为什么不是线程池?
后端·面试
程序员Agions1 小时前
Fypro 新手快速上手与实战指南
后端
舒心的云朵1 小时前
用三张图片详解Asp.Net 全生命周期
后端·asp.net
卷无止境1 小时前
C++ 类型转换变迁之路:从隐式转换到 static_cast
c++·后端
孝顺的书包1 小时前
用Asp.net写自己的服务框架
后端·asp.net
糊涂的世界1 小时前
IE10、IE11 User-Agent 导致的 ASP.Net 网站无法写入Cookie 问题
后端·asp.net
易协同低代码1 小时前
通达OA配置文件详解
后端
uhakadotcom2 小时前
什么是 “无界限的列表积累” 问题
后端·面试·github