Go原生database/sql不支持跨库事务回滚,双写失败时需设计幂等、重试、对账等补偿机制,而非依赖内存标记或单点超时控制。
双写失败时,
事务无法跨库回滚
Go 原生
不支持分布式事务,哪怕两个 MySQL 实例用同一套账号密码,调用
也只对单个
生效。常见错误是:先往 A 库写,再往 B 库写,B 库失败后手动调 A 库的
—— 这看似“回滚”,但网络中断、进程崩溃等场景下,A 库那条记录早已落盘,根本不可逆。
所以不能依赖“事务兜底”,必须从设计上接受“最多一次”或“至少一次”的语义,并配套补偿机制。
优先选“先写主库 + 发消息”模式,而非直连双库写入
若必须同步双写(如迁移期),需在应用层实现幂等 + 重试 + 对账
避免在 HTTP handler 中裸写两个
,应封装为原子操作单元并记录操作日志
用
或
管理双写状态不解决一致性问题
有人试图用
保证“只写一次”,或用
标记写入完成状态——这只能防并发重复触发,完全不防写入中途失败。比如:A 库成功、B 库超时,此时
已设为
,后续请求直接跳过,导致数据永久不一致。
真正需要的是可核查、可重放的操作记录,而不是内存标记。
立即学习
“
go语言免费学习笔记(深入)
”;
每次双写前,先插入一条
表,含
、
(pending)、
(JSON)、
A 和 B 写入成功后,再更新该 log 的
独立对账服务定时扫
超过 5 分钟的日志,重新投递或告警
用
配置
和
防止卡死
双写场景下,一个库响应慢会拖垮整个流程。MySQL 驱动默认无超时,一旦 B 库 hang 住,goroutine 就永远阻塞,连接池耗尽,服务雪崩。
必须显式配置超时参数,且两个库的超时值要错开(避免同时失败):
超时单位必须是
或
,写
不生效
控制连接建立和命令执行总耗时;
单独控制结果读取阶段
不要依赖
包裹
—— 驱动内部可能忽略 context(尤其老版本)
用
发送成功后,仍需本地记录 offset
如果走“先写 DB,再发 Kafka”路径,Kafka 写入成功 ≠ 消费端一定能收到。网络抖动、broker 重启、ISR 缩减都可能导致消息丢失或重复。单纯靠
的
返回成功,只是说明 broker 接收了,不代表已持久化。
更稳妥的做法是:写 DB 成功后,把待发送的 payload 和预期 offset 记到本地表,再发 Kafka;消费端处理完,回调更新该 offset 为
。未 commit 的记录可被定时任务重新推送。
不要把 Kafka 当作唯一事实源,它只是传输通道
offset 记录需带
、
、
,否则无法精准重放
若用
,注意其
是异步的,错误需从 channel 里读,不是函数返回值
实际最难的部分不是代码怎么写,而是决定哪边是权威源、冲突时以谁为准、对账周期设多长、失败后人工介入成本是否可控——这些没法靠 Go 语法解决,得和业务方一起画清楚状态流转图。
database/sqldatabase/sqlBegin()*sql.DBRollback()Exec()sync.Onceatomic.Valuesync.Onceatomic.Valueatomic.Valuetruewrite_logidstatuspayloadcreated_atstatus = 'done'status = 'pending'github.com/go-sql-driver/mysqltimeoutreadTimeout// A 库:写主,容忍稍高
dbA, _ := sql.Open("mysql", "user:pass@tcp(10.0.1.10:3306)/a?timeout=3s&readTimeout=2s")
// B 库:写备,快速失败
dbB, _ := sql.Open("mysql", "user:pass@tcp(10.0.2.20:3306)/b?timeout=1.5s&readTimeout=1s")
sms3timeoutreadTimeoutcontext.WithTimeoutExec()github.com/Shopify/saramasarama.SyncProducerSendMessage()committedtopicpartitionmsg_idconfluent-kafka-goProduceChannel