go中database/sql事务对象不能跨goroutine或服务,saga需每个步骤独立开启/提交db事务并持久化状态,补偿操作须幂等、可重试且失败时标记告警。

database/sql 的事务对象不能跨服务、不能跨 goroutine、不能跨 HTTP/gRPC 调用——Saga 不是它的延伸,而是彻底的替代方案。想用同一套接口抽象本地事务和分布式 Saga,只会让代码更难维护、更易出错。
别把 *sql.Tx 当作 Saga 的起点
很多人试图在 Begin() 后传一个 *sql.Tx 进 Saga 步骤,结果在第二个服务里 panic:sql: Transaction has already been committed or rolled back。这是因为 *sql.Tx 是单数据库连接生命周期内的状态,无法穿透到支付服务或库存服务。
- 每个 Saga 步骤的 DB 操作必须独立
db.Begin()→tx.Exec()→tx.Commit()或tx.Rollback() - 正向步骤成功后,必须立刻持久化该步状态(如写入
saga_instances表),而不是等整个流程跑完再统一提交 - 补偿操作也必须走自己的
*sql.Tx,且要先查下游真实状态(比如查payments表确认是否已退款),再决定是否执行退款逻辑
SagaExecutor 必须带状态驱动,不能靠内存或 channel 编排
用 map[string]*SagaState 存事务状态?服务重启就丢;用 chan 等待下一步?超时、失败、重试全失控。真正的编排器只认数据库里那一行 status 字段。
- 每次推进前,必须用
SELECT ... FOR UPDATE锁住对应gid的记录,再判断当前status是否允许执行下一步 - 支持两种推进方式:轮询(定时查
status = 'step2_done')或事件驱动(MQ 收到payment.charged后触发doStep3()) - 禁用
go doStepX()+sync.WaitGroup—— 这类并发模型破坏顺序依赖,会导致compensateStep2()在doStep3()完成前就被调用
幂等键设计不当,是重复扣款/退款的直接原因
只用 order_id 作幂等键?支付和发货共用一个 key,一次重试可能让发货服务误认为“已发货”而跳过,同时支付服务又因重试再次扣款。
- 幂等键必须组合业务域与步骤名,例如:
order_id:O123:pay、order_id:O123:reserve_stock - Do() 前查该键是否存在(建议建唯一索引);Undo() 前查下游终态(如
SELECT status FROM payments WHERE id = ?),不是查本地缓存 - HTTP 步骤需配合
X-Request-ID+ 去重表,不能依赖编排器重试来保证一致性
补偿失败不告警,等于默认接受数据撕裂
写了 RefundPayment() 函数,但没处理它失败的场景——网络超时、下游限流、余额不足……这些都会让 Saga 卡在中间态,既没完成也没回滚。
- 补偿失败时,必须原子更新
saga_instances表:status = 'compensating_failed',并写入last_error字段(如"503 from payment-gateway") - 补偿函数内部不能
log.Fatal()或忽略context.DeadlineExceeded,要用backoff.Retry包封装重试,上限 3–5 次 - 必须有外部巡检任务(如每分钟扫
WHERE status = 'compensating_failed'),否则没人知道这笔资金卡在哪
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











