结论先说:Spring 事务失效,十有八九不是 @Transactional 没写,而是代理没走到、异常没有触发回滚、传播行为和线程边界理解错了。排查顺序如果对,很多线上问题 10 分钟内就能定性;排查顺序如果错,越查越像“Spring 有 bug”。
很多团队把事务当成“加一个注解就行”的基础设施,等到线上出现“接口报错但数据已经落库”“方法明明标了事务却没有回滚”“异步链路只回滚了一半”时,才发现自己理解的其实只是事务表面。
这篇文章不打算停留在概念解释,而是按真实排障路径来讲:事务到底靠什么生效、哪些写法最容易失效、传播行为该怎么选、哪些副作用动作根本不应该放进同一个本地事务里,以及最终怎样写出更稳的 Spring Boot 事务代码。
一、先用一张表把“事务失效”快速归类
如果你碰到的是下面这些现象,通常已经可以把排查范围快速收敛:
| 现象 | 最常见原因 | 第一步检查什么 |
|---|---|---|
| 方法抛异常,但数据还是提交了 | 异常被吞掉,或抛的是受检异常但没配置回滚规则 | 看是否 catch 后直接返回,或是否用了 rollbackFor |
| 外部调用有事务,类内部互相调用没有事务 | 同类自调用绕过了 Spring 代理 | 看调用是不是 this.xxx() 或同类直接方法调用 |
| 异步方法里的数据库操作没有一起回滚 | 事务上下文没有跨线程传播 | 看是否用了 @Async、线程池或手动新开线程 |
| 两个库或数据库加消息只成功了一部分 | 本地事务边界被误当成了分布式事务 | 看是否涉及多数据源、远程服务、MQ、文件存储 |
| 有些方法标了事务但完全没生效 | 方法没经过 Spring 管理对象,或代理条件不满足 | 看 Bean 是否由 Spring 创建、方法是否为稳定可代理入口 |
很多人一看到“事务失效”就先怀疑数据库、驱动或框架版本,其实大多数问题都发生在应用层调用路径上。先确认调用有没有真正进入事务代理,比先看数据库参数更有判别力。
二、先把原理讲透:Spring 事务不是注解魔法,而是代理 + 连接绑定
@Transactional 本身不会直接开启事务,真正起作用的是 Spring 在方法调用前后织入的事务拦截逻辑。常见流程可以理解成这样:
- 调用进入被 Spring 管理的代理对象,而不是普通 Java 对象。
- 事务拦截器根据注解配置,决定是否开启事务,以及使用哪个事务管理器。
- 当前线程绑定数据库连接或持久化上下文,后续数据库操作加入这个事务边界。
- 方法正常返回时提交事务,抛出满足回滚规则的异常时回滚事务。
- 清理线程上下文,把连接归还给连接池。
这条链路里任何一个前提不成立,事务都可能表现为“像是没生效”。最关键的两个前提是:
- 调用必须经过 Spring 代理。 不经过代理,事务拦截器压根没有出手机会。
- 事务上下文默认只在当前线程内有效。 一旦切线程,除非你自己设计补偿或编排逻辑,否则不要默认它还在同一个事务里。
理解了这两个前提,后面 80% 的事务问题都能解释得通。
三、8 个最常见的事务失效场景,很多项目至少踩过 3 个
1. Bean 根本不是 Spring 管理的对象
最直接也最容易忽略的场景,就是业务类并不是由容器注入出来的,而是你自己 new 出来的。只要对象不是代理对象,@Transactional 就只剩装饰作用。
典型错误包括:在工具类或普通类里手动创建 Service、在测试代码里直接 new 实现类、在框架回调里绕过容器获取对象。排查时先确认调用链上拿到的究竟是不是容器里的 Bean。
2. 同类自调用绕过代理
这是最经典也最容易被误判的场景。一个类里的方法 A 调用同类方法 B,如果 B 上有 @Transactional,并不等于事务一定生效。因为这次调用通常不会经过代理,而是对象内部的直接方法调用。
@Service
public class CouponService {
public void batchSend(List<Long> userIds) {
for (Long userId : userIds) {
this.sendOne(userId);
}
}
@Transactional(rollbackFor = Exception.class)
public void sendOne(Long userId) {
// 插入优惠券记录、更新库存、写发放日志
}
}
这段代码的问题不在于注解,而在于 this.sendOne() 没经过代理。更稳的做法有三个:
- 把事务方法拆到另一个 Service,让外部 Bean 调用它。
- 把批处理逻辑和单次事务逻辑拆成两个职责清晰的应用服务。
- 如果团队明确接受复杂度,也可以显式拿代理再调用,但这通常不是最简洁方案。
3. 方法入口不稳定,代理根本拦不住
事务最稳的入口永远是被外部调用的 public Service 方法。很多团队把事务加在私有方法、静态方法,或者带有代理限制的方法上,然后期待框架自动处理,这种期待通常不成立。
工程实践里最简单的原则就是:事务边界放在 public 应用服务方法上,不要依赖“某些情况下也许能代理到”的边缘行为。这样既降低版本差异带来的不确定性,也让代码审阅时更容易判断。
4. 异常被 catch 住了,事务感知不到失败
事务回滚依赖的是“方法最终以异常退出”,如果你在方法内部把异常吃掉,外层拦截器看到的就是一次正常返回,于是自然会提交事务。
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
try {
orderRepository.save(command.toEntity());
stockService.reserve(command.getSkuId(), command.getQuantity());
} catch (Exception exception) {
log.error("createOrder failed", exception);
return;
}
}
这类代码最危险的地方在于:日志里明明有 error,开发者直觉上会以为事务已经回滚,实际上数据已经提交。更稳的处理方式是二选一:
- 要么记录日志后继续抛出异常,让事务自行回滚。
- 要么明确调用回滚标记,并且保证调用方能感知失败。
5. 抛的是受检异常,但你以为默认会回滚
Spring 默认对 RuntimeException 和 Error 回滚,对普通受检异常并不会自动回滚。很多业务代码里抛的是自定义 Exception、IO 异常或业务受检异常,如果没显式配置,最后很容易出现“接口失败但事务没回滚”。
因此,团队里如果存在大量受检异常,更稳妥的习惯是明确写出:
@Transactional(rollbackFor = Exception.class)
public void pay(PayCommand command) throws Exception {
// ...
}
这不是说所有场景都必须无脑加 rollbackFor = Exception.class,而是你要知道默认规则是什么,再决定是否扩大回滚范围。
6. 传播行为选错了,事务“看起来正常”,结果却不是你想要的
很多问题不属于事务失效,而属于事务按配置正常工作,但配置本身错了。最常见的误区有两个:
- 希望内层方法失败不影响外层,却仍然使用
REQUIRED,结果整个调用链一起回滚。 - 希望外层失败能带着内层一起回滚,却在内层用了
REQUIRES_NEW,结果内层事务已经提前提交。
这类问题最容易在“主订单 + 日志记录”“核心写库 + 审计落表”“支付主流程 + 优惠券发放”这种混合场景里出现。传播行为一旦选错,数据库表现通常和注解看起来的语义完全相反。
7. 用了 @Async、线程池或手动开线程,却还以为在同一个事务里
事务上下文默认是线程绑定的。也就是说,主线程里开启的本地事务,不会自动流到新线程。下面这些写法都要默认视为事务边界已经断开:
- 在事务方法里调用
@Async方法。 - 在事务方法里提交线程池任务。
- 手动
new Thread()或CompletableFuture.supplyAsync()。
如果你需要“数据库提交成功后再异步处理”,正确思路通常不是强行让异步任务加入事务,而是把异步动作设计成提交后执行。例如发 MQ、推送消息、刷新搜索索引、发短信邮件,都更适合放到事务提交之后。
8. 把远程调用、多数据源或消息发送误当成本地事务的一部分
本地事务只能保证当前事务管理器控制范围内的资源一致。你在一个 Spring Boot 方法里同时更新 MySQL、调用另一个服务、发送 MQ、写 ES,再加一个 Redis 删除动作,并不等于这些动作天然是原子性的。
尤其是下面两种场景最容易出事故:
- 多数据源: 方法上有事务,但实际只管住了默认数据源,另一个数据源并不在同一个事务管理器里。
- 本地事务 + 远程副作用: 数据库回滚了,但短信已发、消息已投、下游接口已调用成功。
一旦出现这种跨资源编排,正确问题就不再是“为什么事务没生效”,而是“这本来就不该指望一个本地事务解决”。
四、传播行为别背定义,要结合业务目标来选
面试里大家都能背出 REQUIRED、REQUIRES_NEW、NESTED,但真正写业务时,关键不是背名字,而是想清楚“我到底希望哪一层失败时带谁一起回滚”。
| 传播行为 | 典型语义 | 更适合什么场景 | 常见误区 |
|---|---|---|---|
REQUIRED |
有就加入,没有就新建 | 一个完整业务流程里的主链路数据库操作 | 默认它能自动隔离所有副作用动作 |
REQUIRES_NEW |
挂起外层事务,开启独立事务 | 审计日志、补偿记录等希望单独提交的动作 | 误以为外层回滚时它也会回滚 |
NESTED |
基于保存点的嵌套事务语义 | 明确需要部分回滚控制且底层支持保存点 | 把它当成“等价于 REQUIRES_NEW” |
SUPPORTS |
有事务就加入,没有就非事务执行 | 纯查询或弱依赖事务上下文的逻辑 | 用于必须原子提交的写操作 |
NOT_SUPPORTED |
显式以非事务方式运行 | 不希望长事务包裹的非关键查询或外部调用 | 放入必须和主链路一起提交的数据库更新 |
实战里最稳的策略通常不是“到处加事务”,而是先把业务动作分层:哪些必须同生共死,哪些可以提交后异步做,哪些失败了只要记录补偿即可。传播行为只是这个拆分结果的表达,而不是业务边界的替代品。
五、一个更稳的事务边界写法:数据库操作放事务里,副作用放提交后
如果你把发消息、调库存服务、推送通知、写搜索索引全部塞进一个事务方法里,代码看起来“一站式”,但稳定性通常更差。更靠谱的方式是:事务里只做本地数据库必须原子完成的动作,提交成功后再做外部副作用。
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final StockRepository stockRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderApplicationService(
OrderRepository orderRepository,
StockRepository stockRepository,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.stockRepository = stockRepository;
this.eventPublisher = eventPublisher;
}
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderCommand command) {
Order order = orderRepository.save(Order.create(command));
stockRepository.reserve(command.getSkuId(), command.getQuantity());
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
return order.getId();
}
}
@Component
public class OrderCreatedHandler {
private final MessagePublisher messagePublisher;
public OrderCreatedHandler(MessagePublisher messagePublisher) {
this.messagePublisher = messagePublisher;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
messagePublisher.publishOrderCreated(event.orderId());
}
}
这类写法有几个直接好处:
- 数据库回滚时,不会提前把消息、副作用动作发出去。
- 事务方法职责清晰,排查失败原因时边界非常明确。
- 后续要改成 outbox、MQ、重试或补偿机制时,演进成本更低。
六、线上排查事务问题,建议按这个顺序来
真实线上排障最怕“同时怀疑所有地方”。下面这条顺序更省时间:
- 先确认调用是否经过 Spring 管理的代理 Bean。
- 再确认事务方法是否是明确的 public 外部入口,是否存在同类自调用。
- 检查异常是否被吞掉,或是否属于默认不回滚的异常类型。
- 确认传播行为是否真的符合业务目标,而不是“默认写上去”。
- 确认有没有切线程、异步执行、线程池任务或远程调用。
- 如果涉及多数据源,检查实际使用的是哪个事务管理器。
- 最后再看数据库侧事务隔离级别、锁等待、超时和连接池配置。
这个顺序的核心是:先查应用层调用路径,再查资源层配置。因为前者更常见,也更便宜。
七、几个高频误解,最好尽早改掉
1. “加了事务,远程调用失败也会一起回滚”
不会。本地事务只能管住当前受控资源。你调第三方接口成功了,再抛异常回滚数据库,第三方不会跟着回滚。
2. “一个大事务最安全”
不一定。事务越大,锁持有越久,连接占用越长,回滚成本越高,失败面越大。很多业务真正需要的是清晰边界,而不是更长的事务。
3. “只要数据库支持事务,Spring 事务就一定可靠”
数据库支持事务只是底层前提,Spring 事务能否按预期工作,更大程度取决于调用路径、异常规则、线程模型和资源边界。
八、最后给一个简单但有效的落地原则
如果你不想把事务问题搞得过于抽象,可以记住下面这组实践原则:
- 事务边界优先放在 public 应用服务方法,不要分散在内部辅助方法上。
- 只把必须原子完成的本地数据库操作放进事务里。
- 远程调用、发消息、推送通知、刷新索引优先放到提交后。
- 对异常规则要显式,不要假设所有异常默认都回滚。
- 只要涉及异步、线程池、多数据源或多服务,就不要再用“本地事务思维”硬套。
事务这件事,说到底不是注解技巧,而是边界设计。真正稳的系统,通常不是事务写得最“复杂”的系统,而是边界最清楚、失败方式最可预期的系统。