云计算百科
云计算领域专业知识百科平台

Spring 声明式事务七大失效场景深度剖析(三):同类调用与代理失效深度复盘

封面信息图

在经历了前两篇关于“异常类型吞回滚”、“非 public 修饰符被静默跳过”以及“多数据源单边提交”的洗礼之后,我们来到了 Spring 声明式事务失效大赏中最具有欺骗性、也最容易在老系统重构时引发重大资损事故的终极大坑——同类内部方法调用(Self-Invocation)导致的代理层被完全绕过。

场景是如此熟悉,几乎每个 Java 工程师的代码库里都曾经躺过类似的逻辑:在某个核心的 OrderTradeService 类里,有一个面向外部的公共入口方法 submitOrder()。在这个方法中,开发人员先做了一堆复杂的参数校验、风控打分;随后,为了让代码逻辑更模块化、职责更清晰,他很自然地在同一个类里提取出了一个子方法 doSaveDatabaseTransactional(),并且极为认真地在该子方法上挂了:

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)

在本地单元测试或者自测时,如果走的是正常成功路径,数据库里确实插入了数据,大家觉得一切尽在掌握。直到某一天大促压测或者真实线上故障,内部方法在执行到一半时抛出了数据库唯一键冲突或者未捕获的运行时异常,整个交易链路退出。此时研发同学打开数据库一看,整个人彻底傻了眼:前面的脏数据不仅没有回滚,甚至连那个被寄予厚望的 REQUIRES_NEW 独立事务根本就从来没有被开启过!

今天我们就对这起经典事故做一次深度的源码级“法医切片”,看清动态代理在字节码调用时的物理断层,并给出两套彻底消除此类隐患的现代化重构方案。

一、动态代理的“同心圆”与自调用的“断崖”

要彻底从脑海中抹去对 @Transactional 的迷信,必须看清 Spring 事务切面在内存中的真实寄生形态。

Spring 的声明式事务并不是直接修改了你写的那个原始 .class 文件的字节码指令,而是基于设计模式中的装饰器模式(Decorator)与动态代理(Dynamic Proxy)。

当 Spring 容器把一个被切面增强的 Bean 注入给外部的 Controller 时,Controller 手中拿到的根本不是你写的那份原生对象,而是一个由 CGLIB 动态生成的子类代理实例:

[外部 Controller 发起调用: orderService.submitOrder()]
|
v
+————————————————————-+
| Spring CGLIB 动态代理对象 (OrderService$$SpringCGLIB$$0) |
| |
| 1. 拦截器链介入: 发现 submitOrder() 没有加 @Transactional |
| 2. 决定直接透传,反射调用底层的真实目标实例 Target: |
| |
| +——————————————————-+ |
| | 真实物理目标对象 Target (OrderServiceImpl 裸对象实例) | |
| | | |
| | submitOrder() { | |
| | // 业务校验… | |
| | | |
| | // 关键断崖爆发点! | |
| | // 此时的调用等价于: this.doSaveDatabaseTransactional() |
| | this.doSaveDatabaseTransactional(); <———–+-+– 致命断崖!
| | } | |
| | | |
| | @Transactional | |
| | doSaveDatabaseTransactional() { … } | |
| +——————————————————-+ |
+————————————————————-+

请死死盯住图中的那个断崖:

  • Controller 调用的确实是外层的代理对象(Proxy);
  • 代理对象把控制权移交给了内部的物理真实目标对象(Target);
  • 在物理目标对象的 submitOrder() 内部,执行了 this.doSaveDatabaseTransactional();
  • 此时的 this,指向的是没有被任何切面包装的原生 Java 实例!
  • 该调用直接在 JVM 本地方法区完成了直接跳转,根本没有经过外层代理对象的 MethodInterceptor 拦截链!
  • 既然根本没有走代理拦截器,那么 Spring 的 TransactionInterceptor 压根连这个方法被调用了都毫不知情!什么 doBegin() 开启事务、什么 REQUIRES_NEW 挂起外层连接、什么异常捕获回滚,统统全部沦为空谈!

    二、源码窥探:为什么 CGLIB 也无法拯救 this?

    有些对字节码技术有一定了解的同学会反驳:“JDK 动态代理基于接口,拦截不到 this 容易理解;但 CGLIB 是通过继承目标类、重写目标类的所有方法来生成子类的,难道在子类里面,重写的方法还捕获不到吗?”

    我们来看 CGLIB 生成的代理类字节码的反编译伪代码:

    // CGLIB 生成的代理子类伪代码
    public class OrderServiceImpl$$EnhancerBySpringCGLIB$$1 extends OrderServiceImpl {

    private MethodInterceptor CGLIB$CALLBACK_0;

    @Override
    public void submitOrder() {
    MethodInterceptor interceptor = this.CGLIB$CALLBACK_0;
    if (interceptor != null) {
    // 进入拦截器
    interceptor.intercept(this, method, args, methodProxy);
    } else {
    super.submitOrder();
    }
    }

    @Override
    public void doSaveDatabaseTransactional() {
    // 如果是从外部调用,确实会被拦截!
    MethodInterceptor interceptor = this.CGLIB$CALLBACK_0;
    interceptor.intercept(this, method, args, methodProxy);
    }
    }

    看清楚:在拦截器执行完后,它最终调用的是目标对象的 super.submitOrder()!在执行父类的 submitOrder() 方法体时,编译生成的字节码是:

    aload_0 // 将 this 推入操作数栈 (指向的是原生父类实例上下文)
    invokevirtual #42 <com/yali/trade/service/OrderServiceImpl.doSaveDatabaseTransactional>

    在 JVM 的指令规范中,aload_0 绑定的上下文决定了它调用的依然是自身方法体内部的实现,一旦进入了原生父类的方法内部,后续的内部跳转已经彻底脱离了 CGLIB 代理拦截器的掌控范围!

    三、破局方案一:使用编程式事务(TransactionTemplate)降维打击

    很多架构师在遇到此类问题时,习惯性地推荐用“自注入 @Autowired @Lazy private OrderService self;”去解决。

    但在大型电商交易的核心链路中,我不建议为了一个简单的局部事务去搞这种别扭的自循环依赖。最干净、最彻底、性能最高的方式,是使用 Spring 原生提供的 TransactionTemplate 编程式事务!

    编程式事务完全不依赖动态代理、不依赖切面拦截、不依赖访问修饰符,它直接在代码块内部显式操纵事务生命周期:

    package com.yali.trade.service.impl;

    import com.yali.trade.dao.OrderMapper;
    import com.yali.trade.dto.OrderDTO;
    import com.yali.trade.service.OrderService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.support.TransactionTemplate;

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class RobustOrderServiceImpl implements OrderService {

    private final OrderMapper orderMapper;
    // 注入事务模版(Spring Boot 自动装配)
    private final TransactionTemplate transactionTemplate;

    @Override
    public void submitOrder(OrderDTO order) {
    // 1. 前置复杂参数校验、风控打分(完全不需要事务,零连接占用!)
    validateOrder(order);
    riskCheck(order);

    // 2. 核心持久化动作:利用 TransactionTemplate 显式划定事务边界!
    // 无论此代码处于什么方法内部,无论是不是 private,100% 绝对生效!
    Boolean isSuccess = transactionTemplate.execute(status -> {
    try {
    // 真正的短小核心事务逻辑
    orderMapper.insertOrder(order);
    orderMapper.insertOrderItems(order.getItems());
    return true;
    } catch (Exception ex) {
    log.error("本地订单事务执行失败,显式触发回滚!orderId: {}", order.getId(), ex);
    // 手动标记回滚,或者直接向外抛 RuntimeException
    status.setRollbackOnly();
    throw ex;
    }
    });

    // 3. 事务提交后,再触发发送异步 MQ 消息
    if (Boolean.TRUE.equals(isSuccess)) {
    sendOrderCreatedNotice(order.getId());
    }
    }

    private void validateOrder(OrderDTO order) {}
    private void riskCheck(OrderDTO order) {}
    private void sendOrderCreatedNotice(String orderId) {}
    }

    使用 TransactionTemplate 的三大核心收益:

  • 彻底粉碎代理失效:不依赖任何反射与切面,直接调用底层 PlatformTransactionManager,代码怎么写就怎么生效;
  • 消灭长事务:将耗时的校验、RPC 调用安全地隔离在事务之外,只有真正需要写库的 5 毫秒才去申请数据库连接,极大拯救了数据库连接池;
  • 极佳的可读性与单元测试性:事务的开启与回滚在代码块中黑白分明,Mock 测试极其轻松。
  • 四、破局方案二:领域驱动设计(DDD)的应用层与领域层物理拆分

    如果从更高级的软件工程架构(Clean Architecture / DDD)来看,之所以会在同一个 Service 里写出“非事务方法调内部事务方法”,本质上暴露出的是领域职责的严重混淆!

    • 那个包含参数校验、外部查询、编排流转的 submitOrder(),属于 应用编排服务(Application Service);
    • 那个需要严格保证原子性、执行底层落库的 doSaveDatabaseTransactional(),属于 核心领域服务(Domain Service)。

    将两者物理拆开:

    [OrderApplicationService (应用层)]
    |
    +—> 执行校验、风控
    |
    +—> 调用 orderDomainService.saveTransactional(order) <— 正常的外部 Bean 调用!
    切面完美触发!

    在架构上建立严谨的分层规范:应用服务只管编排,领域服务只管原子事务。物理上的类隔离不仅让代理切面 100% 正常生效,更让系统代码的可维护性与扩展性提升了一个档次。

    五、架构师心法

    软件系统中的 Bug,常常诞生于“以为它会生效”的盲目自信中。

    注解编程(Annotation-driven)极大地提高了生产力,但也把大量的底层控制逻辑隐藏在黑暗之中。看透动态代理在字节码指令层面的物理断崖,不迷信黑魔法,善用 TransactionTemplate 显式掌控事务命运,严守领域职责的物理分界,这才是顶级架构师守护大促核心交易安全的最强基本功。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Spring 声明式事务七大失效场景深度剖析(三):同类调用与代理失效深度复盘
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!