事务管理之@Transactional注解须知

300 阅读12分钟

深入理解 @Transactional:代理机制、原理与常见陷阱剖析

在开发后端业务系统时,我们常常使用 @Transactional 注解为数据操作添加事务(Transaction)支持。事务机制是保障数据一致性的基石,其核心在于确保数据操作满足 ACID 特性:

  • 原子性 (Atomicity) :事务是最小的不可分割工作单元。事务中的所有操作要么全部成功,要么全部失败回滚(Rollback)。任何一步操作失败,整个事务都将撤销。

  • 一致性 (Consistency) :事务应确保数据库从一个一致状态转变为另一个一致状态。无论事务提交成功与否,数据库的完整性约束(如外键、唯一键等)都必须得到维护。一致性是事务的最终目标,依赖于原子性、隔离性和持久性共同实现

  • 隔离性 (Isolation) :并发执行的多个事务应相互隔离,一个事务的中间状态对其他事务不可见。不同的隔离级别(如读未提交、读已提交、可重复读、串行化)用于控制脏读、不可重复读和幻读等问题,在数据一致性和系统性能之间取得平衡。

  • 持久性 (Durability) :一旦事务成功提交,其对数据库的修改将永久生效,即使系统发生故障(如断电、崩溃)也不会丢失。

@Transactional 是开启事务最常用的方式:

@Transactional
public void updateSomething() { 
    // 数据库操作 
}

然而,在使用 @Transactional 时,你是否思考过以下问题?

  • 为什么 @Transactional 注解的方法必须是 public 的,而不能是 private
  • 为什么有时 @Transactional 注解会“失效”?
  • @Transactional 注解究竟是如何生效并控制事务的?

笔者在实际业务开发中也遇到了这些困惑,并进行了深入探究。本文将分享我的分析过程,希望能帮助大家更深入地理解 @Transactional 背后的原理。


一、核心机制:Spring 是如何实现 @Transactional 的?

我们先建立一个关键认知: 当你在方法上添加 @Transactional 注解时,Spring 并不会直接在这个方法内部插入开启事务、提交或回滚的代码。相反,Spring 会为你的 Bean 创建一个 代理对象(Proxy Object)。你可以将代理对象简单的理解为:代理对象 = 原始对象 + 拦截逻辑。 这个代理对象“包裹”着原始对象,在调用目标方法前后,自动加入事务的开启、提交或回滚逻辑。

这就像你去餐厅吃饭:

  • 顾客 (你) :渴望花钱吃上一顿美味且完整的佳肴(期望数据操作成功且一致)。
  • 服务员 (代理) :不亲自做饭,但负责整个用餐流程的协调与保障(Spring 代理对象,负责事务的开启、提交、回滚)。
  • 后厨 (原始对象) :负责实际的烹饪工作(执行真正的业务逻辑和数据库操作)。

整个流程是这样的:

  1. 点单与确认(开启事务) :你(顾客)点完菜后,服务员(代理)会先确认你的支付能力(开启数据库事务),确保后续流程可以进行。
  2. 独立处理(隔离性) :服务员确保你的订单被独立处理,不会和隔壁桌的订单混淆(事务的隔离性)。
  3. 全上齐或重做(原子性与一致性) :当所有菜品做好后,服务员要确保你点的所有菜都正确无误地端上来。如果其中一道菜品(业务步骤)出了问题(抛出异常),他不会只上 (执行) 部分菜 (部分业务步骤),而是会要求后厨把这顿饭(整个事务)重新做一遍,或者直接取消订单并退款(回滚事务),保证你最终要么吃到完整正确的一餐,要么什么也吃不到(原子性),并且餐厅的账本(数据库)始终保持一致(一致性)。
  4. 结账完成(提交事务) :当你确认菜品无误并结账后(方法正常执行完毕),服务员完成订单(提交事务),这笔消费记录就永久记在餐厅的账本里了,不会因为服务员下班而丢失(持久性)。

在这个过程中,服务员(代理)作为你和后厨之间的桥梁,通过控制整个流程,确保了你用餐体验的完整性、正确性和可靠性。这正是 Spring 通过 @Transactional 注解和代理机制,为你的数据操作提供的核心保障。


下面是 Spring 创建代理并管理事务的流程:

graph TD
    A["使用 @Transactional 注解"] --> B(创建代理对象)
    B --> C{"类是否实现接口?"}
    C -->|是| D[使用 JDK 动态代理]
    C -->|否| E[使用 CGLIB 代理]
    D --> F("代理拦截调用:开启事务")
    E --> F
    F --> G["@Transactional 方法执行(目标方法)"]
    G --> H("代理控制后续:提交或回滚事务")

⚠️ 注意

Spring 容器中实际被注入和调用的 Bean,是这个代理对象,而不是你写的原始类。因此,只有当外部(或其他 Bean)通过接口或注入引用调用该 Bean 的方法时,才会经过代理,触发事务逻辑。 如果调用发生在类内部(如 this.method()),则直接访问原始对象,完全绕开代理,事务自然无法生效。这一点是理解后续“自调用”问题的基础。


二、Spring 的两种代理方式详解

Spring 默认根据情况选择两种代理技术:JDK 动态代理CGLIB 代理

1. JDK 动态代理(推荐优先使用)

  • 适用条件:目标类实现了至少一个接口。

  • 原理

    • Spring 会创建一个实现了相同接口的代理类。
    • 这个代理类和原始类“长得一样”(都实现了同一个接口),但内部加入了事务控制逻辑。
    • 当你调用该 Bean 的方法时,实际上调用的是这个代理对象的方法。
  • 限制

    • JDK 动态代理只能代理接口中声明的方法
    • 而且,它只能拦截 public 方法,因为接口中的方法默认就是 public 的,JDK 代理机制本身不支持 protectedprivate 或包级访问的方法。

示例

public interface UserService {
    void saveUser(); // public 方法
}

@Service
public class UserServiceImpl implements UserService {
    @Transactional
    public void saveUser() { ... } // ✅ 可被 JDK 代理拦截
}

2. CGLIB 代理(无接口时使用)

  • 适用条件:目标类没有实现任何接口。

  • 原理

    • CGLIB(Code Generation Library)是一个字节码生成库。
    • Spring 会通过 CGLIB 生成目标类的子类作为代理。
    • 这个子类重写了所有 public 和 protected 方法,在调用父类方法前后加入事务逻辑。
  • 限制

    • CGLIB 通过继承实现代理,因此不能代理 final 类或 final 方法(无法被重写)。
    • 它可以代理 public 和 protected 方法,但仍然无法代理 private 方法,因为子类无法访问父类的私有方法。

示例

@Service
public class OrderService { // 没有实现接口
    @Transactional
    public void createOrder() { ... } // ✅ 可被 CGLIB 代理拦截
}

三、为什么 @Transactional 方法必须是 public

现在我们可以总结原因了:

代理方式能代理的方法访问级别原因
JDK 动态代理public基于接口,接口方法只能是 public,代理机制本身不支持非 public 方法
CGLIB 代理publicprotected(但不能是 final基于继承,子类无法重写或调用父类的 private 方法

✅ 核心总结

无论是 JDK 代理还是 CGLIB 代理,Spring 的事务代理机制都要求目标方法是 public 的。 如果你把 @Transactional 写在 privateprotected 或包级访问的方法上,代理将无法拦截该方法调用,事务逻辑也就不会被执行——这就是所谓的“注解失效”。


四、经典陷阱:自调用(Self-invocation)导致事务失效

1. 自调用常导致事务“失效”:

@Service
public class MyService {

    public void methodA() {
        methodB(); // ❌ 直接内部调用,绕过了代理!这里等于this.methodB()
    }

    @Transactional
    public void methodB() { // ✅ 方法为 public,但自调用仍无法触发事务
        // 数据库操作
    }
}

问题分析

  • methodA() 调用 methodB() 是同一个对象内部的直接调用没有经过代理对象
  • 因此,即使 methodB 有 @Transactional,事务也不会被开启。

这就像前面举的你去餐厅吃饭的例子,作为顾客的你不能直接去找后厨 (原始对象) 为你做饭 (执行事务),对于后厨发起命令的人必须是服务员 (代理)

做法一(不推荐):通过代理调用,例如:
@Service
public class MyService {

    @Autowired
    private MyService self; // 注入自己(需小心循环依赖)

    public void methodA() {
        self.methodB(); // ✅ 通过代理调用,事务生效
    }

    @Transactional
    public void methodB() {
        // 数据库操作
    }
}

⚠️ 注意:通过注入自身(self)的方式虽能解决问题,但可能引入循环依赖,属于“设计异味”,在复杂场景中需谨慎使用。

做法二(推荐):将 @Transactional 放在业务入口方法(如 methodA)上,由它统一管理事务边界,内部方法负责逻辑拆分,无需关心事务代理问题。
@Service
public class MyService {

    @Transactional
    public void methodA() {
        methodB(); // ✅ 正确:事务已在 methodA 开启,内部调用 methodB 会共享同一事务
    }

    public void methodB() {
        // 数据库操作
    }
}

2. 替代方案:使用 AopContext.currentProxy()

另一种避免依赖注入的方式是启用代理暴露,通过 AOP 上下文获取当前代理对象:

@Service
@EnableAspectJAutoProxy(exposeProxy = true) // 需开启 expose-proxy
public class MyService {

    public void methodA() {
        ((MyService) AopContext.currentProxy()).methodB(); // ✅ 强制通过代理调用
    }

    @Transactional
    public void methodB() {
        // 数据库操作
    }
}

说明:此方式不依赖注入,直接从 AOP 上下文中获取代理对象,适用于对依赖结构敏感的项目。


3. this 与 self 的本质区别

this 是原始类的实例,不经过 Spring 代理,事务、AOP 等增强逻辑不会生效

  • this 是 Java 语言层面的概念,和 Spring 无关。
  • this 永远指向你当前编写的这个类的对象。
  • this 不会经过 Spring 的代理机制。

selfAopContext.currentProxy() 是 Spring 管理的代理对象,调用它会触发事务、AOP 等拦截逻辑,增强功能会生效。

  • 注入的 self 是 Spring 容器返回的 Bean,本质上是代理对象(Proxy)。
  • 所以你调用 self.methodB() 时,是通过代理发起的方法调用,事务逻辑就会被正确织入。

✅ 核心总结

Spring 事务的生效前提是:方法调用必须经过代理对象。
this.method() 是 JVM 直接调用,完全绕开了代理,因此事务失效。
理解“代理调用”与“直接调用”的区别,是掌握 @Transactional 的关键。


五、其他常见事务失效场景快速总结

除了访问权限和自调用问题,以下几种情况也常导致 @Transactional “看似失效”,需特别注意:

1. 异常被 try-catch 吞掉

@Transactional
public void updateUser() {
    updateDB(); // 数据库操作
    try {
        callRemoteService(); // 可能抛异常
    } catch (Exception e) {
        log.error("远程调用失败", e);
        // ❌ 异常被捕获且未抛出,事务不会回滚!
    }
}

解决

  • 若需回滚,应重新抛出异常,或使用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 手动标记回滚。
  • 或配置 @Transactional(rollbackFor = Exception.class) 并确保异常被传播。

2. 异常类型不匹配

@Transactional
public void businessLogic() throws IOException {
    // ...
    throw new IOException(); // 检查型异常
}

问题
默认情况下,Spring 仅对 RuntimeExceptionError 回滚。IOException 等检查型异常不会触发自动回滚

解决
显式指定回滚异常类型:

@Transactional(rollbackFor = Exception.class)
public void businessLogic() throws IOException {
    // ...
}

3. 方法为 final 或类为 final

@Service
public class MyService {

    @Transactional
    public final void update() { // ❌ final 方法
        // ...
    }
}

问题
CGLIB 通过继承生成代理子类,final 方法无法被重写,导致代理失效。

解决
避免在 @Transactional 方法上使用 final


4. 事务传播行为配置不当

@Transactional(propagation = Propagation.NEVER)
public void nonTransactionalMethod() { ... }

@Transactional
public void outerMethod() {
    nonTransactionalMethod(); // ❌ 抛出异常:不允许在事务中执行
}

理解
不同 Propagation 行为(如 REQUIRES_NEWNOT_SUPPORTEDNESTED)会影响事务的创建与挂起,使用前需明确其语义。


✅ 核心总结

场景原因解决方案
try-catch 吞异常异常未抛出,事务不感知失败重新抛出或手动 setRollbackOnly
检查型异常未配置默认不回滚 Exception使用 rollbackFor = Exception.class
final 方法/类CGLIB 无法代理避免使用 final
传播行为冲突与现有事务上下文不兼容正确理解并配置 propagation

最后,我们来对开篇的问题做个总结回答

问题1:为什么 @Transactional 注解的方法必须是 public


Spring 通过 动态代理(JDK 或 CGLIB)实现事务管理。

  • JDK 代理 基于接口,只能拦截 public 方法。
  • CGLIB 代理 基于继承,也无法重写 private 方法。

因此,非 public 方法无法被代理拦截,事务逻辑无法织入,导致注解“失效”。


问题2:为什么有时 @Transactional 注解会“失效”?


“失效”并非注解本身无效,而是事务增强逻辑未被触发,常见原因包括:

  1. 方法非 public:代理无法拦截。
  2. 自调用(Self-invocation)this.method() 绕过代理。
  3. 异常被吞或类型不匹配:未触发回滚机制。
  4. final 方法/类:CGLIB 无法生成代理。
  5. 传播行为配置冲突:与当前事务上下文不兼容。

核心原则:事务生效的前提是——调用必须经过 Spring 生成的代理对象


问题3:@Transactional 注解究竟是如何生效的?


@Transactional 的本质是 基于代理的 AOP 增强
Spring 在应用启动时,为添加了该注解的 Bean 创建一个代理对象。当外部调用该 Bean 的方法时,实际执行的是代理对象的逻辑:

  1. 前置:开启数据库事务。
  2. 执行:调用原始对象的目标方法。
  3. 后置/异常:根据执行结果提交或回滚事务。

结语
理解 @Transactional代理机制是掌握其使用与避坑的关键。不要把它当作“魔法注解”,而要清楚它背后的 AOP 原理。只有这样,才能在复杂业务中游刃有余,确保数据的一致性与可靠性。