Spring 事务为什么会失效:从 @Transactional 代理机制到线上问题排查

Spring 事务为什么会失效:从 @Transactional 代理机制到线上问题排查

在 Spring 项目中,我们通常只需要在业务方法上添加一个 @Transactional 注解,就可以获得事务能力:

1
2
3
4
5
6
7
8
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
inventoryRepository.deduct(
order.getProductId(),
order.getQuantity()
);
}

看起来非常简单,但在真实项目中,经常会出现下面这些问题:

  • 方法上明明添加了 @Transactional,数据却没有回滚;
  • 内层方法抛出异常,外层方法捕获后,事务仍然提交失败;
  • 同样的代码在一个 Service 中生效,换到另一个类里却失效;
  • 使用 @Async 后,部分数据库操作没有跟随主事务回滚;
  • 多数据源环境中,只有一个数据库发生了回滚;
  • 使用 REQUIRES_NEW 后,日志提交了,订单却回滚了;
  • 明明已经捕获异常,最终却抛出了 UnexpectedRollbackException

这些现象不一定都是“事务失效”。有些是代理没有生效,有些是回滚规则、传播行为或线程切换带来的正常结果,还有一些则是事务边界设计不合理。

理解 Spring 声明式事务,需要先抓住三个关键点:

  1. @Transactional 是事务元数据,真正的事务逻辑由 Spring AOP 代理执行;
  2. 事务边界由实际的方法调用链决定,而不只是由注解所在的位置决定;
  3. 异常类型、线程、传播行为和事务管理器共同影响最终的提交与回滚。

本文将从一个简单案例开始,逐步分析 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
@Service
public class OrderService {

private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;

public OrderService(
OrderRepository orderRepository,
InventoryRepository inventoryRepository) {
this.orderRepository = orderRepository;
this.inventoryRepository = inventoryRepository;
}

@Transactional
public void createOrder(Order order) {
orderRepository.save(order);

inventoryRepository.deduct(
order.getProductId(),
order.getQuantity()
);

int result = 1 / 0;
}
}

方法执行到:

1
int result = 1 / 0;

时会抛出 ArithmeticException。它属于运行时异常,按照 Spring 的默认回滚规则,当前事务应当回滚,因此订单不会保存、库存也不会扣减。

如果数据没有回滚,应该依次确认:

  • OrderService 是否由 Spring 容器管理;
  • 调用者拿到的是不是 Spring 代理对象;
  • createOrder() 是否通过代理对象调用;
  • 异常是否真正离开了事务方法;
  • 数据库和数据表是否支持事务;
  • 当前数据库操作是否由对应的事务管理器管理。

要理解这些检查项,首先需要弄清楚 @Transactional 到底做了什么。


二、@Transactional 到底做了什么

@Transactional 本身不会直接开启事务。它是一份事务元数据,告诉 Spring:

当这个方法通过 Spring 代理对象执行时,需要在方法调用前后织入事务逻辑。

一个典型调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Controller

Spring AOP 代理对象

TransactionInterceptor

解析 TransactionAttribute

开启或加入事务

执行目标业务方法

根据结果提交或回滚事务

可以将事务代理简化为下面的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Object invoke(MethodInvocation invocation) throws Throwable {
TransactionStatus status = transactionManager.begin();

try {
Object result = invocation.proceed();
transactionManager.commit(status);
return result;
} catch (Throwable exception) {
if (shouldRollbackOn(exception)) {
transactionManager.rollback(status);
} else {
transactionManager.commit(status);
}
throw exception;
}
}

真实实现还需要处理:

  • 事务传播行为;
  • 隔离级别;
  • 超时时间;
  • 只读标记;
  • 回滚规则;
  • 事务挂起与恢复;
  • 多种事务管理器;
  • 嵌套事务与保存点。

但核心思想没有变化:

Spring 必须先拦截方法调用,才能在方法执行前后开启、提交或回滚事务。

因此,排查声明式事务时,第一个问题始终是:

这次方法调用有没有经过 Spring 代理对象?


三、代理与线程:理解事务的两个基础

3.1 JDK 动态代理与类代理

Spring AOP 常见的代理方式有两种:

  • JDK 动态代理:基于接口创建代理;
  • 类代理:基于目标类创建子类代理,底层通常使用 CGLIB。

假设存在接口:

1
2
3
4
public interface OrderService {

void createOrder(Order order);
}

对应实现类:

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

@Override
@Transactional
public void createOrder(Order order) {
// 创建订单
}
}

使用 JDK 动态代理时,调用过程可以表示为:

1
2
3
4
5
代理对象.createOrder()

事务拦截器

OrderServiceImpl.createOrder()

类代理则可以粗略理解为创建了一个目标类的子类,并在可重写的方法外层加入拦截逻辑。因此,privatefinalstatic 方法不能像普通实例方法一样被类代理增强。

需要注意方法可见性的版本差异:

  • 对于接口代理,事务方法必须是接口中声明的 public 方法;
  • 从 Spring Framework 6.0 开始,类代理默认也可以处理 protected 和包可见方法;
  • privatestatic 方法仍不能通过常规 Spring AOP 代理获得事务增强;
  • final 方法不能被类代理重写,因此也无法由类代理拦截。

为了兼容不同代理方式并让事务边界一眼可见,业务代码中仍建议优先把事务放在 Service 层的 public 实例方法上。

无论使用哪种代理方式,都要遵守同一个关键规则:

在默认代理模式下,只有从代理对象外部进入的方法调用才会被拦截。

3.2 事务上下文为什么通常绑定在线程上

对于常见的命令式事务,Spring 通过 TransactionSynchronizationManager 将事务资源绑定到当前线程。绑定的信息可能包括:

  • JDBC Connection;
  • Hibernate Session 或 JPA EntityManager;
  • 当前事务是否激活;
  • 当前事务是否只读;
  • 事务同步回调;
  • 事务名称和隔离级别。

因此,同一个线程中的多个数据库操作可以共享同一个事务:

1
2
3
4
5
线程 A
├── 开启事务
├── 保存订单
├── 扣减库存
└── 提交事务

一旦切换到新线程,原线程的事务上下文通常不会自动传递:

1
2
3
4
5
6
线程 A:订单事务
├── 保存订单
└── 提交或回滚

线程 B:异步任务
└── 独立执行,不自动加入线程 A 的事务

这正是 @Async、线程池、CompletableFuture 和手动创建线程经常改变事务行为的原因。


四、最常见的事务失效与误判场景

4.1 同一个类中的方法自调用

这是最常见、也最容易被忽略的问题。

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

public void createOrder(Order order) {
saveOrder(order);
}

@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
throw new RuntimeException("保存失败");
}
}

外部执行:

1
orderService.createOrder(order);

实际调用过程是:

1
2
3
4
5
6
7
外部调用

代理对象.createOrder()

目标方法 createOrder()

this.saveOrder()

createOrder() 本身没有事务。它在类内部调用 saveOrder() 时,本质上执行的是 this.saveOrder(),没有再次经过代理对象,因此 saveOrder() 上的事务注解不会被拦截器处理。

如果外层方法本身有事务:

1
2
3
4
@Transactional
public void createOrder(Order order) {
saveOrder(order);
}

那么 saveOrder() 中的数据库操作仍会运行在外层事务中,但 saveOrder() 自己声明的传播行为、隔离级别、超时和回滚规则不会单独生效。

例如,下面的 REQUIRES_NEW 不会因为自调用而创建新事务:

1
2
3
4
5
6
7
8
9
@Transactional
public void createOrder(Order order) {
saveLog(order);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Order order) {
logRepository.save(buildLog(order));
}

4.2 对象不是 Spring Bean

手动创建的对象不会被 Spring 包装成事务代理:

1
2
OrderService orderService = new OrderService(orderRepository);
orderService.createOrder(order);

即使 createOrder() 上有 @Transactional,Spring 也没有机会拦截这次调用。

正确做法是让 Spring 管理对象,并通过依赖注入获取它:

1
2
3
4
5
6
7
8
9
@RestController
public class OrderController {

private final OrderService orderService;

public OrderController(OrderService orderService) {
this.orderService = orderService;
}
}

除了显式的 new,还要留意以下情况:

  • 工具类自行创建 Service;
  • 第三方框架创建了对象,但没有交给 Spring;
  • 在 Bean 完成代理前的初始化阶段调用事务方法;
  • 测试代码直接实例化目标类,而不是从 Spring 上下文获取 Bean。

4.3 方法不能被当前代理方式拦截

下面这些写法不适合作为常规声明式事务边界:

1
2
3
@Transactional
private void saveOrder() {
}
1
2
3
@Transactional
public final void saveOrder() {
}
1
2
3
@Transactional
public static void saveOrder() {
}

问题分别在于:

  • private 方法不能从代理外部调用,也不能被子类重写;
  • final 方法不能被类代理重写;
  • static 方法属于类本身,不是代理对象的实例方法调用。

对于 protected 或包可见方法,需要结合 Spring 版本和代理类型判断。除非项目明确依赖类代理及对应版本能力,否则优先使用 public 方法。

4.4 异常被 try-catch 吃掉

1
2
3
4
5
6
7
8
9
@Transactional
public void createOrder(Order order) {
try {
orderRepository.save(order);
doSomethingRisky();
} catch (Exception exception) {
log.error("创建订单失败", exception);
}
}

事务拦截器通常根据方法离开时的结果决定提交还是回滚。上面的异常已经在方法内部被捕获,拦截器看到的是“方法正常返回”,因此会尝试提交事务。

最直接的做法是记录日志后继续抛出异常:

1
2
3
4
5
6
7
8
9
10
@Transactional
public void createOrder(Order order) {
try {
orderRepository.save(order);
doSomethingRisky();
} catch (Exception exception) {
log.error("创建订单失败", exception);
throw new OrderCreateException("创建订单失败", exception);
}
}

业务异常一般可以继承 RuntimeException

1
2
3
4
5
6
public class OrderCreateException extends RuntimeException {

public OrderCreateException(String message, Throwable cause) {
super(message, cause);
}
}

如果业务要求捕获异常并返回特定结果,也可以手动标记回滚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public CreateOrderResult createOrder(Order order) {
try {
orderRepository.save(order);
doSomethingRisky();
return CreateOrderResult.success();
} catch (Exception exception) {
TransactionAspectSupport
.currentTransactionStatus()
.setRollbackOnly();

log.error("创建订单失败", exception);
return CreateOrderResult.failed(exception.getMessage());
}
}

这种写法会让当前事务最终回滚,但事务控制已经侵入业务代码,而且调用链更难推断。能通过明确的异常语义表达失败时,通常仍应优先抛出业务异常。

4.5 抛出受检异常但没有配置回滚规则

Spring 的默认回滚规则是:

  • RuntimeException 及其子类触发回滚;
  • Error 及其子类触发回滚;
  • 受检异常默认不触发回滚。

例如:

1
2
3
4
5
@Transactional
public void importOrder() throws IOException {
orderRepository.save(order);
throw new IOException("文件读取失败");
}

如果没有其他全局或局部回滚规则,IOException 默认不会触发回滚。

可以精确指定需要回滚的异常:

1
2
3
4
5
@Transactional(rollbackFor = IOException.class)
public void importOrder() throws IOException {
orderRepository.save(order);
throw new IOException("文件读取失败");
}

也可以为统一的受检业务异常配置回滚:

1
2
3
4
@Transactional(rollbackFor = BusinessException.class)
public void executeBusiness() throws BusinessException {
// 业务操作
}

不建议在所有方法上机械地设置 rollbackFor = Throwable.class。应该根据项目的异常体系明确哪些异常代表业务失败,哪些异常允许事务提交。

版本提示:Spring Framework 6.2 及以上版本允许通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 全局调整默认回滚策略。排查旧项目或多模块项目时,不要只根据异常继承关系下结论,还要检查全局事务配置。

4.6 使用 @Async、线程池或新线程

1
2
3
4
5
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
asyncService.saveMessage(order);
}

异步方法:

1
2
3
4
@Async
public void saveMessage(Order order) {
messageRepository.save(buildMessage(order));
}

通常情况下:

  • 订单保存运行在线程 A 的事务中;
  • 消息保存运行在线程 B 中;
  • 线程 B 不会自动加入线程 A 的事务;
  • 线程 A 回滚时,线程 B 已提交的数据不会自动回滚。

即使异步方法也添加了事务:

1
2
3
4
5
@Async
@Transactional
public void saveMessage(Order order) {
messageRepository.save(buildMessage(order));
}

它开启或加入的也是异步线程自己的事务,不是调用方原来的事务。

如果必须保证数据库数据和消息的一致性,可以考虑:

  • 本地消息表;
  • Outbox Pattern;
  • 消息队列提供的事务消息;
  • 事务提交后的事件处理;
  • 带重试和幂等控制的可靠投递机制。

不要期望通过线程上下文复制来实现真正的跨线程本地事务。

4.7 数据库操作没有真正加入事务

即使 Spring 正确建立了事务,数据库层也可能无法按预期回滚。常见原因包括:

  • 数据表或存储引擎不支持事务;
  • SQL 使用了另一个未受管理的数据库连接;
  • 错误地开启了自动提交;
  • 执行了会导致数据库隐式提交的 DDL;
  • 动态数据源在事务开启后才切换;
  • 实际操作的数据源不属于当前事务管理器。

以 MySQL 为例,可以检查表的定义:

1
SHOW CREATE TABLE orders;

或查看表状态:

1
SHOW TABLE STATUS LIKE 'orders';

4.8 多数据源使用了错误的事务管理器

假设系统中有两个数据源:

1
2
orderDataSource
logDataSource

分别对应:

1
2
orderTransactionManager
logTransactionManager

下面的方法只明确选择了订单事务管理器:

1
2
3
4
5
@Transactional(transactionManager = "orderTransactionManager")
public void saveOrderAndLog() {
orderRepository.save(order);
logRepository.save(log);
}

orderTransactionManager 通常只能管理订单数据源。日志数据库操作可能使用另一条连接并独立提交,因此不会跟随订单事务一起回滚。

可以在各自的业务边界明确选择事务管理器:

1
2
3
4
@Transactional(transactionManager = "logTransactionManager")
public void saveLog(LogEntity log) {
logRepository.save(log);
}

但要注意:两个本地事务管理器不会天然组成一个原子事务。如果业务要求两个数据库同时提交或回滚,需要进一步评估:

  • XA/JTA;
  • Seata 等分布式事务方案;
  • 最终一致性;
  • 本地消息表;
  • 业务补偿机制。

五、如何解决自调用问题

5.1 拆分到另一个 Service

这是通常最清晰的解决方式:

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

private final OrderPersistenceService orderPersistenceService;

public OrderService(
OrderPersistenceService orderPersistenceService) {
this.orderPersistenceService = orderPersistenceService;
}

public void createOrder(Order order) {
orderPersistenceService.saveOrder(order);
}
}

事务方法放到另一个 Bean:

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

private final OrderRepository orderRepository;

public OrderPersistenceService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}

@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}

调用链变为:

1
2
3
4
5
6
7
OrderService

OrderPersistenceService 代理对象

事务拦截器

saveOrder()

它的优点是事务边界清晰、职责明确、容易测试,也不依赖额外的 AOP 技巧。

5.2 使用 TransactionTemplate

如果事务边界不适合拆分成新的 Service,可以使用编程式事务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class OrderService {

private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;

public OrderService(
TransactionTemplate transactionTemplate,
OrderRepository orderRepository) {
this.transactionTemplate = transactionTemplate;
this.orderRepository = orderRepository;
}

public void createOrder(Order order) {
transactionTemplate.executeWithoutResult(status -> {
orderRepository.save(order);

if (order.getQuantity() <= 0) {
status.setRollbackOnly();
throw new IllegalArgumentException("订单数量必须大于 0");
}
});
}
}

TransactionTemplate 不依赖内部方法调用是否经过代理,适合:

  • 一个方法包含多个事务阶段;
  • 批处理需要逐条提交;
  • 需要显式控制回滚;
  • 事务边界无法自然拆分到其他 Bean。

5.3 注入自身代理

也可以注入当前 Service 的代理对象:

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

private final OrderService self;

public OrderService(@Lazy OrderService self) {
this.self = self;
}

public void createOrder(Order order) {
self.saveOrder(order);
}

@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}

这种方式能够让调用经过代理,但也会带来循环依赖、阅读成本和职责不清等问题,不适合作为默认方案。

5.4 使用 AopContext.currentProxy()

开启代理暴露后,可以获取当前代理对象:

1
@EnableAspectJAutoProxy(exposeProxy = true)
1
2
3
4
public void createOrder(Order order) {
OrderService proxy = (OrderService) AopContext.currentProxy();
proxy.saveOrder(order);
}

这种方式会让业务代码与 Spring AOP 强耦合,并且只能在有效的代理调用链中使用。除非有明确理由,否则不建议在普通业务代码中大量使用。

解决方案的推荐顺序通常是:

1
2
3
4
5
拆分清晰的业务 Bean

TransactionTemplate

自身代理或 AopContext 等特殊方案

六、事务传播行为的实际效果

传播行为回答的是:

当一个事务方法调用另一个事务方法时,内层方法应当加入现有事务,还是创建新的事务边界?

最常见的三种传播行为是 REQUIREDREQUIRES_NEWNESTED

6.1 REQUIRED:有事务就加入,没有就创建

REQUIRED 是默认传播行为:

1
2
3
4
5
6
7
@Transactional(propagation = Propagation.REQUIRED)
public void deductInventory(Order order) {
inventoryRepository.deduct(
order.getProductId(),
order.getQuantity()
);
}

它的规则是:

1
2
外层有事务:加入外层事务
外层无事务:创建新事务

当订单与库存操作位于同一个物理事务中时,任何一步触发回滚,整体都会回滚:

1
2
3
4
订单事务
├── 保存订单
├── 扣减库存
└── 统一提交或回滚

内层方法虽然拥有自己的逻辑事务范围,但加入外层事务后,仍共享同一个物理事务。内层将事务标记为 rollback-only,会影响外层最终能否提交。

6.2 REQUIRES_NEW:始终创建独立事务

1
2
3
4
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOperationLog(OperationLog log) {
logRepository.save(log);
}

它的规则是:

1
2
外层有事务:挂起外层事务,创建独立事务
外层无事务:直接创建独立事务

例如:

1
2
3
4
5
6
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
logService.saveOperationLog(buildLog(order));
throw new RuntimeException("订单创建失败");
}

可能得到:

1
2
日志事务:已经提交
订单事务:发生回滚

这适合必须独立保存的信息,例如审计日志、失败记录或重试任务。但它通常需要额外的数据库连接:外层事务仍占用自己的资源,内层事务还要获取新的连接。连接池容量不足时,可能发生连接池耗尽甚至相互等待。

6.3 NESTED:基于保存点的嵌套事务

NESTED 通常使用 JDBC 保存点实现:

1
2
3
4
@Transactional(propagation = Propagation.NESTED)
public void deductCoupon() {
couponRepository.deduct();
}

可以将它理解为:

1
2
3
4
外层物理事务
├── 创建保存点
│ └── 执行内层操作
└── 继续执行外层操作

内层失败时可以回滚到保存点,外层事务仍有机会继续;但如果外层事务最终回滚,内层已经执行的内容也会一起回滚。

对比项 REQUIRES_NEW NESTED
物理事务 独立事务 通常与外层共用一个事务
数据库连接 通常使用新连接 通常复用外层连接
实现方式 挂起外层并开启新事务 数据库保存点
外层回滚后内层结果 可以保留 一起回滚
内层回滚对外层的影响 通常不直接影响 可回滚到保存点后继续
基础设施要求 支持事务挂起与独立资源 支持 JDBC 保存点

NESTED 主要适用于 JDBC 资源事务,并依赖数据库、驱动和事务管理器的保存点能力。使用 JPA 或其他事务管理器时,不能仅凭注解假设它一定可用。


七、为什么会出现 UnexpectedRollbackException

考虑下面的订单服务:

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

private final InventoryService inventoryService;
private final OrderRepository orderRepository;

@Transactional
public void createOrder(Order order) {
orderRepository.save(order);

try {
inventoryService.deduct(order);
} catch (Exception exception) {
log.error("库存扣减失败", exception);
}
}
}

库存服务如下:

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

@Transactional
public void deduct(Order order) {
inventoryRepository.deduct(
order.getProductId(),
order.getQuantity()
);

throw new RuntimeException("库存服务异常");
}
}

两个方法都使用默认的 REQUIRED,因此加入同一个物理事务:

1
2
3
4
5
6
7
8
9
10
1. OrderService 开启事务
2. 保存订单
3. InventoryService 加入当前事务
4. InventoryService 抛出运行时异常
5. 内层事务拦截器将当前事务标记为 rollback-only
6. OrderService 捕获异常
7. OrderService 方法正常结束
8. 外层事务拦截器尝试提交
9. Spring 发现事务已经是 rollback-only
10. 事务回滚,并抛出 UnexpectedRollbackException

关键在于:

捕获 Java 异常,并不会清除已经设置的 rollback-only 状态。

外层代码认为方法已经恢复正常,但物理事务已经不允许提交。Spring 抛出 UnexpectedRollbackException,是为了避免调用方误以为数据已经成功提交。

7.1 如何处理

解决方式取决于业务语义。

如果库存失败就应该让订单整体失败,最简单的做法是不要吞掉异常:

1
2
3
4
5
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
inventoryService.deduct(order);
}

如果库存失败属于预期业务分支,可以避免通过异常控制流程,改为返回明确结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public DeductResult deduct(Order order) {
boolean success = inventoryRepository.tryDeduct(
order.getProductId(),
order.getQuantity()
);

if (!success) {
return DeductResult.failed("库存不足");
}

return DeductResult.success();
}

也可以根据真实业务语义选择独立事务或配置 noRollbackFor,但不能只为了消除异常而关闭回滚。订单成功、库存失败往往意味着更严重的数据一致性问题。


八、线上如何判断事务是否生效

排查事务问题时,不要只盯着注解。应该沿着“代理、事务状态、异常、线程、数据源”逐层确认。

8.1 开启 Spring 事务日志

可以临时调整日志等级:

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

根据项目实际使用的事务管理器保留对应配置。重点观察:

  • 是否创建了新事务;
  • 是否加入已有事务;
  • 是否挂起和恢复了外层事务;
  • 是否提交或回滚;
  • 哪个异常触发了回滚;
  • 实际识别到的是哪个事务方法。

生产环境的 TRACE 日志量可能很大,应当临时开启、定向采集并及时恢复。

8.2 检查注入对象是否为代理

在调用方检查 Spring 注入的对象:

1
2
3
4
5
import org.springframework.aop.support.AopUtils;

log.info("isAopProxy={}", AopUtils.isAopProxy(orderService));
log.info("isJdkProxy={}", AopUtils.isJdkDynamicProxy(orderService));
log.info("isCglibProxy={}", AopUtils.isCglibProxy(orderService));

不要简单地在目标方法内部用 AopUtils.isAopProxy(this) 下结论。方法内部的 this 通常代表目标对象语义,结果容易造成误判。

8.3 检查当前事务是否激活

可以在问题代码附近临时记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.transaction.support.TransactionSynchronizationManager;

log.info(
"transactionActive={}",
TransactionSynchronizationManager.isActualTransactionActive()
);

log.info(
"transactionName={}",
TransactionSynchronizationManager.getCurrentTransactionName()
);

log.info(
"readOnly={}",
TransactionSynchronizationManager.isCurrentTransactionReadOnly()
);

如果预期的事务方法中输出 transactionActive=false,常见原因包括:

  • 方法没有经过代理;
  • 当前对象不是 Spring Bean;
  • 事务基础设施没有启用或注解没有被识别;
  • 代码已经切换到新线程;
  • 使用了错误的事务管理器。

8.4 检查事务是否已被标记为回滚

在有效的 Spring 事务调用中,可以临时检查:

1
2
3
4
5
boolean rollbackOnly = TransactionAspectSupport
.currentTransactionStatus()
.isRollbackOnly();

log.info("rollbackOnly={}", rollbackOnly);

如果业务异常已经被捕获,但 rollbackOnly=true,那么当前事务最终仍然只能回滚。

这段代码只能在事务拦截器管理的调用上下文中使用,否则可能抛出“找不到事务状态”的异常。

8.5 检查异常是否离开事务方法

重点确认:

  • 异常是否在方法内部被捕获;
  • 捕获后是否只记录日志,没有重新抛出;
  • 是否使用返回值表示失败;
  • 是否抛出了默认不回滚的受检异常;
  • 是否配置了全局或局部回滚规则;
  • 异常是否发生在另一个线程;
  • 异常是否发生在事务提交之后。

8.6 检查是否发生线程切换

重点搜索:

1
@Async
1
new Thread(...)
1
CompletableFuture.runAsync(...)
1
executorService.submit(...)
1
threadPoolTaskExecutor.execute(...)

一旦发生线程切换,就要重新分析事务边界,不能默认新线程会继承原线程事务。

8.7 检查调用链中的自调用

搜索同一个类中的:

1
this.saveOrder();

以及省略 this 的内部调用:

1
saveOrder();

如果被调用方法依赖自己的 REQUIRES_NEWNESTEDrollbackForisolationtimeout 配置,这些配置可能因为自调用而没有机会被事务拦截器读取。

8.8 检查数据源与事务管理器

多数据源项目需要确认:

  • Repository、Mapper 或 EntityManager 实际使用哪个 DataSource;
  • 当前 @Transactional 选择了哪个 TransactionManager;
  • 是否存在动态数据源切换;
  • 数据源路由是否在事务开启之前完成;
  • SQL 是否使用了当前事务绑定的连接。

动态数据源尤其容易出现一个问题:事务开启并绑定连接后,再切换路由标识通常已经太晚。

8.9 从数据库侧验证

继续确认:

  • SQL 是否真正执行;
  • SQL 使用了哪个连接;
  • 当前连接是否关闭自动提交;
  • 表是否支持事务;
  • 是否存在触发器或存储过程;
  • 是否执行了隐式提交操作;
  • 是否有其他线程随后修改了相同数据;
  • 查询结果是否来自缓存。

有时看到“数据没有回滚”,实际情况可能是部分操作走了另一个数据源、数据库触发器写入了其他表,或者远程服务已经完成了本地事务无法撤销的操作。


九、声明式事务还是编程式事务

Spring 常用的事务使用方式包括:

  • 声明式事务:@Transactional
  • 编程式事务:TransactionTemplate

9.1 声明式事务

1
2
3
4
5
6
7
8
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
inventoryRepository.deduct(
order.getProductId(),
order.getQuantity()
);
}

优点:

  • 使用简单;
  • 代码侵入性低;
  • 方法边界与事务边界一致时可读性好;
  • 适合典型的 Service 方法。

缺点:

  • 依赖 Spring AOP 代理;
  • 容易受到自调用影响;
  • 不适合在一个方法中灵活划分多个事务阶段;
  • 复杂的异常和传播配置不容易从调用点看出来。

9.2 编程式事务

1
2
3
4
5
6
7
8
9
10
11
12
public void batchImport(List<Order> orders) {
for (Order order : orders) {
transactionTemplate.executeWithoutResult(status -> {
try {
orderRepository.save(order);
} catch (Exception exception) {
status.setRollbackOnly();
log.error("订单导入失败,order={}", order, exception);
}
});
}
}

在这个例子中,每个订单都运行在独立的 TransactionTemplate 执行范围中。只要外层没有另一个事务改变传播语义,某个订单失败就不会让整个批次一起回滚。

编程式事务的优点是边界明确、控制灵活,适合逐条提交、分段提交和复杂事务编排;缺点是事务代码会侵入业务代码,使用过多会降低可读性。

简单的选择原则是:

1
2
3
4
5
事务边界与 Service 方法边界一致
→ 优先使用 @Transactional

需要分段提交、循环隔离或显式控制状态
→ 考虑 TransactionTemplate

十、一个更合理的订单事务设计

假设订单创建流程包括:

1
2
3
4
5
6
创建订单
├── 保存订单
├── 扣减库存
├── 核销优惠券
├── 保存操作日志
└── 发送消息

这些步骤不应该因为写在同一个方法里,就被机械地塞进同一个数据库事务。首先要分析每一步的一致性要求。

10.1 核心数据使用同一个事务

如果订单、库存和优惠券必须强一致,可以放在同一个本地事务中:

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

private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final CouponService couponService;

@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);

orderRepository.save(order);
inventoryService.deduct(command);
couponService.writeOff(command);
}
}

前提是这些 Repository 使用同一个事务管理器能够管理的资源。

10.2 失败日志使用独立事务

失败日志需要在主事务回滚后仍然保留,可以使用独立 Bean 和 REQUIRES_NEW

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

private final OrderLogRepository orderLogRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveFailureLog(
CreateOrderCommand command,
Exception exception) {
OrderLog log = OrderLog.failure(
command,
exception.getMessage()
);

orderLogRepository.save(log);
}
}

在事务方法外层捕获主流程异常:

1
2
3
4
5
6
7
8
9
10
11
12
public void createOrderSafely(CreateOrderCommand command) {
try {
orderApplicationService.createOrder(command);
} catch (Exception exception) {
try {
orderLogService.saveFailureLog(command, exception);
} catch (Exception logException) {
log.error("保存失败日志异常", logException);
}
throw exception;
}
}

这里需要保证:

  • createOrderSafely() 调用的是 orderApplicationService 代理对象;
  • saveFailureLog() 调用的是 orderLogService 代理对象;
  • 日志事务失败时,不覆盖原始业务异常;
  • 连接池能够承受独立事务带来的额外连接需求。

10.3 消息不要直接绑定在普通本地事务中

下面的代码存在不一致窗口:

1
2
3
4
5
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
messageProducer.send(order);
}

消息可能已经发送,但数据库事务随后回滚。

较轻量的方式是发布应用事件,并在事务提交后处理:

1
2
3
applicationEventPublisher.publishEvent(
new OrderCreatedEvent(order.getId())
);
1
2
3
4
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
messageProducer.send(event);
}

AFTER_COMMIT 只能保证监听逻辑在事务提交后执行,不能保证消息一定发送成功。进程退出、网络故障等问题仍可能导致消息丢失。

一致性要求较高时,更可靠的设计通常是 Outbox:

1
2
3
4
5
6
同一个业务事务
├── 保存订单
└── 保存待发送消息

事务提交后
└── 后台任务扫描消息表并可靠投递

十一、其他常见误区

11.1 readOnly = true 不等于禁止写入

1
2
3
4
@Transactional(readOnly = true)
public Order queryOrder(Long id) {
return orderRepository.findById(id);
}

readOnly = true 主要用于表达意图,并允许事务管理器、数据库驱动或 ORM 进行相应优化。不同技术栈的处理方式不同,它不一定像数据库权限一样严格阻止写操作,因此不能把它当作安全控制机制。

11.2 事务范围不是越大越好

1
2
3
4
5
6
7
8
@Transactional
public void createOrder() {
queryRemoteService();
uploadFile();
calculateComplexData();
saveOrder();
sendMessage();
}

长事务可能导致:

  • 数据库连接长期占用;
  • 锁持有时间过长;
  • 并发性能下降;
  • 死锁概率增加;
  • 事务超时;
  • 回滚成本增加。

更合理的做法通常是先在事务外完成不需要数据库锁保护的远程调用和计算,再把必须原子执行的数据库操作放入尽可能短的事务中。

不过,也不能为了缩短事务而随意把业务拆开。事务范围最终应由一致性边界决定,而不是只由性能决定。

11.3 数据库事务无法回滚远程副作用

普通本地数据库事务不能自动撤销:

  • HTTP/RPC 调用;
  • 文件写入;
  • Redis 普通命令;
  • 消息发送;
  • 第三方支付请求;
  • 短信或邮件发送。

例如:

1
2
3
4
5
6
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
paymentClient.pay(order);
throw new RuntimeException("订单保存失败");
}

订单记录可以回滚,但第三方支付不会自动撤销。这类流程需要结合幂等、状态机、补偿操作、可靠事件或最终一致性方案设计。

11.4 不要在事务中进行长时间等待

1
2
3
4
5
6
@Transactional
public void processOrder(Long orderId) throws InterruptedException {
orderRepository.lockOrder(orderId);
Thread.sleep(30_000);
orderRepository.updateOrder(orderId);
}

如果前面的 SQL 已经持有数据库锁,整个等待期间锁都可能无法释放。类似地,应避免在事务中等待用户输入、调用不受控的慢接口、上传大文件或执行长时间重试。


十二、线上排查流程与检查清单

发生事务问题时,可以按照下面的顺序排查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 确认对象是否由 Spring 管理

2. 确认调用是否经过代理对象

3. 确认事务方法能否被当前代理方式拦截

4. 检查是否存在同类自调用

5. 检查当前事务是否激活

6. 检查异常是否被捕获以及回滚规则

7. 检查事务传播行为和 rollback-only 状态

8. 检查是否发生线程切换

9. 检查数据源、事务管理器和数据库连接

10. 开启事务日志,还原实际提交与回滚过程

最终要回答三个关键问题:

1
2
3
当前代码有没有事务?
当前代码属于哪个物理事务?
这个事务最终为什么提交或回滚?

代理与调用

  • 当前类是否由 Spring 容器管理;
  • 调用方拿到的是否是 Spring Bean;
  • 事务方法是否通过代理对象调用;
  • 是否存在同一个类中的方法自调用;
  • 是否手动 new 了 Service;
  • 方法是否能被当前代理方式拦截;
  • 是否在 privatefinalstatic 方法上使用事务。

异常与回滚

  • 异常是否被 try-catch 捕获;
  • 捕获后是否重新抛出;
  • 当前异常是否属于默认回滚异常;
  • 是否存在全局回滚策略;
  • 是否需要配置 rollbackFor
  • 是否错误配置了 noRollbackFor
  • 当前事务是否已经是 rollback-only
  • 是否可能出现 UnexpectedRollbackException

传播行为

  • 内外层方法的传播行为是否符合业务语义;
  • REQUIRES_NEW 是否真的经过代理调用;
  • NESTED 是否被当前事务管理器支持;
  • 独立事务是否可能导致部分提交;
  • 连接池是否能支撑 REQUIRES_NEW 的额外连接。

线程与异步

  • 是否使用了 @Async
  • 是否使用了线程池;
  • 是否使用了 CompletableFuture
  • 是否手动创建了线程;
  • 是否错误地认为事务会自动跨线程传播。

数据库与数据源

  • 数据表是否支持事务;
  • Repository 是否使用正确的数据源;
  • 是否选择了正确的事务管理器;
  • 是否存在动态数据源切换;
  • 是否操作了多个本地数据库;
  • 是否存在数据库隐式提交;
  • SQL 是否使用了当前事务绑定的连接。

事务设计

  • 事务范围是否过大;
  • 是否在事务中调用慢接口;
  • 是否在事务中发送普通消息;
  • 是否在事务中执行文件或远程操作;
  • 是否更适合使用 TransactionTemplate
  • 是否需要 Outbox 或最终一致性方案;
  • 是否设置了合理的事务超时时间。

十三、总结

Spring 声明式事务看起来只是一个 @Transactional 注解,但它的实际行为依赖于多个条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Spring Bean
+
代理对象调用
+
可被拦截的方法
+
正确的事务管理器
+
合理的传播行为
+
正确的异常与回滚规则
+
同一线程中的事务上下文
+
支持事务的数据库资源

其中最重要的原则是:

@Transactional 不是在方法内部自动生效的,它依赖 Spring 事务基础设施在方法调用外部建立事务边界。

遇到事务问题时,不要只检查注解有没有添加,而应该沿着实际调用链分析:

1
2
3
4
5
6
7
谁调用了这个方法?
调用的是代理对象还是目标对象?
事务在哪里开启?
内层方法加入了哪个物理事务?
异常在哪里被捕获?
事务是否已经被标记为 rollback-only?
最终由哪个事务管理器执行提交或回滚?

理解代理、线程、异常、传播行为和资源边界之后,大多数所谓的“事务失效”都可以被准确解释和快速定位。


参考资料