《后端架构设计·分布式与数据一致性》8.2 消费端幂等、去重表与恰好一次的真实边界

从支付回调重复导致余额加两次的故障出发,拆开“副作用已发生、确认未发出”的窗口,说明去重表必须与副作用同库同事务、键要带生产者作用域、保留期要覆盖重放窗口;给出可运行的 Go 实现,并实测确认丢失后的重投、朴素处理的双倍入账、去重记录过期后的重复应用,以及先标记后副作用导致的丢消息。

余额为什么加了两次

支付网关的回调在超时后会重发,消息队列在消费者崩溃后也会重投。订单服务收到 payment.succeeded 后把金额加到订单的已付余额,然后向 broker 确认。某次回调处理到一半,数据库事务已经提交,进程在发送确认之前被重启;broker 没收到确认,按 at-least-once 语义再次投递。第二次处理没有看到任何“已处理”痕迹,于是同一笔 2599 分又加了一次。

常见的直觉补丁是“处理前查一下订单状态”:如果订单已经是 PAID 就跳过。这个检查在串行重投时有效,但遇到并发重复投递就不成立:两个消费者同时读到 PAID 之前的状态,都判断需要处理,然后都提交。另一个补丁是在内存里维护一个 map[eventID]bool,进程重启后 map 清空,重复照旧发生。

问题不是“有没有判断”,而是“判断依据和副作用是否在同一个原子操作里”。只要判断写在一个地方、副作用写在另一个地方,中间就存在崩溃窗口。去重表要解决的就是把这个窗口压到零。

上一节的 outbox 保证了事件不丢,但 relay 在“发送成功、位点未写”时会重发;站内 分布式幂等与可靠性 也讨论过幂等键的分类。本节只聚焦消费端:确认丢失时会发生什么,去重记录应该放在哪,以及“恰好一次”到底能承诺到什么程度。

副作用与确认之间的窗口

消费者处理一条消息的顺序通常是:执行副作用、提交本地事务、向 broker 确认。崩溃可能发生在任意两步之间:

崩溃位置副作用去重记录broker 是否重投结果
副作用之前未发生无是正常重试
副作用之后、提交之前回滚无是正常重试
提交之后、确认之前已发生已写是重投,必须识别为重复
确认之后已发生已写否正常完成

只有第三行是真正的难题。此时本地事务已经提交,副作用和去重记录都在库里,唯一缺失的是 broker 侧的确认。重投不可避免,消费者必须在第二次处理时发现“这件事已经做过”,并且不产生第二次副作用。这就是 effect-once:副作用在业务上只生效一次,即使消息被投递多次。

注意 effect-once 与 exactly-once 的区别。exactly-once 是对整条链路的承诺,要求生产、传输、消费都不重复;effect-once 只承诺消费端的可观察结果不重复,链路仍然可以重复投递。工程上能落地的是后者。把它写成前者,团队就会在压测时假设“不会重复”,去掉本应保留的去重逻辑。

去重表的三条不变量

去重表可以很简单:一行一个已处理的事件键。难的是把它放对位置。实现必须满足三条不变量:

第一,去重键唯一,并且带生产者作用域。只用事件 ID 不够,两个上游可能各自生成 evt-1;只用订单号更不够,同一订单有支付、退款、发货多个事件。推荐 producer:event_id,必要时再加租户。

第二,去重记录与副作用在同一个本地事务里写。先写副作用再写去重记录,中间崩溃会重复;先写去重记录再写副作用,中间崩溃会永久丢消息。只有同一个 BEGIN ... COMMIT 才能同时排除两个窗口。

第三,去重记录的保留期覆盖最大重放窗口。broker 可能保留七天消息,对账任务可能重放三十天前的批次;去重记录只留一天,重放就会当成新事件再执行一次。保留期应取“broker 保留期、重放窗口、对账窗口”三者的最大值,并留出余量。

这三条不是理论洁癖。第三条尤其容易被当成存储成本问题:去重表每天几百万行,清理脚本一开,历史重放立刻制造重复入账。后面会用测试展示这个过程。

可运行实现

下面的 Ledger 把余额、去重记录和序号放在同一个结构里,ApplyOnce 在一个临界区内完成“查重、加余额、写记录”,对应真实实现里的一个本地事务。Consume 固定为“先副作用、后确认”,MemoryAck 可以丢一次确认来模拟崩溃窗口。

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

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

var (
	// ErrAckLost marks a crash between the local commit and the broker ack.
	ErrAckLost = errors.New("ack lost")
	// ErrSimulatedCrash marks an injected crash in the unsafe ordering test.
	ErrSimulatedCrash = errors.New("simulated crash")
)

// Message is one broker delivery. Seq is the producer's outbox sequence and
// bounds the dedup retention window.
type Message struct {
	Producer    string
	ID          string
	Seq         int64
	OrderID     string
	AmountCents int64
}

// DedupKey scopes the event ID by producer, so two producers may reuse an ID.
func DedupKey(m Message) string {
	return m.Producer + ":" + m.ID
}

// Ledger applies payment amounts to orders. Effects and dedup keys live in
// the same local store, which is what makes the two writes atomic.
type Ledger struct {
	mu       sync.Mutex
	balances map[string]int64
	applied  map[string]int64
	seenSeq  map[string]int64
	applies  int
}

// NewLedger returns an empty ledger.
func NewLedger() *Ledger {
	return &Ledger{
		balances: make(map[string]int64),
		applied:  make(map[string]int64),
		seenSeq:  make(map[string]int64),
	}
}

// ApplyOnce applies the effect and records the dedup key in one critical
// section, modelling one local transaction. It returns false for a duplicate.
func (l *Ledger) ApplyOnce(m Message) (bool, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	key := DedupKey(m)
	if _, ok := l.applied[key]; ok {
		return false, nil
	}
	l.balances[m.OrderID] += m.AmountCents
	l.applied[key] = m.AmountCents
	l.seenSeq[key] = m.Seq
	l.applies++
	return true, nil
}

// NaiveApply applies every delivery. It exists to measure the cost of not
// deduplicating; it is not a safe handler.
func (l *Ledger) NaiveApply(m Message) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.balances[m.OrderID] += m.AmountCents
	l.applies++
}

// MarkBeforeEffect records the dedup key before applying the effect. The
// order is intentionally wrong: a crash in between loses the message, because
// redelivery is mistaken for a duplicate.
func (l *Ledger) MarkBeforeEffect(m Message, crash bool) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	key := DedupKey(m)
	if _, ok := l.applied[key]; ok {
		return nil
	}
	l.applied[key] = m.AmountCents
	l.seenSeq[key] = m.Seq
	if crash {
		return ErrSimulatedCrash
	}
	l.balances[m.OrderID] += m.AmountCents
	l.applies++
	return nil
}

// Prune forgets dedup keys with Seq <= maxSeq, simulating a retention window.
func (l *Ledger) Prune(maxSeq int64) int {
	l.mu.Lock()
	defer l.mu.Unlock()
	removed := 0
	for key, seq := range l.seenSeq {
		if seq <= maxSeq {
			delete(l.applied, key)
			delete(l.seenSeq, key)
			removed++
		}
	}
	return removed
}

// Balance returns the applied amount for an order.
func (l *Ledger) Balance(orderID string) int64 {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.balances[orderID]
}

// Applies reports how many effects were applied.
func (l *Ledger) Applies() int {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.applies
}

// AckStore acknowledges a delivery to the broker.
type AckStore interface {
	Ack(Message) error
}

// MemoryAck records acks and can lose exactly one ack on demand.
type MemoryAck struct {
	mu    sync.Mutex
	acked map[string]bool
	Lost  bool
	acks  int
}

// NewMemoryAck returns an empty ack store.
func NewMemoryAck() *MemoryAck {
	return &MemoryAck{acked: make(map[string]bool)}
}

// Ack records the message or simulates a lost ack.
func (a *MemoryAck) Ack(m Message) error {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.acks++
	if a.Lost {
		a.Lost = false
		return ErrAckLost
	}
	a.acked[DedupKey(m)] = true
	return nil
}

// Acked reports whether the dedup key was acknowledged.
func (a *MemoryAck) Acked(m Message) bool {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.acked[DedupKey(m)]
}

// Acks returns the number of ack attempts.
func (a *MemoryAck) Acks() int {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.acks
}

// Consume applies one delivery and then acks it. The order is the whole
// effect-once argument: effect first, ack second.
func Consume(l *Ledger, m Message, ack AckStore) (bool, error) {
	applied, err := l.ApplyOnce(m)
	if err != nil {
		return false, fmt.Errorf("inbox: apply %s: %w", DedupKey(m), err)
	}
	if err := ack.Ack(m); err != nil {
		return applied, fmt.Errorf("inbox: ack %s: %w", DedupKey(m), err)
	}
	return applied, nil
}

关键在 ApplyOnce 的锁范围:查重、加余额、写去重记录三步不能被拆开。真实数据库里对应唯一索引加 INSERT ... ON CONFLICT DO NOTHING,再在同一个事务里更新余额;两个并发消费者只有一个能插入成功,另一个看到冲突后按重复处理。MarkBeforeEffect 是故意写错的顺序,保留它是为了让后面的测试展示丢消息的窗口,生产代码不应出现。

本机实测:确认丢失、并发范围与过期重放

测试用一条 2599 分的支付事件,让 MemoryAck 在第一次确认时返回 ErrAckLost,模拟“本地已提交、确认未发出”;broker 随后重投。命令与原始输出:

$ go test ./internal/platform/inbox/ -v -count=1
=== RUN   TestSideEffectThenAckLost
    inbox_test.go:41: ack-lost: deliveries=2 effects=1 balance=2599 acks=2
--- PASS: TestSideEffectThenAckLost (0.00s)
=== RUN   TestNaiveRedeliveryDoubleApplies
    inbox_test.go:56: naive-redelivery: deliveries=2 effects=2 balance=5198
--- PASS: TestNaiveRedeliveryDoubleApplies (0.00s)
=== RUN   TestDedupKeyIncludesProducer
    inbox_test.go:73: dedup-scope: producers=2 same_event_id=evt-1 effects=2 balance=600
--- PASS: TestDedupKeyIncludesProducer (0.00s)
=== RUN   TestPruneOpensReplayWindow
    inbox_test.go:98: prune-window: pruned=1 replay_applied=true balance=2400
--- PASS: TestPruneOpensReplayWindow (0.00s)
=== RUN   TestMarkBeforeEffectLosesMessage
    inbox_test.go:120: mark-before-effect: balance=0 effects=0 redelivery_applied=false
--- PASS: TestMarkBeforeEffectLosesMessage (0.00s)
PASS
ok  	archlab/internal/platform/inbox	0.304s

读数分四组。第一组两次投递、一次副作用:第一次返回 ErrAckLost 但余额已经是 2599,重投被识别为重复,余额不变,效果次数为 1。这正是 effect-once 的目标状态。

第二组去掉去重,直接执行两次副作用,余额变成 5198,效果次数为 2。它给出“重复投递会怎样”的基线,也说明 2599 的差额不是精度问题,而是业务损失。

第三组用两个生产者各自的 evt-1:支付事件 300 分、退款事件 300 分。如果键只用事件 ID,第二笔会被误判为重复而漏掉;加上生产者作用域后两条都生效,余额 600。这说明去重键的粒度直接决定“漏处理”还是“重复处理”。

第四组先执行 Prune(7) 删除序号 7 的去重记录,再重放同一条事件,结果被当成新事件,余额从 1200 变成 2400。这不是实现的 bug,而是保留期短于重放窗口的必然结果。最后一个测试故意先写去重记录再制造崩溃,重投时被跳过,余额为 0:消息永久丢失。它说明“先标记后副作用”比重复更危险。

外部副作用与并发同键

上面的余额是本地表,可以和去重记录同事务。支付网关扣款、短信发送、第三方物流下单属于外部副作用,无法加入本地事务。这时本地事务只能记录“意图”,外部调用放在事务之外,再用状态机推进:

副作用类型能否与去重同事务推荐做法残余风险
本地余额、库存能去重记录 + 业务更新同一事务无(在单库内)
调用支付网关不能本地记录 PROCESSING,带幂等键调用,回写结果网关不支持幂等键时仍可能重复扣
发短信、推送不能outbox + 至少一次投递,接受重复用户收到两条
第三方物流下单不能业务单号作为对方幂等键对方键冲突策略不透明

并发同键是另一个边界。两个消费者同时处理同一事件,都先查去重表、都没查到,然后都执行副作用。唯一索引能让其中一个插入失败,但如果副作用在插入之前执行,失败者已经产生了副作用。正确顺序是“先插入去重记录并提交,再执行副作用”,可这又回到丢消息窗口。解决办法是把副作用也做成可重入的状态推进:插入成功者获得执行权,失败者读取已有记录并等待结果,而不是各自执行。对于本地表,直接放进同一个事务最简单;对于外部调用,需要一个带状态和超时的执行记录,不能只靠一张布尔去重表。

常见错误与边界

第一,用内存标记去重。进程重启、扩缩容、多实例部署都会让标记失效;它只能减少同一进程内的短时重复,不能作为正确性依据。

第二,去重键只用订单号。同一订单的支付、退款、发货会互相覆盖,后到的事件被误判为重复。键至少包含生产者与事件 ID,作用域要覆盖所有会产生副作用的维度。

第三,先写去重记录再执行副作用。崩溃后重投被跳过,副作用永远不发生;这种丢消息比重复扣款更难发现,因为日志里一切正常。

第四,去重表清理早于重放窗口。按时间清理看似节省空间,实则把历史重放变成新的业务操作;保留期要由 broker 保留期和对账窗口共同决定。

第五,把 effect-once 说成 exactly-once。前者只约束消费端的可观察结果,后者要求整条链路;对外承诺 exactly-once 会让上游放弃必要的幂等设计。

小结

消费端的正确性不取决于“有没有判断重复”,而取决于判断与副作用是否在同一个原子操作里。去重表提供这个原子点,键要带生产者作用域,记录要与副作用同事务,保留期要覆盖重放窗口。本机测试验证了确认丢失后重投只产生一次副作用、朴素处理会双倍入账、键缺少作用域会漏处理、记录过期后重放会重复应用,以及先标记后副作用会永久丢消息。做到这些,系统获得的是 effect-once,而不是链路级的 exactly-once。下一节把视角拉到多条消息:分区内顺序如何保持,重放从哪里开始,死信又如何与对账任务一起收敛。

阅读导航:上一节:事务性 Outbox:同库写入与投递保证 · 下一节:消息顺序、重放与死信的对账闭环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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