database/sql 无法跨服务保证事务一致性,必须用 saga 模式或本地消息表实现最终一致性;go-zero 或 pgx 中需显式开启事务并正确处理回滚与提交;补偿操作和消息发送均须幂等且状态持久化。

Go 微服务里,database/sql 能可靠管好单库事务,但跨服务调用(比如 HTTP 或 gRPC)天然脱离本地事务上下文——tx.Commit() 对远端服务完全无效。别指望用 context.WithValue 透传事务 ID 来“协调”远程操作,那不是事务,只是自欺欺人的状态标记。
Go-Zero 或原生 pgx 中的本地事务怎么写才不脏数据
很多人以为 Go-Zero 的 model.Insert() 自带事务,其实它默认就是单条 SQL 执行,没事务包裹。多条写入(如创建订单 + 扣库存记录)必须显式开启事务,否则中间出错就会留下半截数据。
- 用
svcCtx.Transact(Go-Zero)或db.BeginTx(ctx, nil)(原生)获取事务对象,所有 DB 操作都走这个tx - 务必用
defer tx.Rollback(),但要在tx.Commit()成功后手动置空,否则 panic 时会误回滚已提交的数据 - 避免在事务内做 HTTP/gRPC 调用:网络超时、重试、服务不可用都会拖长事务持有时间,引发锁等待甚至死锁
- PostgreSQL 推荐配合
SELECT ... FOR UPDATE控制并发更新,比如扣库存前先锁住对应商品行
Saga 模式下补偿操作为什么总重复执行
补偿失败重试是常态,但“恢复库存”被调两次导致库存加回两份,根本原因是补偿函数没做幂等校验。Saga 不是靠“只调一次”来保证正确,而是靠“调多少次结果都一样”。
- 每个补偿操作必须基于数据库状态判断是否可执行,例如:
UPDATE inventory SET stock = stock + 1 WHERE pid = $1 AND status = 'reserved',而不是无条件stock += 1 - 用唯一业务 ID(如
order_id)+ 状态字段(如compensated_at)组合做 UPSERT 或条件更新 - 不要把补偿逻辑写在 HTTP handler 里直接响应请求;应由独立 worker 定时扫描
failed_sagas表触发,便于控制重试节奏和失败归档 - 补偿函数内部仍需事务包裹,并在开头检查该补偿是否已执行过(查
compensated_at IS NOT NULL)
消息发送和 DB 写入如何做到原子性
“先写 DB 再发 MQ”看似简单,但网络抖动或进程崩溃会导致消息丢失;“先发 MQ 再写 DB”则可能 DB 失败而消息已发出。真正的原子性只能靠本地消息表(Outbox Pattern)实现。
- 在同一个事务中,同时插入业务数据和一条消息记录到
outbox表(字段含topic,payload,status) - 用独立 goroutine 轮询
outbox表(WHERE status = 'pending' FOR UPDATE SKIP LOCKED),避免多个 worker 争抢同一条记录 - 发送失败时,更新该行
status = 'failed'并设置next_retry_at,用backoff.Retry控制重试间隔 - 下游消费端收到消息后,也必须先用唯一业务键(如
event_id)UPSERT 到processed_events表,再执行业务逻辑
Saga 和本地消息表不是银弹,它们把“强一致”的负担转成了“可观测、可修复”的工程问题。最容易被忽略的是补偿动作的状态持久化——没有 compensated_at 字段或没在事务里更新它,整个 Saga 就是纸糊的。别省那一个字段、一行 UPDATE。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











