一、Outbox Pattern 核心思想
问题背景:
-
当你在数据库中写入业务数据的同时,需要向消息队列发送事件通知。
-
如果直接在业务逻辑中先写数据库再发送消息,可能出现:
- 写数据库成功,但消息发送失败 → 消息丢失
- 消息发送成功,但数据库未提交 → 消费者读取到不完整数据
解决方案:Outbox Pattern
-
核心思路:
- 将事件消息写入一个 Outbox 表(和业务表同一个事务)
- 通过 异步处理器读取 Outbox 表发送消息
- 消息发送成功后,更新 Outbox 状态
这样可以保证 数据库数据和事件消息的最终一致性。
二、最小 Golang Demo
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
)
// OutboxMessage 表结构
type OutboxMessage struct {
ID int64
EventType string
Payload string
Sent bool
CreatedAt time.Time
}
// 模拟业务表
type Order struct {
ID int64
Amount int
}
// 创建 Outbox 消息
func createOutboxMessage(tx *sql.Tx, eventType, payload string) error {
_, err := tx.Exec(`INSERT INTO outbox_message(event_type, payload, sent, created_at) VALUES (?, ?, 0, ?)`,
eventType, payload, time.Now())
return err
}
// 模拟业务逻辑写入
func createOrderWithOutbox(db *sql.DB, amount int) error {
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// 写业务表
res, err := tx.Exec(`INSERT INTO orders(amount) VALUES (?)`, amount)
if err != nil {
tx.Rollback()
return err
}
orderID, _ := res.LastInsertId()
// 写 outbox 表(同一个事务)
err = createOutboxMessage(tx, "order_created", fmt.Sprintf("order_id=%d", orderID))
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// 异步发送 Outbox 消息
func processOutbox(db *sql.DB) {
for {
rows, err := db.Query(`SELECT id, event_type, payload FROM outbox_message WHERE sent=0`)
if err != nil {
log.Println("query outbox:", err)
return
}
for rows.Next() {
var msg OutboxMessage
rows.Scan(&msg.ID, &msg.EventType, &msg.Payload)
// 模拟发送消息到 MQ
fmt.Printf("Sending message: %s %s\n", msg.EventType, msg.Payload)
// 标记已发送
db.Exec(`UPDATE outbox_message SET sent=1 WHERE id=?`, msg.ID)
}
rows.Close()
time.Sleep(2 * time.Second) // 间隔轮询
}
}
func main() {
db, _ := sql.Open("sqlite3", "file:outbox_demo.db?cache=shared&_foreign_keys=1")
defer db.Close()
// 初始化表
db.Exec(`CREATE TABLE IF NOT EXISTS orders(id INTEGER PRIMARY KEY AUTOINCREMENT, amount INTEGER)`)
db.Exec(`CREATE TABLE IF NOT EXISTS outbox_message(
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT,
payload TEXT,
sent BOOLEAN,
created_at DATETIME
)`)
// 启动 Outbox processor
go processOutbox(db)
// 模拟业务写入
for i := 1; i <= 5; i++ {
err := createOrderWithOutbox(db, i*100)
if err != nil {
log.Println("create order fail:", err)
}
time.Sleep(1 * time.Second)
}
// 阻塞主线程
select {}
}
三、Demo 说明
-
业务写入与消息写入在同一个事务:
tx.Exec(`INSERT INTO orders ...`) createOutboxMessage(tx, ...) tx.Commit()- 保证业务数据和 outbox 消息原子提交。
-
异步处理器发送消息:
- 轮询
outbox_message表,发送消息到 MQ(这里用fmt.Printf模拟) - 发送成功后标记
sent=1
- 轮询
-
优点:
- 消息发送失败不会影响数据库事务
- 消息可以被异步重试,保证最终一致性
-
可扩展性:
- 可以改用 RabbitMQ / Kafka / NATS 发送消息
- 使用 Goroutine 或 Worker Pool 提高并发
- 可以增加 事务提交 Hook 机制(类似你项目里的
TransactionManager+AfterCommitHooks)
四、关键点
-
Outbox Pattern 核心就是:
- 写业务数据 + 写 Outbox 消息 同一个事务
- 异步读取 Outbox 消息 → 发送 MQ
- 更新状态 → 消息不会重复发送
-
在 Golang 里可以结合:
context.Context传递事务- Hook 在事务提交后触发消息发送(和你项目里
EventUsecase类似)
如果你需要,我可以帮你画一张 Outbox Pattern 时序图,展示:
- 事务内 vs 事务外的消息发送流程
- 消息写入、提交、异步发送、状态更新的完整流程
你希望我画吗?
You said:
好的
ChatGPT said:
明白,我给你画一张 Outbox Pattern 时序图,展示事务内和事务外的消息发送流程,帮助你直观理解整个过程。
sequenceDiagram
participant App as 应用
participant DB as 数据库
participant Outbox as Outbox Processor
participant MQ as 消息队列
Note over App,DB: 场景1:事务外发送
App->>DB: 写业务数据
App->>DB: 写 Outbox 消息
DB-->>App: 提交事务
App->>Outbox: 加入本地队列
Outbox->>MQ: 发送消息
MQ-->>Outbox: ack
Outbox->>DB: 更新消息状态为已发送
Note over App,DB: 场景2:事务内发送
App->>DB: 开启事务
App->>DB: 写业务数据
App->>DB: 写 Outbox 消息
App->>DB: 注册 AfterCommitHook
DB-->>App: 提交事务
App->>Outbox: AfterCommitHook触发 → 加入本地队列
Outbox->>MQ: 发送消息
MQ-->>Outbox: ack
Outbox->>DB: 更新消息状态为已发送
🔹 图解说明
-
事务外发送:
- 业务数据和 Outbox 消息写入后,立即加入本地队列
- Outbox Processor 异步发送到 MQ
-
事务内发送:
- 业务数据和 Outbox 消息写入事务中
- 注册
AfterCommitHook,事务提交后触发队列 - 确保消费者读取到数据时,事务已经提交
-
最终一致性保证:
- Outbox 消息只有在事务提交后才会被发送
- MQ 消息发送失败可以重试,不影响数据库事务
这张图基本就是你项目里 EventUsecase + TransactionManager + OutboxMessageLocalPendingQueue 的核心逻辑。
如果你需要,我可以帮你画一个 Golang 代码对应的流程图,标注 PublishToOutbox、DBTx、AddDBTxAfterCommitHook、Outbox Processor 的调用顺序。
你希望我画这个吗?
You said:
好的
ChatGPT said:
明白,我给你画一个 Golang 代码对应的流程图,标注 PublishToOutbox、DBTx、AddDBTxAfterCommitHook、Outbox Processor 的调用顺序,让你对应代码逻辑更清楚。
flowchart TD
A[业务调用 PublishToOutbox(ctx, Event)] --> B{是否在事务中?}
B -- 否 --> C[直接创建 OutboxMessage 并写入 DB]
C --> D[加入 OutboxMessageLocalPendingQueue]
D --> E[Outbox Processor 异步消费队列]
E --> F[发送消息到 MQ]
F --> G[更新 OutboxMessage 状态为已发送]
B -- 是 --> H[创建 OutboxMessage 并写入事务内 DB]
H --> I[注册 AfterCommitHook 到 TransactionManager]
I --> J[事务提交成功触发 Hook]
J --> D
style A fill:#f9f,stroke:#333,stroke-width:1px
style B fill:#ff9,stroke:#333,stroke-width:1px
style E fill:#9ff,stroke:#333,stroke-width:1px
style F fill:#9f9,stroke:#333,stroke-width:1px
style G fill:#f99,stroke:#333,stroke-width:1px
🔹 图解对应代码
-
PublishToOutbox → 节点 A
-
判断是否在事务中 → 节点 B,通过
TransactionManager.HasDBTx(ctx) -
事务外流程:
- 写数据库 + 写 Outbox → 节点 C
- 放入本地 pending 队列 → 节点 D
- Outbox Processor 轮询队列发送 MQ → 节点 E/F/G
-
事务内流程:
- 写数据库 + 写 Outbox → 节点 H
- 注册
AfterCommitHook→ 节点 I - 事务提交成功触发 hook → 节点 J → 放入本地队列 → 后续与事务外相同
✅ 这样就把 你的 Golang 项目逻辑和 Outbox Pattern 流程完全对应起来了:
- 事务内外的处理区别
- Outbox 消息发送的时机
- 最终一致性保证
如果你需要,我可以再画一个 包含 TraceInfo/TxInfo 传递的完整序列图,展示 ctx 中数据如何流动。
你希望我画吗?
You said:
好的
ChatGPT said:
好的,我帮你画一个 完整序列图,展示 PublishToOutbox 中 ctx 传递 TraceInfo/TxInfo 的流动,以及事务内外 Outbox 消息发送的全过程。
sequenceDiagram
participant Client as 应用层调用
participant EventUC as EventUsecase
participant TxMgr as TransactionManager
participant DB as 数据库
participant OutboxQueue as OutboxMessageLocalPendingQueue
participant OutboxProc as Outbox Processor
participant MQ as 消息队列
Client->>EventUC: PublishToOutbox(ctx, Event)
Note over EventUC: 从 ctx 获取 TraceInfo/TxInfo
EventUC->>EventUC: e.SetTrace(traceInfo)\ne.SetTx(txInfo)
EventUC->>DB: NewOutboxMessage(e) → 写入 Outbox 表
alt 不在事务
EventUC->>OutboxQueue: AddOutboxMessage(outboxMessage)
OutboxQueue->>OutboxProc: 异步消费队列
OutboxProc->>MQ: 发送消息
MQ-->>OutboxProc: ack
OutboxProc->>DB: 更新消息状态 sent=1
else 在事务
EventUC->>TxMgr: AddDBTxAfterCommitHook(ctx, func(){ AddOutboxMessage })
TxMgr->>DB: 开启事务 → 写业务表 + Outbox表
DB-->>TxMgr: Commit
TxMgr->>OutboxQueue: AfterCommitHook 触发
OutboxQueue->>OutboxProc: 异步消费队列
OutboxProc->>MQ: 发送消息
MQ-->>OutboxProc: ack
OutboxProc->>DB: 更新消息状态 sent=1
end
🔹 图解说明
-
ctx 中数据:
TraceInfo→ 用于分布式追踪TxInfo→ 事务信息,判断是否在事务中
-
事务外:
- Outbox 消息创建后直接放到本地队列 → 异步发送 MQ
-
事务内:
- Outbox 消息写入事务表 → 注册 AfterCommitHook
- 事务提交后 Hook 执行 → 放到本地队列 → 异步发送 MQ
-
Outbox Processor:
- 轮询本地队列或表
- 发送 MQ 消息
- 更新状态为已发送 → 避免重复发送
这个序列图就完整展示了你项目中 EventUsecase + TransactionManager + OutboxMessageLocalPendingQueue 的流程,包括:
- ctx 数据流
- 事务内外差异
- 消息发送与状态更新
在 Outbox Pattern 里,发送失败是一个核心场景,需要专门设计来保证 最终一致性。我结合你项目的 OutboxMessage 和 Outbox Processor 实现,给你完整说明:
一、发送失败的处理方式
1. 状态管理
OutboxMessage 有一个状态字段:
type OutboxMessageState string
const (
OutboxMessageInited OutboxMessageState = "inited"
OutboxMessagePending OutboxMessageState = "pending"
OutboxMessageFailed OutboxMessageState = "failed"
OutboxMessageSucceeded OutboxMessageState = "succeeded"
)
- 发送成功 →
OutboxMessageSucceeded - 发送失败 →
OutboxMessageFailed - 重试次数记录在
RetryCount字段
代码示例(你项目里):
func (m *OutboxMessage) MarkProcessFailedOnce(err error) error {
if m.State == OutboxMessageInited || m.State == OutboxMessagePending || m.State == OutboxMessageFailed {
m.State = OutboxMessageFailed
m.RetryCount += 1
m.ErrorMsg = err.Error()
m.ProcessedAt = time.Now()
return nil
}
return errors.Newf(codes.FailedPrecondition, "change OutboxMessage state fail, cur state=%s, PK=%d", string(m.State), m.PK)
}
2. 异步重试
Outbox Processor 会轮询本地 pending 队列或数据库 outbox_message 表:
-
只处理
Pending或Failed状态的消息 -
如果发送失败:
- 更新
RetryCount - 保持
Failed状态 - 下一轮继续重试
- 更新
伪代码:
for _, msg := range pendingMessages {
err := sendToMQ(msg)
if err != nil {
msg.MarkProcessFailedOnce(err)
} else {
msg.MarkProcessSucceeded()
}
}
✅ 这样保证消息 不会丢失,最终一定会发送成功(或达到最大重试次数报警)。
3. 重试策略
常见策略:
| 策略 | 描述 |
|---|---|
| 固定间隔重试 | 每隔 N 秒重试一次 |
| 指数退避 (Exponential Backoff) | 随重试次数延长间隔,避免洪水式发送 |
| 最大重试次数 | 超过次数可告警或转人工处理 |
| 持久化失败队列 | 失败的消息单独存储,用人工或外部系统处理 |
4. 事务内发送注意
如果 Outbox 是在事务内注册 Hook:
- 消息只有在事务提交后才会触发发送
- 如果发送失败,状态更新在 Outbox 表里
- 下一次 Outbox Processor 会继续重试
- 不会影响业务表事务已提交的数据
5. 完整处理流程(含失败)
sequenceDiagram
participant App
participant DB
participant OutboxQueue
participant OutboxProc
participant MQ
App->>DB: 写业务 + OutboxMessage
DB-->>App: Commit
App->>OutboxQueue: 添加消息到队列
OutboxQueue->>OutboxProc: 异步消费
OutboxProc->>MQ: 发送消息
alt 发送成功
MQ-->>OutboxProc: ack
OutboxProc->>DB: 更新 OutboxMessage 状态=Succeeded
else 发送失败
OutboxProc->>DB: 更新状态=Failed, RetryCount++
Note right of OutboxProc: 下一轮继续重试
end
🔹 总结
-
消息失败不会丢失,通过状态和重试机制保证最终发送成功
-
事务安全:业务数据提交后,再发送消息
-
可扩展:
- 可以加
Dead Letter Queue(达到最大重试次数) - 可以增加
报警系统,提醒人工干预
- 可以加