qinghuan
← 从 Java 到只读商户 Agent

JAVA 后端 · 事务与事件 · 连续实验

事务与事件:从代理入口到崩溃恢复

先确认业务写入由谁提交,再检查提交后的动作何时发生。最后真正停止实验进程,观察恢复依据与重复处理。

第 01 节 / 共 4 节

事务代理入口与回滚

目标: 能用真实数据库结果说明注解是否生效,区分“抛出异常”和“写入已撤销”。先会写一个 Spring Bean、执行一条 INSERT,并读过事务调用边界。已有文章的原理与模拟保留,本节补真实 Spring 实验。

版本与核查: 2026-10-23 JST。lab/ 锁定 Java 17、Spring Framework 6.2.11、HikariCP 5.1.0、H2 2.4.240。今天官方 reference 显示 7.0.10,此前记录的 7.0.9 已不是该入口当前显示版本;文档更新不意味着本实验或你的项目升级了。

把代理理解成方法门口的一道检查

Spring 从容器交给调用者的对象可能是一层代理。外部调用先经过它,代理按方法的事务配置开始或加入事务,执行业务,再处理提交或回滚。默认代理模式下,同一个对象里直接调用自己的方法,不会重新经过这道检查。外层已经有事务时,原事务仍可能在;“自调用”不能直接翻译成“整个调用链没有事务”。官方代理说明

这次用 AnnotationConfigApplicationContext、@EnableTransactionManagement 和 DataSourceTransactionManager 真正创建代理。业务通过 JdbcTemplate 使用同一数据源。JUnit 测试自身没有事务,测试结束也不会替我们偷偷回滚。最终行数由服务调用结束后的独立连接读取。

最小 Java 片段

下列代码直接摘自下载包 lab/src/main/java/me/qinghuan/transactions/Lab.java 的 Probe,字段和完整配置在同一文件。active 只是记录观察结果,不控制事务。

文中示例 · java
@Transactional public void runtimeFailure() {
  active = TransactionSynchronizationManager.isActualTransactionActive();
  jdbc.update("insert into probe values ('runtime')");
  throw new IllegalStateException("deliberate runtime failure");
}
public void selfCall() { runtimeFailure(); }
@Transactional public void transactionalSelfCall() { runtimeFailure(); }

同一条写入和同一个异常,改变入口就能形成三个对照:外部直接调用 runtimeFailure();外部调用无事务的 selfCall();外部调用有事务的 transactionalSelfCall()。

调用路径方法内观察到事务调用结束后行数
外部代理 → 事务方法是0
外部入口 → 无事务方法 → 自调用否1
外部代理 → 有事务外层 → 自调用是0

这是本次锁定环境的实际断言结果。第二行的 JDBC 写入以自动提交完成,后面的 Java 异常不能把它撤销。

受检异常要单独验

默认规则通常让 RuntimeException 和 Error 回滚,普通受检异常默认不回滚。源码另有两个抛 BusinessChecked 的方法:checkedDefault() 只有 @Transactional,checkedRollback() 使用 @Transactional(rollbackFor = BusinessChecked.class)。本次结果分别留下 1 行和 0 行。

异常必须到达代理边界,规则才能按这次异常处理。Spring 6.2 起支持 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 改全局默认;本实验没有设置它。接入你的项目前,应同时核对方法规则、全局规则和实际管理器,不能强行套本表。官方回滚规则

按这四步练

  1. 解压练习包,先按 README 运行 lab/。找到 LabTest 的前五项测试,画出外部调用和自调用箭头。
  2. 在执行前预测三个入口的事务状态和行数,再读测试断言。不要在测试方法上补 @Transactional。
  3. 只修改受检异常的回滚规则,再运行相关测试。若实际结果和原表不同,记录你改变了哪条规则。
  4. 交付调用路径、异常类型、方法内事务状态和调用结束后的行数。保留实际失败,不把红色测试改成无断言打印。

验收: 能解释三个入口为什么不同,并独立说明受检异常在当前规则下是否回滚。常见误区: 看到注解就认定已开启事务;看到异常就认定数据库已撤销;把自调用误说成外层事务消失。

边界: 验证限定默认代理、单数据源和线程绑定 JDBC 事务;没有验证 AspectJ、响应式事务或多个管理器。数据库回滚不会自动撤销 Redis、MQ、HTTP 或模型调用。下一节继续追踪两个事务方法到底共用几条连接。

第 02 节 / 共 4 节

事务传播与连接池占用

目标: 能从 A、B 两条记录的最终状态,解释共享事务、独立事务和保存点;能复现一次有时限的连接等待。前置是上一节代理实验,以及JDBC 与连接池。本节补真实调用,不重复原文章的传播状态模拟或通用容量讨论。

版本与核查: 2026-10-23 JST;继续使用 lab/ 的 Spring 6.2.11、DataSourceTransactionManager、H2 2.4.240 和 HikariCP 5.1.0。官方传播 reference 当日显示 7.0.10。官方传播说明

用一笔共同账和两笔独立账区分

REQUIRED 在已有事务中通常加入同一笔物理事务。两个方法是两段逻辑范围,不意味着数据库有两次独立提交。内层经代理抛出会回滚的异常后,共用事务可被标成 rollback-only,意思是“只能回滚”。外层捕获异常并正常返回,并不能清掉这个标记。

REQUIRES_NEW 让内层有自己的物理事务。它回滚后,外层可以继续提交;它已提交后,外层后来回滚也不能撤销内层。业务必须接受这种独立结果。NESTED 在本次 JDBC 管理器中使用保存点,仍属于同一物理事务,不是内层提前独立提交。

最小 Java 片段

源码中的外层先写 A,再调用另一个 Spring Bean;内层写 B 后抛异常。下面三个方法都在 Lab.Inner,共同调用的 writeAndFail() 也在下载源码中。

文中示例 · java
@Transactional public void requiredFailure() { writeAndFail(); }
@Transactional(propagation = Propagation.REQUIRES_NEW) public void newFailure() { writeAndFail(); }
@Transactional(propagation = Propagation.NESTED) public void nestedFailure() { writeAndFail(); }
private void writeAndFail() { jdbc.update("insert into probe values ('B')"); throw new IllegalStateException("B failed"); }

外层 Outer.catches() 捕获 IllegalStateException 后正常返回。测试再观察调用者收到什么,以及数据库实际留下什么。

内层传播最外层调用者观察最终数据
REQUIREDUnexpectedRollbackExceptionA、B 都没有
REQUIRES_NEW正常返回只有 A
NESTED正常返回只有 A;本次池容量 1

这些对照都已在真实 Spring/JDBC 中通过。第三行只证明这套 H2 与 JDBC 管理器支持保存点,不证明 JPA、JTA 或任意数据库都支持同样的嵌套事务。

再看连接去了哪里

外层执行 INSERT 后已经持有一条连接。进入 REQUIRES_NEW 时,暂停的是事务关联,不是把外层连接归还给池;内层还需要另一条连接。源码的受控入口是:

文中示例 · java
@Transactional public void needsSecondConnection() {
  jdbc.update("insert into probe values ('A')"); // actually borrows the outer connection
  inner.newSuccess();
}

池容量 1、获取超时 400ms 时,本次出现 CannotCreateTransactionException,根因为 SQLTransientConnectionException,A、B 最终都是 0 行。池容量 2 的单线程对照留下两行。实测等待值在运行记录中;调度和机器负载会让它偏离精确的 400ms,不应断言毫秒级固定耗时。

按这四步练

  1. 阅读两个 Bean 的调用链,确认没有用自调用“切换”传播配置。
  2. 运行 REQUIRED、REQUIRES_NEW、NESTED 三项测试,给每条 SQL 标出物理事务和连接所属范围。
  3. 运行连接池两项测试;先预测为什么容量 1 失败,再查异常因果链和最终行数。
  4. 把并发请求数、嵌套深度和其他任务的连接需求列为生产评估输入,解释为什么单线程池 2 成功不是生产配置答案。

验收: 能说明 catch 为什么救不了已标记回滚的共享事务,为什么独立内层要多借连接,以及保存点为什么不等于独立提交。误区: 用 REQUIRES_NEW 无条件修复任何报错;把连接获取等待算作 SQL 执行;靠无限增加池容量掩盖互等。

边界: 未做并发容量、数据库死锁或吞吐压测;没有给现有项目调整连接池。下一节会用已理解的独立事务,在主业务提交后保存审计记录。

第 03 节 / 共 4 节

事务事件阶段与提交后写入

目标: 能区分事务提交、监听器执行和审计记录落库三个时刻。前置是代理与传播两节;业务场景是“商户修改提交后再记一条审计”,不需要 MQ。

版本与核查: 2026-10-23 JST。真实实验仍为 Spring 6.2.11、H2 2.4.240、HikariCP 5.1.0。重新阅读了当日 7.0.10 Transaction-bound Events 与固定版本 6.2.11 监听器 API。7.0.10 API 地址本次抓取失败,没有把它记成已读。

先判断事件在哪条路径发布

普通 @EventListener 不等于事务阶段监听。@TransactionalEventListener 默认绑定 AFTER_COMMIT。本次将同一种 Changed 事件分别放进成功提交、业务抛异常回滚、无事务三条路径,默认监听的新增调用次数为 1、0、0。

fallbackExecution = true 允许无事务时处理事件,但这是另一种语义:没有一笔原业务事务可以等待提交。实验用单独的 Fallback 事件验证该开关,避免把两类监听次数混在一起。

阶段本次成功事务本次业务直接抛错回滚
BEFORE_COMMIT调用 1 次0 次
AFTER_COMMIT调用 1 次0 次
AFTER_ROLLBACK0 次调用 1 次
AFTER_COMPLETION调用 1 次调用 1 次

这是本次固定失败点的结果。BEFORE_COMMIT 被调用也不是提交成功凭证:后面的提交仍可能失败。

提交后的写入,需要自己的明确事务

监听器 API 提醒:完成阶段虽然还能访问某些原事务资源,但原事务已经结束,不再为这些写入安排一次正常提交。不要把监听器打印了“保存成功”,直接当成审计已可靠保存。

本次实验还保留了一个反直觉观察:监听器直接用原 JdbcTemplate 写审计,监听器内独立连接读取为 0 行,主调用完成清理后读取却变成 1 行。固定版本 DataSourceTransactionManager 的清理代码会恢复 autoCommit=true;JDBC 规定,事务中改变自动提交模式会提交当前事务。这解释了本次现象,也说明“最终一定没有行”不是可以推广的结论。管理器清理源码;JDBC setAutoCommit

这种依赖清理的表面成功,不应成为业务契约。正确对照由监听器调用另一个代理 Bean 的 Audit.save(),明确开启新事务:

文中示例 · java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(String id) { jdbc.update("insert into audit values (?)", id); }

Listeners.afterCommit() 中的 audit.save(event.id()) 真实经过这个代理。测试确认主业务 1 行、审计 1 行;连接需求仍要按上一节考虑。完整代码包含无事务、回滚、默认阶段和 fallback 对照。

按这四步练

  1. 运行 eventsRunOnlyAfterSuccessfulCommitByDefault,分别记录三条发布路径的调用次数及商户行数。
  2. 阅读四种阶段的断言,说明 BEFORE_COMMIT 和 AFTER_COMMIT 分别位于哪一侧。
  3. 对照 recordActualSameResourceOutcomeInsteadOfAssumingPortability 和 newTransactionCommitsAuditAfterBusinessCommit。记录监听器内独立读、主调用结束后独立读,不能只保留最后一张截图。
  4. 另行设计审计写入失败用例:主业务已经提交,应如何向用户呈现、如何补偿?这个新增练习本页未替你执行。

验收: 能解释 1、0、0 的来源,能说明 fallback 改变了什么,并能区分“原业务已提交”与“后续审计已提交”。误区: 把事务事件当普通事件;把无事务默认不处理当作监听器坏了;依赖驱动清理保证审计成功。

边界: 当前是同进程、线程绑定事务;没有验证响应式上下文,也没有进程外消息队列。事务事件本身不持久保存待办,提交后进程退出仍可能来不及处理。下一节把这个窗口变成可恢复的登记记录。

第 04 节 / 共 4 节

事件发布持久化与崩溃恢复

目标: 通过真正终止测试子进程,区分“业务已经提交”“事件尚未完成”和“效果已经做过”。前置为前三节以及本地 outbox 练习。旧练习已讲同事务登记、稳定事件 ID 和本地去重,本节只补 Spring Modulith 真实登记表、状态恢复与进程故障。

版本与核查: 2026-10-23 JST。本节单独放在 durable-lab/,锁定 Java 21、Spring Boot 4.1.1、Spring Framework 7.0.9、Spring Modulith 2.1.1、H2 2.4.240、HikariCP 7.0.2。它与前三节的 Spring 6.2.11 实验是两个独立项目,不混用依赖。

官方兼容性附录虽显示 2.1.1,表格仍停留在 2.0 snapshot,不能据此宣称完整兼容矩阵已更新。本实验依据 2.1.1 标签 POM 中 Boot 4.1.1、Framework 7.0.9 组合另行锁定并运行;这不构成网站或现有业务项目升级建议。

多存一条“谁还没处理”的记录

Spring Modulith 的 Event Publication Registry 会在发布时为相应事务监听器登记待处理记录,并让登记参加原业务事务。监听成功后完成登记;失败或中断留下恢复线索。其 2.x 生命周期区分 PUBLISHED、PROCESSING、COMPLETED、FAILED、RESUBMITTED。持久记录是恢复依据,恢复何时运行仍须配置。官方事件文档

本实验的业务方法和上一节很像,事件不是我们手工 INSERT 到框架表里的。下面直接摘自 DurableApp.Publisher:

文中示例 · java
@Transactional public void change(boolean fail) {
  jdbc.update("insert into merchant values ('merchant-1')");
  events.publishEvent(new Changed("event-1", "merchant-1"));
  if (fail) throw new IllegalStateException("rollback business and publication together");
}

JDBC starter 真实创建并管理 EVENT_PUBLICATION。监听器使用 @ApplicationModuleListener。为形成第二个故障窗口,监听器通过独立 Effect 代理先提交本地效果,再暂停;inbox(event_id, consumer) 与效果表写入属于同一笔独立事务。

两次强停,检查两个不同窗口

测试父进程只启动、等待并终止自己创建的 Java 子进程。子进程到达目标位置后写出标记文件,父进程看见标记才执行 destroyForcibly(),随后等待它结束,再用新数据库连接取证。这里没有按随机延时猜失败时刻,也没有终止用户已有服务。

本次场景强停或回滚后的数据库恢复后的数据库
业务提交前主动回滚商户 0、登记 0、效果 0无需恢复
业务已提交,效果尚未写入时强停商户 1、PROCESSING 登记 1、效果 0商户 1、COMPLETED 登记 1、效果 1
效果已提交,完成标记前强停商户 1、PROCESSING 登记 1、inbox 1、效果 1COMPLETED 登记 1;inbox、效果仍各 1

三项测试已在本节锁定组合通过,子进程日志、JUnit XML 和运行记录一同提供。实际终止的是进程,没有模拟掉电、磁盘损坏或多机故障。

恢复策略必须写清楚

默认的失效监测时长为零,不能假设安装 starter 后自动恢复。实验的恢复进程明确启用很短的 processing 失效阈值,等旧 PROCESSING 记录变为 FAILED,停止失效扫描后再调用 FailedEventPublications.resubmit(...)。这是一条单工作进程、离线恢复路线;停止扫描是为了不让教学用短阈值误判新开始的重放任务。

正式业务须根据最慢合法处理时长、并发实例和重试预算配置失效政策。不能把本例的 1ms 当生产参数。记录完成次数、重试上限和无法恢复时的人工出口,同样属于交付机制。

相同 eventId 重放时,Effect.apply() 先登记 (eventId, "audit"),新增登记成功才写效果。两者同事务,另有主键约束。本实验验证单工作者的顺序重放;多工作者同时插入可能遇到唯一键冲突,应将该次消费事务回滚并按策略重试,不能把一次测试推成高并发保证。

按这四步练

  1. 按 README 用 JDK 21 运行 durable-lab/,检查依赖树确实是本节组合。
  2. 阅读 CrashRecoveryTest,找到每个 marker 的位置、真实终止调用和每次数据库断言。数据库只允许写在 target/crash-lab/ 下。
  3. 对照两个强停场景,画出业务提交、效果提交、登记完成三条线;分别解释重启时应做或跳过什么。
  4. 设计“达到重试上限”和“外部 HTTP 效果成功但响应丢失”的处理。后者需要对方的幂等契约或对账,本地唯一键不能自动覆盖外部系统;这两项扩展尚未运行。

验收: 交付强停前位置、非正常子进程退出、重启前快照、恢复调用及最终数据;能说明重复投递仍发生,为什么本地效果没有重复。误区: 用抛异常重试替代进程故障;把登记表存在等同恢复已配置;把本地效果去重称为所有系统“恰好一次”。

边界: 无 MQ、HTTP、生产库或 Redis;没有验证多实例竞争、真实断电、永久失败出口与清理归档。官方文档及同标签源码属于同一项目来源,不是两次独立实验。