
为什么不能用 db.BeginTx 跨服务包事务
因为 db.BeginTx 只作用于单个数据库连接,而微服务天然隔离——user-service 的 PostgreSQL 连接和 order-service 的 MySQL 完全无关。你在 A 服务里 tx.Commit() 成功,B 服务哪怕网络超时、进程崩溃、SQL 报错,也根本不会感知,更不会回滚。
常见错误现象包括:订单创建成功但库存没扣、支付扣款成功但优惠券没发。日志里只看到一方 INSERT INTO orders 成功,另一方连请求都没收到,却找不到任何“回滚入口”。
- Go 标准库和
gorm/sqlx都不提供跨 DB 事务能力,这不是配置问题,是 CAP 约束下的必然 - 别试图用
context.WithTimeout+ 多次重试 + 全局锁模拟 ACID,高并发下极易死锁或雪崩 - 真正的起点不是“怎么提交”,而是“失败后怎么撤回”——也就是补偿逻辑的设计基线
Saga 补偿逻辑必须幂等,且要防重复执行
补偿不是“再调一次 Cancel 接口”就完事。网络重试、死信队列重投、人工触发补单,都可能导致同一笔事务的补偿被多次执行。比如 refundBalance(userID, amount) 如果没校验原扣款是否已发生,就会重复退款。
实操建议直接写进 SQL 条件里,而不是靠代码 if 判断:
立即学习“go语言免费学习笔记(深入)”;
UPDATE payments SET status = 'refunded' WHERE id = ? AND status = 'charged'
这样即使并发执行,也最多一条成功;或者用 Redis 记录已处理事件 ID:SETNX saga_compensated:order_123 true,过期时间设为 24 小时。
- 所有正向操作(如
deductInventory)和补偿操作(如restoreInventory)都要有幂等键,推荐用业务单号 + 操作类型组合 - 不要在补偿函数里查当前余额再决定退不退——查和更新之间存在竞态,直接用带条件的
UPDATE或唯一约束兜底 - 消息中间件(Kafka/NATS)必须配置为“至少一次投递”,但你的消费端必须自己承担去重责任
用 Dapr 实现 Saga,saga.AddCompensatingStep 的顺序很关键
Dapr 的 Saga 构建块默认按添加顺序执行正向步骤,按**逆序**执行补偿。如果你写成:
saga.AddStep("http://inventory/v1/deduct", ...)
saga.AddStep("http://payment/v1/charge", ...)
saga.AddCompensatingStep("http://payment/v1/refund", ...)
saga.AddCompensatingStep("http://inventory/v1/restore", ...)
那它会在 charge 失败时,先调 refund 再调 restore——这是对的。但如果漏掉某条 AddCompensatingStep,或者顺序写反,Dapr 不会报错,但数据就可能卡在“已扣库存未扣款”的悬挂状态。
- 补偿步骤数必须和正向步骤数严格一致,且一一逆序对应
- Dapr Sidecar 默认不保证补偿接口的可用性,你要确保
refund和restore本身也是幂等、有重试、有监控的 - 生产环境务必配死信队列(如写入
saga_dead_letter表),并用定时任务扫描超时未完成的 saga 实例
重试失败后,别只打日志,得进可观测系统
很多团队把重试逻辑写成 for i := 0; i ,看起来能扛住瞬时故障。但一旦第三次也失败,就只丢一句 <code>log.Printf("compensate failed: %v", err),然后没了。
这会导致两个问题:一是没人知道哪笔订单卡住了;二是无法统计补偿失败率,进而无法判断是网络抖动、下游 bug 还是设计缺陷。
- 每次重试失败,必须上报到 OpenTelemetry 或 Prometheus,打上标签:
saga_id、step_name、error_type - 补偿失败超过阈值(比如 3 次),自动触发告警,并写入独立表
saga_compensation_failure,含原始请求体和错误堆栈 - 别依赖“人盯日志”,要把补偿失败变成可查询、可聚合、可告警的指标
补偿不是写完就完的事,它是分布式事务里最暴露系统脆弱性的环节——网络、下游、幂等、重试、监控,缺一不可。最容易被忽略的,其实是补偿失败后的可观测链路,而不是补偿逻辑本身。










