Go中无开箱即用Saga框架,实现本质是手动构建可恢复、可追踪、可补偿的状态机;必须持久化状态到PostgreSQL/MySQL(含gid、status等字段并行锁更新),每个Do/Undo需带组合业务标识与步骤名的幂等键,支持空回滚,禁用goroutine并发编排,补偿失败须标记状态并告警人工介入。

Go 里没有开箱即用的 Saga 框架,所谓“实现 Saga”,本质是手动构建一套可恢复、可追踪、可补偿的状态机,而不是调一个 RunSaga() 就完事。
状态必须落库,不能靠内存或 Redis
进程重启、节点宕机后,如果 Saga 状态只存在内存或 Redis 里,正向步骤可能已执行,但补偿无从触发——事务就“悬挂”了。这不是 bug,是设计缺陷。
- 建一张
saga_instances表,字段至少含:gid(UUID)、status(如pending/step2_done/compensating)、current_step、compensate_at、last_error - 每次推进前,必须用
SELECT ... FOR UPDATE加锁读取并更新该行,失败则立即写入补偿任务,不重试 - 别用 Redis 存状态——它不支持行级锁,也难做原子性状态迁移;PostgreSQL 或 MySQL 才是底线选择
每个 Do/Undo 必须带幂等键,且 Undo 要支持空回滚
网络重试会让同一个请求打多次。Do 接口重复扣款、Undo 接口重复退款,都是典型后果。
- 幂等键要组合业务标识与步骤名,例如
order_id:O123:pay或user_id:U789:freeze,作为数据库唯一索引或 Redis key -
Do()执行前先查该键是否存在;若已存在,直接返回成功 -
Undo()必须先查下游真实状态——比如RefundPayment(paymentID)要先查支付单是否已是“已退款”,是则直接返回,不发第三方请求 - 别只用
order_id做幂等键:支付和发货共用同一 key,会互相干扰
推进逻辑只能串行,禁用 goroutine 池、WaitGroup、chan 阻塞
Saga 是强顺序依赖的状态机。第 3 步能否执行,取决于第 2 步是否成功且状态已写库。并发编排等于主动放弃一致性。
立即学习“go语言免费学习笔记(深入)”;
- 不要写
go doStep3()+sync.WaitGroup:补偿可能在正向动作完成前就触发 - 不要用
chan阻塞等待结果:破坏状态驱动本质,也掩盖超时问题 - 正确方式只有两种:
– 数据库轮询:查status == "step2_done"再调doStep3()
– 事件驱动:每步完成后发消息(如payment.charged),由独立消费者处理下一步
补偿失败必须设人工干预点,不能自动跳过
补偿本身可能失败:库存服务宕机、支付网关不可达、下游返回 409 冲突……这时候 Saga 不能停,也不能无限重试。
- 补偿失败时,把实例状态设为
failed_compensation,把完整错误(如"inventory service unreachable")写进last_error - 同步触发告警(钉钉/企微),而非继续重试——重试 100 次还是失败,只会浪费资源
-
Undo()接口必须解析响应体里的result_code,区分“业务拒绝”(如已退)和“网络失败”(如 timeout),前者可直接返回,后者才需重试
最易被忽略的点是:Saga 的“事务性”不来自 Go 语法,而来自你对每一步状态变更、幂等判断、补偿边界的手动控制。哪怕用了 DTM 或 Lanerra/saga,只要没在数据库里加锁更新 saga_instances,没给每个 Do/Undo 设带步骤名的幂等键,没把补偿失败导向人工介入——它就不是 Saga,只是个带回滚日志的 HTTP 调用链。


















