2716 字
约 9 分钟
17
S17-X11 · 分布式事务:2PC、TCC 与 Saga 的边界

@Transactional 在单库内靠数据库本地事务保证原子性。业务一旦跨两个库,本地事务立刻失效——没有任何一个数据库能替你管另一个库的 COMMIT。分布式事务要解决的就是:在没有跨库原子提交的前提下,让多个数据修改要么都算生效、要么都能退回"未生效"。

1. 两个无法消除的事实

事实一:没有跨库的原子提交。提交只对当前实例生效,跨实例只能靠协调者统一裁决(XA),或放弃原子性改做"本地提交 + 事后补偿"。

事实二:超时不等于失败。调用方等 3 秒没响应抛 SocketTimeoutException,对方却可能已提交成功,只是响应包丢在回程。

所有方案都在回答同一个问题:结果未知时,凭什么认定最终状态是对的。答案只有两种——留一份可查的裁决记录(控制表、事务消息、对账),或者允许人工介入。

2. XA/2PC:机制清楚,代价也清楚

XA 由 TM 驱动多个 RM(通常是数据库)分两阶段完成:

阶段 动作 失败会怎样
一阶段 prepare 各 RM 执行并锁资源、写 undo/redo 任一失败则全局回滚,代价小
二阶段 commit 全部 prepare 成功后广播 commit RM 逐个提交,此阶段不可回滚
二阶段 rollback 任一 prepare 失败则广播回滚 已 prepare 的 RM 释放锁
spring:
  datasource:
    jta.enabled: true             # 每个库一个 XADataSource
    orders:
      url: jdbc:mysql://db1:3306/orders
      driver-class-name: com.mysql.cj.jdbc.MysqlXADataSource

三个代价:

同步阻塞。prepare 后涉及的行全被锁,持锁时长等于"最慢那个 RM 的响应时间",跨机房 RTT 直接乘进时长。

协调者单点。TM 挂掉后处于 prepared 的 RM 全部悬空,只能等恢复或人工裁决——MySQL 用 XA RECOVER 列出 in-doubt 事务。

提交阶段的失败窗口。二阶段部分 RM 成功、部分失败后无回退路径,只能重试或人工补数。

结论:XA 只适合同机房、低并发、跨库少的内部系统。不是不会配,是不敢背锁与单点。

3. TCC:把两阶段挪进业务

TCC 的假设是业务自己知道怎么"预占资源":

方法 语义 必须满足
try 检查并预占(冻结余额、锁库存、占券) 只预占,不实扣
confirm 把预占变成实扣 幂等,可重复调用
cancel 释放预占 幂等 + 允许空回滚
public interface OrderTccAction {
    @TwoPhaseBusinessAction(name = "orderTcc", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean tryCreate(@BusinessActionContextParameter(paramName = "orderNo") String orderNo,
                      BigDecimal amount);
    boolean confirm(BusinessActionContext ctx);
    boolean cancel(BusinessActionContext ctx);
}

TCC 的本质是把隔离性降级成业务可见的中间态:try 之后用户看到"可用余额减少、冻结金额增加",前端必须展示冻结字段。

4. TCC 的三个坑与一张控制表

三个坑共用一个解法:事务控制表记录每个分支状态。

CREATE TABLE tcc_branch_log (
  xid       VARCHAR(64) NOT NULL,
  branch_id VARCHAR(64) NOT NULL,
  status    TINYINT NOT NULL COMMENT '1=TRIED 2=CONFIRMED 3=CANCELLED',
  PRIMARY KEY (xid, branch_id)
) ENGINE=InnoDB;
坑 触发场景 表现 防护
空回滚 try 没到(丢包/超时),协调者却发 cancel 释放一个从未冻结的余额 cancel 先查表:无 TRIED 记录则直接返回成功
幂等 网络重试、协调者重复下发 重复 confirm 扣两次 (xid, branch_id) 唯一键 + 条件更新
悬挂 cancel 先到、try 后到 预占永不释放 try 执行前查表,已 CANCELLED 则拒绝本次 try

防重复的核心是一条 UPDATE 定胜负,不要"先查后改":

int rows = jdbc.update("""
    UPDATE tcc_branch_log SET status = ?
     WHERE xid = ? AND branch_id = ? AND status = 1
    """, STATUS_CONFIRMED, xid, branchId);
if (rows == 0) return true;   // 已确认或已取消,重复调用直接成功

5. Saga:长流程的正向与反向

TCC 适合"资源可预占"(钱、库存、券),短板是参与方都要写三个方法。Saga 换思路:每步本地提交,失败用补偿抵消已提交步骤。

形态 谁决定下一步 优点 代价
编排 Orchestration 中心协调器按状态机驱动 流程可见、易调试 协调器成为新中心
编制 Choreography 各服务订阅事件自行推进 无中心、松耦合 流程散落,难回溯
SagaDefinition<OrderContext> saga = step()
    .step().invokeParticipant(this::reserveStock, this::releaseStock)
    .step().invokeParticipant(this::freezeBalance, this::unfreezeBalance)
    .step().invokeParticipant(this::createShipping, this::cancelShipping)
    .withCompensation(this::markOrderFailed)
    .build();

补偿本身失败怎么办是 Saga 最容易被忽略处:补偿可重试但要有上限;超限不放弃,转"挂起待人工"并告警;补偿尽量天然可逆(改状态、反向流水),不可逆动作排链尾。

6. 本地消息表与最大努力通知

不要求立刻一致时,成本最低的两条路。本地消息表把"要发的消息"与业务数据写进同一事务,再由独立线程搬运:

CREATE TABLE outbox_msg (
  id      BIGINT AUTO_INCREMENT PRIMARY KEY,
  topic   VARCHAR(64) NOT NULL,
  biz_key VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  status  TINYINT NOT NULL DEFAULT 0 COMMENT '0=待发 1=已发',
  UNIQUE KEY uk_biz (topic, biz_key)
) ENGINE=InnoDB;

搬运用 FOR UPDATE SKIP LOCKED 抓一批待发行,发送后按 id 标记已发,失败退避重排。发送与改状态无法原子,"先发后改"会重复、"先改后发"会丢,必须靠消费端幂等兜底。

最大努力通知:上游反复回调、下游自己来查,适合支付结果通知商户——通知不是合同,查询接口才是。姿势是通知 + 主动查询 + 对账。

7. 可靠消息最终一致的完整链路

@Transactional   // 业务落库与发半消息同一本地事务
public void createOrder(OrderCmd cmd) {
    orderMapper.insert(build(cmd));
    rocketMQTemplate.sendMessageInTransaction("order-created", buildMsg(cmd), cmd);
}

@RocketMQMessageListener(topic = "order-created", consumerGroup = "inventory-cg")
public class StockConsumer implements RocketMQListener<OrderCreatedMsg> {
    @Transactional public void onMessage(OrderCreatedMsg msg) {
        if (!dedupMapper.tryInsert(msg.getMsgKey())) return;   // 唯一键去重
        stockService.deduct(msg.getSkuId(), msg.getQty());
    }
}
环节 作用 缺失后果
本地事务 + 事务消息 业务成功则消息一定发得出 消息丢,下游永不触发
消费端幂等键 抵消重投 重复扣库存
重试 + 死信 + 对账 最终被处理、兜住漏网 静默丢失或永久不一致

对账最容易被砍,却是唯一能在"各方都以为自己成功"时发现问题的机制:

-- 已支付但库存未扣:每分钟扫最近一小时,留 5 分钟延迟窗口
SELECT o.order_no FROM t_order o
  LEFT JOIN t_stock_deduct d ON d.order_no = o.order_no
 WHERE o.status = 'PAID' AND d.order_no IS NULL
   AND o.gmt_modified BETWEEN NOW() - INTERVAL 1 HOUR AND NOW() - INTERVAL 5 MINUTE;

8. 四种方案对照

维度 XA/2PC TCC Saga 可靠消息
一致性强度 强(提交后本地可读) 最终,中间态可见 最终,中间态可见 最终,延迟最大
侵入性 低(改配置) 高(三方法+控制表) 中(每步写补偿) 中(outbox+幂等)
性能 差(锁 + 多轮 RPC) 好(两轮短事务) 好(本地事务串行) 好(异步)
运维复杂度 中(in-doubt 人工) 高(悬挂排查) 中高(补偿挂起) 中(堆积与对账)
典型场景 同机房结算 支付、库存、券 长流程履约 订单→积分/通知

9. 选型判据:先问"写几个库"

跨库写且必须同时成功 → XA(同机房低并发)或 TCC(高并发、可预占);跨服务编排且步骤多、有外部系统 → Saga,优先编排式;只需通知下游 → 本地消息表或事务消息,配幂等 + 对账。反向判据:两个操作其实同库,就不该上分布式事务。

10. 常见坑

  • 以为 cancel 一定执行业务释放。空回滚下正解是"确认无事可做并返回成功",强行释放反而造资损。
  • 补偿写成"反向操作"却不可逆。退款、发券可反,发短信、调第三方开户不可反。
  • 没有对账。机制都依赖"各方都成功",某一方悄悄失败只有对账能发现。
  • 强一致硬套最终一致。支付后立刻查订单必须已支付,读己之写给不了。
  • 控制表与业务表分开事务。状态与实际不符,后续补偿判断全错。

11. 排错:五种典型不一致

现象 常见原因 处理
提交后下游没收到 未实现消息回查,或发送失败被吞 实现 checkLocalTransaction + outbox
库存被扣两次 消费端无幂等键 加 msgKey 唯一索引
冻结余额永不释放 悬挂:cancel 先到、try 后到 try 前查表,已 CANCELLED 则拒绝
对账有差异但无报错 补偿吞异常、日志级别过高 补偿失败必须抛错并落"待人工"表
XA 长期 in-doubt TM 崩溃或提交时断网 XA RECOVER 后人工 commit/rollback

12. 边界:这些情况别硬上

场景 更合适的做法 理由
同实例不同库 合并到一个库或用多表 本地事务够用,别引入协调者
只需通知不需回滚 消息 + 幂等 + 对账 无需补偿就无需事务
跨组织资源 Saga + 人工兜底 对方库不受你控制
离线批处理 幂等重跑 + 覆盖写 天然可重放

一句话收束:能用本地事务就别拆库,能异步就别同步,能补偿就别 prepare,补偿一定要配对账。

13. 自测

  1. 为什么"调用超时"比"调用失败"更难处理?
  2. XA 的提交阶段为什么被称为不可回滚的窗口?出现 in-doubt 怎么定位?
  3. TCC 的 cancel 遇到空回滚时该做什么、不该做什么?
  4. 悬挂的成因是什么,"先查控制表"为什么能防住它?
  5. Saga 补偿自身失败且重试超限后,正确的处理动作是什么?
  6. 可靠消息链路里哪一环是最后的安全网,为什么不能省?
S17-X11 · 分布式事务:2PC、TCC 与 Saga 的边界
https://www.clxhxhhr.top/posts/5885/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。