database/sql 的 Begin/Commit 在微服务中无效,因其仅限单数据库连接,而微服务跨进程、跨 DB 实例;本地事务无法保证跨服务操作的原子性,易导致中间态不一致。

为什么 database/sql 的 Begin/Commit 在微服务里根本不管用
因为它们只作用于单个数据库连接,而微服务天然跨进程、跨 DB 实例。你调用用户服务扣余额,再调用订单服务建单,这两个操作分布在不同服务、不同数据库——本地事务完全失效。强行封装成“一个事务”,系统失败时大概率卡在中间态:比如余额已扣但订单没建,或者反过来。
- 这不是 Go 语言的缺陷,是分布式系统的基本约束
- 2PC(两阶段提交)在 Go 生态几乎没有成熟可靠的开源实现,且会引入严重性能瓶颈和单点故障风险,生产环境应直接排除
- 别指望 ORM 或框架自动帮你“升级”出分布式事务——
sql.Tx就是单机事务的抽象,它不认服务、不认网络、不认 Kafka
本地消息表 + 发件箱模式才是 Go 微服务最稳的落地方式
核心思路很简单:把“发消息”这个动作,变成和业务数据写入同一个数据库事务里的操作。DB 提交成功,消息才真正“发出”;DB 回滚,消息也自动丢弃。没有网络抖动、没有进程崩溃导致的消息丢失。
- 建一张
outbox表,字段至少包含:id、topic、payload、sent_at(null 表示未投递) - 业务逻辑必须在一个
tx内完成:先写业务表(如orders),再写outbox,最后tx.Commit() - 用独立的后台 goroutine 定期扫描
outbox中sent_at IS NULL的记录,调用 Kafka/NATS 客户端发送,并在成功后UPDATE outbox SET sent_at = NOW() WHERE id = ? - 消费者端必须先处理业务逻辑,再调用
conn.CommitOffsets();否则可能丢消息
tx, _ := db.Begin()
_, _ = tx.Exec("INSERT INTO orders (id, status) VALUES (?, ?)", orderID, "created")
_, _ = tx.Exec("INSERT INTO outbox (topic, payload) VALUES (?, ?)", "order.created", payloadJSON)
tx.Commit() // 保证 DB 和 outbox 原子写入补偿任务不能靠 HTTP handler 直接触发,必须进异步队列
Saga 流程中某步失败(比如库存服务超时),主链路不能卡住等补偿执行完再返回——这会让接口响应不可控,还容易雪崩。补偿必须解耦、可重试、带状态追踪。
- 用
github.com/hibiken/asynq推送补偿任务,例如asynq.NewTask("compensate:debit", map[string]interface{}{"tx_id": txID}) - 补偿函数本身必须幂等:
RefundBalance(ctx, userID, amount)要先查refund_log表判断是否已执行,或用INSERT ... ON CONFLICT DO NOTHING防重 - Redis 的
SETNX+ 过期时间可做分布式锁,防止同一笔事务被重复回滚;但别滥用——优先用 DB 行级锁(如SELECT ... FOR UPDATE) - 别让补偿逻辑依赖内存状态或临时 channel;所有中间状态必须落库,否则服务重启就丢失进度
最终一致性不是“随便一致”,关键在于读写分离与可观测性
接受最终一致性,不等于放弃控制。你得明确告诉系统:“这里可以等一会儿,但必须等到;那里必须立刻看到最新。”
立即学习“go语言免费学习笔记(深入)”;
- 强一致读走主库 +
SELECT ... FOR UPDATE(如管理后台查订单详情) - 最终一致读走只读从库、缓存或延迟同步视图(如用户端订单列表)
- 给每个补偿任务设
max_retry = 3和指数退避(如time.Second * 1,* 2,* 4) - 失败日志打到 Loki,监控指标如
inventory_compensation_failed_count必须告警
最容易被忽略的是“状态持久化”——Saga 步骤走到哪一步、哪个补偿失败了、重试过几次,这些都不能存在内存里。一重启,整个事务就断了。真正的可靠性,是从第一行 INSERT INTO transaction_log 开始的。










