Spring 事务边界:逻辑作用域、物理事务与 rollback-only
“方法抛异常就回滚”只描述了最简单的一条路径。真实事故常发生在更深一层:内层异常已经捕获,外层提交时却得到 UnexpectedRollbackException;换成 REQUIRES_NEW 后回滚隔离了,连接池却被占满;提交后发送消息失败,数据库与下游状态永久分叉;异步任务读不到刚才绑定的事务资源。要解释这些现象,必须同时区分方法上的逻辑事务作用域、资源管理器持有的物理事务,以及线程或反应式上下文中的资源绑定。
Spring 声明式事务是 AOP Advisor。同步命令式路径通常由 TransactionInterceptor 进入 TransactionAspectSupport,解析事务属性,选择 PlatformTransactionManager,获取或加入事务,调用目标方法,再根据返回或异常决定提交/回滚。官方 声明式事务参考 给出模型;传播行为说明 特别强调逻辑作用域、独立物理事务和保存点的差异。
事务拦截器建立边界,资源管理器执行物理动作
@Transactional 本身不打开连接。事务属性源把注解或其他元数据解析成传播级别、隔离级别、超时、只读标记与回滚规则;事务管理器针对 JDBC、JPA、JTA 或反应式资源实现 begin、suspend、resume、commit、rollback。命令式事务常借助 TransactionSynchronizationManager 把资源 holder 和同步回调绑定到当前线程。
线程绑定是实现事实,也是边界提示:同一线程中的普通嵌套调用可以发现已有资源,换线程后默认没有。它不意味着所有数据库操作自动参与事务——数据访问组件必须使用与该管理器协作的数据源/会话获取方式。手工从另一个未受管理的数据源取连接,或在同一方法里同时访问两个没有全局协调的资源,会产生两个独立提交点。
事务名、只读和隔离级别也不是数据库无条件保证。readOnly 常是优化提示或连接设置,不是安全沙箱;数据库和驱动是否拒绝写入取决于具体实现。隔离级别只在新建物理事务时生效,加入既有事务的内层声明通常不能偷偷改变外层隔离。需要严格校验时,应启用对现有事务属性不兼容的验证,或者重新划分物理边界。
REQUIRED 共享物理事务,却保留多个逻辑投票点
默认 REQUIRED 在没有事务时新建,在已有事务时加入。外层和内层每个被拦截方法都有逻辑作用域,但它们操作同一物理事务。内层遇到符合回滚规则的异常时,可以把共享事务标记为 rollback-only;即使外层捕获异常并正常返回,也不能把已经判定必须回滚的物理事务提交。
examples/backend-development/spring-framework/transactions/RollbackOnlyDemo.java 将“异常已捕获但事务不能提交”固定成模型:
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-framework/transactions/TransactionPropagationDemo.java examples/backend-development/spring-framework/transactions/RollbackOnlyDemo.java
java -cp examples/backend-development/spring-framework/transactions RollbackOnlyDemoinnerRollbackOnly=true outerRequestedCommit=true committed=false unexpectedRollback=trueUnexpectedRollbackException 不是额外故障,而是在外层请求提交时明确告知:内层逻辑作用域已经投了回滚票,不能谎报成功。若框架静默回滚并正常返回,调用者会以为数据已提交。修复方向不是捕获该异常后忽略,而是决定内层失败究竟应让整个用例失败、转成业务结果,还是拥有独立物理事务。
回滚规则默认关注未检查异常和 Error;检查异常是否回滚要按显式规则判断。规则应以类型为主,字符串模式可能误匹配相近类名或嵌套类。更重要的是,只有异常穿过事务拦截器或代码显式设置 rollback-only,框架才能据此决策。目标方法把异常捕获并返回“成功”,事务拦截器没有理由猜测业务失败。
REQUIRES_NEW 悬挂外层资源,并额外占用一套资源
REQUIRES_NEW 总是启动独立物理事务。若当前已有事务,管理器先悬挂外层事务及其绑定资源,内层获得新的连接/会话,完成后再恢复外层。它的提交和回滚不改变外层 rollback-only,内层也可以使用自己的隔离、超时和只读设置。
第二个实验展示两个物理资源和外层恢复:
java -cp examples/backend-development/spring-framework/transactions TransactionPropagationDemorequiredResource=outer-connection requiresNewResource=inner-new-connection outerResumed=outer-connection physicalTransactions=2它的代价经常被低估。假设并发处理线程数为 T,每个外层事务占一个连接,同时最多嵌套 K 个尚未完成的 REQUIRES_NEW,仅这条路径的理论上界接近 T × (1 + K),还未计入健康检查、定时任务和其他请求。若 100 个线程各持有外层连接,再等待内层连接,而池只有 100,所有线程都可能互相等待。官方建议至少让连接池容量超过并发线程数;工程上还应依据嵌套深度、超时和其他负载做容量预算,而不是把“多一个”理解成全局只多一个。
独立事务适合需要独立落库、且能接受外层最终失败的动作,例如审计尝试记录。但如果外层回滚后该记录会误导读者,它仍不是正确边界。不要用 REQUIRES_NEW 修复所有回滚问题:它改变原子性、可见性、锁持有时间与连接需求,是业务语义决定,不是异常消除技巧。
NESTED 使用保存点,不等于开启另一笔事务
NESTED 通常在一个物理 JDBC 事务内创建保存点。内层失败可以回滚到保存点,外层继续;最终是否提交仍由外层物理事务决定。它不会像 REQUIRES_NEW 那样让内层结果提前独立可见,也不需要同时持有第二个连接。支持程度依赖事务管理器、资源和驱动,不能假定 JPA/JTA 场景都有同样能力。
保存点只能回滚数据库在该连接上的变更,不能撤销已经发出的 HTTP 请求、消息、邮件或文件写入。即使数据库回到保存点,外部系统仍可能已经观察到副作用。因此 NESTED 适合局部数据库操作的容错,不是通用分布式补偿机制。
传播行为应通过调用矩阵验收:无现有事务与有现有事务各测一次;内层正常、抛异常、捕获异常、显式 rollback-only 各测一次;同时断言物理连接 identity、提交/回滚次数和外层最终结果。只看表里有没有数据,无法区分加入、挂起和保存点。
代理边界决定注解能否进入事务基础设施
同类 this.inner() 自调用不重新经过代理,因此 inner 上的新传播行为不会启动。非 public、final 或不可代理方法也要结合代理策略判断。对象若由 new、反序列化或非 Spring 工厂创建,其注解只是元数据,没有事务 Advisor。应把需要独立边界的方法拆到协作 Bean,并从代理外部调用;测试要同时断言代理存在和事务行为。
方法可见性支持随 Spring 版本与代理类型演进,架构规则仍应保持简单:将事务放在清晰、可外部调用的应用服务边界,不依赖隐蔽的可见性例外。接口与实现上的注解位置也应统一,避免切换代理策略后元数据解析发生变化。
@Async、手工线程池、CompletableFuture 默认换线程,命令式事务上下文不会自动跟随。外层方法返回时可能已经提交,而异步任务之后才访问数据库。反应式事务使用 Reactor Context,而不是普通 ThreadLocal,要求反应式事务管理器和受支持返回类型;在线程模型之间复制 ThreadLocal 不是可靠适配。
提交只是数据库边界,不是跨系统完成证明
在事务内直接调用远程服务有两个问题:远程延迟延长锁和连接占用;数据库最终回滚时,远程副作用不能自动撤销。声明式事务不会把事务上下文传播到远程调用,也不会把两个普通资源自动升级成分布式事务。把 HTTP header 命名为 transaction-id 只提供关联,不提供原子提交。
需要“数据库提交后再做”的本地动作可以注册 TransactionSynchronization,理解 beforeCommit、afterCommit、afterCompletion 的时机。但 afterCommit 回调失败时数据库已经提交,不能回滚;进程在提交与回调之间崩溃时,回调可能永远不执行。它适合缓存失效、局部通知等可重建动作,不适合要求可靠投递的唯一机制。
可靠跨系统发布通常使用事务发件箱:业务数据与 outbox 记录在同一数据库事务提交,独立发布器读取、发送并标记,消费者按事件 id 幂等。这样把不可消除的双写窗口改造成可重试状态机。仍需治理积压、重复投递、乱序、毒消息和保留周期;“使用 MQ”本身不会自动解决双写。
超时、锁与重试必须作为一个系统设计
事务超时不是 SQL 驱动、连接池和远程调用超时的替代品。数据库语句可能受 query timeout 约束,锁等待有独立设置,连接获取有池超时,远程调用又有自己的 deadline。外层事务超时 30 秒而连接获取等待 30 秒,会把整个预算耗尽在业务执行之前。应从请求总 deadline 反推各层预算,并在取消或超时时确保资源解绑。
重试只适合可识别的瞬时失败,并且必须围绕正确事务边界。一次死锁或序列化失败后,当前事务通常已不可继续;重试应重新进入代理并创建新事务。若重试切面位于事务切面内部,它可能在同一已失败物理事务中再次执行。幂等键、唯一约束和请求去重是重试安全的前提,不能靠“异常概率很低”代替。
长事务会扩大锁范围、MVCC 版本保留和连接占用。批处理需要明确每批提交规模、失败恢复游标和重跑语义;把百万行循环包进一个事务,既不是更原子,也不是更安全。读取后长时间计算再更新还可能产生丢失更新,应使用版本字段、条件更新或匹配业务不变量的锁策略。
生产证据要覆盖逻辑作用域与物理资源
可观测性至少记录:事务名、传播行为、是否新事务、是否 rollback-only、物理资源标识的脱敏关联、持续时间、挂起/恢复次数、提交/回滚结果和异常类型。连接池同时观察 active、idle、pending、获取耗时与超时,才能识别 REQUIRES_NEW 的资源饥饿。日志不应输出 SQL 参数中的秘密,也不要为每个短事务无采样打印完整堆栈。
故障排查可以沿以下顺序收敛:先确认调用经过事务代理;再确认选中的事务管理器和数据源;读取传播行为及是否已有事务;确认目标异常是否越过拦截器、是否被包装;检查 rollback-only 首次设置位置;最后比对连接池、锁等待和提交日志。若同时使用多个管理器,必须显式记录 manager qualifier,避免“事务存在但管错资源”。
测试完成条件不是 @Transactional 注解存在,而是每个关键用例都能回答:几个逻辑作用域、几笔物理事务、占用几套资源、谁能投回滚票、异常在何处转译、提交后副作用如何重试。把这些问题写进架构边界,Spring 事务才是可证明的一致性工具,而不是一层乐观包装。
