叙事框架现象 → 排查过程 → 根因 → 修复 → 预防问题现象线上数据不一致的根因Service 类中一个 private 方法上的 Transactional 注解未生效。Spring 声明式事务基于 AOP 动态代理代理仅能拦截 public 方法。本文通过源码分析 TransactionAttributeSourcePointcut 的匹配逻辑并给出 3 种正确的解决方案。排查过程第一步验证代理状态张工在本地启动了一个 Debug 端点直接打印 BuggyRefundService 和 FixedRefundService 的代理信息。输出中看到BuggyRefundService 被 Spring 创建了 CGLIB 代理Is AOP proxy: true这说明 Spring 确实为这个 Bean 生成了代理子类。但重点是Transactional present on private method: true——注解确实存在。接着张工加了一个更底层的检查直接用 TransactionAttributeSource 查询代理对象在那个方法上能不能拿到事务属性。结果——BuggyService 的 private 方法返回txAttrnull而 FixedService 的 public 方法正常返回Propagation.REQUIRED。这就说明了注解存在但事务拦截器看不到它。为了彻底看清张工用 javap 反编译了 CGLIB 生成的代理子类字节码。对比发现BuggyRefundService 的代理类中有doRefund()方法但完全没有processRefund()——因为它是 privateCGLIB 无法 override。而 FixedRefundServicepublic 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 个操作在同一个事务中全部成功才提交任一步失败则全部回滚。子原因 1AOP 代理机制限制Spring 声明式事务的核心机制是 AOP 代理。无论是 JDK 动态代理还是 CGLIB 代理都有一个共同的前提被拦截的方法必须是 public 的。JDK 动态代理基于接口——代理对象实现接口调用时通过 InvocationHandler 对接口方法做拦截增强。接口中的方法天然是 public abstract所以 JDK 代理只能拦截接口方法的调用。如果目标类没有实现接口或者事务方法不在接口中声明JDK 代理就无能为力了。CGLIB 基于继承——代理对象是目标类的一个子类实例。子类通过 override 父类的方法来实现方法拦截增强。但 Java 的子类只能 override 访问权限为 public 或 protected 的方法。private 方法在 JVM 字节码层面就是 final 的——所有的 invokevirtual 指令都会直接定位到目标类最具体的实现代理类的 override 版本永远不会被执行。当 TransactionInterceptorSpring 事务拦截器在代理对象的方法调用链中寻找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不是代理对象。要从代理对象发起调用必须从外部注入当前 BeanServicepublicclassRefundFacade{AutowiredprivateTransactionalRefundServicetxService;// 注入的是代理对象publicvoiddoRefund(StringrefundId,StringorderId){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 外还有两个地方也存在同类问题CouponRefundService 中也有 private Transactional 的方法OrderCancelService 中同类自调用事务方法总计涉及 3 个 Service、6 个需要事务的方法。同时需要回刷已经不一致的 47 笔数据。第二步确定方向修复策略分三个层面立即修复将 private Transactional 改为 public把事务逻辑抽取到独立 Service Bean 中确保调用链路走代理验证补全在测试中增加事务代理验证。核心逻辑不是测试业务功能而是验证 CGLIB 代理类能否正确识别 Transactional。用 AopUtils.isAopProxy 确认代理状态用 TransactionAttributeSource 确认方法上有正确的事务属性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()需要 exposeProxytrue。测试事务不等于生产事务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.statusprocessed 但 member_order.total_refund0 就告警问题会提前暴露。代码 Review 要 Check 注解可见性Review 事务相关的代码时除了检查逻辑正确性还应该检查 Transactional 方法的访问权限和调用路径。附完整命令清单# 查看告警指标kubectl get pods-nprod|greprefund kubectl logs refund-service-6b8c9d7f8-xyz01-nprod--tail50|grep-ierror\|exception\|rollback# 排查日志中的部分更新tail-100refund-service.log|grep-A3processRefundzgrep2025-11-15 14:22refund-service.log.1.gz|grep-oE(REF-[0-9]).*(processed|failed)# 数据库一致性查询mysql-uroot-hdb-prod-eSELECT r.id, r.status, o.total_refund FROM refund_order r JOIN member_order o ON r.order_id o.id WHERE r.statusprocessed AND o.total_refund0;# Arthas 代理验证java-jararthas-boot.jar --attach-only# 验证修复mysql-uroot-hdb-prod-eSELECT count(*) AS mismatch FROM refund_order r JOIN member_order o ON r.order_id o.id WHERE r.statusprocessed AND o.total_refund0;kubectl logs--tail20-lapprefund-service-nprod|grepprocessRefund.*error