《后端架构设计·高性能与高可用》7.3 幂等键与去重窗口的实现与故障注入验证

实现订单接口的幂等存储与 Guard:键、请求指纹、PROCESSING/DONE/CONFLICT、结果快照与过期窗口;用故障注入和 -race 真跑并发同键、同键不同参数、处理中崩溃重试与窗口过期,证明重复请求不会二次扣库存或二次支付,并给出存储不可用时的失败关闭策略。

从消费幂等到接口幂等

4.2 消息投递语义与消费幂等 处理的是消费者侧的重复投递:键由生产端的事件 ID 提供,状态是 PROCESSING/DONE,租约用于崩溃接管。订单接口面对的重复来源更多:用户双击、客户端超时重试、网关重试、重试放大器(7.2)都可能在几秒内把同一笔下单请求送到服务端。

两者共享同一组原语,但接口幂等多了两件事:请求指纹和响应快照。同一个键配不同参数不能当成重复请求,必须返回 CONFLICT;已经完成的请求要能原样 replay 当时的响应,而不是重新执行业务。

一条幂等记录要保存什么

Record 保存五类信息:请求指纹(参数摘要)、状态、结果快照、处理租约和记录过期时间。状态只有三个:PROCESSING 表示某个实例正在执行,DONE 表示已有终态和可重放的响应,CONFLICT 表示同键不同指纹。租约和过期时间必须分开:租约决定“处理中崩溃后多久可以接管”,过期时间决定“重复窗口有多长”。

// file: internal/platform/idempotency/store.go
// tier: L1
// verify: go test ./internal/platform/idempotency/ -count=1
package idempotency

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

type State string

const (
	Processing State = "PROCESSING"
	Done       State = "DONE"
	Conflict   State = "CONFLICT"
)

var (
	ErrInFlight = errors.New("idempotent request is in flight")
	ErrConflict = errors.New("idempotency key reused with different parameters")
)

type Response struct {
	Status int
	Body   []byte
}

type Record struct {
	Fingerprint string
	State       State
	Response    Response
	LeaseUntil  time.Time
	ExpiresAt   time.Time
}

type Store interface {
	Begin(ctx context.Context, key, fingerprint string, now time.Time, lease, retention time.Duration) (Record, bool, error)
	Complete(ctx context.Context, key, fingerprint string, resp Response, now time.Time, retention time.Duration) error
}

type MemoryStore struct {
	mu      sync.Mutex
	records map[string]Record
	Fault   func(op string) error
}

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

func (s *MemoryStore) Begin(ctx context.Context, key, fingerprint string, now time.Time, lease, retention time.Duration) (Record, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.Fault != nil {
		if err := s.Fault("begin"); err != nil {
			return Record{}, false, err
		}
	}
	rec, ok := s.records[key]
	if !ok || !now.Before(rec.ExpiresAt) {
		rec = Record{Fingerprint: fingerprint, State: Processing, LeaseUntil: now.Add(lease), ExpiresAt: now.Add(retention)}
		s.records[key] = rec
		return rec, true, nil
	}
	if rec.Fingerprint != fingerprint {
		rec.State = Conflict
		return rec, false, nil
	}
	if rec.State == Done {
		return rec, false, nil
	}
	if now.Before(rec.LeaseUntil) {
		return rec, false, nil
	}
	rec.LeaseUntil = now.Add(lease)
	s.records[key] = rec
	return rec, true, nil
}

func (s *MemoryStore) Complete(ctx context.Context, key, fingerprint string, resp Response, now time.Time, retention time.Duration) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.Fault != nil {
		if err := s.Fault("complete"); err != nil {
			return err
		}
	}
	rec, ok := s.records[key]
	if !ok || rec.Fingerprint != fingerprint {
		return ErrConflict
	}
	rec.State = Done
	rec.Response = resp
	rec.LeaseUntil = time.Time{}
	rec.ExpiresAt = now.Add(retention)
	s.records[key] = rec
	return nil
}

Begin 的五个分支对应五种事实:键不存在或已过期,新建并占位;指纹不同,返回冲突;已完成,返回记录供 replay;租约未到期,说明另一个实例还在处理;租约到期,接管并续租。生产实现把 (key) 放进唯一索引,用一条条件 UPDATE 完成“接管或插入”,而不是先查后写。

Guard:先占位,再执行,最后写终态

Guard.Do 把存储操作包成请求级契约。Begin 失败时默认失败关闭:数据库不可用就返回 503,绝不跳过去重直接执行。占位成功后执行业务,成功则写 DONE 和响应快照;业务返回错误则不写终态,等租约到期后走与崩溃相同的恢复路径。

// file: internal/platform/idempotency/guard.go
// tier: L1
// verify: go test ./internal/platform/idempotency/ -count=1
package idempotency

import (
	"context"
	"errors"
	"time"
)

var ErrUnavailable = errors.New("idempotency store unavailable")

type Outcome string

const (
	Executed    Outcome = "EXECUTED"
	Replayed    Outcome = "REPLAYED"
	InFlight    Outcome = "IN_FLIGHT"
	Conflicted  Outcome = "CONFLICT"
	Unavailable Outcome = "UNAVAILABLE"
)

// Guard rejects a mutating request when the store cannot answer instead of
// executing it without dedup protection.
type Guard struct {
	Store     Store
	Lease     time.Duration
	Retention time.Duration
	Now       func() time.Time
}

func (g *Guard) Do(ctx context.Context, key, fingerprint string, fn func(context.Context) (Response, error)) (Response, Outcome, error) {
	now := time.Now()
	if g.Now != nil {
		now = g.Now()
	}
	rec, claimed, err := g.Store.Begin(ctx, key, fingerprint, now, g.Lease, g.Retention)
	if err != nil {
		return Response{}, Unavailable, ErrUnavailable
	}
	if !claimed {
		switch rec.State {
		case Done:
			return rec.Response, Replayed, nil
		case Conflict:
			return Response{}, Conflicted, ErrConflict
		default:
			return Response{}, InFlight, ErrInFlight
		}
	}
	resp, ferr := fn(ctx)
	if ferr != nil {
		// No terminal write: retry is allowed after the lease expires, which is
		// the same recovery path as a process crash.
		return resp, Executed, ferr
	}
	if err := g.Store.Complete(ctx, key, fingerprint, resp, now, g.Retention); err != nil {
		// The side effect may already be visible; the key stays PROCESSING and
		// the downstream idempotency key must absorb the retry.
		return resp, Executed, err
	}
	return resp, Executed, nil
}

Complete 失败是本节最危险的窗口:库存已经扣减、支付已经发生,但终态没写。此时 Guard 把错误返回给调用方,记录停在 PROCESSING;租约到期后重试会再次进入 handler。要保证不二次扣款,handler 调用的库存和支付适配器也必须按同一个幂等键去重,这就是“去重表是第一层,下游幂等键是第二层”。

故障注入测试

测试用一个内存账本模拟库存和支付:apply 按幂等键记录预留和扣款,同一个键只生效一次。故障注入分两类:complete 失败模拟“业务成功但状态写失败”,存储整体失败模拟数据库不可用。时间用可控时钟推进,覆盖租约内、租约后和去重窗口后三个时点。

// file: internal/platform/idempotency/guard_test.go
// tier: L1
// verify: go test -v ./internal/platform/idempotency/ -count=1
package idempotency

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

var base = time.Date(2026, 10, 19, 4, 0, 0, 0, time.UTC)

type ledger struct {
	mu           sync.Mutex
	stock        int
	reservations map[string]int
	charges      map[string]int
}

func newLedger(stock int) *ledger {
	return &ledger{stock: stock, reservations: map[string]int{}, charges: map[string]int{}}
}

func (l *ledger) apply(key string, qty, cents int) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	if _, ok := l.reservations[key]; ok {
		return nil
	}
	if l.stock < qty {
		return errors.New("OUT_OF_STOCK")
	}
	l.stock -= qty
	l.reservations[key] = qty
	l.charges[key] = cents
	return nil
}

func newGuard(store Store, clock *time.Time) *Guard {
	return &Guard{Store: store, Lease: time.Second, Retention: time.Hour,
		Now: func() time.Time { return *clock }}
}

func handler(l *ledger, key string, calls *atomic.Int32, delay time.Duration) func(context.Context) (Response, error) {
	return func(context.Context) (Response, error) {
		calls.Add(1)
		time.Sleep(delay)
		if err := l.apply(key, 1, 1000); err != nil {
			return Response{Status: 409, Body: []byte("OUT_OF_STOCK")}, nil
		}
		return Response{Status: 200, Body: []byte("paid")}, nil
	}
}

func TestConcurrentSameKeyAndFingerprintConflict(t *testing.T) {
	store := NewMemoryStore()
	now := base
	g := newGuard(store, &now)
	l := newLedger(10)
	var calls atomic.Int32
	const workers = 8
	outcomes := make([]Outcome, workers)
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			_, out, err := g.Do(context.Background(), "order-1", "sku=A&qty=1",
				handler(l, "order-1", &calls, 20*time.Millisecond))
			if err != nil && !errors.Is(err, ErrInFlight) {
				t.Errorf("worker %d: %v", i, err)
			}
			outcomes[i] = out
		}(i)
	}
	wg.Wait()
	executed := 0
	for _, out := range outcomes {
		if out == Executed {
			executed++
		}
	}
	if executed != 1 || calls.Load() != 1 || l.stock != 9 || len(l.reservations) != 1 || len(l.charges) != 1 {
		t.Fatalf("executed=%d calls=%d stock=%d reservations=%d charges=%d outcomes=%v",
			executed, calls.Load(), l.stock, len(l.reservations), len(l.charges), outcomes)
	}
	_, out, err := g.Do(context.Background(), "order-1", "sku=A&qty=2", handler(l, "order-1", &calls, 0))
	if !errors.Is(err, ErrConflict) || out != Conflicted || calls.Load() != 1 {
		t.Fatalf("conflict: out=%s err=%v calls=%d", out, err, calls.Load())
	}
	t.Logf("workers=%d executed=%d calls=%d stock=%d", workers, executed, calls.Load(), l.stock)
}

func TestLeaseTakeoverFaultInjectionAndWindow(t *testing.T) {
	store := NewMemoryStore()
	now := base
	g := newGuard(store, &now)
	l := newLedger(10)
	var calls atomic.Int32
	failures := 1
	store.Fault = func(op string) error {
		if op == "complete" && failures > 0 {
			failures--
			return errors.New("store timeout")
		}
		return nil
	}
	h := handler(l, "order-2", &calls, 0)
	if _, out, err := g.Do(context.Background(), "order-2", "sku=A&qty=1", h); err == nil || out != Executed {
		t.Fatalf("first call: out=%s err=%v", out, err)
	}
	if _, out, err := g.Do(context.Background(), "order-2", "sku=A&qty=1", h); !errors.Is(err, ErrInFlight) || out != InFlight {
		t.Fatalf("inside lease: out=%s err=%v", out, err)
	}
	now = now.Add(2 * time.Second)
	if _, out, err := g.Do(context.Background(), "order-2", "sku=A&qty=1", h); err != nil || out != Executed {
		t.Fatalf("after lease: out=%s err=%v", out, err)
	}
	if calls.Load() != 2 || l.stock != 9 || len(l.reservations) != 1 || len(l.charges) != 1 {
		t.Fatalf("calls=%d stock=%d reservations=%d charges=%d",
			calls.Load(), l.stock, len(l.reservations), len(l.charges))
	}
	now = now.Add(2 * time.Hour)
	if _, out, err := g.Do(context.Background(), "order-2", "sku=A&qty=1", h); err != nil || out != Executed || calls.Load() != 3 {
		t.Fatalf("after window: out=%s err=%v calls=%d", out, err, calls.Load())
	}
	t.Logf("calls=%d stock=%d reservations=%d charges=%d", calls.Load(), l.stock, len(l.reservations), len(l.charges))
	store.Fault = func(string) error { return errors.New("connection refused") }
	if _, out, err := g.Do(context.Background(), "order-3", "sku=A&qty=1", h); !errors.Is(err, ErrUnavailable) || out != Unavailable || calls.Load() != 3 {
		t.Fatalf("store down: out=%s err=%v calls=%d", out, err, calls.Load())
	}
}

真实运行:三次执行,一次扣款

本机 macOS darwin/arm64、Go 1.26.0,命令 go test -v ./... -count=1,原始输出在 .local/reports/be-arch/be-arch-perf-07-3-idempotency-tests.txt;-race 单次运行见 .local/reports/be-arch/be-arch-perf-07-all-race.txt。以下是本机单次运行:

=== RUN   TestConcurrentSameKeyAndFingerprintConflict
    guard_test.go:92: workers=8 executed=1 calls=1 stock=9
--- PASS: TestConcurrentSameKeyAndFingerprintConflict (0.02s)
=== RUN   TestLeaseTakeoverFaultInjectionAndWindow
    guard_test.go:128: calls=3 stock=9 reservations=1 charges=1
--- PASS: TestLeaseTakeoverFaultInjectionAndWindow (0.00s)
PASS
ok  	archlab/internal/platform/idempotency	0.249s

第一组:8 个 goroutine 同时用同一个键下单,只有 1 个拿到 EXECUTED,其余在 IN_FLIGHT;库存从 10 变成 9,预留和扣款各 1 条。第二组按时间顺序发生四件事:第一次调用成功执行业务但 Complete 故障,返回错误且记录停在 PROCESSING;租约内重试被拒绝;租约过期后接管并再次执行;Retention 两小时后再来一次,记录已过期,业务第三次执行。三次 handler 调用只产生 1 次库存扣减和 1 次支付,因为下游账本按同一个幂等键去重。这也标出了去重窗口的真实边界:窗口内由幂等表拦截,窗口外只剩下游幂等键。

存储不可用时失败关闭还是失败开放

默认必须失败关闭。Begin 失败意味着系统无法判断这是不是重复请求;此时执行下单,可能直接造成二次扣款,而数据库故障通常伴随重试和流量放大,正是重复最多的时候。失败关闭的代价是可用性下降:幂等库故障时下单接口整体 503,但这是可恢复的,重复扣款不可恢复。

失败开放只适用于满足全部条件的场景:操作本身天然幂等(例如按 order_id 覆盖写)、重复不会造成资金或库存副作用、且调用方有独立对账。只读查询可以失败开放;创建订单、扣库存、支付、退款必须失败关闭。不要把“提高可用性”当成默许失败开放的理由。

常见错误与边界

第一,只存键不存指纹。同键不同金额的请求会被当成重复并 replay 旧响应,正确做法是返回 CONFLICT 让调用方修正。

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

第三,把租约和去重窗口设成同一个值。租约只需覆盖一次处理,窗口要覆盖客户端和网关的最长重试时间;窗口短于重试时间,旧请求会穿透。

第四,认为幂等表能覆盖外部副作用。支付、短信等不可回滚操作仍可能“成功但状态未写”,必须让下游也支持幂等键或对账补偿。

第五,存储故障时静默放行。失败开放必须显式配置、限定接口,并记录指标;默认行为应当是 503。

小结

接口幂等需要键、指纹、三态、结果快照、租约和过期窗口六件套。arch-lab 的实现让 8 个并发同键请求只执行一次;在 Complete 故障注入下,租约接管和窗口过期一共触发三次 handler,但库存与支付各只发生一次,证明去重表与下游幂等键是两层防线。存储不可用时默认失败关闭,只读和天然幂等操作才允许例外。下一章从单点故障出发,讨论冗余、故障域与单点消除。

阅读导航:上一节:7.2 重试放大与退避抖动 · 下一节:8.1 冗余、故障域与单点消除 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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