@Transactional 用在 private 方法上,事务为什么没生效?
本文是Spring Boot 生产配置实战系列的第 5 篇 叙事框架:
现象 → 排查过程 → 根因 → 修复 → 预防
问题现象
告警触发
某日下午 14:30,告警群突然弹出数据一致性告警——refund-order 服务有 47 笔退款记录的 refund_order.status 已经标记为「processed」,但对应的 member_order.total_refund 仍然为 0。

这是一个 P1 级别的告警——用户在前端看到退款处理成功,但订单的累计退款额没有更新。财务对账时发现账不平,追到了开发这边。
财务给到的信息让张工心头一紧:近 3 小时内系统处理了 169 笔退款,其中 47 笔存在数据不一致,占比 27.8%。这意味着问题不是偶发的,而是每次调用都在以一定概率产生「部分更新」。

从群里的讨论可以确认几个关键信息:CPU 和内存指标正常,应用进程也在正常运行,没有 OOM 或重启。问题局限在数据层面——每次退款操作只完成了部分数据更新。
上机排查遇阻
张工登录到 refund-service 的生产服务器,首先查看了应用日志。

日志显示了一个诡异的模式:每个 processRefund 调用中,refund_order 的 status 总是成功更新为 processed,但 member_order 的 total_refund 更新和 operation_log 的插入操作却时成功时失败。
通过 SQL 查询可以直接看到数据状态——用 tail 看日志发现,REF-2025-0032 这笔退款中 refund_order 更新成功了,但 member_order 更新失败。REF-2025-0033 则是 refund_order 和 member_order 都更新成功了,但 operation_log 插入失败。
这种「部分更新」的模式高度一致:一个事务内的多个操作没有原子性保证。换句话说,事务没有生效。
初步猜测
张工的第一反应是:这个方法的 @Transactional 注解没有实际生效。但代码 review 时确实看到了注解——问题出在哪里?
从日志看,每个操作看起来都在独立提交。如果 @Transactional 存在但没生效,最可能的原因有两个:要么注解放在了 Spring 代理无法拦截的位置(比如 private 方法),要么是同类自调用绕过了代理。
张工翻了上周五上线的新版本代码,发现了问题——processRefund 被标记为 private。
排查过程
第一步:验证代理状态
张工在本地启动了一个 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() 时,调用链是这样的:
- 外部调用
buggyRefundService.doRefund()→ 经过代理 - 代理把调用转发给目标对象的
doRefund()方法 - 目标对象的
doRefund()内部调用this.processRefund()→ this 是目标对象本身,不是代理 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:
@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 层防护
避坑建议
-
@Transactional 只放在 public 方法上:这是 Spring 官方文档明确规定的。private、protected、package-private 都不行。IDE 可以配置检查规则来拦截这类问题。
-
事务方法必须通过代理调用:同一类内不要用 this.xxx() 调用事务方法,要通过注入的外部 Bean 调用。或者使用 AopContext.currentProxy()(需要 exposeProxy=true)。
-
测试事务不等于生产事务:@DataJpaTest 和 @Transactional 测试自带事务边界,会掩盖事务注解失效的问题。测试中应该显式验证事务代理状态。
-
增加 ArchUnit 或 IDE 检查:在 CI 中加入规则,禁止 @Transactional 修饰非 public 方法。IntelliJ IDEA 有「@Transactional on private method」的检查,可以开启为 Error 级别。
-
声明式注解的代理机制是通用的:@Transactional、@Async、@Cacheable、@Retryable 等基于 Spring AOP 的注解都受相同限制。理解了一种注解的代理原理,就等于理解了全部。判断一个注解是否受代理限制:看它是不是通过 AbstractPointcutAdvisor + MethodInterceptor 实现的。如果是,那么 public + 外部调用就是铁律。
-
验证代理边界的手段:用 AopUtils.isAopProxy() 检查 Bean 是否被代理,用 TransactionAttributeSource 查询方法上真正生效的事务属性,不要只看源码上的注解。
-
数据一致性告警要尽早配置:这次 47 笔不一致才发现,是因为没有配置数据对账告警。如果有一致性检查规则(refund_order.status=processed 但 member_order.total_refund=0 就告警),问题会提前暴露。
-
代码 Review 要 Check 注解可见性:Review 事务相关的代码时,除了检查逻辑正确性,还应该检查 @Transactional 方法的访问权限和调用路径。
附:完整命令清单
# 查看告警指标
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'