分布式事务在Go微服务中不能直接用database/sql的Begin/Commit,因其仅作用于单个数据库连接,而微服务跨进程、跨DB实例,本地事务失效;Saga模式通过拆分为带补偿的本地事务链实现最终一致性。
database/sql 的 Begin/Commit
因为 Begin/Commit 只作用于单个数据库连接,而微服务天然跨进程、跨数据库实例。你调用用户服务扣减余额,再调用订单服务创建订单,这两个操作分布在不同服务、不同 DB,本地事务完全失效。强行封装成“一个事务”只会让系统在失败时处于中间态——比如余额已扣但订单没建,或者反过来。
Saga 把一个分布式业务流程拆成一系列本地事务,每个步骤都有对应的补偿操作。Go 生态里没有开箱即用的 Saga 框架,但可以用轻量组合实现:
github.com/celrenheit/slog 或原生 log/slog 记录每步执行状态(含 tx_id、step、status)RefundBalance(ctx, userID, amount) 必须先查 refund_log 表判断是否已执行SETNX + 过期时间做分布式锁,防止同一笔事务被重复回滚github.com/hibiken/asynq 推送补偿任务,避免阻塞主流程func CreateOrderSaga(ctx context.Context, req *CreateOrderRequest) error {
txID := uuid.New().String()
if err := debitBalance(ctx, txID, req.UserID, req.Total); err != nil {
return err
}
if err := createOrder(ctx, txID, req); err != nil {
// 触发补偿:退款
asynqClient.EnqueueContext(ctx, asynq.NewTask("compensate:debit", map[string]interface{}{
"tx_id": txID,
"user_id": req.UserID,
"amount": req.Total,
}))
return err
}
return nil
}
当业务允许短暂不一致(如库存预占后异步扣减),不要在订单服务里循环查库存服务接口。正确做法是:
InventoryReservedEvent 到 Kafka 或 NATSgithub.com/ThreeDotsLabs/watermill 处理,更新本地 order.status = "reserved"
github.com/looplab/fsm),禁止直接 UPDATE order SET status = 'paid' 绕过校验timeout-checker 服务监听 order.created_at,触发 ReleaseInventory 事件很多团队用“先发消息再更新 DB”或“先更新 DB 再发消息”,两者都可能丢事件。真正可靠的方案是把事件写入和业务更新放在同一个本地事务里:
outbox_events 表,字段含 payload TEXT、topic VARCHA
R、processed BOOLEAN DEFAULT false
CreateOrder 的 DB 事务内,用同一 *sql.Tx 插入订单记录 + 插入 outbox 记录outbox_events WHERE processed = false,成功投递后更新 processed = true
SELECT ... FOR UPDATE SKIP LOCKED 避免多实例重复处理这个模式不依赖外部消息队列事务支持,也不要求 Kafka 开启事务(Go 的 segmentio/kafka-go 对事务支持有限且复杂),适合大多数中小规模 Go 微服务。
真正的难点不在代码怎么写,而在如何定义每个服务的“事务边界”和“补偿粒度”——比如退款是按订单退,还是按子项退?这直接影响状态表设计和补偿逻辑复杂度。没想清楚就写 Saga,最后会变成一堆难以调试的补偿嵌套。