Spring Boot 4 全家桶教程 · 第 6 篇配套工程:声明式事务的边界在哪里。
事务注解是 Spring 里最"看起来不用学"的一个东西 —— 加个 @Transactional 就完事了。
但这个工程存在的理由恰恰是:它不生效的时候不会报错。异常照抛、接口照返回、
日志干干净净,只有两个地方看得出问题:数据库里的数据,和事务的 DEBUG 日志。
而这两样平时都没人看。
所以这一篇不写业务,只做实验。工程里准备了 40 多个接口,每个接口对应一个明确的问题, 跑完看余额就知道答案。所有结论都配了可运行的断言(46 个测试用例)。
| 主题 | 具体做法 |
|---|---|
| 回滚规则 | 默认只回滚 RuntimeException 和 Error —— 受检异常抛出去,事务照常提交 |
| 纠错 | rollbackFor / noRollbackFor 的默认值是空数组,不是"Throwable 的子类" |
| 传播行为 | 七个级别全配了接口,重点对照 REQUIRED / REQUIRES_NEW / NESTED 三者的失败路径 |
| 事务失效 | 五类场景:同类内部调用、方法可见性、异常被吞、换线程、事务管理器名字写错 |
| 只读事务 | readOnly = true 在这套技术栈上真的拦得住写,但拦在 JDBC 驱动的客户端 |
| 隔离级别 | Isolation.DEFAULT 的真实语义是"什么都不设",不是"设成数据库默认值" |
| 编程式事务 | TransactionTemplate:能 setRollbackOnly() 而不抛异常,受检异常反而必然回滚 |
| 事务事件 | @TransactionalEventListener 四个相位的实测差异,以及 Framework 6.1 起的启动期强制校验 |
| 探针取证 | 用 TransactionSynchronizationManager + CONNECTION_ID() + hasSavepoint() 读运行时状态 |
| 检查接口 | /api/accounts/transaction-managers 直接告诉你容器里有几个事务管理器、叫什么名字 |
| 组件 | 版本 |
|---|---|
| JDK | 21 |
| Maven | 3.9+ |
| Spring Boot | 4.1.1(Spring Framework 7.0.9、HikariCP 7.0.2) |
| MyBatis-Plus | 3.5.17(mybatis-plus-spring-boot4-starter) |
| MySQL | 8.0+(本工程在 8.4 + Connector/J 9.7.0 上验证) |
只需要一个库。事务这个主题不需要多数据源,
@Transactional 不写名字时的"按类型找唯一实例"规则,在第 5 篇里已经用两个库讲过了。
为什么不用 H2 跑:本篇一半的结论依赖具体数据库和驱动的行为 ——
readOnly 拦不拦得住写、隔离级别有没有落到连接上、NESTED 会不会真的建 savepoint、
子线程拿到的是不是另一条物理连接。换 H2 跑出来的结论不能代表 MySQL。
两个脚本,都在 src/main/resources/sql/ 下:
| 文件 | 作用 |
|---|---|
schema.sql |
建两张表:t_account(账户)、t_tx_event(事务事件) |
data.sql |
四个账户的种子数据 |
-- 库
CREATE DATABASE IF NOT EXISTS springboot4_tx DEFAULT CHARACTER SET utf8mb4;
-- 表
CREATE TABLE IF NOT EXISTS t_account (
id BIGINT NOT NULL AUTO_INCREMENT,
owner VARCHAR(32) NOT NULL,
balance DECIMAL(12, 2) NOT NULL,
version INT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_owner (owner)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
-- 种子:四个账户,总额恒为 2000.00
INSERT INTO t_account (id, owner, balance, version, updated_at)
VALUES (1, '熊大', 1000.00, 0, NOW(3)),
(2, '熊二', 500.00, 0, NOW(3)),
(3, '熊三', 300.00, 0, NOW(3)),
(4, '熊四', 200.00, 0, NOW(3));不过不用手工执行:spring.sql.init.mode=always,应用每次启动都会跑一遍
schema.sql + data.sql,把余额刷回固定初值。脚本留着是为了让你能单独重置数据。
因为传播行为要演示"内外层":
- 外层统一操作 1 号(熊大)→ 2 号(熊二),金额 100.00
- 内层统一操作 3 号(熊三)→ 4 号(熊四),金额 50.00
分成两对账户不是随意的。 如果内外层都动同一对账户,
REQUIRES_NEW 那条新连接会去等外层还没提交的行锁,实验最终以锁等待超时收场 ——
你看到的是 InnoDB 的锁行为,不是传播行为。
分成两对之后,读结果变得极其简单:
| 看谁 | 余额是初值 | 余额变了 |
|---|---|---|
| 熊大(1000.00) | 外层回滚了 | 外层提交了 |
| 熊三(300.00) | 内层回滚了 | 内层提交了 |
这是整篇最容易写错、也最容易骗自己的一点。
假设一个方法内部先转账、然后读余额来判断"提交了没有"—— 读到的当然是转账后的值。未提交的事务当然读得到自己写的值, 这跟它最终会不会提交没有任何关系。
所以工程把读余额这件事单独抽出来了:
- 所有
service层方法只记录过程,不返回余额; ScenarioRunner#record在业务调用返回、事务已经结束之后才去读余额和总额;- 四个账户的
total每次都会一起返回。转账不改变总额,它是一道恒定成立的护栏 —— 这个数字变了说明有笔转账只落了一半,比单独看某个账户更早发现问题。
# 方式一:Maven 直接运行
.workbuddy/tools/mvn.sh spring-boot:run
# 方式二:打包后运行
.workbuddy/tools/mvn.sh -DskipTests package
java -jar target/springboot4-transaction-1.0.0.jar默认端口 8080,数据库指向 192.168.1.97:3306,都可以用环境变量覆盖:
SERVER_PORT=18086 DB_HOST=127.0.0.1 DB_PASSWORD=xxx java -jar target/springboot4-transaction-1.0.0.jarapplication.yml 里已经把这几行打开了:
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.transaction.interceptor: DEBUG
org.springframework.jdbc.support.JdbcTransactionManager: DEBUG为什么必须手动开:事务的每一步都有日志,但一条都不在 INFO 级。 默认配置下"事务到底开没开"是看不见的 —— 这正是它容易出问题却难发现的原因。
打开之后你能看到这些(都是原文):
Creating new transaction with name [com.xncoding.transaction.service.RollbackService.plainCommit]
Participating in existing transaction
Suspending current transaction
Creating nested transaction with name [...]
Resuming suspended transaction after completion of inner transaction
Initiating transaction commit
Initiating transaction rollback
Transactional code has requested rollback
Participating transaction failed - marking existing transaction as rollback-only
Switching JDBC Connection [...]
Releasing JDBC Connection [...]
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/accounts |
当前余额 + 总额 |
| POST | /api/accounts/reset |
刷回固定初值 |
| GET | /api/accounts/transaction-managers |
容器里有几个事务管理器、叫什么名字、什么类型 |
| 路径 | 预期结果 |
|---|---|
/plain |
提交。熊大 900.00(对照组) |
/runtime |
回滚,余额不动 |
/checked |
提交,熊大 900.00,异常照抛 |
/checked-rollback-for |
回滚 |
/no-rollback-for |
提交,异常照抛 |
/exception-class |
回滚 |
| 路径 | 熊大 | 熊三 | 说明 |
|---|---|---|---|
/required-inner-fails-caught |
1000.00 | 300.00 | 抛 UnexpectedRollbackException |
/required-inner-fails-not-caught |
1000.00 | 300.00 | 异常穿出 |
/required-inner-succeeds |
900.00 | 250.00 | 对照组 |
/requires-new-inner-fails-caught |
900.00 | 300.00 | 外层提交成功 |
/nested-inner-fails-caught |
900.00 | 300.00 | 退回 savepoint |
/nested-inner-succeeds |
900.00 | 250.00 | savepoint 白打 |
/not-supported-inner |
1000.00 | 250.00 | 内层那笔留下了 |
/mandatory-without-transaction |
1000.00 | 300.00 | IllegalTransactionStateException |
/never-inside-transaction |
1000.00 | 300.00 | IllegalTransactionStateException |
/supports-outside-transaction |
1000.00 | 250.00 | 不开事务 |
/supports-inside-transaction |
900.00 | 250.00 | 加入外层 |
| 路径 | 熊大 | 结论 |
|---|---|---|
/self-invocation |
900.00 | this 调用,失效 |
/self-invocation-fixed |
1000.00 | 取代理再调,生效 |
/public-method |
1000.00 | 生效 |
/protected-method |
1000.00 | 生效 |
/package-private-method |
1000.00 | 生效 |
/final-method |
— | 抛 NPE(见坑 5) |
/final-method-with-params |
900.00 | 失效,钱留下 |
/static-method |
900.00 | 失效 |
/swallowed |
900.00 | 异常被吞,失效 |
/rethrow |
1000.00 | 生效 |
/rethrow-as-checked |
900.00 | 包成受检异常,失效 |
/swallowed-but-marked |
1000.00 | 生效(setRollbackOnly) |
/child-thread |
1000.00(熊三 250.00) | 子线程那笔留下 |
/child-thread-with-own-transaction |
1000.00(熊三 250.00) | 子线程独立提交 |
/manager-anonymous |
900.00 | 按类型找唯一实例 |
/manager-named |
900.00 | 按名字取 |
/manager-wrong-name |
1000.00 | NoSuchBeanDefinitionException |
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/readonly/writable |
对照:没打 readOnly |
| GET | /api/readonly/read |
只读事务读 |
| GET | /api/readonly/write |
只读事务里写入,被驱动拦下 |
| GET | /api/isolation |
四种隔离级别一次看全 |
| POST | /api/programmatic/commit |
编程式:正常提交 |
| POST | /api/programmatic/rollback-by-flag |
setRollbackOnly(),HTTP 200 但回滚 |
| POST | /api/programmatic/rollback-by-exception |
抛异常回滚 |
| POST | /api/programmatic/rollback-by-wrapped-checked |
受检异常包装后抛 → 必然回滚 |
| POST | /api/programmatic/anonymous-class |
匿名内部类写法 |
| POST | /api/programmatic/requires-new |
用 REQUIRES_NEW 模板 |
| POST | /api/events/commit |
事件:事务提交(4 条) |
| POST | /api/events/rollback |
事件:事务回滚(BEFORE_COMMIT 消失) |
| POST | /api/events/no-transaction |
事件:没有事务(1 条) |
| GET | /api/events |
事件表当前内容 |
每个场景返回的 steps 里都带一行事务状态。为了不靠猜,这个工程用了三个探针:
| 探针 | 读什么 | 能回答什么问题 |
|---|---|---|
TransactionSynchronizationManager |
纯内存状态:有没有事务、事务名、只读标记、隔离级别 | 当前线程上到底绑没绑事务 |
CONNECTION_ID() / DATABASE() |
真去数据库问当前连接 | 两笔操作在不在同一条物理连接上 |
TransactionAspectSupport.currentTransactionStatus() |
事务内部结构 | 有没有 savepoint(NESTED 的唯一直接证据) |
一次典型的读数长这样:
事务=有, 事务名=com.xncoding.transaction.service.PropagationOuterService.requiredInnerSucceeds,
只读=false, 隔离级别=跟随数据库, 连接=8990, savepoint=false
对照 NESTED 的那次:
事务=有, 事务名=com.xncoding.transaction.service.PropagationOuterService.nestedInnerSucceeds,
只读=false, 隔离级别=跟随数据库, 连接=8990, savepoint=true
savepoint=false → savepoint=true,就是 NESTED 真的生效了的全部证据。
连接 ID 全程 8990 没变,说明它没用新连接 —— 这正是 NESTED 和 REQUIRES_NEW 的分野。
先把这个流传很广的错误说法清掉。@Transactional 的 rollbackFor /
noRollbackFor 默认值都是空数组。真正决定回不回滚的是另一处:
// DefaultTransactionAttribute#rollbackOn
return (ex instanceof RuntimeException || ex instanceof Error);也就是说:受检异常抛出去,事务照样提交。异常会正常传播给调用方, 所以调用方以为失败了,数据却已经落库。这是本篇最值钱的一个坑 —— 它的表现和"成功"几乎一样,只有查库才看得出来。
跑一下 /api/rollback/checked 就知道了:
{
"scenario": "默认规则 · 受检异常",
"steps": [
{ "label": "调用抛出 TransferRejectedException",
"detail": "message = 故意抛受检异常,看它回不回滚" }
],
"accounts": [
{ "id": 1, "owner": "熊大", "balance": 900.00 },
{ "id": 2, "owner": "熊二", "balance": 600.00 }
],
"total": 2000.00
}异常抛了,钱也扣了。规矩可以定得很简单:方法签名上只要出现 throws,
rollbackFor 就必须写。
/api/failure/rethrow-as-checked 演示的是比"忘了抛"更隐蔽的一种:
try {
throw new BalanceNotEnoughException("业务上失败了");
} catch (BalanceNotEnoughException e) {
// 看起来"我明明抛出去了"
throw new TransferRejectedException("包装后的受检异常", e);
}异常确实抛出去了,调用方也收到了,HTTP 也返回了 422。
但类型从 RuntimeException 换成了受检异常,默认回滚规则就不认了 —— 钱留下了。
为了匹配方法签名而顺手做的一次类型转换,足以把回滚弄丢。
修法有两种:原样重新抛,或者吞掉异常但显式调
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
这个异常的成因和报错位置是分开的,所以特别难查。
@Transactional
public void outer() {
move(1L, 2L, 100.00);
try {
inner.requiredFails(); // 内层 REQUIRED,和外层是同一条事务
} catch (BalanceNotEnoughException e) {
// "我 catch 了,所以没事"
}
// 走到这里,提交时抛 UnexpectedRollbackException
}内外层是同一条事务。内层抛异常时,事务被标成 rollback-only; 这个标记不是异常,catch 不掉。到提交的那一刻事务管理器才发现问题, 于是抛:
Transaction rolled back because it has been marked as rollback-only
DEBUG 日志里能提前看到征兆:
Participating transaction failed - marking existing transaction as rollback-only
Setting JDBC transaction [...] rollback-only
Global transaction is marked as rollback-only but transactional code requested commit
看到 UnexpectedRollbackException,就去内层找那个被吞掉的异常。
这句话流传极广,但它把两个独立的条件混成了一个。拆开看:
| 修饰符 | Spring 当事务方法 | CGLIB 能覆写 | 实际结果 |
|---|---|---|---|
public |
是 | 是 | 生效 |
protected |
是 | 是 | 生效 |
| 包私有 | 是 | 是 | 生效 |
public final |
是 | 否 | 失效 |
static |
是 | 否 | 失效 |
private |
是 | 否 | 失效 |
关键在于 AnnotationTransactionAttributeSource 的 publicMethodsOnly 字段。
它的默认值确实是 true,但 @EnableTransactionManagement 走的不是无参构造:
// AbstractTransactionManagementConfiguration#transactionAttributeSource
AnnotationTransactionAttributeSource source = new AnnotationTransactionAttributeSource(false);false 就是 publicMethodsOnly。所以从 Spring 6.0 起,
protected 和包私有方法一样会被事务拦截器考虑,再加上 CGLIB 能覆写它们,
两个条件都满足,事务就生效了。
跑一下对比 /api/failure/protected-method 和 /api/failure/package-private-method:
两者的熊大余额都是 1000.00,也就是那笔转账真的回滚了。
唯一的前提是走了 CGLIB 代理。如果项目里把
spring.aop.proxy-target-class设成false改用 JDK 动态代理,这些非 public 方法在代理对象上根本不存在,会一起失效。
这是这个工程里最意外的发现。
/api/failure/final-method 返回的不是"事务失效、钱留下",而是:
java.lang.NullPointerException: Cannot invoke
"com.xncoding.transaction.support.TxProbe.summary()" because "this.probe" is null
原因链有三步:
- CGLIB 覆写不了
final方法,代理类里没有它的覆盖版本; - 于是调用落在代理实例自己身上,而不是被转发给目标对象;
- Spring 用 Objenesis 创建 CGLIB 代理实例,不走目标类的构造器 ——
所以代理实例自己的实例字段全是默认值,也就是
null。
正常调用时完全看不出来,因为代理会把请求转给真正的目标对象(字段都在它身上)。
只有 final 这种"绕过转发"的路径才会撞上。
排错时这条极其容易把人带偏:报的是 NPE,看起来像依赖注入没生效, 根因却是"方法被声明成了 final"。
/api/failure/final-method-with-params 是同一个方法的另一半:
把依赖从实例字段换成方法参数,NPE 消失,露出真实结果 —— 熊大 900.00,事务没生效。
所有失效场景里频率最高的一个,原因却很简单:事务靠代理实现,
而 this.xxx() 里的 this 是原始对象。
判断标准可以缩成一句话:这次调用有没有经过那个被注入到别处的对象。
工程里给了修复版的接口,detail 里会打印实际类型:
实际类型 = com.xncoding.transaction.failure.SelfInvocationService$$SpringCGLIB$$0
留意 $$SpringCGLIB$$ 这个后缀 —— 它也是"Boot 默认用 CGLIB 代理"的直接证据。
/api/failure/child-thread 里,主线程在事务里转出 100.00,
子线程又转出 50.00,然后主线程抛异常。
结果是:主线程那 100.00 回滚了,子线程那 50.00 留在库里。
子线程读到的事务状态是"无" —— 它从连接池另借一条连接、以自动提交的方式跑完。
换成虚拟线程、@Async、CompletableFuture、并行流,结果一模一样:
绑的是线程,不是具体哪种线程。
正确做法不是"想办法把事务传过去"(传不过去,也不该传),
而是让子线程有自己的事务边界 —— 把 TransactionTemplate 传进子线程里执行。
但要说清楚:这修的是"子线程内部要么全成要么全败",
跨线程没有原子性可言,主事务和子事务之间的一致性得靠补偿、对账这类业务手段处理。
@TransactionalEventListener 的 AFTER_COMMIT 相位里要写库,必须配
@Transactional(propagation = REQUIRES_NEW)。不写传播行为的话,
原事务已经结束,REQUIRED 会当成"加入已有事务"处理,
SQL 执行了却没人在最后提交它 —— 程序不报错,数据凭空消失。
Spring Framework 6.1 起,这种写法从"静默丢数据"改成了"启动就报错":
@EnableTransactionManagement 现在注册的是
RestrictedTransactionalEventListenerFactory,它会在启动阶段校验每一个
@TransactionalEventListener 方法。工程里 TrapListener 就是这个反面教材,
只在 listener-trap profile 下加载:
java -jar target/springboot4-transaction-1.0.0.jar \
--server.port=18087 --spring.profiles.active=listener-trap进程退出码 1,报错原文:
org.springframework.beans.factory.BeanInitializationException: Failed to process @EventListener
annotation on bean with name 'trapListener': @TransactionalEventListener method must not be
annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED:
public void com.xncoding.transaction.event.TrapListener.onAfterCommit(...AccountChangedEvent)
Caused by: java.lang.IllegalStateException: @TransactionalEventListener method must not be
annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: ...
LedgerListener(正式的监听器)里的四条 @Transactional 是按规则写对的,
注释里逐条说明了原因。这个校验看着严格,其实是在救你。
先看探针读数 —— 两层都变了:
Spring 声明=true, 数据库会话=只读
写入被拒绝:
TransientDataAccessResourceException:
### Cause: java.sql.SQLException: Connection is read-only.
Queries leading to data modification are not allowed
### SQL: UPDATE t_account SET balance = balance + ? ... WHERE id = ?
关键在于这个错是谁报的。追一下文案来源:
它在 com.mysql.cj.LocalizedErrorMessages.properties 里
(Statement.27 + Statement.28 拼起来),由 com.mysql.cj.jdbc.StatementImpl 抛出。
也就是说,拦截发生在 JDBC 驱动的客户端,不是 MySQL 服务端。
@@session.transaction_read_only 读出来是 1,说明 Connector/J 的 setReadOnly
确实发出了 SET SESSION TRANSACTION READ ONLY。
结论:这条防线依赖"驱动 + 连接池"的实现,换一套就可能只剩语义。
但在这套技术栈上它是真拦的,而且异常是 RuntimeException,事务顺带回滚 ——
这一点和"受检异常不回滚"那条规则正好错开,别混起来记。
四种声明的实测读数:
| 声明 | Spring 侧读数 | 数据库会话 |
|---|---|---|
Isolation.DEFAULT |
null |
REPEATABLE-READ(MySQL 默认) |
READ_COMMITTED |
2 |
READ-COMMITTED |
REPEATABLE_READ |
4 |
REPEATABLE-READ |
SERIALIZABLE |
8 |
SERIALIZABLE |
第一行和第二行看着"结果一样",过程完全不同:DEFAULT 连
SET TRANSACTION ISOLATION LEVEL 这句都没发,数据库保持会话默认值。
换一个默认级别不是 REPEATABLE-READ 的数据库,两者的行为就分开了。
还有一个前提条件:隔离级别是在事务开始、借到连接之后才落上去的。
如果当前方法是加进外层已有事务的(传播行为不是 REQUIRES_NEW / NESTED),
连接早就借好了,这句 SET 不会再执行 —— 声明的隔离级别被静默忽略。
readOnly 同理。
第一,受检异常必然回滚。 不是 Spring 改了规则,而是
TransactionCallback 的方法签名里没有 throws Exception,
受检异常根本传不出 lambda,你必须把它包成 RuntimeException 才抛得出去。
一旦包了就是运行时异常,默认规则自然判它回滚。
于是同一个受检异常出现了两种结果:
| 写法 | 结果 |
|---|---|
声明式 @Transactional + 抛受检异常 |
提交 |
编程式 TransactionTemplate + 抛受检异常 |
回滚(因为必须先包装) |
第二,编程式事务在日志里没有名字。 打开 DEBUG 日志会看到:
Creating new transaction with name [null]
因为 TransactionTemplate 默认不设 name(声明式事务的名字是
TransactionAspectSupport 从"类名 + 方法名"拼出来的)。想让它有名字:
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setName("audit-log"); // 日志里就变成 Creating new transaction with name [audit-log]排查生产问题时这一行能省不少事。
Boot 4.0 迁移指南原文:
spring.dao.exceptiontranslation.enabledis no longer supported. Please usespring.persistence.exceptiontranslation.enabledinstead.
旧名带 level=error 的废弃标记,写进 yml 会直接启动失败。
不过这里有个 Boot 4 自己的不一致:DataSourceTransactionManagerAutoConfiguration
里创建事务管理器的那段代码,读的仍然是旧名:
// 反编译 JdbcTransactionManagerConfiguration#createTransactionManager 得到
environment.getProperty("spring.dao.exceptiontranslation.enabled", Boolean.class, Boolean.TRUE)matchIfMissing = true,所以默认 new 出来的是 JdbcTransactionManager。
它继承自 DataSourceTransactionManager,额外做一层
SQLException → Spring DataAccessException 的翻译。
GET /api/accounts/transaction-managers 能直接看到这个结果:
{ "count": 1, "managers": "transactionManager(JdbcTransactionManager)" }顺带说明:Boot 4 里旧的 DataSourceTransactionManagerConfiguration 分支已被移除,
只剩 JdbcTransactionManagerConfiguration。
@Transactional("txManager") // 容器里只有 transactionManager
public void transfer() { ... }编译通过、启动通过,第一次调用这个方法时才炸:
NoSuchBeanDefinitionException: No bean named 'txManager' available:
No matching TransactionManager bean found for qualifier 'txManager'
- neither qualifier match nor bean name match!
而且那笔转账根本没执行 —— 事务管理器在进方法体之前就要确定。
为什么不在启动时发现?ProxyTransactionManagementConfiguration 只在
txManager 字段非空时才把管理器塞进拦截器,而那个字段只有实现了
TransactionManagementConfigurer 才会被填。默认情况下它是 null,
所以拦截器每次都到调用现场才去容器里找。
工程里的测试直接把这件事断言掉了:
assertThat(interceptor.getTransactionManager())
.as("默认没有绑定:所以名字写错不会在启动时暴露,只会在第一次调用时报错")
.isNull();另外,value 和 transactionManager 互为 @AliasFor:
@Transactional("transactionManager")
@Transactional(transactionManager = "transactionManager")
// 完全等价,不存在"简写会走另一条路"46 个用例,全是真的连 MySQL 跑,不需要额外启动服务。
# 先重置库,保证种子数据干净
bash .workbuddy/reset-transaction-db.sh
.workbuddy/tools/mvn.sh test| 测试类 | 用例数 | 覆盖什么 |
|---|---|---|
RollbackRuleTest |
6 | 默认回滚规则、rollbackFor / noRollbackFor |
PropagationBehaviourTest |
11 | 七个传播级别 + 各自的失败路径 |
TransactionalProxyFailureTest |
17 | 五类失效场景 + 各自的修复写法 |
TransactionInfrastructureTest |
12 | 容器事实、两个纠错点、只读、隔离级别、事件相位 |
为什么必须用真实 MySQL:这套测试里一半的断言是"某个行为真的发生了",
而不是"框架抛了某个异常"。比如 assertThat(balance(result, "熊大")).isEqualByComparingTo("900.00")
断言的是"钱留下了" —— 用 H2 跑出来的结论不能代表 MySQL + Connector/J 的行为。
这组测试有一个特点:它们是绿的,恰恰说明坑还在。 失效场景的"正确行为"就是失效本身。哪天框架收紧了行为、钱不退回了, 用例会变红 —— 那正是提醒我们文章该改的时候。
TransactionInfrastructureTest 里有两条纠错断言,可以直接当文档看:
@Test
@DisplayName("纠错:@Transactional.rollbackFor 的默认值是空数组,不是「Throwable 的子类」")
void rollbackForDefaultIsEmptyArray() throws Exception {
Method rollbackFor = Transactional.class.getMethod("rollbackFor");
assertThat(rollbackFor.getDefaultValue()).isInstanceOf(Class[].class);
assertThat((Class<?>[]) rollbackFor.getDefaultValue()).isEmpty();
}
@Test
@DisplayName("纠错:默认只回滚 RuntimeException 和 Error,受检异常一律提交")
void defaultRollbackRule() {
DefaultTransactionAttribute attribute = new DefaultTransactionAttribute();
assertThat(attribute.rollbackOn(new RuntimeException("运行时异常"))).isTrue();
assertThat(attribute.rollbackOn(new Error("错误"))).isTrue();
assertThat(attribute.rollbackOn(new Exception("受检异常"))).isFalse();
}springboot4-transaction/
├── pom.xml
├── README.md
└── src/
├── main/
│ ├── java/com/xncoding/transaction/
│ │ ├── TransactionApplication.java # 没有任何一行事务配置
│ │ ├── controller/
│ │ │ ├── AccountController.java # 余额 / 重置 / 事务管理器清单
│ │ │ ├── RollbackController.java # 回滚规则 6 个
│ │ │ ├── PropagationController.java # 传播行为 11 个
│ │ │ ├── FailureController.java # 失效场景 17 个
│ │ │ └── AdvancedController.java # 只读 / 隔离 / 编程式 / 事件
│ │ ├── service/
│ │ │ ├── AccountService.java # 无事务的基础层,被所有场景复用
│ │ │ ├── RollbackService.java # 回滚规则
│ │ │ ├── PropagationInnerService.java # 传播行为:内层
│ │ │ ├── PropagationOuterService.java # 传播行为:外层
│ │ │ ├── ReadOnlyService.java
│ │ │ ├── IsolationService.java
│ │ │ ├── ProgrammaticService.java # TransactionTemplate
│ │ │ └── TransactionEventService.java # 事件发布
│ │ ├── event/
│ │ │ ├── AccountChangedEvent.java
│ │ │ ├── TransactionEventRecorder.java # 四个相位各一个监听器
│ │ │ └── TrapListener.java # 反面教材,只在 listener-trap profile 加载
│ │ ├── failure/ # 五类失效场景
│ │ │ ├── SelfInvocationService.java
│ │ │ ├── NonPublicService.java
│ │ │ ├── NonPublicCaller.java # 同包调用方,用来够到 protected 方法
│ │ │ ├── SwallowedExceptionService.java
│ │ │ ├── ChildThreadService.java
│ │ │ └── ManagerNameService.java
│ │ ├── support/
│ │ │ ├── TxProbe.java # 三层探针
│ │ │ ├── ScenarioRunner.java # 重置 → 执行 → 事务外读余额 + 总额
│ │ │ └── TransactionManagerInspector.java # 列出容器里的事务管理器
│ │ ├── mapper/ # 全注解 SQL,无 XML
│ │ ├── domain/ dto/ exception/
│ └── resources/
│ ├── application.yml
│ └── sql/{schema.sql, data.sql}
└── test/
├── java/com/xncoding/transaction/
│ ├── RollbackRuleTest.java
│ ├── PropagationBehaviourTest.java
│ ├── TransactionalProxyFailureTest.java
│ ├── TransactionInfrastructureTest.java
│ └── TestSupport.java
└── resources/application-test.yml
三个设计上的选择值得说一下:
AccountService 不带事务。 它是被所有带事务的服务复用的数据访问层。
这样事务边界全部落在调用它的方法上,"谁开的事务"在代码里一眼可见,
不会藏在某个下层方法里。
演示方法的步骤列表从外面传进来,而不是自己 new 一个返回。 因为有一半方法会以抛异常收场,而抛异常时返回值是拿不到的 —— 步骤存在局部变量里就会跟着异常一起丢,恰恰是"异常被 catch 住了"这种步骤最能说明问题。
所有场景都走 ScenarioRunner。 它固定做三件事,顺序不能换:
先重置、再执行、最后在事务之外读余额。
老工程里的这几条现在都不成立了:
| 旧写法 | 现在 |
|---|---|
spring.datasource.schema / .data |
改用 spring.sql.init.schema-locations / .data-locations,旧名是 level=error 的废弃项 |
spring.dao.exceptiontranslation.enabled |
改名成 spring.persistence.exceptiontranslation.enabled |
@EnableTransactionManagement(order = 2) |
不用手工加了,Boot 自动配置会按 proxyTargetClass=true(CGLIB)注册;要改默认回滚规则用 rollbackOn |
| 认为"非 public 方法事务失效" | protected / 包私有其实生效,只有 private / final / static 失效 |
认为 readOnly = true 只是个提示 |
在 MySQL + Connector/J 上它真的拦写 |
AFTER_COMMIT 里写库不写 REQUIRES_NEW |
Framework 6.1 起直接启动报错 |
断言 rollbackFor 默认是 Throwable 子类 |
默认是空数组,默认只回滚 RuntimeException 和 Error |
MIT License,署名 Xiong Neng。