Go标准库database/sql.Tx不能跨服务,因其仅绑定单个数据库连接,无法感知或协调其他服务事务状态;A服务commit成功后B服务失败,rollback对HTTP调用无效,必须下沉至业务层用Saga等模式编排。

Go 标准库的 database/sql.Tx 为什么不能跨服务用
因为 database/sql.Tx 只绑定单个数据库连接,它无法感知其他服务的事务状态。你调用 A 服务的 tx.Commit() 成功了,B 服务哪怕 HTTP 返回 500,A 的事务也不会自动回滚——这不是“不支持”,而是设计上就不管跨库这事。
常见错误是把多个 http.Post 套在同一个 db.Begin() 里,以为加了 defer tx.Rollback() 就万事大吉。结果 A 扣款成功、B 记账失败,tx.Rollback() 对 B 的 HTTP 请求完全无效。
- 所有跨服务操作必须下沉到业务层做状态编排,不能依赖数据库级事务语义
-
BeginTx的context.Context参数只控制本地 SQL 执行超时,不影响远程调用 - 一旦涉及两个及以上独立数据源(MySQL + PostgreSQL / MySQL + Redis / MySQL + HTTP API),就立刻脱离
database/sql能力边界
Saga 模式下正向操作和补偿操作必须成对定义且幂等
不是“写完 TransferOut 再随手补个 CompensateTransferOut”就行。Saga 的可靠性取决于每一步是否可重入、可逆、可判重。
比如扣库存操作:UPDATE accounts SET balance = balance - ? WHERE user_id = ? AND balance >= ?,它的补偿函数如果只是 UPDATE accounts SET balance = balance + ? WHERE user_id = ?,就存在竞态风险:两次补偿会多加一次余额。
立即学习“go语言免费学习笔记(深入)”;
- 补偿操作也必须带条件 where,例如
WHERE status = 'deducted'或基于唯一键(order_id + step)做 insert ignore - 正向与补偿操作共用同一笔
order_id,并在数据库中用saga_records表持久化每步状态(pending/succeeded/compensated) - 不要在补偿函数里直接发 HTTP;先
INSERT INTO saga_records (step, status, payload),再由独立 worker 轮询执行
消息发送必须和业务操作原子写入本地事务表
别用 go func() { amqp.Publish(...) }() 或 time.AfterFunc 发消息——进程崩溃或容器重启后,这些消息永远丢失。
正确做法是把“待发送事件”当作业务数据的一部分,跟主记录一起 commit。例如订单创建时,在同一 *sql.Tx 中插入 orders 和 outbox_events 两条记录。
-
outbox_events表至少含:id、event_type、payload、processed(bool,默认 false) - 异步 worker 用
SELECT ... FOR UPDATE SKIP LOCKED拉取未处理事件,发送成功后UPDATE outbox_events SET processed = true - RabbitMQ 必须设
DeliveryMode: amqp.Persistent(即delivery_mode=2),Kafka 必须开启acks=all和enable.idempotence=true
补偿任务必须带超时、重试上限和死信路由
轮询 saga_records 表时,如果每次执行都卡在 HTTP 超时或 DB 锁等待,不加控制就会积压成雪崩。
一个没设 context.WithTimeout 的补偿 worker,可能让整个任务队列停滞数小时,而你根本不知道哪条记录卡住了。
- 每个补偿动作启动时必须包裹
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second) - 失败后不立即重试,而是发往死信队列,用
x-dead-letter-routing-key控制延时重投(如延迟 5s) - 连续失败 3 次进最终死信交换器,触发告警而不是继续盲重试
- 所有状态更新操作必须带乐观锁或状态机校验,例如
UPDATE orders SET status='shipped' WHERE id=? AND status='paid',避免覆盖已变更的状态
最常被忽略的一点:补偿不是“补一次就完事”,而是要能反复跑、跑多次都不破坏数据。这意味着每步操作都要有明确的“入口守卫”(如唯一索引 or SELECT 判重)和“出口约束”(如 where status = 'pending')。没有这两层,所谓最终一致性,只是把不一致从秒级拖到了小时级。


















