Administrator
发布于 2019-03-19 / 1497 阅读
21

Spring AOP 失效的那些场景与代理机制

测试提了个 bug:数据没回滚

3 月初做批量导入功能,测试提了个单:导入 1000 条订单,第 500 条数据有误抛了异常,预期全部回滚,结果前 499 条都写进库了。

我第一反应是不可能,@Transactional 明明标着。看代码:

@Service
public class OrderImportService {

    public ImportResult batchImport(List<OrderDTO> list) {
        ImportResult result = new ImportResult();
        for (OrderDTO dto : list) {
            try {
                importOne(dto);                 // 第 31 行:这里调用了自己类里的方法
                result.incrSuccess();
            } catch (Exception e) {
                result.addError(dto.getRowNo(), e.getMessage());
            }
        }
        return result;
    }

    @Transactional(rollbackFor = Exception.class)
    public void importOne(OrderDTO dto) {
        orderMapper.insert(buildOrder(dto));
        stockMapper.deduct(dto.getSkuId(), dto.getQuantity());
    }
}

importOne 上的事务注解根本没起作用。这种"同类内部调用导致切面失效"的问题,我入职第一年就听说过,但真正遇到还是第一次。

先确认它到底有没有被代理

Spring 提供了几个工具方法,可以直接打印出来看:

@Service
public class OrderImportService {

    @PostConstruct
    public void printProxyInfo() {
        System.out.println("isAopProxy      : " + AopUtils.isAopProxy(this));
        System.out.println("isJdkDynamicProxy: " + AopUtils.isJdkDynamicProxy(this));
        System.out.println("isCglibProxy    : " + AopUtils.isCglibProxy(this));
        System.out.println("class           : " + this.getClass().getName());
    }
}

启动的时候输出:

isAopProxy      : true
isJdkDynamicProxy: false
isCglibProxy    : true
class           : com.xxx.service.OrderImportService$$EnhancerBySpringCGLIB$$8a3f21d9

this 确实是个 CGLib 代理对象,说明代理是生成了的。那问题就出在调用路径上。

根因:方法内部的 this 不是代理对象

把 CGLib 生成的代理逻辑简化一下,它是目标类的子类:

// 概念示意,实际的 CGLib 字节码比这个复杂
public class OrderImportService$$EnhancerBySpringCGLIB extends OrderImportService {

    private OrderImportService target;
    private TransactionInterceptor interceptor;

    @Override
    public void importOne(OrderDTO dto) {
        // 代理逻辑:先开事务,再调父类(目标对象)的方法
        interceptor.invoke(() -> super.importOne(dto));
    }
}

外部调用 orderImportService.importOne(dto) 时,拿到的是代理对象,走的是重写后的方法,事务生效。

但是在 batchImport 内部写 importOne(dto),它编译成字节码是 this.importOne(dto),而这个 this 指向的是被代理的目标对象自己,不是外面那层代理。子类重写的方法根本不会被执行,事务拦截器自然也不会被调用。

用一张图说明调用路径的差别:

外部调用(生效)
  caller ──> proxy.importOne() ──> TransactionInterceptor ──> target.importOne()

内部调用(失效)
  caller ──> proxy.batchImport() ──> target.batchImport()
                                        └─> this.importOne()      // 直接进目标对象,绕过代理

这是所有基于代理的 AOP 实现的共同限制,Spring AOP、甚至 JDK 动态代理都一样,不是 Spring 的 bug。

五种改法,各有各的代价

方案一:拆到另一个类(我们最终用的)

最干净,不依赖任何 Spring 特殊机制。把需要事务的方法挪到一个单独的 Service 里,调用方注入它:

@Service
public class OrderImportService {
    @Autowired private OrderWriteService orderWriteService;

    public ImportResult batchImport(List<OrderDTO> list) {
        for (OrderDTO dto : list) {
            try {
                orderWriteService.importOne(dto);      // 走注入进来的代理对象
                ...
            } catch (Exception e) { ... }
        }
    }
}

@Service
public class OrderWriteService {
    @Transactional(rollbackFor = Exception.class)
    public void importOne(OrderDTO dto) { ... }
}

顺带还解决了一个问题:批量导入的循环里每次都开事务,1000 条要开 1000 次。我们后来改成先做全量校验、再一次性批量插入,事务方法只调用一次,导入耗时从 12.4 秒降到 1.8 秒。

方案二:注入自己

Spring 支持把代理对象注入回自己,听起来别扭,但确实能工作:

@Service
public class OrderImportService {

    @Autowired
    private OrderImportService selfProxy;      // 注入进来的是代理对象

    public void batchImport(List<OrderDTO> list) {
        selfProxy.importOne(dto);              // 通过代理调用
    }
}

注意别在 @PostConstruct 阶段用它,那时候可能还没注入完。而且循环依赖检测会报警告,我们没敢在线上用。

方案三:AopContext.currentProxy()

public void batchImport(List<OrderDTO> list) {
    ((OrderImportService) AopContext.currentProxy()).importOne(dto);
}

需要先开配置,默认是关的:

@EnableAspectJAutoProxy(exposeProxy = true)

这个方案把框架细节泄漏到业务代码里,而且强转类型。我在一个老项目里见过,读代码的人第一眼看不懂为什么这么写。

方案四:AspectJ 编译期/加载期织入

真正的字节码织入,不存在代理的问题,连 private 方法都能增强。代价是要改构建流程(AspectJ 编译器)或加 -javaagent,我们评估后放弃了,投入产出比不合适。

方案五:把注解挪到外层方法上

@Transactional(rollbackFor = Exception.class)
public void batchImport(List<OrderDTO> list) {
    // 整批一个事务,一条失败全回滚
}

这个改动最小,但语义变了:从"每条独立事务"变成"整批一个事务"。1000 条的大事务会长时间占着锁,而且 undo log 会膨胀。我们的场景要的是"成功的写进去、失败的记下来",所以不能这么改。

JDK 动态代理还是 CGLib

排查过程中顺带搞清楚了选择规则。Spring 5.1 的默认逻辑在 DefaultAopProxyFactory 里:

@Override
public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
    if (config.isOptimize() || config.isProxyTargetClass()
            || hasNoUserSuppliedProxyInterfaces(config)) {
        Class<?> targetClass = config.getTargetClass();
        if (targetClass == null) {
            throw new AopConfigException("...");
        }
        if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
            return new JdkDynamicAopProxy(config);
        }
        return new ObjenesisCglibAopProxy(config);
    }
    else {
        return new JdkDynamicAopProxy(config);
    }
}

判断顺序是:proxyTargetClass=true 就用 CGLib,否则看目标类有没有实现接口,有接口用 JDK 动态代理,没有用 CGLib。

这里有个版本差异值得记:Spring Boot 2.0 开始,spring.aop.proxy-target-class 的默认值改成了 true,也就是一律走 CGLib。1.x 时代默认是 false(有接口用 JDK 代理)。我们项目是 Spring Boot 2.1.3,所以看到的是 EnhancerBySpringCGLIB

spring:
  aop:
    proxy-target-class: true      # Boot 2.x 的默认值

两种代理的差异:

维度JDK 动态代理CGLib
实现实现目标接口,运行时生成接口实现类继承目标类,字节码生成子类
要求目标类必须实现接口类和方法不能是 final
能增强的方法只有接口里声明的方法所有可继承的 public/protected 方法
JDK 8 下的性能调用略快,创建快创建慢(要生成字节码),调用靠 FastClass 索引,也很快

其他几种 AOP 失效

整理一下我们项目里出现过的,排查顺序基本按这个清单走:

  • 方法是 private。CGLib 靠继承,private 方法不会被重写;JDK 动态代理只能代理接口方法。Spring 会静默跳过,不报错,最难发现。
  • 类或方法是 final。CGLib 无法继承 final 类、无法重写 final 方法。我们有个 final class SmsUtil,加了缓存注解完全没反应,查了半天。
  • 对象不是 Spring 管理的。自己 new 出来的对象、或者从 JSON 反序列化的对象,不在容器里,没有代理。
  • 异常被 catch 吞掉了。就像开头那段代码,即使事务生效,异常没抛出去,TransactionInterceptor 也感知不到,照样提交。
  • 异常类型不匹配@Transactional 默认只回滚 RuntimeExceptionError,受检异常(比如 IOException、自定义的 BizException extends Exception不会触发回滚。这个坑我们踩过一次,后来统一要求写 rollbackFor = Exception.class
  • 数据库引擎不支持事务。MySQL 的 MyISAM。老表里有几张还是 MyISAM,加了注解也没用。用这个命令查:
mysql> SELECT table_name, engine FROM information_schema.tables
       WHERE table_schema = 'order_db' AND engine != 'InnoDB';

小结

  • Spring AOP 是运行时代理,切面生效的前提是"调用必须经过代理对象"。同类内部方法互调时,this 指向目标对象,切面必然失效。这是原理决定的,不是配置能调的。
  • 判断有没有被代理,用 AopUtils.isAopProxy() 打印一下最快,比猜快得多。
  • 修复优先选"拆到另一个类",其次是"注入自己",AopContext.currentProxy() 是最后的手段(它把框架侵入了业务代码)。
  • 事务相关的失效,排查顺序建议是:方法是不是 public → 有没有内部调用 → 异常有没有抛出来 → 异常类型对不对 → 引擎是不是 InnoDB。

顺带说一句,@Async@Cacheable、自定义的 @LogRecord 走的都是同一套代理机制,所以"内部调用失效"这一条对它们全部适用。我上次给同事做的参数校验框架也踩过同样的坑,那个是用 @Valid 手动调用解决的版本。

参考