2PC、TCC 与 Saga:跨服务状态怎样提交、撤销或补偿
账户 A 扣减 10,账户 B 增加 10。如果两个账户在同一数据库的一次事务中,数据库可以统一提交;分属两个数据库后,第二次提交失败会留下 A 已扣、B 未加的结果。给外层方法加一个普通 @Transactional,无法把两个独立资源自动变成一个原子事务。
跨资源操作需要先决定允许出现怎样的中间状态:两边都预备好再提交,先预留业务资源再确认,还是允许各步先提交、失败后执行补偿。2PC、TCC 与 Saga 分别采用这些方式。
跨资源提交需要保存哪些状态
本地事务与全局事务
本地事务由一个资源管理器完成。连接上的 BEGIN、SQL 和 COMMIT 构成一次提交;隔离级别规定并发事务能观察什么,约束与锁保护相应数据。多个 Repository 方法只要使用同一事务连接,就可以参加这次事务。连接、代理传播和异常回滚的具体行为见 JDBC 与事务。
全局事务需要把多个资源分支关联起来:
全局操作 TRANSFER-1
├── 协调记录:参与者集合、全局决定、各分支完成情况
├── 分支 A:账户库 A,扣减 10,本地事务标识 A-1
└── 分支 B:账户库 B,增加 10,本地事务标识 B-1全局 ID 用来查找同一次操作,分支 ID 区分不同资源工作。协调器必须在崩溃后找回参与者和已作出的决定;参与者必须能查询自己的分支状态。只在内存里记录“第二步还没调用”,进程重启后就失去了恢复依据。
XA 规范把应用、事务管理器与资源管理器分开:事务管理器驱动各分支的 start/end、prepare、commit/rollback 和 recover。JDBC 驱动的 XA 支持提供资源接口,数据库提供持久化原语;完整事务管理器还负责恢复扫描与故障处理。接口定义见 Java XAResource。
响应丢失留下的是待查询操作
A 返回成功以后,客户端对 B 的调用超时。B 可能没有收到请求,可能执行失败,也可能已提交但响应丢失。重新生成业务 ID 再执行,可能制造第二次转账。
调用方应保存原操作 ID,并通过它查询或重试同一操作。数据库约束、参与者幂等接口和协调记录各自处理不同重复窗口。HTTP 客户端的截止时间只限制等待,无法撤回远端已经完成的提交。
2PC:先持久化准备,再执行同一个决定
Prepare 与 Commit 两轮交互
第一阶段,协调器询问所有参与者是否能够提交。投 Yes 的参与者已经完成必要工作,并持久保存足以在恢复后继续提交的状态。不能准备的分支投 No;只读分支可按协议结束,不必继续保持一个待写事务。
第二阶段,协调器在所有必要分支投 Yes 后持久化 COMMIT 决定,再通知各分支提交。只要已作出这个决定,重试和恢复都必须继续提交;不能因为某分支暂时联系不上改成回滚。Consensus on Transaction Commit讨论了原子提交与协调器故障的关系。
协调器 A B
├── prepare ──────────→ 持久化 PREPARED
├── prepare ───────────────────────────────────→ 持久化 PREPARED
←── Yes ───────────────┤ │
←── Yes ────────────────────────────────────────┤
├── 持久化 COMMIT │ │
├── commit ───────────→ 提交并释放资源 │
└── commit ────────────────────────────────────→ 提交并释放资源投 Yes 之后的参与者不能独自按超时回滚。它不知道协调器是否已通知另一分支提交。协调器不可达、决定无法确认时,分支可能停留在 in-doubt 状态,继续等待恢复。共识复制可以提高协调记录的可用性,仍需要处理参与者本身的故障和通信中断。
全局原子提交约束各分支的最终决定。不同分支收到提交通知的时间可以不同;若读请求绕过全局事务协议、独立查询两个数据库,仍可能观察到提交传播中的中间组合。跨库隔离与全局可见性需要另外设计。
运行 PostgreSQL 预备事务实验
下载 分布式状态实验工程。Linux 宿主需要 Docker Engine、Compose v2、Bash、unzip;Java 测试使用一次性 Maven 容器。镜像为 PostgreSQL 18.6、Maven 3.9.12,源码编译目标 Java 17,可用 Java 17 或 25 运行。
unzip distributed-state-lab.zip
cd distributed-state
docker version
docker compose version
docker compose up -d --wait a b
docker compose ps两个数据库应都处于 healthy。a、b 位于独立的 ds14-state-network,没有映射宿主数据库端口。初始化创建普通数据库角色 lab 及其拥有的实验库;管理员密码和应用密码是包内固定演示值,只用于隔离环境。官方镜像入口负责数据目录初始化,postgres 服务进程使用镜像的数据库用户,业务连接不使用超级用户。
Compose 将 max_prepared_transactions 设置为 16,并用具名卷保存数据。该配置只为观察预备事务;没有负责恢复的事务管理器时,生产环境应保持这项功能关闭。准备后的事务脱离原会话,继续持有锁并影响旧版本回收,详见 PREPARE TRANSACTION。
先运行可重复的重启实验:
bash prepared-recovery.sh脚本验证容器属于本实验,然后执行:
- 在两个库分别建立
restart_lab.wallet,初始余额均为 100;若发现本实验遗留的 prepared 分支,立即停止,避免覆盖待恢复数据。 - A 扣 10、B 加 10,各自执行 PREPARE,原连接结束。
- 用另一笔本地事务持久保存
TRANSFER-1 → COMMIT。 - 重启两个数据库容器,等待 TCP 就绪,再检查两条 prepared 分支、旧余额和锁冲突。
- 读取既有 COMMIT 决定,从新连接提交两分支,检查余额 90/110 及 prepared 数量归零。
最后输出:
after-restart prepared=2 before=100,100 after=90,110 prepared-left=0中间出现 SQLSTATE 55P03 是脚本主动制造并检查的锁超时。这个实验使用真实数据库重启和持久卷;没有模拟断电、磁盘损坏或数据库副本切换。协调记录为便于观察放在 A 的另一张表中,A 整库不可用时仍然无法读取它,不能拿这份脚本作为高可用事务管理器。
直接观察准备后的分支
脚本在 A 库执行的核心 SQL 如下。它在数据库事务内执行,gid 是供后续恢复查找的唯一标识:
BEGIN;
UPDATE restart_lab.wallet SET balance = balance - 10 WHERE id = 1;
PREPARE TRANSACTION 'ds14-restart-a';PREPARE 返回后,这个连接已不再持有活动事务。换一个连接可以查询:
docker compose exec -T -e PGPASSWORD=lab_password_only a \
psql -X -h 127.0.0.1 -U lab -d lab -c \
"SELECT gid, owner, database FROM pg_prepared_xacts;"完整脚本结束后应返回零行;如果脚本停在恢复前,会看到对应 gid。pg_prepared_xacts 中一行对应一个当前准备好的事务,提交或回滚后移除。系统视图字段可用于关联事务管理器的分支记录。
当可信协调记录确认 COMMIT 时,原事务所有者或有权限的管理员在事务块外执行 COMMIT PREPARED 'gid'。权限与执行条件见 COMMIT PREPARED。若收到“事务不存在”,应核对分支是否已经完成、数据库是否正确以及日志是否属于同一个全局操作;不要重新执行扣款 SQL。
阻塞恢复从决定记录开始
| 查到的状态 | 可以采取的动作 |
|---|---|
| 可信全局决定为 COMMIT,部分分支尚未完成 | 对原 gid 重试提交,记录分支确认 |
| 可信全局决定为 ABORT | 回滚仍处于 prepared 的分支,并记录完成 |
| 参与者已 prepared,但协调记录暂不可读 | 修复决定存储或通信,限制新增事务,保留分支 |
| 协调记录缺失且无法证明决定历史 | 按事务管理器恢复协议调查,必要时人工处置;不能按事务年龄猜测 |
| 可信协议证明全局提交从未成立 | 按该协议恢复为中止,处理未完成分支 |
ROLLBACK PREPARED 同样需要正确的分支和执行权限,语法见 PostgreSQL 回滚预备事务。按年龄批量回滚可以迅速释放锁,也可能把已经部分提交的全局操作永久拆开。
TCC:把资源预留写成业务状态
Try、Confirm 与 Cancel 各自提交
库存初始有 10 件。Try 预留 3 件后,可售数变成 7、预留数变成 3;Confirm 把预留转成已售;Cancel 将预留退回可售。这三个阶段使用短本地事务,阶段之间保留的是业务记录,而非持续占用数据库行锁。
分支记录:branch_id、请求数量、当前状态
不存在 ── Try(3) ──→ RESERVED ── Confirm ──→ CONFIRMED
│ └── Cancel ────→ CANCELLED
└── 先收到 Cancel ─────────────────────→ CANCELLED
│
迟到 Try 必须被拒绝Try 要检查业务条件并真实预留资源。只查询“库存足够”然后返回成功,后续其他订单仍能卖掉这些库存,Confirm 就无法可靠完成。
参与者用分支唯一键和行锁串行处理同一分支。库存变化与分支状态在同一事务中提交,重复调用才能根据已保存状态返回原结果。工程的 Reservation.java 采用以下规则:
| 已保存状态 | Try | Confirm | Cancel |
|---|---|---|---|
| 无记录 | 尝试建立记录并预留 | 拒绝无预留确认 | 建立取消屏障 |
| RESERVED | 相同数量返回成功,不再扣库存 | 预留转已售 | 预留退回可售 |
| CONFIRMED | 相同操作返回既有结果 | 幂等成功 | 拒绝相反终态 |
| CANCELLED | 拒绝迟到请求 | 拒绝 | 幂等成功 |
同一个 branch ID 携带不同数量是冲突请求,不能返回“重复成功”。Cancel 早于 Try 时写入的取消屏障解决空回滚后的悬挂;如果仅因“没有预留”直接返回,迟到 Try 仍会建立永远无人确认的预留。Seata TCC Fence给出了幂等、空回滚与悬挂的框架处理方式。
执行参与者正反测试
沿用解压目录,用当前 Linux 普通用户创建缓存并运行:
mkdir -p .m2
LAB_DIR="$(pwd -P)"
docker run --rm --network ds14-state-network \
--user "$(id -u):$(id -g)" -e HOME=/tmp -e MAVEN_CONFIG=/m2 \
--entrypoint mvn \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work maven:3.9.12-eclipse-temurin-25 \
-B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp \
-Dtest=TransactionTest test预期 6 个测试、零失败、零错误、零跳过,报告位于 target/surefire-reports。其中包含两库 prepared 恢复、遗留分支保护、重复 Try/Confirm、先 Cancel 后 Try、重复取消和 Saga 退款。测试直接查询实际库存、余额与分支记录;故意失败由异常断言捕获,正常套件仍应整体成功。重置实验前先检查全部参与者,任何一侧仍有 prepared 分支都保留两边的决定记录。
Maven 按宿主 UID/GID 写工程和缓存;它不需要 Docker socket。数据库连接使用普通 lab 角色,仅在包内命名的实验 schema 中建表。依赖下载失败先检查批准的 Maven 镜像和网络配置,不能靠跳过测试获得通过。受限环境可以预先保存固定镜像和同目标的 Maven 缓存,再验证离线执行。
协调器和业务期限仍然存在
参与者能正确处理重复消息后,还需要协调器持续投递 Confirm 或 Cancel,记录每个分支结果。调用返回 202 时,查询接口应给出处理中、成功或取消状态,客户端沿同一个 operation ID 查询。
预留期限必须与全局决定协调。若协调器已经选择提交,而某参与者独自按本机 TTL 释放库存,另一参与者可能已确认。可以由统一决定驱动取消,也可以采用业务明确约定的到期协议;不能把一个缓存过期时间直接当作全局事务回滚。
TCC 适合能够明确预留的配额、余额冻结和库存。它要求业务实现三套接口及恢复规则,改造成本通常高于普通本地事务。示例只实现参与者和受控协调测试,不包含生产调度器、全局日志复制或管理后台。
Saga:已提交的步骤怎样继续或补偿
补偿是新的业务操作
Saga 把长流程拆成独立本地事务。前面的步骤已经提交并释放锁,其他请求可能观察到它们。后续失败时,补偿按业务含义抵消先前效果,例如创建退款单,而不是把数据库恢复成某个旧快照。Sagas 原论文讨论了这种允许中间交错的长事务。
操作:扣款提交 → 预订失败 → 保存退款任务 → 退款尝试失败 → 重试退款成功
余额: 70 70 70 70 100
状态:CHARGED CHARGED REFUND_PENDING REFUND_PENDING REFUNDED示例 SagaPayment 先用 operation ID 幂等扣款,再单独提交 REFUND_PENDING。退款事务同时增加余额和把状态改成 REFUNDED;在提交前注入异常时,两项一起回滚,待退款记录仍然存在。下一次重试完成退款,后续重复执行不再增加余额。
如果真实退款通过远端支付机构完成,本地数据库无法把远端扣款和自己记录状态包在同一个事务里。请求必须携带机构支持的幂等键;超时后先查询该笔退款,再决定是否重试。示例里的单库余额变化用于验证持久步骤和幂等,不能直接替代支付接口的协议。
编排、协同与隔离
编排式 Saga 由流程协调器保存下一步和各步结果,便于查明当前卡在哪里。协同式 Saga 由事件触发后续服务,各服务保存自己的处理记录;订阅关系、事件循环和全局进度需要额外治理。框架执行与持久状态机示例见 Seata Saga。
两种方式都需要稳定操作 ID、结果查询、重试期限和幂等动作。长流程中可能有人修改地址、取消订单或消费刚到账的余额;补偿必须检查当前业务状态,使用增减操作、版本条件或预留模型处理交错。直接写回“原余额 100”会覆盖其他已经合法提交的变动。
不可逆动作应尽量放在能够确定继续执行的位置。例如实体发货不能用“删除发货记录”撤回,必须转成拦截、退货或人工处理。补偿重试耗尽时保留失败步骤、原因和下一次处置方式;删除失败记录会让未完成业务从查询和监控里消失。
根据数据约束选择方式
| 业务条件 | 优先考虑 | 需要承担的代价 |
|---|---|---|
| 数据可放入一个资源的一次事务 | 本地事务 | 数据和模块划分受约束 |
| 多资源支持预备事务,需要统一原子决定 | 2PC/XA | prepared 阻塞、协调日志与恢复运维 |
| 资源能明确预留,确认阶段应能完成 | TCC | 业务三阶段、预留库存与重复调用处理 |
| 流程较长,各步可独立提交并有业务补偿 | Saga | 中间状态可见、补偿和人工接管 |
| 业务提交后可靠更新消息或读模型 | 本地事务 + Outbox | 异步延迟、重复、对账,见 Outbox 与 Inbox |
Outbox 保存“业务已提交后需要发布什么”,不会自动让两个服务的业务写原子提交。它可以成为 Saga 的可靠消息通道;TCC 的参与者也可以在本地阶段事务里记录待发送事件。机制能够组合,前提是每层的完成条件可查询。
故障恢复与实验收尾
2PC 卡住先查协调决定、gid 和数据库锁,恢复决定存储后继续原分支。TCC 库存长期预留先查全局状态和最后一次 Confirm/Cancel,不按库存记录年龄直接释放。Saga 退款反复失败先区分可重试网络错误、机构拒绝和业务状态冲突;已经成功的远端退款应通过查询回填结果。
新增协议或升级协调器时,要回归未完成操作的旧版本记录。回退应用版本也必须能识别已保存的状态和补偿任务;不能只验证全新订单。对预备事务数量、最老等待时间、预留量和待补偿任务分别告警,告警对象应能关联到具体全局操作。
确认本次实验已没有 prepared 分支,再停止容器:
docker compose down具名卷保留余额与恢复记录。确认不再需要实验数据后,才使用 docker compose down -v 删除本项目卷;不要对共享环境执行全局 Docker 清理。若脚本在 COMMIT 决定保存后中断,先按该决定完成原分支,再销毁实验环境。
权威资料与规范地址
- Java XAResource 接口:https://docs.oracle.com/en/java/javase/25/docs/api/java.transaction.xa/javax/transaction/xa/XAResource.html
- 原子提交与协调故障:https://www.microsoft.com/en-us/research/publication/consensus-on-transaction-commit/
- PostgreSQL PREPARE TRANSACTION:https://www.postgresql.org/docs/18/sql-prepare-transaction.html
- PostgreSQL pg_prepared_xacts:https://www.postgresql.org/docs/18/view-pg-prepared-xacts.html
- PostgreSQL COMMIT PREPARED:https://www.postgresql.org/docs/18/sql-commit-prepared.html
- PostgreSQL ROLLBACK PREPARED:https://www.postgresql.org/docs/18/sql-rollback-prepared.html
- Seata TCC Fence:https://seata.apache.org/blog/seata-tcc-fence/
- Sagas 论文:https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- Seata Saga:https://seata.apache.org/docs/dev/mode/saga-mode/
