《后端架构设计·分布式与数据一致性》8.1 事务性 Outbox:同库写入与投递保证

从订单支付后先提交数据库再发消息的崩溃窗口出发,把 outbox 行与业务行放进同一本地事务,用轮询 relay、持久位点与至少一次投递保证不丢消息;给出可运行的 Go 实现,并实测提交后崩溃恢复投递、投递后崩溃导致重复两个窗口,附原始输出与选型判据。

支付成功之后、消息发出之前

订单服务里常见的写法是:在数据库事务里把订单改成 PAID,提交成功后再调用消息队列发送 order.paid,库存服务消费后扣减预占。代码顺序看起来天经地义:

if err := tx.Commit(); err != nil {
    return err
}
if err := mq.Publish(ctx, "order.paid", event); err != nil {
    return err
}

问题出在两次写之间没有事务。提交成功、Publish 之前进程被 kill,订单已经是 PAID,下游永远收不到消息,库存预占不会转为真实扣减;用户看到支付成功,仓库却不会发货。反过来先 Publish 再提交,消费者可能在订单还不存在时收到事件,回查得到 ErrNotFound,重试几次后进死信。两条路径都违反同一个业务不变量:订单的可见状态与下游收到的通知必须同时成立。

直觉上的修补通常有三种。第一种是给 Publish 加重试,但进程崩溃时没有代码在执行,重试不会发生。第二种是定时扫订单表补发,扫描条件只能猜“已支付但未发货”,既分不清正常在途订单,也会给重复消费埋雷。第三种是把发送放进 defer,它只在函数返回时执行,崩溃同样跳过。它们共同的问题是把“是否要发”记在内存或推测里,而不是记在数据库里。

事务性 outbox 换了一个角度:不要求数据库和消息队列原子,而是把“业务变更”和“待发送消息”写进同一个数据库事务,再让一个独立的 relay 从库里读出消息去投递。数据库保证这两行同生共死,消息队列只负责传输。站内 分布式事务 一节已经把本地消息表作为事务可见性手段介绍过;本节把它收敛成可运行的 relay,并集中处理位点与重复。

三个窗口与一条不变量

把“业务写”和“消息发”拆开之后,崩溃点可以穷举。下表按执行顺序列出窗口和后果,判断标准是业务状态是否已经对外可见:

顺序崩溃位置订单状态下游后果
先提交后发送提交后、发送前PAID无事件漏发,库存不扣减
先提交后发送发送后、返回前PAID有事件重复发送
先发送后提交发送后、提交前CREATED有事件幽灵事件,回查不到订单
先发送后提交提交失败CREATED有事件同上,且无法撤销

outbox 方案只接受第一行的第一个窗口:消息可能晚到,但不会丢;可能重复,但不会凭空出现。它要守住的不变量只有一条:

对于每一个已提交的业务变更,outbox 中恰好存在一条对应记录;未提交的变更不产生任何 outbox 记录。

“恰好一条”由本地事务保证:INSERT order 和 INSERT outbox 要么都成功,要么都回滚。注意这里说的是记录数,不是投递次数。relay 崩溃恢复后可能把同一条记录发两次,这是至少一次(at-least-once)语义的正常表现;消费端的去重是下一节的主题。

实现上有两个容易踩空的细节。第一,outbox 表必须与业务表在同一个数据库、同一个事务里,不能写到另一个库再用分布式事务补,否则又把问题绕回去了。第二,outbox 的序号要在事务内分配,通常用自增主键或同一张表的序列;如果用应用侧时间戳排序,时钟回拨会让 relay 跳过或重复一段记录。

relay:位点只能向前

relay 是一个很笨的循环:读一批未发送的记录,逐条投递,成功后把位点推进到这条记录的序号。笨在这里是优点,因为所有状态都在数据库里,进程可以随时重启。

位点的推进顺序决定了故障语义。先发送、后推进位点,崩溃时最多重复一条;先推进位点、后发送,崩溃时这条记录永远不会再被读到,直接丢失。所以位点必须在发送成功之后写,而且只能单调向前:

position = 0
loop:
  rows = SELECT * FROM outbox WHERE seq > position ORDER BY seq LIMIT batch
  for row in rows:
    broker.send(row)          -- 失败则退出本轮,不推进
    UPDATE relay_position SET seq = row.seq

这段伪代码里没有“标记已发送”字段,只有一条全局位点。好处是状态少、推进快;代价是单条记录失败会阻塞后面的记录,形成队头阻塞。订单场景通常可以接受,因为同一订单的事件本来就要有序;如果不同订单之间不允许互相阻塞,就按订单号哈希到多个 relay 通道,每个通道一条位点。

位点存在哪里也有取舍。放在内存里,重启后从 0 开始,等于把所有历史消息重发一遍;放在消息队列的消费位点里,relay 崩溃和队列重平衡的语义会缠在一起;放在业务库的 relay_position 单行表里,和 outbox 同库,重启后能精确续传。本节采用第三种。

可运行实现

下面的代码把业务行、outbox 行和 relay 位点放在同一个 Store 里,每个方法就是一次本地事务。PlaceOrder 同时写订单和消息;RunOnce 读一批、逐条发送、成功后推进位点;CrashAfterSend 用来在“发送成功、位点未写”之间注入崩溃。

// file: internal/platform/outbox/outbox.go
// tier: L2
// verify: go test ./internal/platform/outbox/ -count=1
package outbox

import (
	"errors"
	"fmt"
	"sync"
)

var (
	// ErrConflict is returned when an order ID is written twice.
	ErrConflict = errors.New("order conflict")
	// ErrInvalidState is returned when an order fails validation.
	ErrInvalidState = errors.New("invalid order state")
	// ErrSimulatedCrash marks an injected crash between send and offset save.
	ErrSimulatedCrash = errors.New("simulated crash")
)

// Order is the aggregate persisted by the order service.
type Order struct {
	ID          string
	AmountCents int64
	Status      string
}

// Message is one outbox row. Seq is assigned inside the same transaction as
// the business write and is the relay's delivery order.
type Message struct {
	Seq     int64
	ID      string
	Topic   string
	Key     string
	Payload string
}

// Store models one database: business rows, outbox rows and the relay offset
// all live in it. Each method is a single local transaction.
type Store struct {
	mu       sync.Mutex
	orders   map[string]Order
	outbox   []Message
	position int64
	seq      int64
}

// NewStore returns an empty store.
func NewStore() *Store {
	return &Store{orders: make(map[string]Order)}
}

// PlaceOrder writes the order and its outbox row atomically. A returned
// error means neither write happened.
func (s *Store) PlaceOrder(o Order) (Message, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.orders[o.ID]; ok {
		return Message{}, fmt.Errorf("outbox: order %s exists: %w", o.ID, ErrConflict)
	}
	if o.AmountCents <= 0 {
		return Message{}, fmt.Errorf("outbox: amount %d: %w", o.AmountCents, ErrInvalidState)
	}
	if o.Status == "" {
		o.Status = "CREATED"
	}
	s.orders[o.ID] = o
	s.seq++
	msg := Message{
		Seq:     s.seq,
		ID:      fmt.Sprintf("evt-%s-%d", o.ID, s.seq),
		Topic:   "order.created",
		Key:     o.ID,
		Payload: fmt.Sprintf("%s:%d", o.ID, o.AmountCents),
	}
	s.outbox = append(s.outbox, msg)
	return msg, nil
}

// Position returns the last contiguous offset the relay marked delivered.
func (s *Store) Position() int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.position
}

// AdvancePosition moves the relay offset forward. It never moves backward.
func (s *Store) AdvancePosition(seq int64) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if seq <= s.position {
		return fmt.Errorf("outbox: position %d already at %d: %w", seq, s.position, ErrInvalidState)
	}
	s.position = seq
	return nil
}

// Pending returns unsent rows after the offset, in Seq order.
func (s *Store) Pending(after int64, limit int) []Message {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Message, 0, limit)
	for _, msg := range s.outbox {
		if msg.Seq <= after {
			continue
		}
		out = append(out, msg)
		if len(out) == limit {
			break
		}
	}
	return out
}

// OutboxLen reports how many outbox rows exist.
func (s *Store) OutboxLen() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.outbox)
}

// Sender delivers one message to the broker.
type Sender interface {
	Send(Message) error
}

// Relay polls the outbox and advances the offset only after a successful
// send. CrashAfterSend injects the crash window for tests.
type Relay struct {
	store          *Store
	sender         Sender
	batch          int
	CrashAfterSend bool
}

// NewRelay returns a relay with a fixed batch size.
func NewRelay(store *Store, sender Sender, batch int) *Relay {
	if batch <= 0 {
		batch = 100
	}
	return &Relay{store: store, sender: sender, batch: batch}
}

// RunOnce polls one batch. It is safe to call again after an error: rows the
// offset did not cover are redelivered.
func (r *Relay) RunOnce() (delivered int, err error) {
	msgs := r.store.Pending(r.store.Position(), r.batch)
	for _, msg := range msgs {
		if sendErr := r.sender.Send(msg); sendErr != nil {
			return delivered, fmt.Errorf("relay: send seq %d: %w", msg.Seq, sendErr)
		}
		delivered++
		if r.CrashAfterSend {
			r.CrashAfterSend = false
			return delivered, ErrSimulatedCrash
		}
		if advErr := r.store.AdvancePosition(msg.Seq); advErr != nil {
			return delivered, fmt.Errorf("relay: advance to %d: %w", msg.Seq, advErr)
		}
	}
	return delivered, nil
}

三个位置值得单独看。PlaceOrder 在同一个互斥区里写 orders 和 outbox,对应真实实现里的同一个 BEGIN ... COMMIT;任一步失败都不会留下半条记录。RunOnce 只在 Send 返回 nil 之后调用 AdvancePosition,把重复窗口收敛到“发送成功、位点未写”这一条路径。CrashAfterSend 之所以在测试里存在,是因为这个窗口无法靠正常返回值触发,必须显式制造。

真实的 relay 还需要处理发送超时。超时属于“结果未知”:消息可能已经进了 broker,也可能没有。此时不能推进位点,只能重试,于是又产生一次重复。这正是 at-least-once 无法消除的部分,后面的去重表负责吸收。

本机实测:两个崩溃窗口

测试用 RecordingSender 记录每次投递,用 CountBySeq 统计同一条 outbox 记录被发了几次。命令与原始输出如下(macOS darwin/arm64,Go 1.26.0,单进程内存实现):

$ go test ./internal/platform/outbox/ -v -count=1
=== RUN   TestCommitThenCrash
    outbox_test.go:77: commit-then-crash: rows=3 delivered=3 position=3 duplicates=0
--- PASS: TestCommitThenCrash (0.00s)
=== RUN   TestCrashAfterDelivery
    outbox_test.go:110: crash-after-delivery: seq=1 deliveries=2 position=1 outcome=duplicate
--- PASS: TestCrashAfterDelivery (0.00s)
=== RUN   TestFailedWriteLeavesNoOutbox
    outbox_test.go:134: failed-writes: outbox_rows=1 orders=1
--- PASS: TestFailedWriteLeavesNoOutbox (0.00s)
=== RUN   TestBatchDeliveryOrder
    outbox_test.go:162: batch-order: rows=5 batch=2 runs=3 sequence=1..5
--- PASS: TestBatchDeliveryOrder (0.00s)
=== RUN   TestSendErrorKeepsPosition
    outbox_test.go:190: send-error: position_after_failure=1 position_after_retry=3 attempts=4
--- PASS: TestSendErrorKeepsPosition (0.00s)
PASS
ok  	archlab/internal/platform/outbox	0.445s

第一组是“提交后崩溃”:3 笔订单提交后不运行 relay,直接丢弃进程;新的 relay 从持久位点 0 开始,把 3 条记录各投递一次,位点推进到 3。它证明业务提交与 outbox 记录同生共死,崩溃只延迟投递,不丢记录。

第二组是“投递后崩溃”:一条记录发送成功,CrashAfterSend 在写位点之前触发;重启后同一条 seq=1 被再次投递,总计 2 次,位点最终为 1。这不是实现缺陷,而是位点后置的必然结果。测试里 duplicates=0 与 deliveries=2 并不矛盾:前者统计“提交后崩溃”组的重复,后者统计“投递后崩溃”组,两组互不影响。

第三组验证失败的写不会产生消息:重复订单返回 ErrConflict,金额为 0 返回 ErrInvalidState,outbox 仍然只有 1 行。第四组用 5 条记录、每批 2 条,3 轮发完,序号严格为 1 到 5。第五组让 broker 在 seq=2 首次失败,位点停在 1;重试时先重发 2、再发 3,总发送尝试 4 次。这说明失败不会推进位点,也不会跳过记录。

这些数字是单进程内存存储上的行为验证,不是生产吞吐。真实环境还要把位点更新与发送放在同一个可观测链路里,并处理 relay 多实例的归属问题;本节没有测量磁盘 fsync 或 broker 往返延迟。

投递保证与上线判据

outbox 只承诺“不丢”,不承诺“只发一次”。把这一点写进设计文档,比在代码里假装 exactly-once 更有用。下表是常见实现路径的对比,选择依据是延迟、数据库耦合和重复放大倍数:

方案与业务写入的原子性典型延迟重复来源适用条件
同库 outbox + 轮询 relay同一本地事务轮询间隔位点后置、发送超时已有关系库,能接受秒级延迟
CDC 订阅事务日志同一本地事务日志解析延迟位点重放、快照切换有成熟的 CDC 管道与 schema 管理
本地消息表 + 定时补偿同一本地事务补偿周期补偿与实时发送重叠低频、可人工兜底
事务消息( broker 两阶段)依赖 broker 实现毫秒到秒回查与提交竞态绑定特定 broker,团队熟悉其语义

上线前至少检查五项,任何一项不满足都不要打开流量:

检查项通过条件不通过的动作
同库同事务outbox 与业务表在同一事务、同一连接拆表或换库前先改设计
位点顺序只在发送成功后推进,且单调回退代码,补崩溃测试
单写者同一通道只有一个 relay 持有租约加租约或按 key 分片
清理策略只清理位点之前、且超过保留期的行先停清理,排查消费滞后
重复可观测能按消息 ID 统计重复投递补 trace 与消费端指标

“单写者”这一项经常被忽略。两个 relay 同时读同一个位点,会各自发送同一批记录,重复次数随实例数放大;更糟的是位点更新互相覆盖,可能出现 A 推进到 10、B 用旧值写回 8 的回退。位点表加版本条件更新,或者用租约保证同一时刻只有一个 relay,都是必要措施。

常见错误与边界

第一,把 outbox 写到另一个库。业务库提交成功、outbox 库写入失败时,两行不再同生共死;用分布式事务补写又把 2PC 的阻塞问题带回来。outbox 的前提就是同库。

第二,先发消息再提交。消费者收到事件时订单可能还不存在,回查失败后重试,重试次数耗尽就进死信;如果订单最终提交成功,这条事件却已经被丢弃。顺序必须是先提交、后可见。

第三,位点在发送前推进。网络超时被当成失败、位点却已经前进,这条消息再也不会被读到。发送结果未知时只能重试,不能假设失败。

第四,清理早于消费确认。outbox 的位点只代表“已交给 broker”,不代表“下游已处理”。按位点清理是安全的;按时间清理必须留足消费滞后和重放窗口,否则重放时会发现源记录已经没了。

第五,把 outbox 当成 exactly-once。它解决的是生产者不丢消息,重复仍然存在;消费端如果直接加余额、扣库存,重复投递就是重复扣款。这正是下一节要处理的窗口。

小结

事务性 outbox 把“业务变更”和“待发消息”变成一个本地事务,用数据库的原子性替换跨系统原子性;relay 从同库读出记录、发送成功后再推进位点,因此崩溃只会造成延迟或重复,不会丢消息。本节用 3 条订单记录验证了提交后崩溃能完整续传,用一条记录验证了投递后崩溃会重发一次,并用失败注入确认位点不会越过未发送的记录。它没有消除重复,只是把重复变成了一个可度量、可收敛的已知量。下一步要回答的是:消费端如何在这个重复窗口里保证副作用只发生一次。

阅读导航:上一节:TCC、本地消息表与事务状态的可见性 · 下一节:消费端幂等、去重表与恰好一次的真实边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查