GORM 的 db.Transaction() 仅保证单库 ACID,无法解决跨服务分布式一致性;发件箱模式通过在同事务中写业务数据和消息事件到同一库的 outbox_events 表,再由独立消费者轮询投递,实现可靠异步通信。

微服务里用 GORM 做本地事务,db.Transaction() 能保证单库 ACID,但跨服务没法靠它落库——它不解决分布式一致性。发件箱(Outbox)模式才是生产环境真正能落地的方案,核心是把业务变更和消息写进同一张数据库表,再由独立消费者投递到消息队列。
为什么 GORM 的 Transaction 不能直接用于跨服务落库
常见误区是以为在 db.Transaction() 里先改数据、再调用 http.Post 或 kafka.Producer.Send 就算“事务完成”。实际不是:
- HTTP 请求或 Kafka 发送失败时,GORM 事务已提交,数据已落库,但下游没收到通知 → 状态不一致
- 即使加了重试,也无法保证幂等消费和恰好一次(exactly-once)语义
- GORM 本身不感知外部系统,
Transaction的 commit/rollback 仅作用于当前*gorm.DB实例所连的数据库连接
发件箱表结构与 GORM 模型定义
发件箱本质是一张普通表,但约定字段语义。GORM 可以像操作业务表一样建模,关键是让业务变更和消息生成发生在同一个事务内:
例如定义 outbox_events 表:
type OutboxEvent struct {
ID uint `gorm:"primaryKey"`
Aggregate string `gorm:"index"` // 如 "user:123"
Type string `gorm:"index"` // 如 "user_created"
Payload []byte `gorm:"type:json"`
CreatedAt time.Time `gorm:"index"`
Processed bool `gorm:"default:false;index"`
}
关键点:
-
Payload用[]byte+ JSON 序列化,避免 GORM 对复杂嵌套结构的映射歧义 -
Processed字段必须有索引,后续消费者查询未处理事件才不会全表扫 - 不要依赖
DeletedAt软删除——发件箱记录是事实快照,删掉就丢消息
在 GORM 事务中写入发件箱事件
真实业务逻辑(如创建用户)和发件箱事件必须塞进同一个 db.Transaction():
err := db.Transaction(func(tx *gorm.DB) error {
// 1. 写业务数据
user := User{Username: "alice", Email: "a@example.com"}
if err := tx.Create(&user).Error; err != nil {
return err
}
<pre class="brush:php;toolbar:false;">// 2. 写发件箱事件(同一事务)
event := OutboxEvent{
Aggregate: fmt.Sprintf("user:%d", user.ID),
Type: "user_created",
Payload: toJSON(user), // 自定义序列化函数
}
if err := tx.Create(&event).Error; err != nil {
return err
}
return nil // 提交事务})
这样只要 Create(&user) 或 Create(&event) 任一失败,整个事务回滚,数据和事件原子性一致。
注意:
- 别在事务外起 goroutine 去发消息——事务还没提交,goroutine 看不到新数据
-
toJSON函数要避开循环引用,建议用json.Marshal而非 GORM 的Save钩子,后者可能触发额外 DB 操作 - 如果业务表和发件箱表不在同一库(极少见),GORM 无法跨库事务,此方案失效
独立消费者轮询并投递事件
发件箱模式要求一个长期运行的消费者进程(非 HTTP handler),它只做一件事:查未处理事件 → 投递到 Kafka/RabbitMQ → 标记为已处理。
典型轮询逻辑(伪代码):
for {
events, _ := db.Where("processed = ?", false).Limit(100).Find(&[]OutboxEvent{})
for _, e := range events {
if err := kafkaProducer.Send(e.Payload); err == nil {
db.Model(&e).Update("processed", true)
}
}
time.Sleep(100 * time.Millisecond)
}
关键约束:
- 投递失败时不标记
processed,下次轮询重试(天然支持至少一次语义) - 更新
processed必须用UPDATE ... WHERE id = ? AND processed = false,防重复处理 - 轮询间隔和批量大小需压测调整;太小增加 DB 压力,太大延迟高
发件箱最难的不是代码,而是运维闭环:消费者进程必须高可用(比如用 systemd 或 Kubernetes Job 控制重启),且要监控 outbox_events 表里堆积的未处理记录数——这个数字一旦上涨,说明下游消息系统或消费者本身出问题了,得立刻告警。


















