《后端架构设计·高性能与高可用》4.2 消息投递语义与消费幂等

以 arch-lab 通知消费者为例,实现 message_id 去重表、PROCESSING 租约与 DONE 终态,覆盖重复投递、并发重复和处理中崩溃后重试,并说明恰好一次在业务层的真实含义、去重窗口与代理重投窗口的边界,附本机 -race 真实输出。

“至少一次”不是实现缺陷

4.1 的 Dispatcher 在处理器失败时把事件留在 pending,下一轮再试;即使投递成功,若进程在标记前崩溃,消息也会再投一次。代理侧能做到的语义只有至少一次或最多一次,“恰好一次投递”在跨系统边界上不成立。消息队列自身的重投、分区和确认机制已在 消息队列深度解析 中讲过,本节只回答一个问题:重复到达时,业务怎样只生效一次。

先给结论:业务层追求的不是“消息只来一次”,而是“副作用只发生一次”。这需要去重键、处理状态和终态记录三者配合;只用一个 seen 集合,无法区分“正在处理”和“已经处理完”,并发重复与崩溃恢复都会漏。

三种重复要分开处理

第一种是代理重投:同一条 message_id 在短时间内到达多次。去重表命中 DONE 就可以直接确认,不执行业务。

第二种是并发重复:两个消费者实例同时拿到同一条消息。此时第一条还在处理中,第二条不能执行,也不能简单丢弃后确认;正确做法是返回“处理中”,让代理稍后重投,或由调用方延迟确认。

第三种是处理中崩溃:消费者拿到租约、开始执行业务,进程在写终态前挂掉。去重表里留下 PROCESSING,租约到期后必须允许另一个实例接管,否则这条消息永久卡死。

这三种情况对应三个状态转换,不能靠一个布尔值表达。Record 因此需要 State、LeaseUntil、Attempts 和 UpdatedAt。

消费循环与去重存储

Consume 先调用 Begin 申请处理权:新消息返回 claimed=true;DONE 返回重复;PROCESSING 且租约未过期返回 ErrInFlight;租约过期则接管并递增尝试次数。业务成功后写 DONE,失败时故意不清租约,让失败与崩溃走同一条恢复路径。

// file: internal/consumer/consumer.go
// tier: L1
// verify: go test -race -v ./internal/consumer/
package consumer

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

var ErrInFlight = errors.New("message is being processed by another consumer")
type State string

const (
	StateProcessing State = "PROCESSING"
	StateDone       State = "DONE"
)
type Result string

const (
	ResultApplied   Result = "APPLIED"
	ResultDuplicate Result = "DUPLICATE"
	ResultInFlight  Result = "IN_FLIGHT"
)
type Message struct {
	ID    string
	Topic string
}
// Record 是去重表的一行:message_id + 处理状态 + 租约。
// 只有“已处理/未处理”两态无法区分“正在处理”和“可以抢占”。
type Record struct {
	State      State
	Attempts   int
	LeaseUntil time.Time
	UpdatedAt  time.Time
}

type Handler func(context.Context, Message) error
type Store interface {
	Begin(ctx context.Context, consumer, messageID string, now time.Time, lease time.Duration) (Record, bool, error)
	Complete(ctx context.Context, consumer, messageID string, now time.Time) error
	Prune(ctx context.Context, before time.Time) (int, error)
}
// Consumer 实现至少一次消费:代理可以重复投递,业务效果由去重记录收敛到一次。
type Consumer struct {
	Name   string
	Store  Store
	Lease  time.Duration
	Now    func() time.Time
	Handle Handler
}

func (c *Consumer) Consume(ctx context.Context, m Message) (Result, error) {
	lease := c.Lease
	if lease <= 0 {
		lease = 30 * time.Second
	}
	now := time.Now()
	if c.Now != nil {
		now = c.Now()
	}
	rec, claimed, err := c.Store.Begin(ctx, c.Name, m.ID, now, lease)
	if err != nil {
		return "", err
	}
	if !claimed {
		if rec.State == StateDone {
			return ResultDuplicate, nil
		}
		return ResultInFlight, ErrInFlight
	}
	if err := c.Handle(ctx, m); err != nil {
		// 不主动清租约:失败与崩溃走同一条恢复路径,等租约到期后重试。
		return "", err
	}
	if err := c.Store.Complete(ctx, c.Name, m.ID, now); err != nil {
		return "", err
	}
	return ResultApplied, nil
}

// MemoryStore 是单实例演示。生产实现把 (consumer, message_id) 放进唯一索引,
// 并与业务写入共享同一事务。
type MemoryStore struct {
	mu      sync.Mutex
	records map[string]Record
}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{records: map[string]Record{}}
}

func key(consumer, messageID string) string { return consumer + "|" + messageID }

func (s *MemoryStore) Begin(ctx context.Context, consumer, messageID string, now time.Time, lease time.Duration) (Record, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	k := key(consumer, messageID)
	rec, ok := s.records[k]
	if !ok {
		rec = Record{State: StateProcessing, Attempts: 1, LeaseUntil: now.Add(lease), UpdatedAt: now}
		s.records[k] = rec
		return rec, true, nil
	}
	if rec.State == StateDone {
		return rec, false, nil
	}
	if now.Before(rec.LeaseUntil) {
		// 另一个消费者仍持有租约,不能并发执行业务处理。
		return rec, false, nil
	}
	rec.Attempts++
	rec.LeaseUntil = now.Add(lease)
	rec.UpdatedAt = now
	s.records[k] = rec
	return rec, true, nil
}

func (s *MemoryStore) Complete(ctx context.Context, consumer, messageID string, now time.Time) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	k := key(consumer, messageID)
	rec, ok := s.records[k]
	if !ok {
		return fmt.Errorf("complete unknown message %s", messageID)
	}
	rec.State = StateDone
	rec.LeaseUntil = time.Time{}
	rec.UpdatedAt = now
	s.records[k] = rec
	return nil
}

func (s *MemoryStore) Prune(ctx context.Context, before time.Time) (int, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	removed := 0
	for k, rec := range s.records {
		if rec.State == StateDone && rec.UpdatedAt.Before(before) {
			delete(s.records, k)
			removed++
		}
	}
	return removed, nil
}

这里有两个容易被忽略的边界。第一,Begin 与 Complete 在内存实现里是两次独立加锁;生产实现必须把“业务写入 + 去重记录完成”放进同一个事务,否则业务提交成功、状态写失败时仍会重复执行。第二,失败不解租约意味着重试要等租约到期;如果业务需要快速重试,应把失败原因和下次可见时间写入记录,而不是在 Consume 里忙等。

测试覆盖三类重复

测试用固定时钟推进租约:重复投递验证 APPLIED → DUPLICATE → DUPLICATE;并发测试让 6 个 goroutine 抢同一条消息,只允许一个 APPLIED;崩溃测试直接调用 Begin 后不写终态,验证租约内不可抢占、过期后可接管。

// file: internal/consumer/consumer_test.go
// tier: L1
// verify: go test -race -v ./internal/consumer/
package consumer

import (
	"context"
	"errors"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

var base = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
func fixedConsumer(store Store, calls *atomic.Int32) *Consumer {
	var tick atomic.Int64
	return &Consumer{
		Name: "notifier", Store: store, Lease: 30 * time.Second,
		Now: func() time.Time { return base.Add(time.Duration(tick.Add(1)) * time.Second) },
		Handle: func(ctx context.Context, m Message) error {
			calls.Add(1)
			return nil
		},
	}
}

func TestDuplicateDeliveryAppliesOnce(t *testing.T) {
	store := NewMemoryStore()
	var calls atomic.Int32
	c := fixedConsumer(store, &calls)
	want := []Result{ResultApplied, ResultDuplicate, ResultDuplicate}
	for i, expected := range want {
		got, err := c.Consume(context.Background(), Message{ID: "m-1", Topic: "order.placed"})
		if err != nil || got != expected {
			t.Fatalf("delivery %d: got=%s want=%s err=%v", i, got, expected, err)
		}
	}
	if calls.Load() != 1 {
		t.Fatalf("handler calls=%d want 1", calls.Load())
	}
}

func TestConcurrentDuplicatesApplyOnce(t *testing.T) {
	store := NewMemoryStore()
	var calls atomic.Int32
	c := &Consumer{
		Name: "notifier", Store: store, Lease: time.Second,
		Handle: func(ctx context.Context, m Message) error {
			calls.Add(1)
			time.Sleep(20 * time.Millisecond)
			return nil
		},
	}
	const workers = 6
	results := make([]Result, workers)
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			r, err := c.Consume(context.Background(), Message{ID: "m-concurrent"})
			if err != nil && !errors.Is(err, ErrInFlight) {
				t.Errorf("worker %d: %v", i, err)
			}
			results[i] = r
		}(i)
	}
	wg.Wait()
	applied := 0
	for _, r := range results {
		if r == ResultApplied {
			applied++
		}
	}
	if applied != 1 || calls.Load() != 1 {
		t.Fatalf("applied=%d calls=%d results=%v", applied, calls.Load(), results)
	}
}

func TestHandlerFailureIsRetried(t *testing.T) {
	store := NewMemoryStore()
	var calls atomic.Int32
	var tick atomic.Int64
	c := &Consumer{Name: "notifier", Store: store, Lease: time.Second}
	c.Now = func() time.Time { return base.Add(time.Duration(tick.Add(1)) * time.Second) }
	c.Handle = func(ctx context.Context, m Message) error {
		if calls.Add(1) <= 2 {
			return errors.New("provider timeout")
		}
		return nil
	}
	ctx := context.Background()
	for i := 0; i < 2; i++ {
		if _, err := c.Consume(ctx, Message{ID: "m-retry"}); err == nil {
			t.Fatalf("attempt %d: want error", i+1)
		}
	}
	if got, err := c.Consume(ctx, Message{ID: "m-retry"}); err != nil || got != ResultApplied {
		t.Fatalf("third attempt: result=%s err=%v", got, err)
	}
	if calls.Load() != 3 {
		t.Fatalf("calls=%d want 3", calls.Load())
	}
}

func TestCrashBeforeAckLeasesUntilExpiry(t *testing.T) {
	store := NewMemoryStore()
	ctx := context.Background()
	if _, claimed, err := store.Begin(ctx, "notifier", "m-crash", base, time.Second); err != nil || !claimed {
		t.Fatalf("first claim: claimed=%v err=%v", claimed, err)
	}
	// 模拟处理中崩溃:没有 Complete,也没有 Fail,租约留在表里。
	if rec, claimed, err := store.Begin(ctx, "notifier", "m-crash", base.Add(500*time.Millisecond), time.Second); err != nil || claimed {
		t.Fatalf("within lease: claimed=%v rec=%+v err=%v", claimed, rec, err)
	}
	rec, claimed, err := store.Begin(ctx, "notifier", "m-crash", base.Add(2*time.Second), time.Second)
	if err != nil || !claimed || rec.Attempts != 2 || rec.State != StateProcessing {
		t.Fatalf("after lease: claimed=%v rec=%+v err=%v", claimed, rec, err)
	}
	if err := store.Complete(ctx, "notifier", "m-crash", base.Add(2*time.Second)); err != nil {
		t.Fatal(err)
	}
	if _, claimed, _ := store.Begin(ctx, "notifier", "m-crash", base.Add(3*time.Second), time.Second); claimed {
		t.Fatal("completed message must not be claimed again")
	}
}

func TestPruneDefinesDedupWindow(t *testing.T) {
	store := NewMemoryStore()
	var calls atomic.Int32
	c := fixedConsumer(store, &calls)
	ctx := context.Background()
	if got, err := c.Consume(ctx, Message{ID: "m-window"}); err != nil || got != ResultApplied {
		t.Fatalf("first: result=%s err=%v", got, err)
	}
	if removed, err := store.Prune(ctx, base.Add(time.Hour)); err != nil || removed != 1 {
		t.Fatalf("prune removed=%d err=%v", removed, err)
	}
	// 去重记录被清理后,同一个 message_id 会再次执行业务。
	if got, err := c.Consume(ctx, Message{ID: "m-window"}); err != nil || got != ResultApplied {
		t.Fatalf("after prune: result=%s err=%v", got, err)
	}
	if calls.Load() != 2 {
		t.Fatalf("calls=%d want 2", calls.Load())
	}
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),命令 go test -race -v ./... -count=1,原始输出保存在 .local/reports/be-arch/be-arch-perf-04-2-delivery-semantics-consumer-idempotency-race.txt:

=== RUN   TestDuplicateDeliveryAppliesOnce
--- PASS: TestDuplicateDeliveryAppliesOnce (0.00s)
=== RUN   TestConcurrentDuplicatesApplyOnce
--- PASS: TestConcurrentDuplicatesApplyOnce (0.02s)
=== RUN   TestHandlerFailureIsRetried
--- PASS: TestHandlerFailureIsRetried (0.00s)
=== RUN   TestCrashBeforeAckLeasesUntilExpiry
--- PASS: TestCrashBeforeAckLeasesUntilExpiry (0.00s)
=== RUN   TestPruneDefinesDedupWindow
--- PASS: TestPruneDefinesDedupWindow (0.00s)
PASS
ok  	archlab/internal/consumer	1.551s

-race 没有报告数据竞争;并发测试里 6 个 goroutine 只有 1 个返回 APPLIED,其余返回 DUPLICATE 或 IN_FLIGHT。这是本机单次运行,不能据此推断线上吞吐。

“恰好一次”在业务层的真实含义

“恰好一次”通常指恰好一次业务效果,而不是恰好一次传输。它成立需要三个条件同时满足:去重键稳定且由生产端生成;去重记录与业务副作用在同一个事务边界内;重复窗口内的消息都能查到去重记录。缺任何一个,就只能退化为“至少一次 + 业务幂等”。

以通知为例,发短信是不可回滚的外部副作用。即使去重表显示 DONE,也可能出现“短信已发出、状态写失败”的窗口;此时重试会再发一次。要彻底消除,需要供应商支持幂等键,或把“已发送”作为业务记录先落库再调用供应商,并接受极小概率的重复。工程上更现实的目标是:重复率可观测、重复可补偿、用户不会被重复扣款。

去重窗口的边界

去重记录不能无限保留。arch-lab 的口径是按事件时间保留 7 到 30 天,并且保留期必须大于代理的最长重投窗口;否则代理在第 8 天重投一条旧消息,去重表已经清空,业务会再执行一次。

Prune 的测试故意展示了这个边界:清理掉 DONE 后,同一个 message_id 会重新返回 APPLIED。这不是 bug,而是窗口设计的结果。窗口之外只能靠业务幂等兜底,例如通知按 order_id + template 覆盖写、支付按幂等键查询供应商结果。

还有一个跨库边界:去重表与业务表不同库时,无法共享事务。此时要么用业务唯一键做最终约束,要么接受“业务成功但状态未写”的重复,并通过对账修正。不要把跨库的两阶段提交当成默认方案。

常见错误与边界

第一,只记录“处理过”的 message_id,不记录处理中状态。并发重复会同时通过检查,业务执行两次。

第二,先执行业务、后写去重记录。进程在两步之间崩溃,重投会再次执行;顺序必须是先占位、再执行、最后写终态。

第三,去重窗口短于代理重投窗口。窗口外的重复没有任何拦截,等于把正确性交给了运气。

第四,把去重键交给消费者生成。同一事件被不同实例消费时键不一致,去重表形同虚设;键必须来自生产端的事件 ID。

小结

至少一次投递无法消除重复,能消除的是重复带来的业务效果。arch-lab 用 message_id + PROCESSING/DONE + 租约 把重复投递、并发重复和崩溃重试收敛到一次业务执行,并用 Prune 明确了去重窗口的边界。下一篇继续处理重复之外的运维问题:积压如何度量、顺序如何保证、毒消息如何进入 DLQ,以及定时对账如何兜住最终一致性。

阅读导航:上一节:4.1 同步改异步的收益与代价 · 下一节:4.3 积压、顺序、死信与定时对账补偿 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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