跳转到主要内容

分布式事务在金融场景的取舍

对比 2PC、TCC、Saga 与本地消息表在金融场景的适用边界,并说明为什么对账才是最终防线。

金融系统聊分布式事务,很容易陷入"选哪个框架"的讨论。但真实的约束是:钱不能错,且必须能查清为什么错。这两条决定了方案选择的顺序和别的行业不太一样。

金融场景的真实约束

  • 不允许静默不一致。可以暂时不一致,但必须有机制发现并修复,不能出现谁也不知道差在哪的状态。
  • 必须能人工干预。任何自动补偿机制都有失效的可能,最终要留一条人工处理通道,且要有审计留痕。
  • 联机响应时间有硬指标。核心交易通常要求 100ms 内,多阶段协调的额外网络往返基本吃不下。
  • 补偿不总是可行。已经出账到人民银行的报文没法回滚,只能发反向报文,业务语义完全不同。

最后一条最容易被忽略:很多补偿型方案假设操作可逆,但金融业务里大量操作在真实世界里是不可逆的。

几种方案的适用边界

  • 2PC / XA:强一致,但同步阻塞、协调者单点、锁持有时间长。只在同库多表或同一数据库集群内使用。跨服务用它,第一次网络抖动就会看到大批悬挂事务。
  • TCC:一致性和性能都不错,代价是每个参与方要写三份逻辑(try / confirm / cancel),且必须自己处理空回滚、悬挂、幂等。开发量大约是普通接口的三倍,适合参与方少且业务确定的核心链路。
  • Saga:适合长流程、多参与方,比如开户流程里的证件核验、账户开立、卡片制发。要求每步都有语义补偿,且能接受中间态对外可见。
  • 本地消息表 + 可靠投递:把消息写入和业务操作放在同一个本地事务,再由后台任务投递。实现简单、无外部依赖、可靠性靠数据库保证。缺点是只能做最终一致。

我们的默认选择:本地消息表 + 对账

绝大多数场景我们用本地消息表。理由很朴素:它没有引入新的中间件,所有状态都在业务库里,出问题时排查路径最短。

@Transactional
public void transfer(TransferCmd cmd) {
    // 1. 本地账务操作
    Voucher voucher = accountingService.post(cmd.toVoucher());

    // 2. 同一事务内写待发消息,保证原子性
    OutboxMessage msg = OutboxMessage.of(
            "transfer.posted",
            cmd.bizNo(),                 // 业务唯一键,下游据此幂等
            JsonUtil.toJson(voucher.toEvent()));
    outboxRepository.save(msg);
}

// 独立线程扫描待发消息,投递成功后标记;失败按退避重试
// 超过最大重试次数转入人工处理队列不允许静默丢弃

配套三件事一个都不能省:

  1. 下游幂等。用业务唯一键做唯一索引,重复消息直接忽略。
  2. 重试上限加人工队列。无限重试等于把问题藏起来。
  3. 日终对账。这才是最终防线。
# 日终核对上下游流水,差异入待处理表并告警
recon-cli run --date "$(date -d yesterday +%Y%m%d)" \
  --left  core.voucher_flow \
  --right settlement.txn_flow \
  --key   biz_no \
  --compare amount,currency,direction \
  --output recon.diff_pending \
  --alert-on-diff

我的结论是:对账做扎实了,分布式事务方案可以选最简单的那个;对账没做,再精巧的 TCC 也只是把不一致藏得更深。

选型的顺序应该是:能不拆服务就不拆,同库能解决就用本地事务;必须跨服务时优先最终一致加对账;只有确实无法接受中间态的核心链路才上 TCC。别一开始就找框架。