Java 面试追问实战:Spring 事务为什么没按预期回滚?
本篇是一场围绕“下单、写记录、发通知”的教学演练。每轮先预测数据库里最终留下什么,再解释原因。不能只说“注解失效了”,要指出哪次调用经过代理、哪个异常到达拦截器、谁拥有最终的提交边界。
开场:30秒参考回答
我会先确认调用是否经过事务代理、用哪个事务管理器,以及有没有外层事务。未修改规则时,RuntimeException和Error触发回滚,受检异常默认不触发;内部捕获后正常返回,也不能只凭曾经抛过异常判断回滚。同类自调用不会让内层注解重新生效,但可能仍处于外层事务。方法体返回不等于物理提交,提交成功也不等于消息已经送达。
版本、资源与题设先说清
截至 2026-11-06 联网核对:Spring官方版本政策将 7.0.x列为当前生产线,6.2.x开源支持已于2026年6月结束,商业长期支持另有安排。官方文档当前指向 7.0.10稳定版;预览版与仍能访问的旧版本文档不等于当前开源受支持版本。
为与本站题库和 事务边界指南衔接,以下仍按 Spring Framework 6.2语义、JDK 17/21、默认proxy模式、PlatformTransactionManager管理的JDBC本地事务演练。这是明确教学基线,不要求升级现有框架。假定订单与outbox共用一个数据源及同一事务管理器,不讨论JTA、多数据源或响应式上下文。
回滚题还假定没有定制全局规则、没有数据库或其他参与者提前标记回滚。Spring 6.2允许通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS)改变全局默认,因此“受检异常默认提交”必须附上配置条件。见 @Transactional配置说明。
第一轮:public方法上的注解,为什么还不够
面试官:一个Spring Bean的普通入口方法调用 this.save();save是public且有 @Transactional,写一行订单后抛 IllegalStateException,会回滚吗?
先补条件:入口没有事务,也没有类级事务声明;JDBC连接为普通自动提交模式。
参考追答:这次自调用绕过代理,save上的注解没有建立事务。写入已自动提交,后面的Java异常不能把它撤销。应把事务入口放到另一个Bean,通过注入的代理调用;或者让外部经代理调用本来就应作为事务边界的入口。把方法改成public不会改变 this 的调用路径。Spring AOP官方说明
再追问:如果外层入口本身经代理开启了事务呢? save仍在外层已有事务中执行,自调用并不会自动“脱离事务”。此时异常继续逃出外层,符合回滚规则,就会使外层回滚。若内层标 REQUIRES_NEW,自调用也不会凭这个注解另开事务。要在图里写清“外部 → 入口代理 → 目标入口 → this.save”,不能笼统回答“同类调用一定没事务”。
第二轮:只改变异常和捕获位置,结局如何变化
面试官:改为从外部经代理调用save,写入成功后抛异常。你能给出判断表吗?
以下每行都是独立场景,使用默认回滚规则;异常是手工抛出的Java异常,数据库执行本身没有失败:
| 变化 | 拦截器观察到什么 | 正常情况下最终状态 |
|---|---|---|
| IllegalStateException逃出事务方法 | RuntimeException | 回滚 |
| IOException逃出,未配置rollbackFor | 受检异常 | 提交,然后把异常继续交给调用者 |
| IOException逃出,配置rollbackFor = IOException.class | 匹配的受检异常 | 回滚 |
| 在方法内部捕获手工抛出的IllegalStateException,正常返回 | 正常返回,且无rollback-only标记 | 提交 |
| 在代理外的Controller捕获IllegalStateException | 事务拦截器此前已经见到异常 | 不会因为Controller捕获而撤销已作出的回滚决定 |
参考追答:不是按“代码里有没有throw”判断,而是看异常有没有越过事务拦截边界,以及匹配什么规则。规则依据见 Spring官方回滚文档。如果捕获的是数据库异常,数据库或事务参与者可能已改变事务状态,不能直接照搬表中“捕获手工异常”的结论。
第三轮:外层捕获了,为什么还有UnexpectedRollbackException
面试官:外层A有事务,通过另一个Bean的代理调用B,B也是默认REQUIRED。B写入后抛运行时异常,A捕获并继续返回,为什么最后仍失败?
参考追答:A和B的逻辑事务范围共用同一物理事务。在默认管理器行为下,B的拦截器把参与的事务标为rollback-only;A捕获异常并没有清除这个标记。A走到提交边界时实际要回滚,调用方会收到 UnexpectedRollbackException,避免误认为已提交。官方传播机制
继续改变条件:如果B内部自己捕获手工异常,并正常通过B代理返回,且没有其他回滚标记,就不能再推断出同样结果。如果B通过代理使用REQUIRES_NEW,它的物理事务独立,回滚不必然撤销A;但这也改变了业务原子性,并需要额外连接。若B又变成 this.b(),REQUIRES_NEW没有获得代理拦截,不能当作已经隔离。传播选择应按业务决定,不能作为“吞掉异常”的通用补丁。
第四轮:目标方法返回、数据库提交、消息送达是三件事
场景题:订单方法写完SQL,打印“下单成功”,再调用消息接口,最后返回。通知已经发出,但数据库提交失败,如何解释?
参考推理:这里的日志和发送都发生在目标方法体内;拥有事务的拦截器还未完成物理提交。若加入外层REQUIRED事务,内层代理返回后更要等外层结束。把普通消息调用放在最后一行,也没有把消息资源加入数据库事务。SQL成功或flush成功只能证明执行进展,不能证明最终提交。
追问:改成AFTER_COMMIT监听就可靠了吗? @TransactionalEventListener默认在AFTER_COMMIT阶段执行,无事务发布时默认不执行;fallbackExecution = true只是允许无事务执行。它约束触发阶段,不提供持久化投递队列。Spring事务事件官方文档
请把四个状态分开记录:业务数据库提交、生产端发送调用完成、消息系统确认接收、消费端完成业务处理。它们不是同一个成功标记;具体发送API返回意味着什么,还需按所选消息系统的官方契约核对。
| 故障发生点 | 只用内存提交后监听的缺口 | 本轮方案推理 |
|---|---|---|
| 订单提交后,监听发送前进程退出 | 重启后没有内存事件可重放 | 订单与outbox待办在同库同事务内写入,由周期扫描恢复 |
| 消息已发送,outbox尚未标记完成便退出 | 重启可能再次发送 | 以稳定事件ID重试,接收端把去重记录和业务效果原子处理 |
| 消费端收到消息,但业务处理失败 | 生产端“发送成功”不能说明消费完成 | 定义重试、告警与业务对账,按实际协议验证 |
这套outbox时序是对本站已有方案的设计推导,本文没有运行消息中间件或崩溃恢复测试。扫描器还要处理并发领取、重试退避与失败告警。不能仅用“加了outbox”承诺恰好一次;同库写入保证的是业务变更与投递意图一起提交,重复投递仍需处理。
再追问:监听里直接写审计表呢? 提交后原资源可能尚未清理,但不会再随原事务提交。确需新写入,应经另一个Bean代理开启独立事务并处理失败,不能在本轮回调里假设原事务还可继续提交。TransactionalEventListener API警告
还有一个易误判点:直接注册的 TransactionSynchronization.afterCommit 抛运行时异常可以传给提交调用者,但不能撤销已经完成的提交。这个具体API的行为不应不加区分地套给所有事件监听器。收到异常后应按业务幂等键查状态。TransactionSynchronization API
错误答案为何不成立
| 错误回答 | 缺少的条件或步骤 |
|---|---|
| “注解写在public方法上就能生效” | 注解需要被事务基础设施识别,调用还需经过相应代理;自调用路径没有改变 |
| “catch之后一定能提交” | 需检查捕获位置,以及内层代理、数据库是否已经标记rollback-only |
| “方法返回就是提交,异常就是没写进去” | 需区分目标方法、事务拦截器与提交后回调;提交之后仍可能报告回调异常 |
| “AFTER_COMMIT保证消息不丢” | 阶段监听不能恢复进程崩溃丢失的内存事件,也不能证明消费成功 |
可执行练习:六组代理与异常对照
下面是新增的练习建议代码,本篇未编译或运行,表中结果是按规则推导的预期,不是实测记录。 在独立的Maven练习项目中准备JDK 17或21,并使用项目已选定、相互兼容的 spring-context、spring-jdbc、spring-tx 和 com.h2database:h2;Spring模块保持同一版本。H2需在运行classpath中。此练习不需要Web容器、Spring Boot或JUnit,也不改现有项目的框架版本。
将以下代码保存为 TransactionProbe.java。为使连接、代理与默认配置明确,代码自己创建小型Spring容器;每组先清空表,再经代理执行,最后在事务调用结束后用独立连接查询:
import java.io.IOException;
import javax.sql.DataSource;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.*;
import org.springframework.transaction.annotation.*;
public class TransactionProbe {
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = true)
public static class Config {
@Bean public DataSource dataSource() {
return new DriverManagerDataSource(
"jdbc:h2:mem:tx_probe;DB_CLOSE_DELAY=-1", "sa", "");
}
@Bean public JdbcTemplate jdbc(DataSource ds) {
return new JdbcTemplate(ds);
}
@Bean public PlatformTransactionManager transactionManager(DataSource ds) {
return new DataSourceTransactionManager(ds);
}
@Bean public Inner inner(JdbcTemplate jdbc) { return new Inner(jdbc); }
@Bean public Service service(JdbcTemplate jdbc, Inner inner) {
return new Service(jdbc, inner);
}
}
public static class Inner {
private final JdbcTemplate jdbc;
public Inner(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional public void fail() {
jdbc.update("insert into orders(id) values (1)");
throw new IllegalStateException("inner");
}
}
public static class Service {
private final JdbcTemplate jdbc;
private final Inner inner;
public Service(JdbcTemplate jdbc, Inner inner) {
this.jdbc = jdbc; this.inner = inner;
}
private void insert() {
jdbc.update("insert into orders(id) values (1)");
}
public void self() { this.runtime(); }
@Transactional public void runtime() {
insert(); throw new IllegalStateException("runtime");
}
@Transactional public void checked() throws IOException {
insert(); throw new IOException("checked");
}
@Transactional(rollbackFor = IOException.class)
public void checkedRollback() throws IOException {
insert(); throw new IOException("checked");
}
@Transactional public void swallowed() {
insert();
try { throw new IllegalStateException("manual"); }
catch (IllegalStateException expected) { /* 演示内部捕获 */ }
}
@Transactional public void catchInner() {
try { inner.fail(); }
catch (IllegalStateException expected) { /* 内层代理已见到异常 */ }
}
}
@FunctionalInterface interface Action { void run() throws Exception; }
static void probe(String name, JdbcTemplate jdbc, Action action) {
jdbc.update("delete from orders");
String outcome = "normal";
try { action.run(); }
catch (Exception ex) { outcome = ex.getClass().getSimpleName(); }
int rows = jdbc.queryForObject("select count(*) from orders", Integer.class);
System.out.println(name + " -> " + outcome + ", rows=" + rows);
}
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
var jdbc = ctx.getBean(JdbcTemplate.class);
jdbc.execute("create table orders(id int primary key)");
var service = ctx.getBean(Service.class);
probe("self", jdbc, service::self);
probe("runtime", jdbc, service::runtime);
probe("checked", jdbc, service::checked);
probe("checkedRollback", jdbc, service::checkedRollback);
probe("swallowed", jdbc, service::swallowed);
probe("catchInner", jdbc, service::catchInner);
}
}
}在已准备依赖的独立Maven项目根目录,用PowerShell执行;这里使用Windows的classpath分隔符,类文件写入练习目录:
mvn dependency:build-classpath "-Dmdep.outputFile=cp.txt" "-Dmdep.includeScope=test"
if ($LASTEXITCODE -ne 0) { throw "dependency resolution failed" }
$exerciseCp = (Get-Content -LiteralPath cp.txt -Raw).Trim()
javac -encoding UTF-8 -cp "$exerciseCp" TransactionProbe.java
if ($LASTEXITCODE -ne 0) { throw "compilation failed" }
java -cp ".;$exerciseCp" TransactionProbe逐组核对以下预期,框架自己的日志另行保存:
| 组名 | 预期向外结果 | 预期rows | 检验什么 |
|---|---|---|---|
| self | IllegalStateException | 1 | 没有外层事务,自调用绕过事务拦截 |
| runtime | IllegalStateException | 0 | 外部经过代理,默认运行时异常回滚 |
| checked | IOException | 1 | 默认受检异常不回滚,异常仍向外传播 |
| checkedRollback | IOException | 0 | 显式rollbackFor命中 |
| swallowed | normal | 1 | 手工异常在内部捕获,没有回滚标记 |
| catchInner | UnexpectedRollbackException | 0 | 内层REQUIRED经代理标记共享事务回滚 |
如果结果不同,先核对实际Spring版本、全局回滚配置、取到的是不是代理、数据源是否自动提交以及修改过哪些代码。此练习没有使用默认回滚的事务测试方法包住main;在其他测试框架中复现时,也要确认断言发生在真实提交或回滚之后。
继续练习:验证提交与投递的两个窗口
复用 原指南的订单与outbox示意,在自己的练习项目中补齐仓储和扫描器。以下仍是未执行的集成练习建议,不能从上面的六组程序推断已经验证了消息可靠性。
- 固定业务幂等键和事件ID,使订单与pending待办同库同事务写入。在外层REQUIRED调用返回后主动抛运行时异常,再用独立连接检查两者都不存在;提交后发送记录应为空。
- 正常提交,在发送前设置断点并终止进程。重启后不重放原内存事件,只启动周期扫描;检查待办仍存在,消息能够重新进入投递流程。
- 让消息系统确认接收后、更新待办状态前暂停并终止。重启扫描后允许同一事件ID重发,检查消费端去重与业务效果记录,不能只数生产端调用次数。
保留请求、数据库状态、事件ID、故障位置、重启日志和仍未覆盖的窗口。扫描恢复需要你实际实现并运行,不能仅凭设计图把这三项填为完成。
项目表达示例:先讲验证范围
这是完成对应练习后可按真实记录填写的表达模板,不是作者经历:
我在本地订单练习中对照了自调用、代理调用、受检异常和内层REQUIRED回滚。通过方法外的数据库查询核对最终状态,区分了内部捕获与内层代理已标记回滚的情况。消息部分,我把订单与投递意图放在同库事务中;【填入实际完成的故障注入及记录】。尚未验证的中间件故障或并发领取路径,我会单独说明。
只运行六组程序时,就只讲六组结果;未做投递练习就说“设计了恢复流程,尚未验证”。如果被问“能保证消息只处理一次吗”,应回答具体幂等存储、事务边界和测试窗口,而不是把数据库提交与消费成功合为一个承诺。
相关深阅读与资料
- Spring事务边界指南:继续核对回调、物理提交和outbox的适用条件。
- 自测工作台:先做同类调用与提交后事件两题,再用本篇变化条件反问自己。
- Java面试资料:保存自己的原回答、修订与证据;练习代码尚无已验证的事务下载包。
- Java面试路线与 工程指南:把代理、数据库、幂等与排障连接到同一条请求链。
