Seata:AT、TCC、XA 与 TC 恢复的 2.5.0 实现边界
跨资源事务与长流程讲清了 2PC 承诺、TCC 状态机、空回滚防悬挂这些协议层应该承诺什么;本页是那句话的续篇:把承诺落到 Apache Seata 2.5.0 的组件实现上——undo_log 前后镜像、全局锁重试、TC 五张表、fence 四状态、XA prepare。本页区分固定 tag 源码结论和实验预期;实验运行状态以公开证据为准,推演项单列。
版本、维护状态与 Cloud Alibaba 适配登记
查询日期 2026-10-29。本页固定 Seata 2.5.0,tag v2.5.0(annotated)指向完整 commit 989db47c77fcfdc60879821962be619ecbe6b70a(2025-07-19,core/src/main/java/org/apache/seata/core/protocol/Version.java 的 CURRENT = "2.5.0")。版本常量类是 org.apache.seata.core.protocol.Version——org.apache.seata.common.Version 在该 tag 不存在;TCC 拦截类是 TccActionInterceptorHandler(无 TccActionInterceptor);fence 实际类是 DefaultCommonFenceHandler/CommonFenceStore/CommonFenceStoreDataBaseDAO + CommonFenceConstant 的 int 常量(TRIED=1/COMMITTED=2/ROLLBACKED=3/SUSPENDED=4,无枚举)。引用旧教程时先核对这些类名,别拿 1.x 的记忆对 2.x 排障。
维护状态:apache/incubator-seata 未归档、默认分支 2.x、release 节奏 v2.5.0(2025-07)→ v2.6.0(2026-01)→ v2.7.0(2026-10),约半年一版。Seata 2.5.0 自身构建 POM指定 Java 1.8 + Boot 2.7.18 / Framework 5.3.39;固定版本的 CI 配置列出 Boot 3.0.13、3.1.6、3.2.0、3.3.0 的测试矩阵,配置存在本身不证明该组合在本地运行成功,也不等于官方长期支持承诺。本页 Boot 2.7.18 实验实际依赖解析的 Framework 版本应以运行记录为准,不与上游构建 POM 混写。Boot 4.0.x 与 Seata 2.5.0 的兼容性尚无本轮运行证据,课程新增工程基线(Boot 4.0.9)不能与本页实验基线混写。
与 Cloud 的关系用三个可验证事实说清:Spring Cloud 2025.1.4 BOM没有 seata 条目,所以"Seata 由 Cloud 核心内置"是错误陈述;Spring Cloud Alibaba 2025.1.0.0 BOM显式管理 org.apache.seata:seata-spring-boot-starter:2.5.0 与 spring-cloud-starter-alibaba-seata:2025.1.0.0;spring-cloud-starter-alibaba-seata POM引入了 Seata starter。依赖解析与实际应用运行必须分别记录,不能仅凭解析认定 Boot 版本组合兼容;本轮 -Psca 解析得到 Seata 2.5.0,但依赖树同时出现 Boot 2.7.18 和部分 Boot 4.0.0 工件;该混合组合未运行,不能据此声明兼容。本页 starter 实验采用 Boot 2.7.18。
starter 装配:自动代理与 Boot 基线
starter 用例在 Boot 2.7.18 上下文实跑,验证以下行为(环境和输出见运行证据)。第一,seata-spring-boot-starter 通过自动配置注册 GlobalTransactionScanner,@GlobalTransactional 方法被 AOP 包装后经 TransactionalTemplate 与 TC 通信;第二,seata.enable-auto-data-source-proxy=true 时容器里的 DataSource bean 被 SeataAutoDataSourceProxyAdvice 增强,匹配上下文时通过已登记的 DataSourceProxy 获取连接——实验断言代理 advisor 存在;第三,失败路径上 @GlobalTransactional 全局回滚后业务行消失、undo_log 被清理。
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
// do checkers
if (!doCheckers(bean, beanName)) {
return bean;
}
try {
synchronized (PROXYED_SET) {
if (PROXYED_SET.contains(beanName)) {
return bean;
}
if (!NEED_ENHANCE_BEAN_NAME_SET.contains(beanName)) {
return bean;
}
interceptor = null;
ProxyInvocationHandler proxyInvocationHandler =
DefaultInterfaceParser.get().parserInterfaceToProxy(bean, beanName);
if (proxyInvocationHandler == null) {
return bean;
}
interceptor = new AdapterSpringSeataInterceptor(proxyInvocationHandler);
LOGGER.info(
"Bean [{}] with name [{}] would use interceptor [{}]",
bean.getClass().getName(),
beanName,
interceptor.toString());
if (!AopUtils.isAopProxy(bean)) {
bean = super.wrapIfNecessary(bean, beanName, cacheKey);
} else {
AdvisedSupport advised = SpringProxyUtils.getAdvisedSupport(bean);
Advisor[] advisor = buildAdvisors(beanName, getAdvicesAndAdvisorsForBean(null, null, null));
int pos;
for (Advisor avr : advisor) {
// Find the position based on the advisor's order, and add to advisors by pos
pos = findAddSeataAdvisorPosition(advised, avr);
advised.addAdvisor(pos, avr);
}
}
PROXYED_SET.add(beanName);
return bean;
}
} catch (Exception exx) {
throw new RuntimeException(exx);
}用户 Bean 退让与覆盖的边界:seata.enabled=false 关闭 Seata 自动装配;disableGlobalTransaction=true 禁止全局事务扫描(数据源代理仍按配置)。seata.excludes-for-auto-proxying 按数据源类的全限定名排除,作用于该类而非单个 bean 名;实现 SeataProxy 接口的类和 AbstractRoutingDataSource 也会跳过自动代理。SeataAutoDataSourceProxyCreator 为符合条件的数据源建立 AT/XA 代理并登记进 DataSourceProxyHolder;SeataAutoDataSourceProxyAdvice 在全局事务分支类型匹配或要求全局锁的上下文中,把 DataSource 方法调用转发到代理,否则直接调用原数据源。自动代理只接入调用链,不保证提交/回滚语义符合业务不变量。
可复现的 starter 配置与正反例。 以下限定 Seata 2.5.0、JDK 21、Boot 2.7.18,TC 与 MySQL 是本机隔离实验,不把它外推到 Boot 4。完整依赖见实验的聚合 POM与 starter POM:后者包含 Seata starter、Boot JDBC、MySQL 驱动(来自 core 模块)和测试依赖。运行入口为 python3 labs/seata/run.py,由入口创建独有数据库、安装 undo_log 并启动单机 TC;发行包、端口和数据库前提见实验说明。测试使用 Spring 属性和临时数据库 URL 注入;下列 YAML 展示等价的应用侧配置,不是本次测试直接读取的文件:
spring:
application:
name: u38-client
datasource:
url: ${U38_DB_URL} # 指向独有业务库,勿使用生产数据库
username: ${U38_DB_USER}
password: ${U38_DB_PASSWORD:}
seata:
enabled: true
tx-service-group: u38_tx_group
enable-auto-data-source-proxy: true
data-source-proxy-mode: AT
config:
type: file
registry:
type: file
service:
vgroup-mapping:
u38_tx_group: default
grouplist:
default: 127.0.0.1:18091tx-service-group 是客户端事务分组,映射值 default 必须和地址组键相同;文件注册方式的 grouplist 指向 TC RPC 地址,不是 MySQL 地址。enable-auto-data-source-proxy 在该版本默认 true、data-source-proxy-mode 默认 AT,此处显式写出以便核对;事务超时单位为毫秒。完整测试还包括 file.conf、registry.conf 和业务服务与断言;不要把这段 YAML 与不同注册中心配置混用。
测试的服务入口可概括为:
@GlobalTransactional(rollbackFor = Exception.class)
public void placeOrderThenFail(long id, String value) {
jdbc.update("INSERT INTO u38_order_starter (id, value) VALUES (?, ?)", id, value);
throw new IllegalStateException("trigger-starter-rollback");
}在独有数据库及已初始化的 undo 表上,正常输入 (1, "S-OK") 的目标结果是本地行保留;反例输入 (2, "S-FAIL") 在写入后抛异常,目标结果是全局回滚后该行不存在。可运行的表结构、服务初始化、成功与失败断言均在上述测试工程,实际结果以运行证据为准。排障时先检查业务 bean 是否有事务拦截器、数据源是否在符合上下文时转发到代理、TC 地址与组映射,再检查 xid、分支和 undo;若绕过 Spring 代理自行 new 业务类,或工作线程丢失 xid,不能把其写入假定为同一全局事务。
xid、分支注册与三方角色
一次 Seata 全局事务涉及三种角色(TM 与 RM 可以在同一进程):TM(事务发起者,向 TC 申请 begin/commit/rollback)、RM(资源方,向 TC 注册分支并执行一阶段/接受二阶段)、TC(协调者,持久化全局/分支会话并驱动二阶段)。xid 是全局事务的唯一标识,形如 ip:port:transactionId;RootContext 用 ThreadLocal 保存当前线程的 xid,跨线程和进程传播需要相应适配器或显式传递——TM begin() 后 RootContext.bind(xid)(源码见下节),RM 侧拦截器从 RootContext.getXID() 取到 xid 后才把本地连接挂进全局事务。
分支注册发生在资源方法执行路径上:AT 在一阶段 commit() 时注册;TCC 在 prepare() 拦截时注册;XA 在 setAutoCommit(false) 时注册。注册成功才有 branchId;TC 侧 branch_table 一行对应一个分支。这个时序决定了各模式的失败窗口:注册前的异常根本不产生分支(TC 无感知),注册后、二阶段前发生故障时,恢复需要已持久化的会话及后续决策;既有决定的重放和未决事务超时后的处理是不同场景。下文 TC 重启实验仅覆盖后者。
实验 1 在一阶段提交后查询 xid 对应的 global_table.status,断言为 Begin 或 AsyncCommitting,并检查 undo 中的 xid 与正数 branchId;全局提交后轮询 undo 清理。本次测试没有查询 branch_table 或 lock_table 的行,也没有直接测量释放锁的时点;上文分支和全局锁的机制来自固定版本源码。跨进程传播在 Dubbo/HTTP 客户端里的失败模式(header 丢失、线程池切换丢 ThreadLocal)回指 Dubbo 调用链与服务治理与 异步执行与上下文传播的上下文传递讲解。
线程池传播的受控示例。 固定版本的 RootContext.bind/unbind 分别绑定和移除当前线程的 xid,并同步处理该 xid 的日志 MDC;默认上下文实现使用线程局部存储。以下独立程序只演示 xid 字符串的捕获、安装和恢复,xid-a 与 worker-before 是合成标识,没有创建 TC 全局事务,不执行数据库操作。保存为 XidPoolDemo.java,使用 JDK 21 和本机已有的 Seata 2.5.0 seata-all、SLF4J 与 Netty 依赖编译运行:
import java.util.concurrent.*;
import org.apache.seata.core.context.RootContext;
public class XidPoolDemo {
static <T> Callable<T> captureXid(Callable<T> work) {
String captured = RootContext.getXID(); // 提交线程,此时捕获
return () -> {
String previous = RootContext.getXID(); // 可能是内联执行的外层上下文
try {
if (captured == null) RootContext.unbind();
else RootContext.bind(captured);
return work.call();
} finally {
RootContext.unbind();
if (previous != null) RootContext.bind(previous);
}
};
}
static void check(boolean ok) {
if (!ok) throw new AssertionError("xid check failed");
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
pool.submit(() -> RootContext.bind("worker-before")).get(5, TimeUnit.SECONDS);
RootContext.bind("xid-a");
check("xid-a".equals(pool.submit(captureXid(RootContext::getXID)).get(5, TimeUnit.SECONDS)));
try {
pool.submit(captureXid(() -> { throw new IllegalStateException("expected"); }))
.get(5, TimeUnit.SECONDS);
throw new AssertionError("expected failure");
} catch (ExecutionException e) {
check(e.getCause() instanceof IllegalStateException);
}
check("worker-before".equals(pool.submit(RootContext::getXID).get(5, TimeUnit.SECONDS)));
RootContext.unbind(); // 下一任务捕获空 xid,不继承 worker 的值
check(pool.submit(captureXid(RootContext::getXID)).get(5, TimeUnit.SECONDS) == null);
check("worker-before".equals(pool.submit(RootContext::getXID).get(5, TimeUnit.SECONDS)));
pool.submit(RootContext::unbind).get(5, TimeUnit.SECONDS); // 清掉演示用哨兵
check(pool.submit(RootContext::getXID).get(5, TimeUnit.SECONDS) == null);
System.out.println("xid-local-context=PASS");
} finally {
try {
RootContext.unbind();
} finally {
pool.shutdownNow();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) throw new IllegalStateException("worker did not exit");
}
}
}
}在已准备好本机 Maven 缓存的 macOS/Linux 环境中,可用以下离线命令运行;缺文件时不要把编译失败当作传播失败,先按环境约束准备依赖。示例 classpath 中 : 是路径分隔符:
SEATA_XID_CP="$HOME/.m2/repository/org/apache/seata/seata-all/2.5.0/seata-all-2.5.0.jar:$HOME/.m2/repository/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar:$HOME/.m2/repository/io/netty/netty-common/4.1.101.Final/netty-common-4.1.101.Final.jar:$HOME/.m2/repository/commons-lang/commons-lang/2.6/commons-lang-2.6.jar"
javac -cp "$SEATA_XID_CP" XidPoolDemo.java
java -cp ".:$SEATA_XID_CP" XidPoolDemo2026-10-30 在 macOS arm64、BellSoft JDK 21.0.11 使用上述本地依赖,编译和运行退出码均为 0,输出 xid-local-context=PASS。该次 SLF4J 使用无操作日志和 MDC 实现,未验证真实 MDC 恢复。首次尝试遗漏 Commons Lang,初始化失败;补齐已有依赖后重跑通过。
正常输入应在任务内看到 xid-a;异常任务通过 Future.get() 的 ExecutionException 返回,但 finally 仍恢复 worker 的原值;空捕获的任务内应看到 null,复用后原值仍为 worker-before,清理哨兵后为 null。这里故意预置原值以验证恢复,不应把遗留在普通线程池中的旧事务 xid 当成正常状态:生产入口应检查或阻止污染,并在任务边界按其上下文协议清理。只调用 unbind() 而不恢复 previous 会破坏可能的内联或嵌套上下文;空捕获时不清理则会错误沿用上一任务的 xid。
该包装只处理 xid,不复制 JDBC 连接,也不处理分支类型、全局锁标记、超时、认证或其他 MDC;若使用 XA、TCC、@GlobalLock 或已有框架上下文,必须采用已验证的对应适配并处理它们的完整语义,不能把示例当作通用传播器。调用方必须有界等待任务真正结束,并将异常交给事务决策;超时或 cancel(true) 不保证任务已停止,不能仅凭取消就提交、回滚或复用仍可能写入的资源。真实全局事务还要检查 worker 是否在决策前完成、是否产生正确分支及失败后的回滚结果;HTTP/RPC header 传播需另行验证,合成 xid 检查不能证明这些行为。
TM:begin、commit、rollback 状态机
TM 侧编程式入口是 GlobalTransactionContext.createNew() → DefaultGlobalTransaction。它有 Launcher/Participant 两种角色:发起方才真正执行 begin/commit/rollback,参与者调用这些方法直接忽略。begin 做三件事:断言当前线程没有挂着的全局事务、向 TC 注册得到 xid、绑定到 RootContext:
public void begin(int timeout, String name) throws TransactionException {
this.createTime = System.currentTimeMillis();
if (role != GlobalTransactionRole.Launcher) {
assertXIDNotNull();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Ignore Begin(): just involved in global transaction [{}]", xid);
}
return;
}
assertXIDNull();
String currentXid = RootContext.getXID();
if (currentXid != null) {
throw new IllegalStateException("Global transaction already exists,"
+ " can't begin a new global transaction, currentXid = " + currentXid);
}
xid = transactionManager.begin(null, null, name, timeout);
status = GlobalStatus.Begin;
RootContext.bind(xid);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Begin new global transaction [{}]", xid);
}
}
@SuppressWarnings("lgtm[java/constant-comparison]")commit/rollback 带重试循环(默认 TM 重试次数由 client.tm.* 控制),重试耗尽抛 TransactionException;finally 里 suspend(true) 解绑 RootContext——同线程紧接着开下一个全局事务不会撞上"already exists":
TM 重试配置和未知结果。 固定版本读取 client.tm.commitRetryCount 和 client.tm.rollbackRetryCount,缺省值各为 5 次调用(包含首次调用),配置为 0 或负数时也回退到 5;这是次数而非毫秒。两个值保存在 DefaultGlobalTransaction 的静态字段中,应在该类初始化前完成配置,不应假定运行时修改会更新已加载的值。以本页实验的 file 配置形式,加入以下片段;显式 5 与缺省值相同,而不是新的运行结果:
client {
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
}例如发出全局 commit 后连接中断,TM 连续 5 次调用都抛异常,最终向调用者抛 TransactionException;这只能说明客户端没有取得确定响应,不能推断 TC 没有收到或没有提交。应保留 xid 和业务请求身份,检查 TC/RM 日志、持久事务状态与业务结果;状态缺失也可能是完成后已清理,不能独自当作回滚证明。由业务对账或明确的恢复协议裁决后再处理,不能对结果未知的业务操作直接换身份重做。该断网反例是源码推演,本轮没有注入 TM 网络中断。配置键、默认值及 读取和重试分支按 2.5.0 固定源码核对。
public void commit() throws TransactionException {
if (role == GlobalTransactionRole.Participant) {
// Participant has no responsibility of committing
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Ignore Commit(): just involved in global transaction [{}]", xid);
}
return;
}
assertXIDNotNull();
if (LOGGER.isInfoEnabled()) {
LOGGER.info("transaction {} will be commit", xid);
}
int retry = COMMIT_RETRY_COUNT <= 0 ? DEFAULT_TM_COMMIT_RETRY_COUNT : COMMIT_RETRY_COUNT;
try {
while (retry > 0) {
try {
retry--;
status = transactionManager.commit(xid);
break;
} catch (Throwable ex) {
LOGGER.error(
"Failed to report global commit [{}],Retry Countdown: {}, reason: {}",
this.getXid(),
retry,
ex.getMessage());
if (retry == 0) {
throw new TransactionException("Failed to report global commit", ex);
}
}
}
} finally {
if (xid.equals(RootContext.getXID())) {
suspend(true);
}
}
if (LOGGER.isInfoEnabled()) {
LOGGER.info("[{}] commit status: {}", xid, status);
}
}
@SuppressWarnings("lgtm[java/constant-comparison]")K04 条件边界:transactionManager.commit(xid) 返回的是全局状态(如 TimeoutRollbacking)而不是"业务提交完成"。TM 提交对 AT 模式是异步二阶段的起点(TC 转 AsyncCommitting 后由定时任务驱动),TM 返回成功时 undo_log 可能还没删——实验 1 用轮询等待而不是立即断言,就是为这个异步窗口。TCC 分支不满足 AT 的异步提交条件:正常提交路径由 TC 调用 RM confirm;失败后的重试另有调度路径。
AT 一阶段:分支注册、undo_log 与本地提交的原子序
AT 模式的核心是 DataSourceProxy 包住业务数据源后,把"本地事务提交"改造成"分支注册 → 写 undo_log → 目标提交"三步。ConnectionProxy.commit() 先走 LockRetryPolicy(锁冲突重试策略),doCommit() 按 RootContext 状态分流:全局事务内走 processGlobalTransactionCommit(),@GlobalLock 本地事务走 processLocalCommitWithGlobalLocks(),否则退化成普通提交:
public void commit() throws SQLException {
try {
lockRetryPolicy.execute(() -> {
doCommit();
return null;
});
} catch (SQLException e) {
if (targetConnection != null && !getAutoCommit() && !getContext().isAutoCommitChanged()) {
rollback();
}
throw e;
} catch (Exception e) {
throw new SQLException(e);
}
} private void processGlobalTransactionCommit() throws SQLException {
try {
register();
} catch (TransactionException e) {
recognizeLockKeyConflictException(e, context.buildLockKeys());
}
try {
UndoLogManagerFactory.getUndoLogManager(this.getDbType()).flushUndoLogs(this);
targetConnection.commit();
} catch (Throwable ex) {
LOGGER.error("process connectionProxy commit error: {}", ex.getMessage(), ex);
report(false);
throw new SQLException(ex);
}
if (IS_REPORT_SUCCESS_ENABLE) {
report(true);
}
context.reset();
}顺序有三条推论。其一,需要注册的分支发生全局锁冲突且重试耗尽时,目标提交不继续;本地事务是否已回滚仍须按异常处理与数据库状态核验。其二,在需要记录 undo 的 AT 写路径中,业务变更与写入的 undo 位于同一个本地数据库事务;若 flushUndoLogs 抛异常,目标提交不会继续,但事务最终是否回滚仍要核验。register() 在上下文没有 undo 或没有 lock key 时直接返回,不能把所有连接提交都当作已有分支。事后查不到 undo 也可能是二阶段或过期清理,不能单凭缺失认定整笔回滚。其三,AT 一阶段本地提交后其他事务可能读到已提交数据;实验 5 的 XA prepare 期间新读取旧值是在其 MySQL 隔离条件下的对照。
undo_log 的形状(实验 1 读取):branch_id + xid 唯一键、context 记录序列化器、rollback_info 是 jackson 序列化的 BranchUndoLog,内含 sqlUndoLogs 列表,元素含 beforeImage/afterImage 两份 TableRecords(行 + 字段)及表名,log_status=0 为正常状态、=1 为防悬挂墓碑。Jackson 的类型包装会影响 JSON 形状,应使用对应版本的解析器还原对象再检查镜像。回滚前用 afterImage 与当前行比对;反向操作根据 SQL 类型使用对应的前后镜像,例如 UPDATE 使用前镜像恢复旧值、INSERT 使用后镜像定位删除。
AT 二阶段:undo_log 反向补偿与异步删除
二阶段由 TC 驱动:全局提交走异步删除,全局回滚走反向补偿。回滚路径上,RM 的 AbstractUndoLogManager.undo() 先读 undo_log 并按 log_status 过滤(非 0 状态直接返回),再反序列化出 SQLUndoLog 列表——多条时 Collections.reverse() 按一阶段的逆序执行:
while (rs.next()) {
exists = true;
// It is possible that the server repeatedly sends a rollback request to roll back
// the same branch transaction to multiple processes,
// ensuring that only the undo_log in the normal state is processed.
int state = rs.getInt(ClientTableColumnsName.UNDO_LOG_LOG_STATUS);
if (!canUndo(state)) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("xid {} branch {}, ignore {} undo_log", xid, branchId, state);
}
return;
}
String contextString = rs.getString(ClientTableColumnsName.UNDO_LOG_CONTEXT);
Map<String, String> context = parseContext(contextString);
byte[] rollbackInfo = getRollbackInfo(rs);
String serializer = context == null ? null : context.get(UndoLogConstants.SERIALIZER_KEY);
UndoLogParser parser = serializer == null
? UndoLogParserFactory.getInstance()
: UndoLogParserFactory.getInstance(serializer);
BranchUndoLog branchUndoLog = parser.decode(rollbackInfo);
try {
// put serializer name to local
setCurrentSerializer(parser.getName());
List<SQLUndoLog> sqlUndoLogs = branchUndoLog.getSqlUndoLogs();
if (sqlUndoLogs.size() > 1) {
Collections.reverse(sqlUndoLogs);
}
for (SQLUndoLog sqlUndoLog : sqlUndoLogs) {
TableMeta tableMeta = TableMetaCacheFactory.getTableMetaCache(dataSourceProxy.getDbType())
.getTableMeta(conn, sqlUndoLog.getTableName(), dataSourceProxy.getResourceId());
sqlUndoLog.setTableMeta(tableMeta); // For example, the business processing timeout, the global transaction is the initiator rolls back.
// To ensure data consistency, we can insert an undo_log with GlobalFinished state
// to prevent the local transaction of the first phase of other programs from being correctly submitted.
// See https://github.com/seata/seata/issues/489
if (exists) {
deleteUndoLog(xid, branchId, conn);
conn.commit();
if (LOGGER.isInfoEnabled()) {
LOGGER.info(
"xid {} branch {}, undo_log deleted with {}",
xid,
branchId,
State.GlobalFinished.name());
}
} else {
insertUndoLogWithGlobalFinished(xid, branchId, UndoLogParserFactory.getInstance(), conn);
conn.commit();
if (LOGGER.isInfoEnabled()) {
LOGGER.info(
"xid {} branch {}, undo_log added with {}", xid, branchId, State.GlobalFinished.name());
}
}undo_log 存在时二阶段是"删除 undo_log 并提交";不存在时(分支一阶段根本没执行完,或其它进程正持有)插入 GlobalFinished 状态的防御性 undo_log(log_status=1),阻止迟到的本地事务把已被回滚的数据提交进去——这是 issue #489 的经典悬挂窗口修补。理解这段代码才能回答"回滚时 undo_log 已被删了会怎样"。
AT 脏写:Unretriable 的边界
反向补偿执行前,AbstractUndoExecutor 先比较前后镜像:相同表示没有数据变化,跳过反向 SQL。若前后镜像不同,再比较后镜像与当前行:一致则执行反向 SQL;不一致时还要比较前镜像与当前行,若相等则无需继续回滚,只有两者都不等才抛 SQLUndoDirtyException。例如一阶段之后有非 Seata 外部写入,且当前值既不是前镜像也不是后镜像,便落入脏写分支:
// data, too. No need continue to undo if current data is equivalent to the before data snapshot
Result<Boolean> beforeEqualsCurrentResult = DataCompareUtils.isRecordsEquals(beforeRecords, currentRecords);
if (beforeEqualsCurrentResult.getResult()) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Stop rollback because there is no data change "
+ "between the before data snapshot and the current data snapshot.");
}
// no need continue undo.
return false;
} else {
if (LOGGER.isInfoEnabled()) {
if (StringUtils.isNotBlank(afterEqualsCurrentResult.getErrMsg())) {
LOGGER.info(afterEqualsCurrentResult.getErrMsg(), afterEqualsCurrentResult.getErrMsgParams());
}
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("check dirty data failed, old and new data are not equal, " + "tableName:["
+ sqlUndoLog.getTableName() + "]," + "oldRows:["
+ JSON.toJSONString(afterRecords.getRows()) + "]," + "newRows:["
+ JSON.toJSONString(currentRecords.getRows()) + "].");
}
throw new SQLUndoDirtyException("Has dirty records when undo.");
}
} return;
} catch (SQLIntegrityConstraintViolationException e) {
// Possible undo_log has been inserted into the database by other processes, retrying rollback undo_log
if (LOGGER.isInfoEnabled()) {
LOGGER.info("xid {} branch {}, undo_log inserted, retry rollback", xid, branchId);
}
} catch (Throwable e) {
if (conn != null) {
try {
conn.rollback();
} catch (SQLException rollbackEx) {
LOGGER.warn("Failed to close JDBC resource while undo ... ", rollbackEx);
}
}
if (e instanceof SQLUndoDirtyException) {
throw new BranchTransactionException(
BranchRollbackFailed_Unretriable,
String.format(
"Branch session rollback failed because of dirty undo log, please delete the relevant undolog after manually calibrating the data. xid = %s branchId = %s",
xid, branchId),
e);
}
throw new BranchTransactionException(
BranchRollbackFailed_Retriable,
String.format(
"Branch session rollback failed and try again later xid = %s branchId = %s %s",
xid, branchId, e.getMessage()),SQLUndoDirtyException 被翻译成 BranchRollbackFailed_Unretriable:该失败路径不再按可重试分支自动重试,与普通失败的 BranchRollbackFailed_Retriable(TC 周期性重试)形成机制级分界;人工处置或其他操作另行判断,不能概括成任何情况下都永不重试。实验 3 验证以下链条:全局事务超时 → TC timeoutCheck 转 TimeoutRollbacking → RM 反向补偿 → afterImage 失配 → TimeoutRollbackFailed,且外部修改值保留、undo_log 不删(Unretriable 分支不消耗 undo_log)。恢复责任回到人工:应先保全现场、定位事务和业务状态并确认修复方案,不应把删除 undo_log 当成恢复本身。TC 的 undoLogDelete 向 RM 发清理请求;MySQL 实现按 log_created 删除过期行,并不识别脏写,默认保留 7 天,清理可能删除尚有调查价值的记录。
K05 反例:把"AT 模式对外部写入是安全的"当默认假设不成立——绕过 DataSourceProxy 的写入(运维手工 UPDATE、其他非 Seata 应用)可能使回滚变 Unretriable。即使取得了代理连接,写入时也必须处于有效的全局 xid 上下文,或显式使用 @GlobalLock 的受保护本地事务路径,才能分别进入 AT 分支或全局锁检查;前者和后者的保护范围并不相同。若既无全局事务上下文又无全局锁标记,ConnectionProxy.doCommit() 直接提交目标连接,不自动注册 AT 分支或执行全局锁检查。
脏写后的处置与复验(推演,不是自动恢复实验)。 实验的确定输入为主键 1:前镜像 BASE,一阶段后镜像 DIRTY-TARGET,外部连接把当前值改为 EXTERNAL;测试只断言失败状态、外部值和当时仍在的 undo。假设业务核实 EXTERNAL 是另一笔有效修改,且原全局事务应撤销,不能直接把当前值覆盖为 BASE——那会抹掉有效操作。由业务负责人裁定正确终态及补偿责任,运维先隔离该行的继续写入,记录 xid、branchId、三份镜像、当前值、全局状态和外部操作凭据,并保全证据;再由负责人制定并执行经审核的业务修正或补偿,例如保留有效外部变更并单独撤销原业务效果。若无法证明哪个结果有效,保持待处理并告警,不把状态标作恢复成功。
复验时用同一业务身份重新读取目标行、关联业务记录和外部操作结果,确认它们满足裁定的不变量,并核对失败事务是否已进入由运维跟踪的终态;只有数据与业务结果均符合裁决才完成对账。后续 undo 的处置要依据实际状态、固定版本运维流程和保留要求单独决定;清理日志既不是回滚,也不能撤回外部副作用。该示例没有运行人工校准与复验,不假定 Unretriable 会自动重新执行或删掉日志。
AT 全局锁与写冲突重试
AT 的写-写隔离靠 TC 的全局行锁。锁在分支注册时申请(register() 携带 lockKeys);SELECT ... FOR UPDATE 路径取得本地锁后检查 TC 全局锁,冲突时按策略释放本地锁并重试;该检查本身不申请全局锁:
LockRetryController lockRetryController = new LockRetryController();
ArrayList<List<Object>> paramAppenderList = new ArrayList<>();
String selectPKSQL = buildSelectSQL(paramAppenderList);
while (true) {
try {
// #870
// execute return Boolean
// executeQuery return ResultSet
rs = statementCallback.execute(statementProxy.getTargetStatement(), args);
// Try to get global lock of those rows selected
TableRecords selectPKRows = buildTableRecords(getTableMeta(), selectPKSQL, paramAppenderList);
String lockKeys = buildLockKey(selectPKRows);
if (StringUtils.isNullOrEmpty(lockKeys)) {
break;
}
if (RootContext.inGlobalTransaction() || RootContext.requireGlobalLock()) {
// Do the same thing under either @GlobalTransactional or @GlobalLock,
// that only check the global lock here.
statementProxy.getConnectionProxy().checkLock(lockKeys);
} else {
throw new RuntimeException("Unknown situation!");
}
break;
} catch (LockConflictException lce) {
if (sp != null) {
conn.rollback(sp);
} else {
conn.rollback();
}
// trigger retry
lockRetryController.sleep(lce);
}
}
} finally {LockRetryController 按 client.rm.lock.retryTimes/retryInterval(本实验 50 次 × 100ms)自旋,预算耗尽抛 LockWaitTimeoutException。实验 2 的两组预期:持有方 400ms 后全局提交,等待方 selectForUpdate 重试约 0.4~1.5s 后成功拿到锁并写入(最终值是等待方的);持有方把锁握 6.5 秒,等待方在约 5 秒预算耗尽后 fail-fast,数据保持持有方值。注意提交路径中 TC 先将会话转入提交状态,再调用 GlobalSession.clean() 释放全局锁;AT undo 清理由异步二阶段继续,不应把锁释放等同于 undo 删除。回滚路径需等待相应分支处理,重试预算应考虑业务耗时、TC/RM 调度和网络延迟,不能只按方法耗时估算。
与 @GlobalLock 的区别:@GlobalLock 标注的是没进全局事务的本地事务,让它在提交前 checkLock 全局锁,用于"本地写 + 全局写"混布场景的防脏写;它不产生分支、不写 undo_log。两个概念共用同一把 TC 锁表,但保护对象不同。
本地事务使用例(源码推演,未实跑)。 使用上文 starter 的 Seata 2.5.0、Boot 2.7.18、数据源代理、事务分组和 TC 配置,在独有业务库创建 CREATE TABLE u38_lock_example (id BIGINT PRIMARY KEY, value VARCHAR(64) NOT NULL) 并插入 (1, 'BASE')。以下两个公开 Spring bean 分别保存为 LocalRowTransaction.java 和 ProtectedLocalWriter.java,使用相同包名并放在应用组件扫描路径中;分别添加示例中用到的 import。由外部 bean 调用 ProtectedLocalWriter.write,不要通过 this 自调用。外层 @GlobalLock 必须覆盖内层本地事务的提交,因而示例用两个 bean 显式表达调用边界:
import org.apache.seata.spring.annotation.GlobalLock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class LocalRowTransaction {
private final JdbcTemplate jdbc;
public LocalRowTransaction(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional(rollbackFor = Exception.class)
public void update(long id, String value) {
int rows = jdbc.update("UPDATE u38_lock_example SET value = ? WHERE id = ?", value, id);
if (rows != 1) throw new IllegalStateException("row missing");
}
}
@Service
public class ProtectedLocalWriter {
private final LocalRowTransaction local;
public ProtectedLocalWriter(LocalRowTransaction local) { this.local = local; }
@GlobalLock(lockRetryInterval = 100, lockRetryTimes = 50)
public void write(long id, String value) {
local.update(id, value);
}
}准备另一条真实 AT 全局事务,先将同一行更新为 GLOBAL 并完成本地一阶段,但在全局决策前用受控闩锁暂停;随后调用 write(1, "LOCAL")。按固定源码,受保护的本地事务提交时检查对应全局锁:若持有方在预算内释放,可重试后提交 LOCAL;若一直持有至预算耗尽,应观察调用失败、根因及本地回滚,而不能把 LOCAL 当作已提交。
这里的 UPDATE 已在显式本地事务中取得数据库行锁;提交时 checkLock 遇全局锁冲突,ConnectionProxy.LockRetryPolicy 在同一本地事务内等待并重试,期间可能仍持有该数据库行锁。若全局方选择提交,TC 的提交路径可释放全局锁,本地检查随后有机会通过;若全局方选择回滚,RM 的 undo 恢复同一行可能等待这个本地数据库行锁,而本地事务又等待全局锁释放,形成相互等待。根据数据库锁等待、Seata 重试及超时条件,本地重试可能耗尽并回滚释放数据库锁,此后全局回滚才有机会继续;也可能出现其他超时或失败,不能保证双方在固定时间内恢复。该分析针对示例的显式 UPDATE 提交路径:上面的 SELECT ... FOR UPDATE 执行器在冲突分支会回滚到保存点或回滚连接后再重试,不能把两条路径混为一谈。
两个任务须有界等待并按实际事务结果清理;上述提交、回滚与相互等待均是固定源码和事务锁行为的推演,不是实验 2 的运行结果。实验 2 实际比较的是两个全局事务,没有执行本例。
排障先检查两个 bean 的代理与数据源自动代理是否生效、TC 地址与事务分组是否一致,并确认入口没有已有 xid;再以持有方 xid、表和主键检查 TC 锁及业务值,记录重试耗尽的异常链。锁长期不释放时检查持有事务状态与恢复者,不应通过无代理连接绕过锁强行覆盖。@GlobalLock 的区间和次数分别以毫秒和次数计;非正间隔、负次数回退到全局配置。机制分别见 GlobalLockTemplate、ConnectionProxy 和 GlobalLock 注解。
TC 存储:五张表与会话持久化
TC 以 store.mode=db 运行时,会话状态落在五张表:global_table(全局事务,含 status/timeout/begin_time)、branch_table(分支)、lock_table(全局行锁,row_key=资源+表+主键)、distributed_lock(TC 集群互斥)、vgroup_table(事务分组映射)。global_table 的列直接决定了恢复能力:
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;status + begin_time + timeout 三列让"TC 重启后补决策"成为可能——在本实验的 db 模式中,timeoutCheck 通过会话管理器查询存储中的 Begin 会话;file 模式则查询重建后的内存会话映射。实验 6 观察到的时序:TC 被杀前 global_table.status=1、undo_log 已落库、业务值已一阶段可见;kill -9 后超时窗口流逝;重启 TC 后业务值被补偿回原值、undo_log 消失;结合源码可解释为 timeoutCheck 对已持久化的超时 Begin 会话发起回滚,RM 使用 undo_log 补偿。崩溃前尚无提交或回滚决策,本实验不证明既有决策重放。TC 持久存储支持重启后找回已持久化的会话;这不保证任意进程或存储故障都不丢意图。file 模式依赖本地文件及其存储故障边界,db 模式依赖数据库的持久性和可用性。
TC 定时任务:超时检查、重试与 undo 清理
TC 使用多个路径推进二阶段;以下四项定时任务的默认周期为 1000ms:retryRollbacking、retryCommitting、asyncCommitting、timeoutCheck。undoLogDelete 默认初始延迟 3 分钟、周期 24 小时;MAX_COMMIT_RETRY_TIMEOUT/ROLLBACK_RETRY_TIMEOUT 默认 -1 不设上限:
public void init() {
retryRollbacking.scheduleAtFixedRate(
() -> SessionHolder.distributedLockAndExecute(RETRY_ROLLBACKING, this::handleRetryRollbacking),
0,
ROLLBACKING_RETRY_PERIOD,
TimeUnit.MILLISECONDS);
retryCommitting.scheduleAtFixedRate(
() -> SessionHolder.distributedLockAndExecute(RETRY_COMMITTING, this::handleRetryCommitting),
0,
COMMITTING_RETRY_PERIOD,
TimeUnit.MILLISECONDS);
asyncCommitting.scheduleAtFixedRate(
() -> SessionHolder.distributedLockAndExecute(ASYNC_COMMITTING, this::handleAsyncCommitting),
0,
ASYNC_COMMITTING_RETRY_PERIOD,
TimeUnit.MILLISECONDS);
timeoutCheck.scheduleAtFixedRate(
() -> SessionHolder.distributedLockAndExecute(TX_TIMEOUT_CHECK, this::timeoutCheck),
0,
TIMEOUT_RETRY_PERIOD,
TimeUnit.MILLISECONDS);
undoLogDelete.scheduleAtFixedRate(
() -> SessionHolder.distributedLockAndExecute(UNDOLOG_DELETE, this::undoLogDelete),
UNDO_LOG_DELAY_DELETE_PERIOD,
UNDO_LOG_DELETE_PERIOD,
TimeUnit.MILLISECONDS);
rollbackingSchedule(0);timeoutCheck 只做一件事:把存储里 Begin 且超时的全局事务转成 TimeoutRollbacking;真正的回滚由 retryRollbacking 消费该状态并逐分支下发:
protected void timeoutCheck() {
SessionCondition sessionCondition = new SessionCondition(GlobalStatus.Begin);
sessionCondition.setLazyLoadBranch(true);
Collection<GlobalSession> beginGlobalSessions =
SessionHolder.getRootSessionManager().findGlobalSessions(sessionCondition);
if (CollectionUtils.isEmpty(beginGlobalSessions)) {
return;
}
if (!beginGlobalSessions.isEmpty() && LOGGER.isDebugEnabled()) {
LOGGER.debug("Global transaction timeout check begin, size: {}", beginGlobalSessions.size());
}
SessionHelper.forEach(beginGlobalSessions, globalSession -> {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(globalSession.getXid() + " " + globalSession.getStatus() + " "
+ globalSession.getBeginTime() + " " + globalSession.getTimeout());
}
SessionHolder.lockAndExecute(globalSession, () -> {
if (globalSession.getStatus() != GlobalStatus.Begin || !globalSession.isTimeout()) {
return false;
}
LOGGER.warn(
"Global transaction[{}] is timeout and will be rollback,transaction begin time:{} and now:{}",
globalSession.getXid(),
DateFormatUtils.format(globalSession.getBeginTime(), TIME_FORMAT_PATTERN),
DateFormatUtils.format(System.currentTimeMillis(), TIME_FORMAT_PATTERN));
globalSession.close();
globalSession.changeGlobalStatus(GlobalStatus.TimeoutRollbacking);
// transaction timeout and start rollbacking event
MetricsPublisher.postSessionDoingEvent(
globalSession, GlobalStatus.TimeoutRollbacking.name(), false, false);
return true;
});K04 边界:timeoutCheck 默认每 1 秒检查一次;超时发现受扫描时点和调度影响,并无固定的额外 1–2 个周期下限;distributedLockAndExecute 包裹保证多 TC 集群下同一任务只有一个实例执行(db 模式靠 distributed_lock 表)。undoLogDelete 默认启动 3 分钟后开始,每 24 小时向 RM 发清理请求;默认保留 7 天。MySQL 实现按创建时间删除,并不判断事务是否完成或是否脏写;清理不能替代业务恢复,也可能移除尚需调查的记录。
TCC 注册与二阶段调用
TCC 把"资源"变成三个方法:try/confirm/cancel。RM 侧 @TwoPhaseBusinessAction 注解被 TccActionInterceptorParser 解析成 TCCResource(记录 prepare/commit/rollback 三个 Method 与参数映射)并注册到 DefaultResourceManager;调用 try 时 TccActionInterceptorHandler 先注册分支、再执行业务:
protected Object doInvoke(InvocationWrapper invocation) throws Throwable {
if (!RootContext.inGlobalTransaction() || RootContext.inSagaBranch()) {
// not in transaction, or this interceptor is disabled
return invocation.proceed();
}
Method method = invocation.getMethod();
Annotation businessAction = parseAnnotation(method);
// try method
if (businessAction != null) {
// save the xid
String xid = RootContext.getXID();
// save the previous branchType
BranchType previousBranchType = RootContext.getBranchType();
// if not TCC, bind TCC branchType
if (getBranchType() != previousBranchType) {
RootContext.bindBranchType(getBranchType());
}
try {
TwoPhaseBusinessActionParam businessActionParam = createTwoPhaseBusinessActionParam(businessAction);
initTransactionalAnnotationContext(method, targetBean, businessActionParam.getBusinessActionContext());
// Handler the TCC Aspect, and return the business result
return actionInterceptorHandler.proceed(
method, invocation.getArguments(), xid, businessActionParam, invocation::proceed);
} finally {
// if not TCC, unbind branchType
if (getBranchType() != previousBranchType) {
RootContext.unbindBranchType();
}
// MDC remove branchId
MDC.remove(RootContext.MDC_KEY_BRANCH_ID);
}
}
// not TCC try method
return invocation.proceed();
}
/**
* Initializes the transaction annotation context注意顺序:分支注册发生在 fence 检查与业务 try 之前(ActionInterceptorHandler.proceed 里 doTxActionLogStore 在前)——这意味着"分支已注册但 try 没执行成功"是常态窗口,cancel 必须能为"从未 try 过的分支"做出正确语义,这正是 fence 存在的理由(下节)。二阶段是 TC 回调:TCCResourceManager.branchCommit/branchRollback 反射调用 confirm/cancel 方法,BusinessActionContext 从 applicationData JSON 重建。
// add idempotent and anti hanging
if (Boolean.TRUE.equals(businessActionContext.getActionContext(Constants.USE_COMMON_FENCE))) {
try {
result = DefaultCommonFenceHandler.get()
.commitFence(commitMethod, targetTCCBean, xid, branchId, args);
} catch (SkipCallbackWrapperException | UndeclaredThrowableException e) {
throw e.getCause();
}
} else {
ret = commitMethod.invoke(targetTCCBean, args);
if (ret != null) {实验 7 拟检查 TC 驱动的端到端:全局提交时 TC 回调 confirm(业务计数器 +1、fence 转 COMMITTED);全局回滚后回调 cancel(fence 转 ROLLBACKED)。TCC 不写 undo_log、不占全局行锁——它的隔离性由业务模型自己保证(预留/冻结资源)。
二阶段需要订单号等参数时,BusinessActionContextParameter 必须在拦截器实际收到的方法参数上可见:fetchActionRequestContext 读取传入 Method 的参数注解,再写入 action context。接口和实现方法的参数注解不会自动相互继承;CGLIB 路径若读取实现方法,只在接口上标注可能让二阶段缺少订单号。固定代理方式后,应在对应方法声明上显式标注并以测试核对 confirm/cancel 收到的真实键值;跨代理类型同时覆盖接口与实现声明时,也要保持名称一致。
protected Map<String, Object> fetchActionRequestContext(Method method, Object[] arguments) {
Map<String, Object> context = new HashMap<>(8);
Annotation[][] parameterAnnotations = method.getParameterAnnotations();
for (int i = 0; i < parameterAnnotations.length; i++) {
for (int j = 0; j < parameterAnnotations[i].length; j++) {
if (parameterAnnotations[i][j] instanceof BusinessActionContextParameter) {
// get annotation
BusinessActionContextParameter annotation =
(BusinessActionContextParameter) parameterAnnotations[i][j];
if (arguments[i] == null) {
throw new IllegalArgumentException("@BusinessActionContextParameter 's params can not null");
}
// get param
Object paramObject = arguments[i];
if (paramObject == null) {
continue;
}
// load param by the config of annotation, and then put into the context
ActionContextUtil.loadParamByAnnotationAndPutToContext(
ParamType.PARAM, "", paramObject, annotation, context);TCC fence:幂等、空回滚与防悬挂
useTCCFence=true 时,二阶段与 try 都经过 fence 层(状态落 tcc_fence_log,四状态:TRIED=1/COMMITTED=2/ROLLBACKED=3/SUSPENDED=4)。空回滚:cancel 到达时无 fence 记录(try 从未执行),插入 SUSPENDED 墓碑且不执行业务 cancel:
public boolean rollbackFence(
Method rollbackMethod, Object targetTCCBean, String xid, Long branchId, Object[] args, String actionName) {
TransactionTemplate template = createTransactionTemplateForTransactionalMethod(
MethodUtils.getTransactionalAnnotationByMethod(rollbackMethod, targetTCCBean));
return template.execute(status -> {
try {
Connection conn = DataSourceUtils.getConnection(dataSource);
CommonFenceDO commonFenceDO = COMMON_FENCE_DAO.queryCommonFenceDO(conn, xid, branchId);
// non_rollback
if (commonFenceDO == null) {
boolean result =
insertCommonFenceLog(conn, xid, branchId, actionName, CommonFenceConstant.STATUS_SUSPENDED);
LOGGER.info("Insert common fence record result: {}. xid: {}, branchId: {}", result, xid, branchId);
if (!result) {
throw new CommonFenceException(
String.format(
"Insert common fence record error, rollback fence method failed. xid= %s, branchId= %s",
xid, branchId),
FrameworkErrorCode.InsertRecordError);
}
return true;
} else {
if (CommonFenceConstant.STATUS_ROLLBACKED == commonFenceDO.getStatus()
|| CommonFenceConstant.STATUS_SUSPENDED == commonFenceDO.getStatus()) {
LOGGER.info(
"Branch transaction had already rollbacked before, idempotency rejected. xid: {}, branchId: {}, status: {}",
xid,
branchId,
commonFenceDO.getStatus());
return true;
}
if (CommonFenceConstant.STATUS_COMMITTED == commonFenceDO.getStatus()) {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn(
"Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}",
xid,
branchId,防悬挂:SUSPENDED 墓碑之后迟到的 try 在 prepareFence 的 insert 处撞唯一键、被拒并抛 SkipCallbackWrapperException,业务 try 不执行——cancel 与 try 的竞争被数据库唯一键裁决。幂等:COMMITTED 状态下重复 commitFence 直接返回 true 不再调用业务 confirm(实验 7 通过 DefaultCommonFenceHandler API 检查重复确认后的业务计数器)。ROLLBACKED/SUSPENDED 后收到 commit 时 commitFence 返回 false,不调用业务 confirm;缺少 fence 记录则走异常路径——两阶段状态竞争的裁决点就在这段代码。
实现选型边界:DefaultCommonFenceHandler 本身实现 FenceHandler,但只负责委派,未设置目标处理器时会报错;spring 模块的 SpringFenceHandler 提供基于 Spring 事务模板与数据源的具体处理,因此本页 fence 实验设计使用 starter 上下文。脱离 Spring 事务时需要提供并登记适用的 fence 处理器,不能以委派类本身声称具备数据库事务能力。fence 表本身需要业务库建表(script/client/tcc/db/mysql.sql),过期清理由 CommonFenceConfig 的定时任务按 seata.tcc.fence.clean-period 执行。
XA 模式:一阶段的尽头是数据库 prepare
XA 模式把分支下沉到数据库原生 XA。ConnectionProxyXA.setAutoCommit(false) 就是"开启分支":先向 TC branchRegister 拿 branchId,再 xaStart:
public void setAutoCommit(boolean autoCommit) throws SQLException {
if (currentAutoCommitStatus == autoCommit) {
return;
}
if (isReadOnly()) {
// If it is a read-only transaction, do nothing
currentAutoCommitStatus = autoCommit;
return;
}
if (autoCommit) {
// According to JDBC spec:
// If this method is called during a transaction and the
// auto-commit mode is changed, the transaction is committed.
if (xaActive) {
commit();
}
} else {
if (this.xaBranchXid != null && currentAutoCommitStatus) {
return;
}
if (xaActive) {
throw new SQLException(
"should NEVER happen: setAutoCommit from true to false while xa branch is active");
}
// Start a XA branch
long branchId;
try {
// 1. register branch to TC then get the branch message
branchRegisterTime = System.currentTimeMillis();
branchId = DefaultResourceManager.get()
.branchRegister(BranchType.XA, resource.getResourceId(), null, xid, null, null);
} catch (TransactionException te) {
cleanXABranchContext();
throw new SQLException(
"failed to register xa branch " + xid + " since " + te.getCode() + ":" + te.getMessage(), te);
}
// 2. build XA-Xid with xid and branchId
this.xaBranchXid = XAXidBuilder.build(xid, branchId);
// Keep the Connection if necessary
keepIfNecessary();
try {
start();
} catch (XAException e) {
cleanXABranchContext();
throw new SQLException("failed to start xa branch " + xid + " since " + e.getMessage(), e);
}
// 4. XA is active
this.xaActive = true;
}
currentAutoCommitStatus = autoCommit;
}
@Override
public boolean getAutoCommit() throws SQLException {
return currentAutoCommitStatus;
}与 AT 的关键差异在"一阶段结束点":在全局 XA 分支仍活跃且 xaBranchXid 非空时,连接 close() 执行 xaEnd(TMSUCCESS) + xaResource.prepare(),而非直接提交;未满足条件的关闭不触发该分支。本实验的另一连接普通读取未读到已 prepare 的变更;测试没有显式设置或记录该连接隔离级别,不能据源码注释认定实际隔离参数:
}
@Override
public void close() throws SQLException {
try (ResourceLock ignored = resourceLock.obtain()) {
try {
if (xaActive && this.xaBranchXid != null) {
// XA End: Success
try {
end(XAResource.TMSUCCESS);
} catch (SQLException sqle) {
// Rollback immediately before the XA Branch Context is deleted.
String xaBranchXid = this.xaBranchXid.toString();
rollback();
throw new SQLException(
"Branch " + xaBranchXid + " was rollbacked on committing since " + sqle.getMessage(),
SQLSTATE_XA_NOT_END,
sqle);
}
long now = System.currentTimeMillis();
checkTimeout(now);
setPrepareTime(now);
int prepare = xaResource.prepare(xaBranchXid);
// Based on the four databases: MySQL (8), Oracle (12c), Postgres (16), and MSSQL Server (2022),
// only Oracle has read-only optimization; the others do not provide read-only feedback.
// Therefore, the database type check can be eliminated here.
if (prepare == XAResource.XA_RDONLY) {
// Branch Report to TC: RDONLY
reportStatusToTC(BranchStatus.PhaseOne_RDONLY);
}
}
} catch (XAException xe) {
// Branch Report to TC: Failed
reportStatusToTC(BranchStatus.PhaseOne_Failed);
throw new SQLException(
"Failed to end(TMSUCCESS)/prepare xa branch on " + xid + "-" + xaBranchXid.getBranchId()
+ " since " + xe.getMessage(),
xe);实验 5 在 MySQL 8.4.7 上观察到:prepare 期间新连接读该行仍是旧值,新写入者被行锁阻塞并在 innodb_lock_wait_timeout=1 下报 1205。该测试条件下,AT 一阶段提交后可见,XA 的 prepared 分支尚未对外提交;AT 的全局锁和 undo 不能阻止绕过代理的外部写入。全局提交时 TC 回调 ResourceManagerXA.branchCommit → xaCommit;回滚时 xaRollback(见 241-260 行 rollback:xaEnd(TMFAIL) + xaRollback + 向 TC 报 PhaseOne_Failed)。代价包括 prepared 事务持有资源和锁;持锁时长取决于 prepare 到完成决策的时间,吞吐取决于工作负载,未作性能测量——K08 选型时 XA 换的是"数据库保证的隔离与原子性",付的是并发容量。
SAGA 状态机引擎(推演)
seata-saga-engine 的 StateMachineEngine/ProcessCtrlStateMachineEngine 以 JSON 状态机定义编排服务:ServiceTask 可定义服务调用和补偿,Choice、Succeed、Fail 等承担路由或终态;DB 存储配置中执行记录由 StateLogStore 持久化,ProcessController 按状态机图驱动前进/回退。与 TCC 的差异:SAGA 以事后业务反向操作补偿已执行的步骤,不要求所有参与者提供 TCC 式 Try 预留;具体业务步骤仍可选择预留,例如下方实验的 RESERVED。最终一致是依赖幂等补偿、重试与必要人工恢复的目标,永久失败时并不自动保证。长流程(跨天审批、跨系统编排)应根据事务时长、资源占用和补偿可行性选型。协议层推导(补偿顺序、恢复者协议)在 跨资源事务与长流程。
独立 SAGA 实验在 Seata 2.5.0 和本机 MySQL 8.4.7 下验证正常路径,以及补偿失败后保留 RESERVED,再由应用在同一 JVM 显式调用 compensate 得到 RELEASED 并重读状态日志;运行证据记录输入、版本、两项测试和资源清理。实验未注册 SAGA 分支,也未证明跨进程或 TC 自动恢复;下方独立示例和跨天审批时序仍为推演。
固定版本的配置、状态定义与调用。 下例是基于 Seata 2.5.0 上游测试的使用说明,不是本轮实测;本仓库的 U38 八项实验也不包含 SAGA。工程需固定 org.apache.seata:seata-saga-spring:2.5.0 及其传递的同版本 engine/store/RM、Spring 与数据库驱动;具体版本及管理见上游 Saga Spring POM和 engine POM。为有界实验先创建独有空业务库,在该库执行固定版本 Saga MySQL 建表 SQL;建表会修改所选数据库,切勿指向已有业务库。配置可参照上游完整 Spring XML:该文件的 dataSource 是 H2 内存数据源和测试建表脚本,不能直接当成 MySQL 配置。
上游的 DemoService 和 DemoException 只在测试源码中,不随 Saga 运行时工件提供。以下把一个简化教学实现放进同一包,以匹配上游状态 JSON 的异常类名;这不是 Seata 内置服务,也不用于生产。示例依赖可以在独立 Maven 工程中声明:
<parent>
<groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version><relativePath/>
</parent>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.apache.seata</groupId><artifactId>seata-saga-spring</artifactId><version>2.5.0</version></dependency>
<dependency><groupId>org.apache.seata</groupId><artifactId>seata-all</artifactId><version>2.5.0</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><version>9.7.0</version></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency>
</dependencies>把这段放入常规 Maven project 根元素,并给工程自己的 groupId、artifactId 和 version。在 src/main/java/org/apache/seata/saga/engine/mock/DemoException.java 中定义异常:
package org.apache.seata.saga.engine.mock;
public class DemoException extends RuntimeException {
public DemoException(String message) { super(message); }
}在同目录 DemoService.java 中提供状态定义引用的方法。它只返回输入并按参数模拟异常,不代表库存或发货服务:
package org.apache.seata.saga.engine.mock;
import java.util.Map;
public class DemoService {
private Map<String, Object> apply(Map<String, Object> input) {
if (input == null) return null;
if ("true".equals(input.get("throwException"))) throw new DemoException("模拟失败");
return input;
}
public Map<String, Object> foo(Map<String, Object> input) { return apply(input); }
public Map<String, Object> bar(Map<String, Object> input) { return apply(input); }
public Map<String, Object> compensateFoo(Map<String, Object> input) { return apply(input); }
public Map<String, Object> compensateBar(Map<String, Object> input) { return apply(input); }
}保存如下完整 bean 包装到 src/main/resources/saga-demo.xml。U38_DB_URL、U38_DB_USER、U38_DB_PASSWORD 由本地环境提供并只指向前述独有数据库;服务端 TC 和客户端事务组地址也必须提前按下方资源文件配置:
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<context:property-placeholder/>
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="${U38_DB_URL}"/>
<property name="username" value="${U38_DB_USER}"/>
<property name="password" value="${U38_DB_PASSWORD}"/>
</bean>
<bean id="stateMachineEngine" class="org.apache.seata.saga.engine.impl.ProcessCtrlStateMachineEngine">
<property name="stateMachineConfig" ref="dbStateMachineConfig"/>
</bean>
<bean id="dbStateMachineConfig" class="org.apache.seata.saga.engine.config.DbStateMachineConfig">
<property name="dataSource" ref="dataSource"/>
<property name="resources" value="saga/statelang/*.json"/>
<property name="enableAsync" value="false"/>
<property name="applicationId" value="u38-saga-demo"/>
<property name="txServiceGroup" value="u38_tx_group"/>
<property name="sagaBranchRegisterEnable" value="false"/>
<property name="sagaJsonParser" value="jackson"/>
</bean>
<bean id="demoService" class="org.apache.seata.saga.engine.mock.DemoService"/>
</beans>上面的 dataSource 仍依赖环境变量与独有 MySQL 库;resources 是状态机文件路径匹配。这里将上游异步示例改为同步,避免为讲解引入线程池。sagaBranchRegisterEnable=false 是此示例显式选择,不能用它证明已向 TC 注册 Saga 分支。参考本页 starter 的registry.conf 与 file.conf,分别放入独立工程 src/main/resources/registry.conf 和 src/main/resources/file.conf;其中 u38_tx_group、default 和 127.0.0.1:18091 必须对应示例 XML 与独有 TC 的实际配置,不能指向其他进程。TC 的启动和权限前提见实验说明;本仓库入口会在自己的测试结束后关闭 TC,不会替这个单独的示例保持 TC 运行。
把完整上游状态定义保存为 src/main/resources/saga/statelang/simple_statelang_with_compensation.json。定义名是 simpleCompensationStateMachine,调用 demoService.foo、经 ChoiceState 决定是否执行 bar,异常可进入 CompensationTrigger;CompensateFirstState 等定义补偿方法。文件中的 org.apache.seata.saga.engine.mock.DemoException 必须与上面两份教学源码的包名一致。比如原文件 FirstState 的以下局部说明服务与补偿的绑定,不能单独作为完整状态机运行:
{
"Type": "ServiceTask",
"ServiceName": "demoService",
"ServiceMethod": "foo",
"CompensateState": "CompensateFirstState",
"Next": "ChoiceState"
}可复制下面的独立调用类 src/main/java/example/SagaDemo.java。把所有依赖解析到本地后,按所选工程方式以 JDK 21 启动该 main;它不包含 TC 启动或建库逻辑,必须先完成上面的隔离环境和配置。正常输入传 ok,反例传 fail,记录进程退出码、实例状态、补偿状态与库中状态行;若启动或调用失败,保留异常与上下文,不以预期替代实测。
package example;
import java.util.HashMap;
import java.util.Map;
import org.apache.seata.saga.engine.StateMachineEngine;
import org.apache.seata.saga.statelang.domain.StateMachineInstance;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class SagaDemo {
public static void main(String[] args) {
Map<String, Object> params = new HashMap<>();
params.put("a", 1);
if (args.length > 0 && "fail".equals(args[0])) params.put("barThrowException", "true");
try (ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("saga-demo.xml")) {
StateMachineEngine engine = context.getBean("stateMachineEngine", StateMachineEngine.class);
StateMachineInstance instance = engine.start("simpleCompensationStateMachine", null, params);
System.out.println("instance=" + instance.getId() + " status=" + instance.getStatus()
+ " compensation=" + instance.getCompensationStatus());
}
}
}在独立工程根目录,设置指向独有库的 U38_DB_URL、U38_DB_USER、U38_DB_PASSWORD 后,可用已缓存的 Maven 依赖构建 classpath,再分别启动正常和失败输入:
MAVEN_OPTS='-Xms32m -Xmx256m -XX:ActiveProcessorCount=2' mvn -o -B compile dependency:build-classpath -Dmdep.outputFile=target/saga.classpath
JAVA_TOOL_OPTIONS='-Xms32m -Xmx192m -XX:ActiveProcessorCount=2' java -cp "target/classes:$(cat target/saga.classpath)" example.SagaDemo ok
JAVA_TOOL_OPTIONS='-Xms32m -Xmx192m -XX:ActiveProcessorCount=2' java -cp "target/classes:$(cat target/saga.classpath)" example.SagaDemo fail命令中的 : 是 macOS/Linux 的 classpath 分隔符;离线 Maven 缺少工件或插件会报错,需先取得下载授权并准备缓存。该独立示例尚未在本轮编译或运行,也未验证跨 TC 重启后的状态:以上命令和输出均不能当作通过证据。使用完成后仅清理本次创建的库和资源。
服务方法及测试输入的上游依据是固定版本的 DemoService、DemoException 和数据库状态机测试。上游反例传 a=1、barThrowException="true",上游测试期望实例状态 UN、补偿状态 SU。这只是上游测试的预期与断言,并非本轮对上述教学代码、MySQL 或真实业务补偿的运行结果;正常输入应与同一状态定义的实际服务输出一起验证,不能从 SU 推出外部业务必然一致。startWithBusinessKey 可传业务键,但仍需业务侧约束与幂等,不能以键名推导跨系统副作用只执行一次。
从源码看,DbStateMachineConfig.afterPropertiesSet() 要求数据源并根据实际数据库类型建立状态日志存储,再初始化状态定义存储;自定义存储已存在时走对应分支:
public void afterPropertiesSet() throws Exception {
if (dataSource == null) {
throw new IllegalArgumentException("datasource required not null!");
}
dbType = getDbTypeFromDataSource(dataSource);
if (getStateLogStore() == null) {
DbAndReportTcStateLogStore dbStateLogStore = new DbAndReportTcStateLogStore();
dbStateLogStore.setDataSource(dataSource);
dbStateLogStore.setTablePrefix(tablePrefix);
dbStateLogStore.setDbType(dbType);
dbStateLogStore.setDefaultTenantId(getDefaultTenantId());
dbStateLogStore.setSeqGenerator(getSeqGenerator());
if (StringUtils.hasLength(getSagaJsonParser())) {
ParamsSerializer paramsSerializer = new ParamsSerializer();TC 回滚 Saga 分支时,RM 先尝试按 xid 重载实例;未找到实例时直接报告分支回滚完成。实例存在时,超时且恢复策略为 Forward 会返回可重试的前向状态,否则调用补偿;补偿状态为 SU 时报告分支回滚完成。源码后续还将 StateMachineInstanceNotExists 异常作为回滚完成,其他异常返回可重试回滚失败;这些分支不能代替对业务副作用的核查:
@Override
public BranchStatus branchRollback(
BranchType branchType, String xid, long branchId, String resourceId, String applicationData)
throws TransactionException {
try {
StateMachineInstance stateMachineInstance =
StateMachineEngineHolder.getStateMachineEngine().reloadStateMachineInstance(xid);
if (stateMachineInstance == null) {
return BranchStatus.PhaseTwo_Rollbacked;
}
if (RecoverStrategy.Forward.equals(
stateMachineInstance.getStateMachine().getRecoverStrategy())
&& (GlobalStatus.TimeoutRollbacking.name().equals(applicationData)
|| GlobalStatus.TimeoutRollbackRetrying.name().equals(applicationData))) {
LOGGER.warn("Retry by custom recover strategy [Forward] on timeout, SAGA global[{}]", xid);
return BranchStatus.PhaseTwo_CommitFailed_Retryable;
}
stateMachineInstance =
StateMachineEngineHolder.getStateMachineEngine().compensate(xid, null);
if (ExecutionStatus.SU.equals(stateMachineInstance.getCompensationStatus())) {
return BranchStatus.PhaseTwo_Rollbacked;
}重启后由谁重新提交、何时重试、何种情况下人工终止,要依据状态与故障处理流程明确;调用 forward、compensate 前先核对实例 ID、恢复策略和业务侧实际结果。不能对一个结果未知的远端调用盲目重复扣减,也不能把 TC 返回的分支状态替代对账。
带失败窗口的状态推演。 订单 O-100 先创建一条发货申请 A,再执行仓库审核 B;A 的补偿 C 是取消仍可取消的发货申请。业务不变量是同一订单至多一个有效申请,A、B 和 C 都使用稳定业务操作标识并做幂等校验。下面是教学状态表,不是可执行的 Seata 状态机 JSON,也不是实测日志:
| 时点 | 状态及持久化要求 | 失败与恢复责任 |
|---|---|---|
| 1 | 记录实例 O-100 与 A 的调用意图;A 成功后记录申请号 S-100 和完成状态 | 记录成功前调用结果不确定,恢复者先按业务标识查询 A,不能盲目重复创建 |
| 2 | 调用 B;B 明确失败时记录失败,按定义进入 A 的补偿 C | 先核实 B 的实际结果;超时只表示调用者未知,不等于 B 未执行 |
| 3 | C 用 O-100/S-100 撤销申请,重复执行不产生第二次副作用 | C 失败就记录待恢复,受控重试并告警;若申请已发货,交由人工按业务规则处理,不能标作已回滚 |
| 4 | 只有在各目标状态已核验时记录流程结束 | 流程日志与各参与方状态需定期核对;存储故障或永久补偿失败时一致性仍未达成 |
对照 TCC:若业务可在短窗口冻结库存,Try 预留、Confirm 扣减、Cancel 释放可以降低事后补偿的业务歧义,但要承担冻结资源、超时与二阶段恢复;对已发货这类不可直接撤销的操作,补偿必须是业务认可的新动作而不是假称数据库回滚。源码状态及接口只解释可能的实现,表中整个流程尚未运行。
与幂等、Outbox、Sentinel 课的边界与证据等级
| 问题 | Seata 的答案 | 权威归属 |
|---|---|---|
| 跨两个资源的一致性 | AT/TCC/XA 分支协议 | 本页 |
| 提交与"对外发布意图"的原子性 | 不是 Seata 的目标——用 Outbox | Outbox_事务消息_最终一致性 |
| 消费方重复执行的防御 | 不是 Seata 的目标——业务幂等 | 重复消费_幂等_顺序消息 |
| 二阶段回调的调用方保护 | fence/幂等表 | 本页(实现)+ 协议模型(arch-19-04) |
| 流量隔离与熔断 | 不相关 | Sentinel规则与隔离边界 |
容量与恢复取舍的计算示例(教学假设,非实测)。 一条下单链路每秒稳定进入 120 单,库存不变量为可用库存不小于零、同一订单扣减至多一次;下游和数据库均为本机模型,连接池上限 80。假设每单只占一个数据库连接、平均持有 0.4 秒,则稳定态连接占用估算为 120 单/秒 × 0.4 秒 = 48 个,剩余 32 个连接只是该假设下的余量,不是峰值保证。若所有请求持有时间增至 2 秒,需求变为约 240 个;在连接池 80、无其他瓶颈且一直满载的简化假设下,吞吐上限约 80 / 2 = 40 单/秒,120 单/秒持续输入时未被处理的需求每秒增长约 80 单,须通过限流、排队上限和拒绝处理保持资源有界。实际工作负载需要用延迟分布、连接占用和错误率测量验证。
一种选择是 XA:数据库提供 prepare 与事务决策边界,但 prepare 后锁和连接/资源可持续占用;故障后要由 TC、数据库恢复者及运维核对未决分支。另一种选择是 AT:假设本地事务连接平均仅占 0.04 秒,则同样稳态平均连接占用约 120 × 0.04 = 4.8 个;这不表示系统容量提升十倍,因为全局锁争用、TC、热点行和回滚会形成其他瓶颈。一阶段提交后外部可见,绕过代理的写会带来镜像冲突;遇脏写时先保全记录并由业务与运维核对、修复和复验。若必须将下单提交与消息发布意图原子绑定,用本地事务写订单与 Outbox,再由投递者重试、消费方按业务标识去重;它不自动保证跨库同步隔离。TCC 的冻结与释放也要由业务实现和对账。恢复目标、排队与重试阈值、负责人的交接和可观察指标须在实际场景中明确,不能从上述数字推断生产配置。
AT 与 Outbox 同用时的失败窗口。 订单和 Outbox 行在 AT 一阶段随同一本地事务提交后,普通 relay 已可能读到并发布;若随后全局事务回滚,即使 undo 成功撤销数据库中的订单和意图行,也不能撤回已经送出的外部消息;若 relay 又绕过代理修改了意图行,还可能造成镜像不匹配而使自动回滚失败。例:订单 O-1 与事件 E-1 本地提交 → relay 发布 E-1 → 另一分支失败 → TC 回滚本分支;消费方可能已处理一个最终未成立的订单。消费幂等只能防重复效果,不能消除这条过早发布。需要跨资源全局提交语义时,应为发布设计可持久查询的全局终态或其他经验证的业务协议,并考虑终态不可查询、结果未知、重试、对账和补偿;不能仅凭本地 Outbox 行可见就当作全局提交完成。单独采用本地事务加 Outbox 时,其承诺只覆盖本地提交事实和可恢复发布意图。
证据等级分层(K09):本页源码结论基于 tag 989db47c… 固定摘录(整文件 SHA-256 与本地源码快照核对);2026-10-30 的 8 组 seata-server 与 MySQL 用例、另一个 SAGA 实验的 2 项用例通过,RPC 监听地址由实验 agent 改为回环地址,事务逻辑未修改;实际版本、观察行、退出状态和清理记录见各实验 evidence JSON。SAGA 跨进程自动恢复、Redis/raft store、namingserver、多 TC 集群、Boot 4.0.x 组合、SCA starter 实跑均未验证,正文与账本单列。不得把本页结论未经查证外推到其他 Seata 版本。
闭卷验收
本页 16 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
实验
本页结论的可复现入口:python3 labs/seata/run.py(8 测试 + 9 观察行,真实 seata-server 2.5.0 发行版进程、store.mode=db 指向本机 MySQL study_u38_* 专用库,结束后定向清理)。实验设计与公开工程见 labs/seata,本轮 运行证据 记录了结果与限制;范围冻结、独立复核与运行记录在仓库的 docs/audits/java-depth-u38-scope.md。