Spring 事务怎样生效?为什么加了 @Transactional 仍不回滚?

资深 Java 面试到 Agent 开发工程师 · 第 17 集

上一集讲到,Spring 最后交给调用方的对象可能是代理。事务就常通过这层代理生效:调用先进入事务拦截器,再执行目标方法,最后根据结果提交或回滚。

假设保存订单以后,后续步骤失败:

java 复制代码
@Transactional
public void place() {
    jdbcTemplate.update("insert into orders(id) values (?)", 1);
    throw new IllegalStateException("后续处理失败");
}

从容器取得服务,通过代理调用 place(),默认回滚规则会让这次插入撤销。但如果在同一个对象里写 this.place(),或者把异常捕获后正常返回,结果就可能不同。

注解没有直接把方法变成数据库事务。要判断能否回滚,需要看调用是否经过事务拦截、SQL 是否参加了它管理的事务,以及拦截器最终看到了什么结果。

代理在什么时候创建,调用又怎样进来?

AOP 把事务、日志等处理放在业务方法调用的前后。切点描述哪些方法适用,通知描述要做的处理;Advisor 把匹配条件和处理逻辑组织在一起。事务 Advisor 就把事务属性检查与 TransactionInterceptor 关联起来。

启用注解事务后,自动代理创建器作为 Bean 后处理器参与容器创建。常规流程中,它检查这个 Bean 是否有适用的 Advisor,需要增强时返回代理。没有匹配到的方法,不会仅凭"对象是代理"就自动获得事务。

这里有两种常见代理方式:

方式 代理对外提供什么 需要注意的条件
JDK 动态代理 目标实现的接口 通过代理接口暴露方法,调用方按接口类型使用
CGLIB 类代理 目标类的子类 不能继承 final 类,也不能通过覆盖拦截 final、private 方法

代理方式由配置和目标类型共同决定;开启类代理后,即使目标实现了接口,也可以采用 CGLIB。以下事务示例使用 public 方法,通过容器提供的代理对外调用。

外部拿到的服务引用指向代理,代理把调用交给拦截器和目标对象。目标方法里执行 this.place(),接收调用的是目标对象自身,没有重新从代理入口经过。换成类代理也不会自动消除 Spring AOP 的这种自调用问题。

拦截器链怎样执行到业务方法?

以 JDK 代理为例,入口是 JdkDynamicAopProxy.invoke():根据目标方法取得适用的拦截器链,再由 ReflectiveMethodInvocation.proceed() 继续执行。

Spring 5.3.23 的 proceed()包含以下关键语句。动态匹配分支在节选中省略:

java 复制代码
if (this.currentInterceptorIndex ==
        this.interceptorsAndDynamicMethodMatchers.size() - 1) {
    return invokeJoinpoint(); // 拦截器执行完,调用目标方法
}
Object interceptorOrInterceptionAdvice =
        this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);
// 省略带运行时条件的匹配分支;普通拦截器分支如下
return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);

currentInterceptorIndex 记录执行到哪一个拦截器;列表保存这次方法调用需要的处理。还没走完,就取下一个拦截器;已经走完,才执行 invokeJoinpoint() 调目标方法。

拦截器在自己的逻辑中调用 proceed(),就能把执行权交给后面的处理。目标方法返回或抛异常后,执行过程再返回先前的拦截器,因此事务代码可以包住业务方法。

TransactionInterceptor.invoke() 会进入 TransactionAspectSupport.invokeWithinTransaction()。它先读取方法及目标类型对应的事务属性,选定事务管理器,再安排目标调用和完成处理。属性中包括传播行为、只读标志、超时和回滚规则;只有注解、却没有配置生效的事务基础设施,不能直接得到这套处理。

事务管理器到底怎样控制数据库?

先看本地 JDBC 事务。DataSource 是提供数据库连接的数据源对象。DataSourceTransactionManager 管理其中一个数据源的连接资源;业务使用同一个数据源的 JdbcTemplate 时,可以取到当前线程已经绑定的连接。

创建新事务时,管理器取得连接,需要时关闭自动提交。这样执行一条 SQL 后,数据库不会立刻把它提交,后面才能决定一起提交还是回滚。管理器还会把连接和事务信息保存到当前线程,供业务代码取用。以下是 doBegin()中的两个位置,省略日志、中间配置和状态处理:

java 复制代码
if (con.getAutoCommit()) {
    txObject.setMustRestoreAutoCommit(true);
    con.setAutoCommit(false);
}
// 省略中间配置
if (txObject.isNewConnectionHolder()) { // 这次新取得的连接资源才在这里绑定
    TransactionSynchronizationManager.bindResource(
            obtainDataSource(), txObject.getConnectionHolder());
}

con 是 JDBC 连接;txObject 记录这次事务使用的连接及需要恢复的状态;ConnectionHolder 是保存连接和事务信息的包装对象。资源按数据源绑定;后续操作需要通过同一个数据源找到这条事务连接。

JdbcTemplate 经 DataSourceUtils 获取连接,先找当前线程里同一个数据源绑定的资源,于是同一事务中的多次更新能使用同一连接。提交时调用该连接的 commit(),回滚时调用 rollback();完成后再解除绑定、恢复需要恢复的连接状态并释放连接。连接池中的连接通常被归还到池中。

如果业务自己另取了一条不参与这项资源管理的连接,或切到另一个线程执行 SQL,就不能仅凭外层注解认定它们属于同一事务。新的线程不会自动继承这种线程绑定的事务;HTTP、Redis 等外部操作也不会因为 JDBC 回滚自动撤销。

抛了异常,拦截器为什么仍可能提交?

invokeWithinTransaction()中,普通命令式事务的关键路径如下,省略特殊返回值处理等分支:

java 复制代码
TransactionInfo txInfo = createTransactionIfNecessary(
        ptm, txAttr, joinpointIdentification);
Object retVal;
try {
    retVal = invocation.proceedWithInvocation(); // 继续执行,最终进入目标方法
}
catch (Throwable ex) {
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
}
finally {
    cleanupTransactionInfo(txInfo);
}
// 省略特殊返回值检查
commitTransactionAfterReturning(txInfo);
return retVal;

ptm 是事务管理器,txAttr 是事务属性,txInfo 保存当前事务调用的信息。createTransactionIfNecessary() 会按传播行为取得事务状态,可能创建,也可能参加已有事务;不是每次看到注解都重新开一个数据库事务。

目标方法抛异常,拦截器进入 completeTransactionAfterThrowing(),根据回滚规则决定怎么完成事务。目标方法正常返回,才继续走后面的提交处理。方法名里写着 commit,也不保证数据库一定提交;事务已被标为只能回滚时,管理器还会按状态回滚。

这里的 cleanupTransactionInfo() 恢复事务切面自己的调用信息,不是把 JDBC 连接提前关闭。数据库资源的解绑和释放由事务管理器在实际完成处理中负责。

默认回滚判断在 DefaultTransactionAttribute.rollbackOn():

java 复制代码
return (ex instanceof RuntimeException || ex instanceof Error);

因此,未被自定义规则覆盖时,RuntimeException、Error 触发回滚,受检异常默认不触发。throws Exception 只是 Java 的方法声明,没有修改事务规则。业务确实要求受检异常也回滚,可以写 @Transactional(rollbackFor = Exception.class),并确认异常穿过拦截器。

目标方法实际结果 没有其他回滚标记时的处理
抛出 RuntimeException 或 Error 回滚并继续抛异常
抛出受检异常 默认提交,异常仍继续抛出
本方法 catch 异常后正常返回 拦截器看到正常返回,走提交处理
配置 rollbackFor 且异常匹配 回滚并继续抛异常

如果这次失败要求撤销写入,可以让异常继续抛出,并配置匹配的回滚规则;必须在方法内捕获时,也可以通过当前事务状态显式标记回滚,向上层返回失败结果。只打印异常后正常返回,通常会让拦截器继续提交。

同类调用为什么不生效?先看外层有没有事务

java 复制代码
public void submit() {
    this.place();
}

@Transactional
public void place() {
    jdbcTemplate.update("insert into orders(id) values (?)", 1);
    throw new IllegalStateException("后续处理失败");
}

外部调用没有事务的 submit(),再由目标对象内部调用 place(),不会再次经过事务拦截器。若本来就没有事务,插入可能按连接的自动提交执行,随后抛异常也无法让这次已经提交的插入回滚。

如果外部先通过代理进入一个带事务的外层方法,里面的 this.place() 仍在同线程使用外层事务连接。失去的是内层方法单独触发事务属性的机会。例如内层标成 REQUIRES_NEW,自调用也不会把它变成一份新事务。

可以把需要事务边界的操作放到另一个职责明确的 Bean,注入后调用它的代理。需要在一段代码中直接控制事务时,也可以用 TransactionTemplate:把数据库操作放进回调,由模板负责开始和完成事务。修改后用失败路径重新查库,确认写入是否撤销。

catch 住异常,为什么外层最后还是回滚?

再换一种调用:外层 OrderService.place() 已经开事务,它通过另一个 Bean 的代理调用 StockService.reserve(),两边都是默认的 REQUIRED。

REQUIRED 表示有事务就参加,没有才新建。这里有两个事务方法的拦截边界,但共用同一个物理事务。库存方法抛出运行时异常,异常穿过库存代理时,事务管理器在默认配置下会把共同事务标成 rollback-only,也就是"这份事务不能再提交"。

订单方法随后 catch,不能把已经设好的回滚标记抹掉。订单代理最后尝试提交,管理器发现这个状态,回滚并抛出 UnexpectedRollbackException。它是在告诉调用方:你期待提交,但实际已经回滚。

这个检查在 AbstractPlatformTransactionManager.commit() 中。下面保留回滚标记分支,省略日志:

java 复制代码
if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
    // 共享事务已经只能回滚,外层不能继续提交
    processRollback(defStatus, true);
    return;
}

defStatus 是当前事务状态;这里的 global 指共享物理事务上的回滚标记,不是在说分布式事务。processRollback(..., true) 执行回滚,并在这条路径向调用方报告意外回滚。所以外层 catch 改变了 Java 异常的走向,却没有清除连接资源上已经记录的事务状态。

如果库存操作确实需要独立事务,通过另一 Bean 的代理用 REQUIRES_NEW,外层事务会挂起,内层新建事务;在这里采用的本地 JDBC 管理器下,会用另一条连接。内层失败回滚,外层捕获异常后,可以按自己的状态继续提交。内层若成功提交,外层以后回滚也撤不回内层的提交。

事务分开,不会阻止异常向外传播。内层异常如果继续抛到外层,且符合外层的回滚规则,外层仍会回滚。

这也改变了业务含义:订单与库存不再保证同一事务一起成功。挂起外层也没有释放外层持有的连接,锁可能仍在,额外连接还可能造成连接池压力。订单与库存需要一起成功时,应该让整笔事务失败,再由调用方处理失败结果;改成 REQUIRES_NEW 会破坏这个要求。

用数据库结果验证,而不只看日志

下面是一组 H2 内存数据库上的 Spring JDBC 对照,每种情况单独执行,调用结束后再查实际记录。

调用方式 实际结果
外部通过代理调用,抛运行时异常 插入回滚,记录数 0
外层无事务,this 调用内层注解方法 内层没有获得注解事务,记录保留 1
抛受检异常,未配置 rollbackFor 异常抛出,记录保留 1
受检异常匹配 rollbackFor 插入回滚,记录数 0
事务方法本身捕获异常并正常返回 没有其他回滚标记时,记录保留 1
外层 catch 另一个 REQUIRED 方法抛出的运行时异常 共同事务回滚,抛 UnexpectedRollbackException,记录数 0
另一个 REQUIRES_NEW 方法失败,外层 catch 后完成自己的两次写入 内层记录 0,外层记录 2

工作中遇到"没有回滚",先确认服务引用及调用路径,再看实际事务管理器、数据源和执行线程;之后核对异常是否穿过拦截器、回滚规则和 rollback-only。用独立查询检查成功、失败后的数据状态,避免只凭"打印过异常"判断事务结果。

常见面试题怎么答?

1. Spring AOP 怎样拦截方法?为什么自调用会绕过?

容器交付代理,外部调用先进入代理,再按匹配结果执行拦截器链,最后进入目标方法。目标内部的 this 调用没有重新经过代理,所以那次调用不会单独触发增强。若外层已经有事务,内部操作仍可能参加外层事务,不能说自调用永远没有事务。

2. @Transactional 怎样让多条 SQL 一起回滚?

事务拦截器读取属性并交给事务管理器。本地 JDBC 管理器取得或参加事务,同线程同数据源的 JdbcTemplate 操作复用事务连接;异常符合规则时调用连接回滚。是否生效还取决于调用经过代理、连接参加了同一事务,以及实际的异常和状态。

3. catch 住异常后,事务会提交吗?

看异常在哪一层被捕获。本事务方法自身捕获后正常返回,且没有别的回滚标记时通常走提交;异常已经穿过另一个参与事务的代理并设为 rollback-only,外层再捕获也无法恢复提交,最终可能收到 UnexpectedRollbackException。

4. REQUIRED 与 REQUIRES_NEW 怎样影响外层事务?

REQUIRED 参加已有事务,内层失败可能把共同事务标为回滚;REQUIRES_NEW 挂起外层再新建,内外事务可以分别提交或回滚,但异常继续向外抛时仍可能让外层回滚;它还需要额外资源,也改变了一起成功的业务约束。两者属性都要通过有效拦截或显式事务 API 才会被处理。

读完自测

1. 外层事务方法内部 this 调用一个 REQUIRES_NEW 方法,外层最后抛运行时异常,内层写入能保留吗?

参考要点:自调用没有触发新事务,内层仍使用外层事务连接时,其写入和外层一起回滚。需要独立事务时要改变有效事务调用边界,并重新评估业务一致性。

2. 方法声明 throws Exception,实际抛出受检异常,默认会回滚吗?

参考要点:方法声明不改变规则。受检异常默认不触发回滚;若业务要求撤销,配置匹配的 rollbackFor,并确保异常到达拦截器、验证实际数据。

3. 外层 catch 了另一个 REQUIRED 服务的异常,随后正常返回,却收到 UnexpectedRollbackException,先查什么?

参考要点:检查异常是否已经穿过内层事务代理并将共同事务设为 rollback-only。外层正常返回不能清除这个标记,不能只在外层再套 catch 就把事务恢复成可提交。

参考:Spring AOP 代理机制、声明式事务、事务传播。源码节选对应 Spring Framework 5.3.23,正文讨论普通命令式、基于代理的本地 JDBC 事务。

相关推荐
学到老活到老1 小时前
Spring 怎样创建 Bean?@Autowired 在哪一步生效?
java·spring
用户112936112191 小时前
我扫了项目里 739 个 Controller,发现最危险的越权漏洞不是没认证
java
小羊没烦恼!1 小时前
分布式事务 | 使用DTM 的Saga 模式
java·linux·运维·数据库·wpf
JPower_mr.g1 小时前
SmartCall v1.0.9 发布:智能体知识库上线,让 AI 客服先查资料再回答
java·ide·人工智能·macos·架构·开源·xcode
SL_staff1 小时前
破除执行黑箱:用目标地图、资源热力图与健康度看板构建可下钻的执行事实链
java·开源·全栈
骑着蜗牛撵大象3272 小时前
让 Codex 值夜班:把告警排查与巡检接成可回滚的自动化链路
android·java·运维·ai·自动化·codex·智能体
木头科技2 小时前
【AI 工程化第十八篇】Spring AI 语音 Agent 实战:实时转写、WebSocket、流式 TTS 与打断机制
java·人工智能·安全·spring·性能优化
步行cgn2 小时前
Spring MVC 核心配置文件详解:springmvc.xml 每一行配置的意义
xml·spring·mvc
cfm_29142 小时前
SpringPrototype作用域
java