Spring 事务题很容易被答成“加一个 @Transactional 就行”。但真实面试里,面试官往往会继续问:为什么注解加了却没回滚?为什么同一个类里调用事务方法不生效?为什么一个方法回滚会把外层也带回去?这时如果只说数据库支持事务,就已经偏题了。
Spring 声明式事务的关键在代理。你调用的不是一个带魔法的普通方法,而是经过代理对象织入了开启事务、提交、回滚等逻辑。理解这个边界,很多“事务失效”问题就能解释清楚。
自调用为什么容易失效
如果一个类里的方法 A 直接调用本类方法 B,调用路径没有经过 Spring 代理对象,B 上的事务增强就可能不会执行。表现上看像是注解失效,本质是调用边界不对。
解决方式不应该机械背“把方法拆到另一个 Service”。更准确的取舍是:如果 B 确实是独立事务语义,把它抽成另一个 Bean 是合理的;如果只是为了让注解生效而拆,说明业务边界可能还没想清楚。事务边界最好和业务动作一致,比如创建订单、扣减库存、写流水,而不是围绕工具方法随意切。
回滚规则不是所有异常都一样
默认情况下,Spring 对运行时异常和 Error 回滚,对受检异常不一定回滚。很多项目里远程调用、文件处理、业务校验会抛出各种异常,如果没有明确 rollbackFor,可能出现代码抛错但事务提交的情况。
面试里可以把这点讲成工程习惯:事务方法要明确哪些异常代表业务失败,哪些异常可以被捕获后转成失败状态。如果 catch 了异常却不重新抛出,事务管理器可能认为方法正常结束,就会提交。这不是数据库的问题,是异常语义被吞掉了。
传播行为要看业务关系
REQUIRED 是最常见的传播行为,能加入外层事务就加入,没有就新建。REQUIRES_NEW 会挂起外层事务,开启一个新事务。NESTED 依赖数据库保存点能力,和 REQUIRES_NEW 不是一回事。
这些概念放到业务里才有意义。订单主流程失败时,订单数据应该回滚;但审计日志、失败流水、风控记录可能希望独立保存,用 REQUIRES_NEW 才合理。反过来,如果把核心库存扣减放进新事务,外层订单失败后库存已经提交,就可能制造更难处理的不一致。
异步线程是另一个常见边界
把任务丢到线程池、发事件、用 @Async,都会让执行线程发生变化。事务上下文通常绑定在线程上,不能默认跨线程延续。外层事务还没提交,异步任务就去读数据,可能读不到;外层事务最终回滚,异步任务却已经发了消息或写了日志,也会出问题。
因此事务和异步要搭配事件发布、消息表、事务提交后回调或补偿机制设计。不能把“异步提高性能”和“事务保证一致性”混在一起想。
回答时要从一致性目标出发
一段成熟回答可以这样组织:先说明 Spring 事务基于代理增强,调用必须经过代理边界;再说明回滚依赖异常传播和 rollback 规则;然后结合业务选择传播行为;最后补充异步、消息和外部系统不能天然参与本地事务,需要用最终一致或补偿闭环。
这样讲,事务就不只是一个注解,而是 Java 后端系统里最核心的一致性设计。