Saga是Go微服务中唯一可落地的跨服务事务方案,因2PC、TCC、XA缺乏生产级实现或侵入性强;database/sql无法跨服务,同步HTTP编排易卡死,必须依赖状态持久化、幂等补偿、重试策略与context透传。

Saga 是 Go 微服务中处理跨服务长事务唯一可落地的方案,其他如 2PC、TCC、XA 在生产环境基本不可用——不是没实现,就是侵入太深、运维成本爆炸。别指望 database/sql 或 gorm.Transaction 能跨服务生效,它连本地多库都管不了。
为什么不能用同步 HTTP 调用编排 Saga
常见错误是写个 for 循环依次调用 CreateOrder() → DeductStock() → ChargePayment(),任一环节超时或失败,整条链就卡死或重试失控。
- HTTP 超时只覆盖单次请求,Saga 层需要的是「步骤执行完成」超时,不是「连接建立」超时
- 没有状态持久化,进程重启后无法续跑,变成悬挂事务(
Hanging Saga) - 补偿失败时无重试策略,比如
RefundPayment()因下游服务短暂不可用返回context.DeadlineExceeded,上层直接 panic,没人再试第二次 - 所有步骤的
context必须透传saga_id和step_id,否则日志里查不到哪一步挂了
补偿函数必须先落库再发请求
典型坑:写个 CompensateDeductStock() 直接调 HTTP 接口退库存,网络抖动导致请求失败,上层重试又跑一遍,结果库存加了两次。
- 补偿动作本身要先插入一条记录到
saga_records表,字段含saga_id、step、status = 'pending' - 独立 worker 轮询该表,拿到
pending记录才真正发起 HTTP/gRPC 请求 - 请求成功后,UPDATE
status = 'compensated',失败则按指数退避重试 3–5 次 - 幂等靠数据库唯一约束:建表
compensation_log(saga_id, step_name, created_at),用ON CONFLICT DO NOTHING(PostgreSQL)防重
用 select + channel 控制执行流,避免阻塞
Go 里真正可控的编排方式不是串行调用,而是每个步骤启动 goroutine,主流程用 select 统一收口。
- 每个步骤封装为
func(ctx context.Context) (result error),内部启动 goroutine 发请求,结果写入chan error - 主循环用
select同时监听doneChan和time.After(timeout),一超时就触发对应补偿 - 不要把
http.Client的超时当 Saga 超时用——它不感知补偿是否已执行完毕 - 所有
ctx都要带WithTimeout和WithValue注入saga_id,方便追踪和人工干预
Outbox 模式比纯 Saga 更容易落地
很多团队猛攻 Saga 状态机,最后发现消息丢了、重试没去重、下游没幂等,干脆退回 Outbox 模式——它把「改 DB」和「发事件」塞进同一个本地事务,天然规避双写不一致。
- 业务更新和写
outbox表必须在同一个*sql.Tx里:UPDATE orders SET status = 'paid'和INSERT INTO outbox (topic, payload) -
outbox表必须和业务表同库,否则事务无法原子提交 - 轮询 worker 间隔建议 1–5 秒,太激进(比如 100ms)会拖垮 DB
- 下游服务接收事件时,必须用
idempotency-key请求头或 DB 唯一键做幂等,不能只靠内存缓存

















