@Transactional 配了等于没配?同一个类里方法调方法的隐形陷阱

场景:运营批量导入 48 条用户数据,@Transactional 加了但只成功了 30 条——同类自调用导致事务没生效 排查链路:检查代理状态 → 验证 @Transactional 可见性 → 定位调用方式 → 三种修复方案对比

断言

断言:@Transactional 写在 public 方法上,大多数人以为事务生效了

但真相是——同一个类里另一个方法用 this 调用它,@Transactional = 没写。

大多数人的直觉:"方法上有 @Transactional,事务就该管到它。" 但 Spring 声明式事务基于 AOP 代理。当你在同一个类的另一个方法里直接用 this.事务方法() 调用时,这个调用走的是原始对象,不是代理对象。

看一下生产上发生了什么——运营同学在管理后台导入了 48 条用户数据。系统返回"批量创建已完成,部分数据异常"。开发查数据库——user 表多了 30 条记录,第 31 条插入报唯一键冲突,但前 30 条没回滚。整个 batchCreate 方法明明加了 @Transactional

排查发现罪不在 @Transactional 放错了位置(不是 private,是 public)——而是 batchCreate 里用 this.createUser() 调了目标方法。这个 this,Spring 不认识。

常见误解:注解在 ≠ 事务在

现场

事故重现

运营批量导入 48 行 CSV,系统处理完返回"部分成功"。

{
  "code": 200,
  "message": "处理完成,部分数据因异常跳过",
  "data": {
    "success": 30,
    "failed": 18
  }
}

没有 500 错误——因为 batchCreate 里 for 循环的异常被 try-catch 吞掉了。运营以为"部分成功"是正常的分批处理机制,直到对账发现少了 18 条数据。

日志:部分插入 + 唯一键冲突

日志中能看到三个特征: 1. 第 31 次循环报 DuplicateKeyException 2. 异常被 batchCreate 的 catch 块捕获并吞掉 3. 没有 TransactionInterceptor 的事务创建日志——关键线索

张工检查了事务日志级别,发现 TransactionInterceptor 在当前调用链上压根儿没被触发。不是事务回滚了——是事务根本就没创建。

排查链路

张工的排查路径:

① @Transactional 存在?     ✅ createUser() 方法上有
② 方法可见性?              ✅ public
③ 事务管理器配置?           ✅ @EnableTransactionManagement
④ Spring 代理状态?          ✅ Bean 被 CGLIB 代理
⑤ 调用方式?                ❌ this.createUser()——没走代理

第五步是根因。张工在本地加了一段验证代码:

// 验证:从代理对象上获取事务属性
AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource();
Method createUserMethod = UserService.class.getMethod("createUser", User.class);

// 从代理类的 Method 上查事务属性
boolean hasTxOnProxy = tas.getTransactionAttribute(
    createUserMethod, UserService$$EnhancerByCGLIB.class) != null;

// 直接在目标类上查事务属性
boolean hasTxOnTarget = createUserMethod.getAnnotation(Transactional.class) != null;

log.info("代理类上有事务: {}, 目标类上有事务: {}", hasTxOnProxy, hasTxOnTarget);
// 输出: 代理类上有事务: true, 目标类上有事务: true

注意——代理类和方法上确实能查到事务属性。问题不在注解不可见,而在调用路径。batchCreate 内部 this.createUser() 根本没走代理的入口,拦截器没有机会执行。

为什么 48 条里 30 条写进去了

跟 private 方法那篇不同——createUser 是 public 的,代理子类确实 override 了它。问题在谁调了它

batchCreate 内部 for 循环:this.createUser(user)——这个 this 是目标对象,不是代理。所以:

  1. 外部调用 proxy.batchCreate(users) → 经过代理 → batchCreate@Transactional,拦截器链为空,直接调用目标
  2. 目标对象的 batchCreate 方法体执行 this.createUser(user)——this 指向原始对象
  3. 第 1-30 次:各自内部 userMapper.insert() 独立 auto-commit 提交
  4. 第 31 次:唯一键冲突,异常抛出——但前 30 条已经提交了,无法回滚

不是事务失效——是事务根本就没进入。

48 条数据执行力示意图

拆解

this.createUser() 为什么跳过了事务拦截器?

Spring 为 @Transactional 创建代理的完整机制。当容器启动时,检测到 Bean 上有 @Transactional 注解,就会通过 CglibAopProxy(默认)为这个 Bean 创建代理对象。代理对象持有两个关键引用:目标对象引用target,原始 Bean)和拦截器链(由 @Transactional 生成的 TransactionInterceptor)。

外部调用者的所有方法调用都会经过代理:

调用方 → proxy.batchCreate(users)
          ↓
     CglibAopProxy.DynamicAdvisedInterceptor.intercept()
          ↓ 检查 batchCreate 是否有 @Transactional
          ↓ 没有 → 拦截器链为空
          ↓
     methodProxy.invoke(target, args)
          ↓
     目标对象.batchCreate(users)    ← 方法体中的 this 指向 target
          ↓ for 循环中
     this.createUser(user)          ← this = 目标对象, 不是代理
          ↓
     UserService.createUser() 原始实现
          ↓
     userMapper.insert(user)        ← auto-commit, 无事务

关键在 CGLIB 的调用方式。

重点methodProxy.invoke(target, args) 直接操作目标对象的方法体,不走虚方法表。target 是原始 UserService 实例,不是代理。在这个调用期间,target 方法体内的所有 this.xxx() 引用都指向这个原始实例——CGLIB 生成的代理子类中的 override 方法(含 interceptor.intercept())根本不会被调用。target 是原始 UserService 实例,不是代理。在这个调用期间,target 方法体内的所有 this.xxx() 引用都指向这个原始实例——CGLIB 生成的代理子类中的 override 方法(含 interceptor.intercept())根本不会被调用。

代理调用链路对比:外部调用 vs 内部自调用

上图清晰展示了差别:左侧外部调用 proxy.createUser() —— 经过 CGLIB 拦截器 → TransactionInterceptor 创建事务 → 执行目标方法。右侧内部自调用 this.createUser() —— 直接执行目标对象的原始方法体,拦截器无介入。

这里的 this 指向差异是理解自调用问题的关键。为什么 Object target = getTarget() 时拿到的是原始对象?因为 CGLIB 的 MethodProxy.invoke(target, args) 是 FastClass 机制的索引调用,它直接通过索引调用目标类的方法实现,不经过虚方法分派。调用时传入的 target 就是原始对象。

对比一下 JDK 动态代理的情况:

// JdkDynamicAopProxy.java — org.springframework.aop.framework (Spring 6.x)
// 行号:约 150-210
@Nullable
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object target = getTarget();
    Class<?> targetClass = AopUtils.getTargetClass(target);
    List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);

    if (chain.isEmpty() && !canApplyToMethod(method, targetClass)) {
        // ↓↓↓ 没有拦截器且方法不可应用 → 反射调用,this 指向 target ↓↓↓
        return method.invoke(target, args);
    }
    // 有拦截器 → 构建 ReflectiveMethodInvocation
    invocation = new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
    return invocation.proceed();
}

JDK 动态代理通过 method.invoke(target, args) 反射调用目标方法——这里 target 同样是原始对象。方法体内部的 this 同样指向原始对象。所以 JDK 动态代理和 CGLIB 在自调用问题上表现一致:只要方法体执行上下文是目标对象,内部的 this.xxx() 都不触发代理。

事务拦截器的触发条件

TransactionInterceptor 的触发路径如下:

// TransactionInterceptor.java — org.springframework.transaction.interceptor
// 行号:约 80-120
@Override
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
    return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed);
}

invoke 方法的入参是 MethodInvocation 对象——它只在代理拦截器链构建时创建。同类自调用走 this.createUser() 时,没有经过拦截器链,所以没有 MethodInvocationTransactionInterceptor.invoke 方法从未被执行。

下面的决策树可以帮助判断 @Transactional 是否实际生效:

事务生效决策树

从决策树可以看出:自调用路径上,即使方法级别条件全部满足(public + @Transactional + proxy 存在),只要调用方式是 this.method(),事务就不生效。

为什么新人最容易踩这个坑

自调用问题的隐蔽性在于:

  1. 代码上看不出问题@Transactional 标在 public 方法上,IDE 不会报错
  2. 单测可能通过:如果测试类本身加了 @Transactional,测试框架的事务包裹了外部边界,'糊'住了内部失效
  3. 小数据量不暴露:3-5 条数据很少触发唯一键冲突或行锁,看起来一切正常
  4. 异常被吞掉时最危险:外层 try-catch 把异常转成"部分成功"的消息,运营以为这是正常机制

对声明式注解的通用启示

这个问题的本质不是 @Transactional 的 bug,而是 Spring AOP 代理机制的天然限制。所有基于 AOP 代理的声明式注解都有同样的约束:

注解 功能 同类自调用是否失效
@Transactional 声明式事务 ✅ 失效
@Async 异步执行 ✅ 失效
@Cacheable 方法缓存 ✅ 失效
@Retryable 重试机制 ✅ 失效
@Lock(集成锁) 分布式锁 ✅ 失效

判断一个注解是否受代理限制的标准:看它是不是通过 AbstractPointcutAdvisor + MethodInterceptor 实现的。如果是,那么必须通过代理对象调用才能生效。

处方

三种解法对比

针对同类自调用事务失效,有三种主流解法:

方案 做法 适用场景 风险
分离到不同 Bean 将事务方法抽取到独立的 @Service,通过注入调用 新项目、重构 类数量增加但结构清晰
自我注入 @Autowired UserService selfself.createUser() 遗留系统最小改动 循环依赖(构造注入时)
AopContext.currentProxy() @EnableAspectJAutoProxy(exposeProxy=true) + ((UserService) AopContext.currentProxy()).createUser() 不改 Bean 结构 侵入性强,需要显式开启 exposeProxy

方案一:分离到不同 Bean(推荐 ✅)

将需要事务的方法抽取到独立的 Service 中:

// 旧代码:同一类内 this.createUser() 调用——事务失效
@Service
public class UserService {
    public void batchCreate(List<User> users) {
        for (User user : users) {
            this.createUser(user);  // ❌ 事务不生效
        }
    }

    @Transactional
    public void createUser(User user) {
        userMapper.insert(user);
    }
}
// 修复后:将事务方法分离到独立 Bean
@Service
public class UserService {
    @Autowired
    private UserCreator userCreator;  // 注入的是代理对象

    public void batchCreate(List<User> users) {
        for (User user : users) {
            userCreator.createUser(user);  // ✅ 走代理,事务生效
        }
    }
}

@Service
public class UserCreator {
    @Transactional
    public void createUser(User user) {
        userMapper.insert(user);
    }
}

这是最干净的方案——符合 Spring 的设计哲学:按职责拆分 Bean,依赖注入调用代理。调用路径变为:

batchCreate → userCreator.createUser()(注入的代理对象)
              ↓
           proxy.createUser() → CGLIB 拦截器 → TransactionInterceptor → 创建事务 → 执行

这个方案不光解决了事务问题,还让代码结构更清晰——UserCreator 的职责就是"用户创建"。

方案二:自我注入(快速修复)

@Service
public class UserService {
    @Autowired
    private UserService self;  // 注入当前 Bean 的代理

    public void batchCreate(List<User> users) {
        for (User user : users) {
            self.createUser(user);  // ✅ 走代理
        }
    }

    @Transactional
    public void createUser(User user) {
        userMapper.insert(user);
    }
}

自我注入的原理:Spring 在 Bean 初始化完成后,发现当前 Bean 需要注入自身,就将代理对象注入到 self 字段。后续通过 self.createUser() 调用时,调用链经过代理,事务拦截器得以介入。

风险点 1:如果使用构造器注入,self 在构造期内为 null。必须用字段注入或 @Lazy 延迟注入:

风险点 2@Lazy 创建的是延迟初始化代理——第一次调用 self.xxx() 时才真正初始化目标 Bean。这带来两问题:① 首次调用有微小延迟(Bean 首次初始化);② @PostConstruct 等初始化回调在首次调用前不会执行。如果 createUser 方法依赖 @PostConstruct 加载的配置,会在第一次调用时遇到未初始化状态。

@Service
public class UserService {
    @Autowired
    @Lazy
    private UserService self;
}

方案三:AopContext.currentProxy()(最小侵入)

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)  // 必须显式开启
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@Service
public class UserService {
    public void batchCreate(List<User> users) {
        for (User user : users) {
            ((UserService) AopContext.currentProxy()).createUser(user);  // ✅ 走代理
        }
    }

    @Transactional
    public void createUser(User user) {
        userMapper.insert(user);
    }
}

AopContext.currentProxy()ThreadLocal 中获取当前代理对象——这是唯一一个不改变 Bean 结构就能拿到代理的方式。

代价@EnableAspectJAutoProxy(exposeProxy = true) 会给所有 AOP 代理增加一个额外的 ExposeInvocationInterceptor,代理工厂需要把它加入每个代理对象的拦截器链。虽然这个拦截器逻辑很轻(只是把代理放到 ThreadLocal),但有极小的性能开销。

重要限制AopContext.currentProxy() 必须在 AOP 拦截器的执行上下文中调用。也就是说,它只能在一个被 @Transactional(或其他 AOP 注解)生效的方法体内使用。如果在非代理拦截的普通方法(如 batchCreate)中调用,会抛出 IllegalStateException: Cannot find current proxy。所以这个方案只能用在"被拦截方法又要同类调另一个被拦截方法"的场景——而非解决批量操作自调用问题的直接手段。

三种解法架构对比

决策树

你的场景是?
│
├─ 新项目、新模块 → 方案一:分离到独立 Bean
│    理由:结构最干净,不引入 Spring 特定 API
│
├─ 遗留系统,不改目录结构 → 方案三:AopContext
│    理由:只在方法上加一行,不改接口
│
├─ 遗留系统,少量改动可接受 → 方案二:自我注入 + @Lazy
│    理由:直观,同事一看就懂
│
└─ 完全不改代码 → 无解,必须改

极简方案:AspectJ 织入(Spring 官方的终极解法)

如果你希望团队里从此不再有人踩自调用事务失效的坑——把代理模式切换为 AspectJ 织入模式:

@SpringBootApplication
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

AspectJ 在编译期或加载期将事务逻辑织入目标类字节码,不依赖代理对象。同一个类内 this.createUser() 也会触发事务——因为字节码已经被修改了。

代价:需要添加 spring-aspects 依赖和 AspectJ 编译插件,构建流程变复杂。对于已有项目,改造成本高于分离 Bean。

验证手段

修复后如何验证事务生效?

// 集成测试中验证
@SpringBootTest
class UserServiceTest {
    @Autowired
    private UserService userService;

    @Autowired
    private UserCreator userCreator;

    @Test
    void verifyProxy() {
        // 验证注入的是代理对象
        assertTrue(AopUtils.isAopProxy(userCreator));
    }

    @Test
    void verifyTransactionAttribute() {
        // 验证方法上有事务属性
        AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource();
        Method method = UserCreator.class.getMethod("createUser", User.class);
        TransactionAttribute txAttr = tas.getTransactionAttribute(method, UserCreator.class);
        assertNotNull(txAttr);
        assertEquals(Propagation.REQUIRED, txAttr.getPropagationBehavior());
    }
}

雷标

🔴 搜索你的项目

# 搜索 1:同一类中调 @Transactional 方法
grep -rn "@Transactional" --include="*.java" . \
  | grep -v "test" \
  | grep "public" \
  | grep -oP '^\S+' \
  | xargs -I{} grep -r -l "this\.\w\+(" {} \
  | sort -u

# 搜索 2:搜索所有 @Transactional 方法被同类中其他方法调用的模式(含跨行)
grep -rzn "this\.\w*(" --include="*.java" . \
  | grep -B5 "@Transactional"

# 搜索 3:确认项目是否已使用了自我注入模式
grep -rn "AopContext.currentProxy()" --include="*.java" .

检查清单

  1. 搜索所有 @Transactional 方法 → 检查每个方法是否被同类中其他方法 this.xxx() 调用
  2. 搜索 this.方法名( 模式 → 找出所有同类自调用,反向判断目标方法是否加了声明式注解
  3. 新增代码必查 → Code Review 时专门问一句:"这个方法上的 @Transactional/@Async/@Cacheable 是通过代理调用的吗?"
  4. 批量操作重点关注 → 循环内调事务方法 + try-catch 吞异常 = 最危险组合

记住这句

"最危险的配置不是设错了,是你根本没设过——@Transactional 也一样,注解写上了不意味着事务在跑,就像装了指纹锁但门没关。"

引流块

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」


本系列下一篇预告:事务传播级别 Propagation.REQUIRES_NEW 嵌套后意外回滚——内层事务到底什么时候能独立提交?