直接用saga库易踩坑,因go生态无官方实现,社区库仅提供编排骨架,不解决补偿失败、消息丢失、重复执行等真实问题;必须依赖外部持久化状态、幂等设计与人工巡检兜底。

为什么直接用 Saga 库容易踩坑
Go 生态里没有官方 Saga 实现,社区库(如 go-saga 或 asynq-saga)大多只提供编排器骨架,不处理补偿失败、消息丢失、重复执行等真实问题。你写完 Begin() 和 Compensate(),一上生产就遇到补偿超时没触发、本地事务已提交但补偿消息发不出去、或者重试时 Compensate() 被调两次——这些都不是库能自动兜底的。
关键不在“有没有 Saga”,而在“谁负责确认每一步的终态”。编排器本身不持有业务状态,必须靠外部存储(比如 PostgreSQL 的 saga_instances 表)持久化当前步骤、重试次数、最后更新时间。漏掉这层,整个流程就是内存里的纸老虎。
Saga 编排器必须自己管理事务上下文
Go 的 database/sql 事务对象不能跨 goroutine 传递,而 Saga 的每个步骤常需异步执行(比如调用 HTTP 服务或发 MQ)。如果你在 Execute() 里直接开新 goroutine 并试图复用同一个 *sql.Tx,会立刻 panic:sql: Transaction has already been committed or rolled back。
- 每个步骤的 DB 操作必须单独开启/提交/回滚
*sql.Tx,并在成功后显式写入 saga 状态表 - HTTP 步骤必须自带幂等性(例如用
X-Request-ID+ 去重表),不能依赖编排器重试保证一致性 - 补偿操作不是“反向执行”,而是独立的正向操作(比如
ChargeMoney()的补偿是RefundMoney(),而非UnchargeMoney())
如何让补偿真正可靠:三要素缺一不可
补偿失败是 Saga 最常见的断裂点。只写 Compensate() 函数远远不够,必须同时满足:
- 补偿操作自身支持重试(例如用
backoff.Retry包封装 HTTP 调用) - 补偿失败时,状态表里该步骤标记为
compensating_failed,并记录错误(如"503 Service Unavailable"),而不是直接 panic - 必须有外部巡检任务(比如每分钟扫一次
WHERE status = 'compensating_failed'),人工介入或触发告警——编排器不会主动“救火”
示例:补偿函数里别直接 log.Fatal(),要这样写:
if err := refundService.Refund(ctx, txID); err != nil {
// 写入 saga_steps 表,status='compensating_failed', error_msg=err.Error()
return errors.Wrap(err, "refund failed")
}
不要把 Saga 当作“分布式 ACID”来用
很多人期望 Saga 能像本地事务一样回滚所有已执行步骤,但实际上它只保证“最终一致性”,且存在中间态暴露窗口。比如订单创建成功、库存扣减成功、支付却失败了——此时用户看到的是“订单已生成但未支付”,这是设计使然,不是 bug。
这意味着:
- 前端必须能展示“处理中”“已取消”等中间状态,不能只依赖最终 success/fail
- 查询接口不能只查 DB,得聚合 saga 状态表 + 各子系统状态(比如查支付状态要调支付网关 API)
- 如果业务要求强实时一致性(如银行转账),Saga 不适合,该上 TCC 或两阶段提交(2PC)方案
最易被忽略的一点:Saga 的“可补偿性”必须在业务建模阶段确认。如果某步骤天然不可逆(比如发短信、写 Kafka 日志),它就不能放进 Saga 主干流程,得挪到 confirm 阶段之后作为“通知”旁路执行。











