分布式事务与最终一致性:从 2PC 到 Saga 的取舍

举报
yd_236430038 发表于 2026/11/03 23:49:52 2026/11/03
【摘要】 分布式事务与最终一致性:从 2PC 到 Saga 的取舍 前言单体应用里一个 @Transactional 就能解决的事,拆成微服务后必须自己实现。动手前先想清楚一句话:绝大多数业务需要的不是强一致,而是"不丢、不重、可对账"。 一、先确认:真的需要强一致吗场景一致性要求合适方案账户扣款与加款强一致单库事务 / TCC / 2PC下单扣库存最终一致本地消息表 + 补偿下单发积分、发券最终一...

分布式事务与最终一致性:从 2PC 到 Saga 的取舍

前言

单体应用里一个 @Transactional 就能解决的事,拆成微服务后必须自己实现。动手前先想清楚一句话:绝大多数业务需要的不是强一致,而是"不丢、不重、可对账"。

一、先确认:真的需要强一致吗

场景 一致性要求 合适方案
账户扣款与加款 强一致 单库事务 / TCC / 2PC
下单扣库存 最终一致 本地消息表 + 补偿
下单发积分、发券 最终一致 事务消息 / 消息表
跨系统对账 最终一致 异步核对 + 差异修复

判断标准:如果中间态(钱扣了、券没发)被人看到会不会引发资损或投诉。会,才付昂贵的强一致成本。

二、2PC / XA:为什么生产上很少用

两阶段提交:协调者先让所有参与者 prepare(锁住资源、不提交),全部成功后发 commit,任一失败则 rollback。

XA START 'tx1';
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'tx1';
XA PREPARE 'tx1';   -- 此处行锁与连接一直被占住
XA COMMIT 'tx1';

问题很硬:

  • 同步阻塞:prepare 到 commit 之间,资源全锁,并发度极差;
  • 协调者单点:协调者挂掉,参与者只能一直等;
  • 长事务:数据库连接被长期占用,连接池很快打满;
  • 性能差:MySQL 的 XA 相比本地事务有数量级损耗。

结论:只在低频、低并发、必须原子的内部账务场景考虑,别拿它当通用方案。

三、TCC:把锁从数据库挪到业务

Try(预留资源)→ Confirm(确认)→ Cancel(释放)。以扣库存为例:

try {
  // Try:冻结 1 件,可用库存 -1,冻结库存 +1;用唯一约束防重复
  inventoryService.freeze(orderId, skuId, 1);
  // Confirm:冻结 → 已扣
  inventoryService.confirm(orderId, skuId, 1);
} catch (Exception e) {
  // Cancel:解冻
  inventoryService.unfreeze(orderId, skuId, 1);
}

三个必修的坑:

  1. 空回滚:Try 没执行,Cancel 却先到了。必须记录事务状态,发现"从未 Try"直接返回成功;
  2. 悬挂:Cancel 先于 Try 到达。Try 执行前要先查状态,已 Cancel 则拒绝执行;
  3. 幂等:Confirm / Cancel 都可能被重复调用,靠事务控制表 + 唯一索引兜底。

TCC 的代价是每个参与方都要改三套接口,侵入性最强,通常只在资金类核心链路使用。

四、本地消息表:性价比最高的方案

思路:业务写入和消息写入放在同一个本地事务里,之后异步投递,失败重试。

CREATE TABLE outbox (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_id VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  status TINYINT NOT NULL DEFAULT 0,  -- 0 待投递 1 已投递 2 失败超限
  retry_count INT NOT NULL DEFAULT 0,
  next_retry_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_biz (biz_id)
);
BEGIN;
INSERT INTO orders (id, user_id, amount) VALUES (1001, 7, 99);
INSERT INTO outbox (biz_id, payload, next_retry_at)
VALUES ('order-1001', '{"type":"order.created","id":1001}', NOW());
COMMIT;

后台 worker 扫 status = 0 AND next_retry_at <= NOW() 投递 MQ,成功置 1,失败按退避时间递增重试(retry_count 超限转人工)。

优点:不引入新中间件、可靠性一目了然;缺点:有秒级延迟、消息表需要定期归档。语义是"至少一次",所以消费方必须幂等。

五、事务消息:托管版本地消息表

RocketMQ 的流程:先发 half message(对消费者不可见)→ 执行本地事务 → 提交 commit/rollback → 若没回执,Broker 定期回查生产者本地事务状态。

本质与本地消息表相同,只是把"存消息 + 重试"托管给了消息队列。注意 Kafka 没有这个能力,需要自己实现 outbox 模式。

六、Saga:面向长流程

把一个长事务拆成 T1…Tn,每个 Ti 配一个补偿 Ci,失败时逆序执行补偿。

维度 编排式(Orchestration) 协同式(Choreography)
控制中心 有,集中调度 无,靠事件驱动
可观测性 好,流程一目了然 差,链路散落各处
耦合度 编排器知道所有步骤 服务之间只认事件
适用 步骤多、需人工介入 步骤少、事件流清晰

示例:下单 → 扣库存 → 支付 → 发券。发券失败则退支付、恢复库存、关闭订单。

Saga 没有隔离性,中间态对外可见,所以业务上要设计"未完成订单"这类状态,并保证补偿幂等、可重试、有人工兜底入口——补偿失败不能静默丢弃。

七、兜底三件套

任何最终一致性方案,缺了这三样都会出事:

  1. 幂等:唯一键去重表 / 状态机只允许单向流转 / 消息 ID 去重;
  2. 重试:指数退避 + 最大次数 + 死信队列,禁止无脑立即重试;
  3. 对账:T+1 定时核对两边的流水与总额,差异自动生成修复工单。

总结

选型对照:

方案 一致性 侵入性 性能 适用
2PC / XA 强 低(数据库层) 差 低频强一致内部账务
TCC 强(最终) 高(三套接口) 好 资金、库存核心链路
本地消息表 最终 中 好 跨服务事件通知,性价比最高
事务消息 最终 中 好 已用 RocketMQ 的团队
Saga 最终 中高 好 长流程、多步骤业务编排

落地顺序推荐:先上本地消息表解决 80% 场景 → 核心资金链路再考虑 TCC → 长流程用 Saga 编排。记住分布式事务的第一原则:能靠单库事务解决的,就不要跨服务。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。