测试提了个 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默认只回滚RuntimeException和Error,受检异常(比如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 手动调用解决的版本。