Saga是跨服务事务的务实选择,因其放弃强一致性,以“本地事务+补偿操作”实现最终一致性,适配Go生态松耦合现实,避免2PC的单点、锁资源和网络分区问题。

为什么 Saga 是跨服务事务的务实选择
分布式系统里硬要强一致性,基本等于给自己挖坑。两阶段提交(2PC)在微服务间几乎不可用:协调器单点、参与者长期锁资源、网络分区时状态不确定。Saga 不追求 ACID,而是用「一连串本地事务 + 对应补偿操作」来保证最终一致性,更适合 Go 生态中 HTTP/gRPC 服务松耦合的现实。
它不是银弹,但能落地:每个服务只管自己的 DB 和消息队列,不依赖全局事务管理器,也避开了 database/sql 的跨库事务限制。
用 Go 实现 Saga 的三个核心组件
一个可运行的 Saga 至少得有:
-
SagaExecutor:编排逻辑,按序调用各服务动作,失败时反向执行补偿 -
Command和Compensate接口:每个步骤封装为「正向操作 + 补偿函数」,比如ChargePayment对应RefundPayment - 持久化存储:必须记录当前 Saga 状态(如
Pending/Compensating),否则重启后无法续跑。别用内存存——用 PostgreSQL 表或 Redis Hash 都行,字段至少含saga_id、step、status、payload
常见错误是把补偿逻辑写成「查完再退」,导致竞态。正确做法是补偿操作必须幂等且基于原始请求 ID(比如用 payment_id 直接查账单状态,而不是查「用户余额是否已扣」)。
如何避免补偿失败导致悬挂(Hanging Saga)
Saga 最怕卡在中间:第 3 步成功,第 4 步超时,补偿第 3 步又失败——整个流程既没完成也没回滚。
- 所有
Compensate函数必须返回明确错误,且不能忽略context.DeadlineExceeded - 在
SagaExecutor中加重试策略:对补偿失败的步骤,用指数退避重试 3–5 次,超过则标记为Failed并告警 - 关键补偿操作(如资金类)建议加「异步核对 Job」:每分钟扫一次
status = 'Compensating'的记录,调用独立的ReconcilePayment函数兜底
示例片段:
// 补偿函数需自带重入保护
func (s *PaymentService) RefundPayment(ctx context.Context, paymentID string) error {
// 先查该 payment 是否已退款(幂等关键)
if exists, _ := s.db.ExistsRefund(ctx, paymentID); exists {
return nil
}
_, err := s.db.InsertRefund(ctx, paymentID)
return err
}Go 生态里推荐的消息驱动 Saga 架构
HTTP 同步调用 Saga 容易雪崩(一个服务慢,整条链超时)。更稳的做法是用消息队列解耦:
- 每个服务监听专属 Topic(如
order.created、payment.charged) -
SagaExecutor作为独立服务,消费事件并发布下一步命令(如发inventory.reserve) - 所有命令和事件都带
saga_id和trace_id,便于日志串联
注意 Kafka 分区键必须设为 saga_id,否则同个 Saga 的消息可能乱序;用 RabbitMQ 则要启用 publisher confirms 和手动 ACK,防止消息丢失。
补偿消息也得进队列(不要直调),否则消费者宕机时补偿就丢了。
Saga 的复杂度不在代码量,而在状态机设计和边界 case 处理——比如网络请求成功但响应解析失败、补偿操作本身触发新 Saga、数据库主从延迟导致补偿查不到刚写的记录。这些地方没日志、没监控、没人工干预入口,线上出问题只能翻表硬查。


















