线上一个看似简单的扣款接口,失败时没有回滚,导致用户被扣了款但订单状态仍是"待支付",客服那边一天收到几十条投诉。代码里明明写了 @Transactional,事务怎么就没生效?这是 Spring 老手都不一定能一次答对的问题,@Transactional 失效的场景至少有七种,新人几乎必踩,老手偶尔翻车。这篇把我们团队踩过的七种典型失效场景全部整理出来,每种给最小复现、根因、修法,以及最后一套能在 code review 阶段就拦住的检查清单。看完之后,你应该能对 Spring 事务的失效机制有一个完整的认知。
故障现场
背景是一个支付回调接口,逻辑很简单,先更新订单状态,再扣减库存,再写流水,任何一步失败都要全部回滚。代码上加了 @Transactional,review 时也没看出问题,测试环境跑得好好的,上线之后开始出现"扣款成功但订单未支付"的脏数据。一周之内累计了三百多条问题订单,客服那边压力巨大,直到 DBA 在数据库里发现明显的部分提交痕迹,才意识到事务根本没生效。
| 时刻 | 事件 |
|---|---|
| 上线第一天 | 测试环境跑通, 单元测试全过 |
| 上线第三天 | 客服收到首条"我付了钱订单还在待支付" |
| 上线第七天 | 类似投诉累计 312 条, 升级到 P1 |
| 排查第一天 | 怀疑数据库连接池, 排除 |
| 排查第二天 | DBA 发现部分提交, 确认事务未生效 |
| 排查第三天 | 定位到自调用问题, 紧急修复 |
失效场景一:类内部方法自调用
这是最高频的失效场景,占我们团队事故的百分之四十以上。原因是 Spring 的 @Transactional 是基于 AOP 代理实现的,只有通过代理对象调用方法时,事务切面才会生效。如果在类内部直接 this 调用,绕过了代理对象,事务自然就失效了。
// 失效示范
@Service
public class OrderService {
@Transactional
public void payAndUpdate(Long orderId) {
// 通过 this 调用, 不经过代理, 事务失效
updateStatus(orderId);
deductStock(orderId);
writeLog(orderId);
}
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long orderId) {
// 这个 @Transactional 完全没用
orderMapper.updateStatus(orderId, "PAID");
}
}
正解是把内部方法抽到另一个 Service 里,或者通过 AopContext 拿到代理对象再调用。
// 修法 1: 抽到另一个 Service
@Service
public class OrderService {
@Autowired private OrderInnerService inner;
public void payAndUpdate(Long orderId) {
inner.updateStatus(orderId); // 走代理
}
}
// 修法 2: AopContext 拿代理 (需要 @EnableAspectJAutoProxy(exposeProxy = true))
@Service
public class OrderService {
@Transactional
public void payAndUpdate(Long orderId) {
((OrderService) AopContext.currentProxy()).updateStatus(orderId);
}
}
// 修法 3: 注入自身 (Spring 4.3+, 不推荐, 容易循环依赖)
@Service
public class OrderService {
@Autowired private OrderService self;
}
失效场景二:方法不是 public
Spring 默认的事务代理只对 public 方法生效。如果你给 protected 或 private 方法加 @Transactional,注解会被静默忽略,不会有任何报错,这是新人最容易踩的坑。Spring 这么设计是因为 CGLIB 代理无法拦截 private 方法,JDK 动态代理只能代理接口方法,综合考虑下来只支持 public。
// 失效示范
@Service
public class UserService {
@Transactional
private void createUser(User user) { // private 注定失效
userMapper.insert(user);
accountMapper.insertDefault(user.getId());
}
}
正解是把方法改成 public,或者用 AspectJ 织入代替 Spring AOP。AspectJ 是编译期或类加载期织入,不依赖代理对象,可以拦截非 public 方法,但配置成本较高,一般业务系统不推荐。
失效场景三:抛了 checked exception 但没配 rollbackFor
Spring 默认只对 RuntimeException 和 Error 回滚,checked exception(继承 Exception 但不继承 RuntimeException 的异常)默认不回滚。这是 Spring 早期为了兼容 EJB 的设计,延续到了今天。如果你的业务代码抛了 IOException、SQLException 这些,事务会照常提交,数据就会落到部分提交状态。
// 失效示范
@Transactional
public void importData(File file) throws IOException {
dataMapper.insert(...);
Files.write(file.toPath(), ...); // 抛 IOException, 但事务不回滚!
}
// 正解 1: 明确指定 rollbackFor
@Transactional(rollbackFor = Exception.class)
public void importData(File file) throws IOException {
dataMapper.insert(...);
Files.write(file.toPath(), ...);
}
// 正解 2: 把 checked 异常包成 RuntimeException
@Transactional
public void importData(File file) {
try {
dataMapper.insert(...);
Files.write(file.toPath(), ...);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
我们团队最终的实践是所有 @Transactional 都强制写 rollbackFor = Exception.class,通过 code review 强制检查。这条规矩避免了大量歧义,新人写代码不需要记 Spring 的默认行为,直接套模板就对。
失效场景四:异常被 catch 后没重新抛出
事务回滚依赖于异常向外抛出,Spring 的事务切面在方法返回时检查是否有异常,有就回滚,没有就提交。如果异常在方法内部被 catch 住,Spring 看到的是"方法正常返回",自然就提交了。
// 失效示范
@Transactional(rollbackFor = Exception.class)
public void process(Order order) {
try {
orderMapper.update(order);
stockMapper.deduct(order.getProductId()); // 假设这里抛异常
} catch (Exception e) {
log.error("处理失败", e); // 吃掉异常, 事务正常提交!
}
}
正解是要么不要 catch,让异常自然向外冒;要么 catch 之后手动标记事务回滚,通过 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
// 修法: catch 之后手动标记回滚
@Transactional(rollbackFor = Exception.class)
public void process(Order order) {
try {
orderMapper.update(order);
stockMapper.deduct(order.getProductId());
} catch (Exception e) {
log.error("处理失败", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw new BusinessException("处理失败", e);
}
}
失效场景五:传播行为配错
Spring 提供了七种事务传播行为,默认是 REQUIRED,意思是"如果当前有事务就加入,没有就新建"。新人经常不理解传播行为,写出"嵌套调用但其中一个标了 REQUIRES_NEW 又被 catch"这种奇怪组合,结果数据状态完全不可预测。
最常见的坑是本想要"子事务独立回滚不影响父事务",用了 REQUIRED 而不是 REQUIRES_NEW。REQUIRED 是共生死的,子方法抛异常会把整个父事务一起拉下水,即使你在父方法里 catch 了,Spring 也会因为"事务被标记为 rollback-only"而最终回滚。这种行为对新人来说非常反直觉,需要专门学一下。
失效场景六:多数据源没配对应的事务管理器
项目里如果有多个数据源,每个数据源都需要独立的 PlatformTransactionManager,@Transactional 需要明确指定用哪一个,否则可能落到错误的事务管理器上,导致操作另一个数据源时事务完全不生效。
// 多数据源配置
@Configuration
public class DataSourceConfig {
@Bean("primaryTxManager")
public PlatformTransactionManager primaryTx(@Qualifier("primaryDs") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
@Bean("secondaryTxManager")
public PlatformTransactionManager secondaryTx(@Qualifier("secondaryDs") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
}
@Service
public class OrderService {
// 明确指定事务管理器, 否则用默认的 (可能是错的)
@Transactional(value = "primaryTxManager", rollbackFor = Exception.class)
public void createOrder() {
// 操作 primary 数据源
}
}
多数据源场景下还有一个深坑是跨数据源的事务不能用 @Transactional 保证一致性。如果你的业务同时操作两个数据源,需要分布式事务方案,比如 Seata 的 AT 模式或 TCC,Spring 原生的 @Transactional 只能管一个数据源。这一点很多人不知道,以为加了 @Transactional 就万事大吉,生产里出过不少跨库数据不一致的事故。
失效场景七:@Async 异步方法里的事务
@Async 注解会把方法放到另一个线程执行,而 Spring 的事务是基于 ThreadLocal 绑定的,跨线程之后事务上下文就丢了。如果你在 @Async 方法里调用了 @Transactional 方法,需要重新开事务,而且与调用方的事务完全独立。
// 失效示范
@Service
public class NotifyService {
@Transactional
public void sendAndLog(Order order) {
sendNotify(order); // @Async, 跳到另一个线程
logMapper.insert(...); // 当前事务里
}
@Async
public void sendNotify(Order order) {
notifyMapper.insert(...); // 这个 insert 不在 sendAndLog 的事务里!
}
}
正解是不要假设 @Async 方法跟调用方共享事务,需要事务的话在 @Async 方法上自己加 @Transactional,并且明确知道这是一个独立事务。
七种失效场景对比
| 场景 | 频率 | 难度 | 修法 |
|---|---|---|---|
| 1. 类内自调用 | 极高 | 低 | 抽到另一个 Service / AopContext |
| 2. 非 public 方法 | 高 | 低 | 改 public 或用 AspectJ |
| 3. checked exception | 极高 | 低 | 显式 rollbackFor = Exception.class |
| 4. 异常被吃掉 | 高 | 中 | 不 catch 或手动 setRollbackOnly |
| 5. 传播行为配错 | 中 | 高 | 理解传播语义, 谨慎用 REQUIRES_NEW |
| 6. 多数据源 | 中 | 中 | 明确指定事务管理器, 跨库用 Seata |
| 7. @Async 跨线程 | 中 | 中 | 异步方法独立加 @Transactional |
排查事务失效的标准流程
遇到怀疑事务失效的问题,第一步是开 Spring 的事务日志,看 Spring 到底有没有给这个方法开事务。日志开关是 logging.level.org.springframework.transaction=DEBUG,开了之后控制台会打印每次事务的开启、提交、回滚记录,一目了然。如果某次调用根本没有事务日志,说明事务切面没有生效,十有八九是类内自调用或者方法不是 public。
# application.yml 开事务日志
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.jdbc: DEBUG
org.mybatis: DEBUG
第二步是看代理对象的类型。Spring AOP 默认对接口走 JDK 动态代理,对类走 CGLIB。如果你的 Bean 是 JDK 代理但调用了一个接口里没有的方法,事务也不会生效。可以在调用点打个断点,看一下 this 的实际类型,如果是 EnhancerByCGLIB 或 Proxy 开头,说明代理对象是对的,继续往下查方法签名。如果是普通类名,说明根本没经过代理,八成是自调用。
第三步是用最小复现确认根因。把怀疑的代码抽出来,写一个最简单的单元测试或集成测试,故意制造异常,观察数据是否回滚。这种最小复现的能力在排查复杂问题时至关重要,光看代码很难想清楚,必须动手验证。我们团队曾经因为不会写最小复现,排查一个事务失效问题花了两天,后来学会之后类似问题平均一小时就能定位。
容易被忽略的几个细节
第一个细节是事务的隔离级别和数据库默认值的关系。@Transactional 的 isolation 属性如果不指定,用的是数据库的默认隔离级别,MySQL 默认是可重复读,Oracle 和 PostgreSQL 默认是读已提交。同一段代码在不同数据库下行为可能不一致,这种"隐式约定"在跨数据库迁移时会引发各种诡异问题。我们的实践是在关键业务方法上显式指定 isolation,避免依赖数据库默认值。
第二个细节是事务超时。@Transactional 的 timeout 属性可以限制事务的最大执行时间,超时会自动回滚。这个属性默认值是负一,意思是不超时,实际生产环境强烈建议设一个合理的值,比如三十秒。否则一个慢查询能让事务挂几分钟,期间持有的锁会引发雪崩。我们项目里曾经因为一个忘了加超时的事务,在数据库慢的时候挂了五分钟,期间整个订单服务接近瘫痪。
第三个细节是只读事务的优化。如果业务方法只读不写,加上 @Transactional(readOnly = true) 可以让 Spring 和数据库做一些优化,比如跳过 dirty check、走只读副本。这种小优化在高 QPS 场景下能带来不小的性能提升。但要注意只读事务里不能写,写了会报异常,这是一种安全约束而不是性能特性。
第四个细节是事务里不要做远程调用。HTTP 请求、RPC 调用、消息发送这些都不应该放在事务里。原因是远程调用的耗时不可控,会让事务长时间持有数据库连接,可能耗尽连接池;而且远程调用失败时回滚事务,也无法回滚远程系统的状态,造成更复杂的一致性问题。我们的规矩是事务里只做本地数据库操作,远程调用都放到事务提交之后,通过事件机制触发。
code review 阶段就能拦住的检查清单
- 所有 @Transactional 必须显式写 rollbackFor = Exception.class,不允许依赖默认值。
- 禁止在 private 或 protected 方法上加 @Transactional,IDE 应该有红线提示。
- 类内调用同类的 @Transactional 方法必须经过代理,否则 PR 拒绝。
- 事务方法里禁止 catch 异常不抛出,如果要 catch 必须显式 setRollbackOnly。
- 所有事务必须设 timeout,推荐三十秒,长事务必须经过架构评审。
- 事务里禁止做远程调用,包括 HTTP、RPC、消息发送、Redis 操作。
- 多数据源场景必须显式指定事务管理器,不允许走默认值。
- 新加的 @Transactional 方法必须配套写一个故意制造异常的回滚测试。
团队最终立的几条铁律
事故复盘之后,我们把上面的检查清单转换成了 ArchUnit 测试,集成到 CI 里。ArchUnit 可以在编译期检查代码的架构约束,比如"@Transactional 必须有 rollbackFor"、"@Transactional 方法必须 public",违反规则的 PR 直接构建失败,不可能 merge 进主干。这种"用代码保证规矩"的做法比人工 review 可靠得多,新人也不用记一堆潜规则,IDE 和 CI 会自动帮他纠正。
另一条铁律是每个 @Transactional 方法必须有对应的回滚测试。测试要故意在中间抛异常,然后验证所有数据库操作都没有部分提交。这种测试的价值在事故时才能体现,平时跑过就过,一旦事务失效,测试立刻能挡住。我们项目里几次重构都靠这套测试发现了潜在的事务问题,投入产出比非常高。
第三条铁律是所有跨数据源、跨服务的事务都要走分布式事务方案。@Transactional 只管单库,跨库需要 Seata 或类似的工具。这条规矩很多老项目执行起来困难,因为历史代码里到处都是"假装能跨库的 @Transactional",改造成本很高。但不改的话,数据一致性问题会持续发生,长期看改造成本反而更低。技术债不会因为不管就消失,只会越积越大。
跟其他框架的对比
| 框架 | 事务实现 | 失效坑 |
|---|---|---|
| Spring @Transactional | AOP 代理 | 自调用 / private / checked exception |
| Spring Data JPA | 同上 | 同上, 额外有 flush 时机问题 |
| MyBatis-Spring | 同上 | 同上 |
| Hibernate native | Session API | 需要手动 begin/commit, 容易忘 |
| Quarkus @Transactional | 编译期织入 | 不依赖代理, 几乎不失效 |
| Micronaut @Transactional | 编译期织入 | 同 Quarkus |
从对比可以看出,Spring 的 @Transactional 是基于运行时代理的实现,这种设计带来了灵活性,但也带来了大量失效场景。新兴的 Quarkus 和 Micronaut 走的是编译期织入,几乎没有自调用、private 之类的坑,代价是反射和动态代理的能力受限。如果是新项目,从运维和稳定性的角度考虑,编译期织入的方案更省心。
给读者的几条实操建议
第一条建议是立刻去检查你项目里所有的 @Transactional。看看有没有不写 rollbackFor 的、写在 private 方法上的、被类内自调用的。这种检查可以用 IDEA 的结构搜索快速完成,十分钟就能扫一遍中等规模的项目。能发现的问题数量通常会让你大吃一惊,我们团队第一次扫的时候发现了三十多处潜在问题。
第二条建议是把事务日志开起来,至少在测试和预发环境开。事务日志能让你直观看到每次方法调用的事务行为,debug 时是救命稻草。生产环境如果日志量太大可以关掉,但出问题时一定要能快速开启。
第三条建议是不要害怕重构。如果发现某个 Service 方法被频繁地"类内自调用",别犹豫,抽到另一个 Service 里去。这种重构成本很低,但能彻底消除一类隐患。我们项目里大概抽了二十多次,代码结构反而更清晰了,事务也更可控。
第四条建议是分布式事务用得越少越好。Seata、TCC 这些方案虽然能解决跨服务的一致性,但引入了大量复杂度。如果业务能拆得开,尽量让每个事务只涉及一个服务,通过事件最终一致性来同步。这种架构比强一致性的分布式事务简单得多,可维护性也好得多。
关于 Spring 事务设计的一些思考
Spring @Transactional 的设计本质上是声明式事务的代表,通过注解的方式把事务管理从业务代码里解耦出去。这种设计在二零零三年提出时是革命性的,把 EJB 时代繁琐的事务配置简化到一行注解,极大降低了开发门槛。但二十年过去了,这套模型暴露出来的问题也越来越多,主要集中在"魔法太多,行为不直观"。
新人很难一次就理解为什么自调用不行、为什么 private 不行、为什么 checked exception 不回滚,这些都是 Spring 实现细节带来的副作用,跟事务本身的语义没有任何关系。这种"实现细节泄漏到使用层"的现象,在大型框架里非常普遍,是声明式编程的固有代价。要解决这个问题,要么换框架(Quarkus、Micronaut),要么写大量的工具和规约去补漏洞。
但话说回来,Spring 的生态优势是任何新框架短期内都难以撼动的。大部分团队还是会选择继续用 Spring,通过 ArchUnit、code review、培训等方式来规避失效场景。这是一种工程上的务实选择,接受不完美但成熟的方案,通过工程实践弥补语言或框架的不足。这种思维方式比追求"理论最优"更适合大多数生产环境。
事故复盘的几点反思
第一点反思是不要轻信代码"看起来对"。Spring 事务失效的几个场景,代码层面都"看起来很合理",但实际运行行为完全相反。这种"语法正确但语义错误"的代码,是最难发现的 bug,review 时根本看不出来。要避免这类问题,必须依靠自动化测试和 CI 检查,人工是不可靠的。
第二点反思是框架的默认行为要主动了解。Spring 默认只回滚 RuntimeException,这种行为如果你不主动去查,可能用了三年都不知道。框架文档里有的东西,新人入职时要专门学一遍,而不是出事了再查。这种"主动学习默认行为"的习惯,是程序员成熟度的一个标志。
第三点反思是测试要覆盖异常路径。我们的单元测试都只测了正常路径,异常路径基本没测,导致事务失效在测试环境完全暴露不出来。这是非常普遍的问题,大家写测试时倾向于测"应该工作的情况",很少测"应该失败的情况"。后来我们规定每个新加的事务方法必须配套异常路径测试,这条规矩挡住了好几次潜在事故。
实战中容易被忽略的几个高级用法
除了上面七种常见失效场景,实际生产里还有几种更高级的用法,新手不常碰到,一旦碰到就容易出问题。第一种是事务同步器,可以在事务提交之后、回滚之前、回滚之后注册回调,做一些清理或通知动作。这个机制对于"事务提交后才发消息"这种场景特别有用,可以避免事务回滚但消息已经发出去的不一致问题。具体用法是通过 TransactionSynchronizationManager.registerSynchronization,传入一个 TransactionSynchronization 对象,里面实现需要的回调方法。这是 Spring 事务里少有的能感知到"提交完成"这个事件的方式,值得每个后端工程师掌握。
第二种是编程式事务。@Transactional 注解虽然方便,但灵活性有限,某些动态决定事务边界的场景需要用 TransactionTemplate 或 PlatformTransactionManager 手动控制。编程式事务的好处是边界完全可控,你可以根据运行时条件决定是否开事务、是否回滚、何时提交。坏处是代码冗长,容易出错。我们的实践是只在确实需要动态控制的地方用编程式事务,其他场景都用注解式,保持代码风格统一。
第三种是嵌套事务和保存点。Propagation.NESTED 这种传播行为允许在同一个物理事务内创建保存点,子方法失败时只回滚到保存点,父方法可以继续。这对于"批量处理某个项失败不影响其他项"的场景很有用,但需要数据库支持保存点,大部分关系型数据库都支持,只是少数老版本不行。使用嵌套事务时要注意保存点的开销,频繁创建保存点会影响性能,适合粒度较粗的场景。
同行交流时听到的几个典型故事
第一个故事来自一位金融行业的同行,他们项目里一个转账接口长期偶发"扣款成功但收款方没到账"的问题,排查半年才发现是事务超时太短导致的。转账接口在数据库慢的时候执行时间超过了配置的十秒,事务被强制回滚,但应用层的代码已经认为成功,继续往下走,引发严重的数据不一致。后来他们把超时调到了六十秒,并且在事务回滚时主动检测重做。这个案例提醒大家,超时设置不是越短越好,要根据业务的真实耗时分布来定。
第二个故事来自一位电商同行,他们一个营销活动接口里同时调用了三个 @Transactional 方法,结果其中一个方法的事务因为传播行为配置错误,完全没有加入主事务。活动期间大约百分之一的订单出现了营销发放错误,损失了几十万。他们后来把所有营销相关的事务全部抽到一个 facade 方法里,统一管理,再也没出过类似问题。这个案例的教训是事务边界要尽量集中,不要分散,越分散越容易出错。
第三个故事来自一位 SaaS 同行,他们的多租户系统每个租户一个数据库,跨租户的事务无法用 @Transactional 解决,只能走分布式事务。但他们最初选择了一个不太成熟的开源方案,稳定性很差,经常出现"协调器挂掉导致悬挂事务"的问题。后来他们改用 Seata 的 AT 模式,问题才得以解决。这个案例说明分布式事务方案的选型很关键,不要为了省事用小众工具,踩坑的代价比节省的时间高得多。
总结
Spring @Transactional 是 Java 后端最常用的注解之一,也是失效场景最多的注解之一。理解它的关键是搞清楚"事务是通过 AOP 代理实现的",所有失效场景都跟代理机制有关。一旦理解这一点,自调用、private 方法、传播行为这些坑都能避开。配合 rollbackFor、timeout、readOnly 等参数的正确使用,以及 ArchUnit 等工具的硬约束,可以把事务失效的概率压到极低。
这次事故让我们整个团队对 Spring 事务有了更深的认识,也让我意识到框架的复杂度是有代价的。Spring 简单易用的背后,是大量的隐式约定和魔法,这些约定不是天生就懂的,需要时间和事故慢慢积累。希望这篇能帮你少踩一些事务相关的坑,把精力用在更有价值的事情上。如果你的项目里有 @Transactional,强烈建议今天就去扫一遍,看看有没有上面提到的七种失效场景,提前修掉,比线上事故再修要省心一万倍。
最后想说的是,任何框架的注解都是一种抽象,抽象的好处是简化,坏处是隐藏细节。要真正用好这些注解,必须了解它的实现机制,而不是只看文档上的"用法示例"。Spring 事务、Spring AOP、Spring 依赖注入,这些核心机制每个 Java 工程师都应该花时间深入学习一遍,投入回报极高。从看懂源码到运用自如,可能需要几个月时间,但这段投入会让你的职业生涯受益十年以上,远比追逐新框架更划算。如果你正在用 Spring,真心建议把 @Transactional 的原理啃下来,不会让你失望。
另外补充一点,Java 生态最近几年在事务管理上也有新的发展,比如 Spring 6 引入了更完善的响应式事务支持,Quarkus 把事务做成了编译期织入,Helidon 也在探索新的事务模型。这些新方案各有优劣,适合不同的场景。保持对新技术的关注,在合适的时候做技术升级,是工程师长期保持竞争力的关键。技术是不断演进的,固步自封迟早会被淘汰,但盲目追新也会让项目陷入混乱,这之间的平衡需要长期实践才能找到。
—— 别看了 · 2026