某天订单系统出现了一个数据不一致问题:用户支付成功了,但订单状态没更新,优惠券却已经被核销。排查后发现,开发同学在 Service 中写了一个内部方法调用, @Transactional 悄无声息地失效了。

这类问题在实际开发中屡见不鲜。要彻底理解 @Transactional 为何失效,我们必须先搞清楚它是如何生效的。

@Transactional 的原理

声明式事务

@Transactional 的本质是 AOP 代理 + 事务拦截器。理解这一点是分析所有生效/失效场景的基础。

当我们在方法上标注 @Transactional 时,Spring 并不会对我们的代码做任何修改。它的魔法在于 代理模式

Spring 在启动时会扫描所有带有 @Transactional 注解的 Bean,然后为这些 Bean 创建一个代理对象。当外部调用这个 Bean 的方法时,实际上调用的是代理对象,而不是原始对象。代理对象会在调用真实方法前后插入事务管理的逻辑。

没有代理时的调用链:

1
调用方 → 目标对象.方法()

有代理时的调用链:

1
调用方 → 代理对象.方法() → 开启事务 → 目标对象.方法() → 提交/回滚事务

这就是为什么 @Transactional 被称为”声明式事务”——你只需要声明(标注注解),Spring 帮你实现事务管理。但这也意味着, 如果调用绕过了代理对象,事务就不会生效

代理实现的方式

Spring 支持两种代理方式:

JDK 动态代理:基于接口实现。如果你的 Bean 实现了接口,Spring 默认使用这种方式。代理对象和目标对象实现同一个接口,调用时通过接口方法进入代理逻辑。

CGLIB 代理:基于继承实现。如果你的 Bean 没有实现接口,Spring 会使用 CGLIB 生成一个目标类的子类作为代理。子类覆写了父类的方法,在覆写的方法中加入事务逻辑。

CGLIB 基于继承的特性决定了 final 方法无法被代理,子类无法覆写父类的 final 方法。

事务是如何被拦截和管理的

事务拦截器

Spring 事务的核心拦截器是 TransactionInterceptor,它实现了 MethodInterceptor 接口。每当代理对象的方法被调用时,都会先经过这个拦截器。

拦截器的工作流程如下:

  1. 解析事务属性:读取方法上的 @Transactional 注解,获取传播行为、隔离级别、超时时间、回滚规则等配置。
  2. 获取事务管理器:根据配置找到对应的 PlatformTransactionManager(如 DataSourceTransactionManager)。
  3. 开启事务:调用事务管理器的 getTransaction() 方法,根据传播行为决定是开启新事务还是加入已有事务。
  4. 执行业务方法:调用目标对象的真实方法。
  5. 处理执行结果:如果方法正常返回,提交事务;如果抛出异常,根据回滚规则决定回滚还是提交。

关键源码

事务的核心处理逻辑在 TransactionAspectSupport 类的 invokeWithinTransaction 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {

// 第一步:获取事务属性
// 这里会解析 @Transactional 注解的所有配置项
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = tas.getTransactionAttribute(method, targetClass);

// 第二步:获取事务管理器
final TransactionManager tm = determineTransactionManager(txAttr);

// 第三步:创建事务(如果需要)
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, methodId);

Object retVal;
try {
// 第四步:执行真实的业务方法
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// 第五步:异常处理——决定回滚还是提交
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}

// 第六步:正常返回时提交事务
commitTransactionAfterReturning(txInfo);
return retVal;
}

事务回滚规则

completeTransactionAfterThrowing 方法中:

1
2
3
4
5
6
7
8
9
10
protected void completeTransactionAfterThrowing(TransactionInfo txInfo, Throwable ex) {
if (txInfo.transactionAttribute.rollbackOn(ex)) {
// 匹配回滚规则,执行回滚
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
else {
// 不匹配回滚规则,仍然提交!
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}

默认的回滚规则定义在 DefaultTransactionAttribute 中:

1
2
3
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}

这意味着: 默认情况下,只有 RuntimeException 及其子类,以及 Error,才会触发回滚。 如果抛出的是 IOExceptionSQLException 这类受检异常,事务会正常提交。

事务生效五个条件

@Transactional 生效必须同时满足的条件:

Bean 必须由 Spring 容器管理

这是最基本的前提。只有被 Spring 管理的 Bean,Spring 才有机会为其创建代理。如果你手动 new 了一个对象,或者类上没有 @Service@Component 等注解,那这个对象就是一个普通的 Java 对象,与 Spring 无关,自然也不会有事务能力。

方法必须是 public

Spring 在解析 @Transactional 注解时,会显式检查方法的访问修饰符。如果方法不是 public,注解会被直接忽略。

这个限制的原因与代理机制有关。JDK 动态代理只能代理接口方法(接口方法默认都是 public);CGLIB 虽然理论上可以代理非 public 方法,但 Spring 为了保持一致性和明确性,统一要求必须是 public 方法。

必须通过代理对象调用

这是最容易踩坑的地方。即使前两个条件都满足了,如果调用方式绕过了代理对象,事务依然不会生效。典型的场景就是同一个类中的方法互相调用(后面会详细讲解)。

事务管理器必须正确配置

Spring 需要一个 PlatformTransactionManager 来实际管理事务。对于 JDBC 操作,通常是 DataSourceTransactionManager;对于 JPA,是 JpaTransactionManager。如果你使用 Spring Boot,自动配置通常会帮你搞定这一切。但在某些复杂场景(如多数据源),你需要手动指定使用哪个事务管理器。

底层存储引擎必须支持事务

事务是数据库层面的概念。如果你使用的存储引擎不支持事务(如 MySQL 的 MyISAM 引擎),那么无论 Spring 层面怎么配置,事务都不会生效。MySQL 请务必使用 InnoDB 引擎。

事务失效八个场景

同类中的内部方法调用

这是最常见、最隐蔽的失效场景。
假设有这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class OrderService {

public void createOrder(Order order) {
// 保存订单
saveOrder(order);
// 绑定优惠活动
bindPromotion(order); // 直接调用本类方法
}

@Transactional
public void bindPromotion(Order order) {
promotionDao.bindOrder(order);
// 如果这里抛异常,期望回滚
}
}

开发者的预期是 bindPromotion 方法有独立的事务,如果失败应该回滚。但实际上,这个事务根本不会生效。

原因分析:当外部调用 orderService.createOrder() 时,确实是通过代理对象进入的。但在 createOrder 方法内部调用 bindPromotion 时,此时的 this 指向的是目标对象(原始的 OrderService 实例),而不是代理对象。调用直接发生在目标对象内部,完全绕过了代理层的事务拦截器。

解决方案

方案一:注入自身的代理对象

1
2
3
4
5
6
7
8
9
10
11
@Service
public class OrderService {

@Autowired
private OrderService self; // 注入自己,拿到的是代理对象

public void createOrder(Order order) {
saveOrder(order);
self.bindPromotion(order); // 通过代理对象调用
}
}

方案二:使用 AopContext 获取当前代理

1
2
3
4
5
6
7
8
9
@Service
public class OrderService {

public void createOrder(Order order) {
saveOrder(order);
// 需要配置 @EnableAspectJAutoProxy(exposeProxy = true)
((OrderService) AopContext.currentProxy()).bindPromotion(order);
}
}

方案三:将方法拆分到不同的类中
这是最推荐的做法,既解决了自调用问题,也符合单一职责原则。

方法不是 public

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class UserService {

@Transactional
void updateUser(User user) { // 包级别访问权限
userDao.update(user);
}

@Transactional
protected void deleteUser(Long id) { // protected
userDao.delete(id);
}

@Transactional
private void innerUpdate(User user) { // private
userDao.update(user);
}
}

以上三种写法, @Transactional 都不会生效。Spring 在 AbstractFallbackTransactionAttributeSource 类中有明确的检查:

1
2
3
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null; // 返回null表示没有事务属性
}

这个限制的设计初衷是:非 public 方法通常是内部实现细节,不应该暴露给外部调用。如果你需要事务,说明这个方法应该是一个对外的业务入口,理应是 public 的。

异常被 catch 后没有重新抛出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class PaymentService {

@Transactional
public void processPayment(Payment payment) {
try {
paymentDao.save(payment);
thirdPartyApi.charge(payment); // 可能抛异常
} catch (Exception e) {
log.error("支付失败", e);
// 异常被吞掉了,方法正常返回
}
}
}

这段代码的问题在于: catch 块捕获了异常后,只是打印了日志,没有重新抛出。从事务拦截器的视角来看,方法正常执行完毕并返回了,没有任何异常抛出到方法边界之外。根据前面分析的源码逻辑,正常返回会触发 commitTransactionAfterReturning,事务被提交了。

解决方案

方法一:重新抛出异常

1
2
3
4
5
6
7
8
9
10
@Transactional
public void processPayment(Payment payment) {
try {
paymentDao.save(payment);
thirdPartyApi.charge(payment);
} catch (Exception e) {
log.error("支付失败", e);
throw new RuntimeException("支付处理失败", e); // 重新抛出
}
}

方法二:手动标记事务回滚

1
2
3
4
5
6
7
8
9
10
11
@Transactional
public void processPayment(Payment payment) {
try {
paymentDao.save(payment);
thirdPartyApi.charge(payment);
} catch (Exception e) {
log.error("支付失败", e);
// 手动标记当前事务需要回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}

抛出了受检异常但没有配置 rollbackFor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class FileService {

@Transactional
public void uploadFile(MultipartFile file) throws IOException {
fileDao.saveMetadata(file);

if (file.getSize() > MAX_SIZE) {
throw new IOException("文件过大"); // 受检异常
}

storageService.store(file);
}
}

这个方法抛出了 IOException,这是一个受检异常(Checked Exception),不属于 RuntimeException 的子类。根据默认的回滚规则,这个异常不会触发回滚,事务会正常提交。

正确写法

1
2
3
4
@Transactional(rollbackFor = Exception.class)  // 指定所有异常都回滚
public void uploadFile(MultipartFile file) throws IOException {
// ...
}

或者更精确地指定:

1
2
3
4
@Transactional(rollbackFor = {IOException.class, BusinessException.class})
public void uploadFile(MultipartFile file) throws IOException {
// ...
}

阿里巴巴 Java 开发手册建议, @Transactional 注解应该始终指定 rollbackFor = Exception.class,避免受检异常导致的意外提交。

类没有被 Spring 管理

1
2
3
4
5
6
7
8
// 注意:没有 @Service、@Component 等注解
public class StandaloneService {

@Transactional
public void doSomething() {
// 这个类不是 Spring Bean,注解无效
}
}

使用时如果手动创建实例:

1
2
StandaloneService service = new StandaloneService();
service.doSomething(); // 没有事务

这种情况下,Spring 根本不知道这个类的存在,自然也不会为它创建代理。 @Transactional 注解就是一个普通的注解标记,没有任何运行时效果。

多线程调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class BatchService {

@Transactional
public void batchProcess(List<Order> orders) {
// 使用并行流处理
orders.parallelStream().forEach(order -> {
orderDao.save(order); // 每个线程独立,不在主事务中
});

// 或者手动创建线程
new Thread(() -> {
orderDao.save(someOrder); // 新线程,没有事务
}).start();
}
}

Spring 的事务信息是存储在 ThreadLocal 中的。 ThreadLocal 的特性是线程隔离:每个线程有自己独立的变量副本。当你创建新线程或使用并行流时,新线程无法访问主线程的事务上下文,因此它们的数据库操作是在没有事务的情况下执行的。

主线程中的事务回滚,不会影响子线程已经提交的操作;子线程的操作失败,也不会导致主线程事务回滚。数据一致性完全无法保证。

解决方案:避免在事务方法中使用多线程,或者为每个线程单独管理事务(但这样就不是同一个事务了)。

使用了 final 或 static

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class FinalService {

@Transactional
public final void finalMethod() {
// CGLIB 无法覆写 final 方法
}

@Transactional
public static void staticMethod() {
// static 方法不属于实例,无法被代理
}
}

对于 final 方法:CGLIB 通过继承目标类来创建代理,但子类无法覆写父类的 final 方法,因此事务拦截逻辑无法插入。

对于 static 方法:静态方法属于类而不是实例,代理对象拦截的是实例方法调用。你调用 FinalService.staticMethod() 时,根本不经过任何代理对象。

底层存储引擎不支持事务

现在 MySQL 默认是 InnoDB 存储引擎,但是如果你的 MySQL 表使用了 MyISAM 引擎:

1
2
3
4
CREATE TABLE orders (
id INT PRIMARY KEY,
amount DECIMAL(10,2)
) ENGINE = MyISAM;

MyISAM 引擎不支持事务。无论你的 Java 代码如何配置,数据库层面根本不会开启事务。Spring 的事务管理器以为自己在管理事务,实际上数据库根本不配合。

传播行为的困惑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class OuterService {
@Autowired
private InnerService innerService;

@Transactional
public void outerMethod() {
orderDao.createOrder(order); // 操作1

try {
innerService.innerMethod(); // 这个方法会抛异常
} catch (Exception e) {
log.error("内部方法执行失败,但我想继续", e);
}

orderDao.updateOrderStatus(order); // 操作2,期望能执行
}
}

@Service
public class InnerService {

@Transactional // 默认 REQUIRED 传播行为
public void innerMethod() {
// 做一些操作后抛出异常
throw new RuntimeException("业务异常");
}
}

开发者的预期是: innerMethod 失败后,我在外层 catch 住异常,继续执行后续逻辑。但实际运行时,当执行到 orderDao.updateOrderStatus 时,会抛出 UnexpectedRollbackException,提示事务已被标记为回滚。

原因分析:默认的传播行为是 REQUIRED,表示”如果当前存在事务,就加入该事务”。所以 innerMethodouterMethod 实际上在同一个事务中。当 innerMethod 抛出 RuntimeException 时,事务被标记为 rollback-only(必须回滚)。即使你在外层 catch 住了异常,这个标记仍然存在。当 outerMethod 尝试正常返回时,事务管理器发现事务已被标记为必须回滚,于是抛出异常。

解决方案:如果希望内层方法的失败不影响外层事务,可以使用 REQUIRES_NEW 传播行为:

1
2
3
4
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod() {
// 独立的新事务,回滚不影响外层
}

排查事务问题

开启事务调试日志

application.yml 中配置:

1
2
3
4
logging:
level:
org.springframework.transaction.interceptor: TRACE
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

代码中检查事务状态

1
2
3
4
5
@Transactional
public void someMethod() {
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("当前是否在事务中: {}", isActive); // 应该输出 true
}

检查代理类型

1
2
3
4
5
6
7
8
@Autowired
private OrderService orderService;

public void checkProxy() {
System.out.println(orderService.getClass().getName());
// 如果是代理,会输出类似:
// com.example.service.OrderService$$EnhancerBySpringCGLIB$$xxxxx
}

如果输出的是原始类名,说明没有被代理。

总结

核心原则:

  1. 理解代理机制@Transactional 的生效依赖于 AOP 代理,任何绕过代理的调用都会导致失效。
  2. 让异常飞出去:事务的回滚依赖于异常抛出。如果你 catch 了异常,要么重新抛出,要么手动标记回滚。
  3. 明确指定 rollbackFor :不要依赖默认的回滚规则,建议始终配置 rollbackFor = Exception.class

@Transactional 的本质是通过代理在方法执行前后插入事务管理代码。理解了这一点,所有失效场景都能用”是否经过代理”和”异常是否抛出”这两个问题来分析。