11.3 至少一次投递下的正确性
先说一个必须接受的现实:在传输层,「只投递一次」做不到。客户端不知道自己的包有没有到达,所以它只有两个选择——不发(可能丢)或重发(可能重)。要保证不丢,就得允许重;要保证不重,就得允许丢。协议设计里没有第三种可能,TCP 也只是把「重」这件事藏进了内核,到了应用层照样会因为你超时重发而出现两次业务命令。
所以正确的目标不是「恰好投递一次」,而是「至少投递一次 + 恰好处理一次」。前者是传输语义,后者是业务语义,两者之间的桥就是幂等。这一节把前两节的零件拼成一个完整的 Settler:它用窗口挡掉绝大多数重试,用持久唯一约束挡住窗口漏掉的那部分,再用状态机保证「即使唯一约束也拦不住(比如同一件事被两个不同键描述)」时,重复执行也只是空转。
三层组合:窗口、唯一约束、状态机
三层各管一段,缺一层都会在某个边界上漏:
请求 ──► ① 幂等窗口(内存,上界 r×ttl)
│ 命中 → 判重,不落库、不执行 handler
▼ 未命中
② 唯一约束(持久,键 = fingerprint + 业务主键)
│ 命中 → 判重(这正是「窗口已过期」的边界)
▼ insert 成功
③ 状态机(New → Granted → Delivered)
推进状态,真正发奖
- ① 窗口负责「快」:挡住客户端秒级重试这个最常见的情况,成本 37ns、零分配。
- ② 唯一约束负责「对」:
Begin是一次原子的 insert-or-get,等价于 SQL 的INSERT ... ON CONFLICT或 Redis 的SET NX。它不受窗口过期影响,是「绝不重复发奖」的真正保证。 - ③ 状态机负责「可恢复」:发奖不是一步原子操作(可能要先改背包、再改货币、再写流水)。状态机把这件事拆成有明确中间态的几步,中途崩溃后能从
StateGranted续跑,而不是从头再发一次。
下面这段是 L1,go test 可以直接跑:
// file: internal/idem/reward.go
// tier: L1
// verify: go test ./internal/idem/ -run TestSettle -v
package idem
import (
"sync"
"time"
)
// State is the lifecycle of one reward grant. The state machine is what makes a
// replayed grant a no-op instead of a second payout: a key that is already past
// StateNew can never be granted again.
type State uint8
const (
StateNew State = iota
StateGranted
StateDelivered
)
// Store is the durable half of dedup: a unique constraint keyed by the request
// fingerprint. Begin must be atomic, which is exactly what a SQL UNIQUE index
// or a Redis SET NX gives you.
type Store interface {
// Begin inserts key if absent and reports the resulting state. created
// tells the caller whether it now owns the grant; when created is false the
// returned state is the one already on record.
Begin(key uint64) (st State, created bool)
// Commit advances the recorded state for key.
Commit(key uint64, st State)
}
// MemStore is an in-process Store. A real deployment backs the same interface
// with a UNIQUE index (SQL) or SETNX plus a state field (Redis).
type MemStore struct {
mu sync.Mutex
m map[uint64]State
}
// NewMemStore returns an empty store.
func NewMemStore() *MemStore { return &MemStore{m: make(map[uint64]State)} }
// Begin implements Store.
func (s *MemStore) Begin(key uint64) (State, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if st, ok := s.m[key]; ok {
return st, false
}
s.m[key] = StateGranted
return StateGranted, true
}
// Commit implements Store.
func (s *MemStore) Commit(key uint64, st State) {
s.mu.Lock()
s.m[key] = st
s.mu.Unlock()
}
// SettlerStats counts where duplicates were caught.
type SettlerStats struct {
Granted int // first time a fingerprint was seen
WindowDup int // retry caught by the in-memory window (fast path)
StoreDup int // retry caught by the durable constraint (window had expired)
}
// Settler grants a reward at most once per fingerprint. It stacks three layers:
// a cheap in-memory window for the common retry, a durable unique constraint
// that survives window expiry, and a state machine so a replay is a no-op.
type Settler struct {
win *Window
store Store
mu sync.Mutex
stats SettlerStats
}
// NewSettler builds a settler from a window and a store.
func NewSettler(win *Window, store Store) *Settler {
return &Settler{win: win, store: store}
}
// Settle applies the reward identified by fp. fresh=true means this call
// granted it; fresh=false means the request was a duplicate, rejected either by
// the window (fast path) or by the durable constraint (window-expired path).
func (s *Settler) Settle(fp Fingerprint, now time.Time) (st State, fresh bool) {
// Layer 1: the in-memory window. A retry inside the window never reaches
// the store.
if prev, existed := s.win.Put(uint64(fp), uint64(StateDelivered), now); existed {
s.count(func(st *SettlerStats) { st.WindowDup++ })
return State(prev), false
}
// Layer 2: the durable unique constraint. This is what catches a retry that
// arrives after the window has slid past it.
cur, created := s.store.Begin(uint64(fp))
if !created {
s.count(func(st *SettlerStats) { st.StoreDup++ })
return cur, false
}
// Layer 3: we own the grant, so advance the state machine. A crash between
// Begin and Commit leaves StateGranted on record; recovery resumes from
// there rather than re-granting.
s.store.Commit(uint64(fp), StateDelivered)
s.count(func(st *SettlerStats) { st.Granted++ })
return StateDelivered, true
}
// Stats returns a snapshot of the counters.
func (s *Settler) Stats() SettlerStats {
s.mu.Lock()
defer s.mu.Unlock()
return s.stats
}
func (s *Settler) count(f func(*SettlerStats)) {
s.mu.Lock()
f(&s.stats)
s.mu.Unlock()
}
Settle 返回 (state, fresh) 而不是简单的 bool,是刻意的:重复请求同样需要一个回应,客户端要拿它判断「我这条到底成没成」。所以窗口那个 uint64 值槽里存的就是状态,命中重复时直接把记录里的状态回放给客户端——这也是上一节说「窗口的值可以指向响应缓存」的落点。
边界:窗口过期,但请求真的重发了
三层里最容易出错的就是 ①→② 的交接。设想一条时间线:玩家在 t=0 领奖成功,窗口 ttl=10s;客户端因为网络抖动、进程被系统挂起、或者用户手动点了「重试」,在 t=12s 把同一条请求又发了一遍。此刻窗口早就把它忘了,Settle 的第①层返回未命中——如果没有第②层,这次就会被当成新请求,第二次发奖。
TestSettleCatchesRetryAfterWindowExpiry 就是钉这个边界的。它先把时钟拨到 500ms 之后,显式断言窗口里已经查不到这枚指纹,然后再调一次 Settle,要求它依然被唯一约束挡住:
// snippet: internal/idem/reward_test.go(节选)
func TestSettleCatchesRetryAfterWindowExpiry(t *testing.T) {
win := NewWindow(100*time.Millisecond, 4)
s := NewSettler(win, NewMemStore())
fp := FingerprintOf(0x0103, 42, 7, 9001)
if _, fresh := s.Settle(fp, base); !fresh {
t.Fatal("first settle was not fresh")
}
// The window has slid past the request...
if _, ok := win.Lookup(uint64(fp), base.Add(500*time.Millisecond)); ok {
t.Fatal("fingerprint still in window after ttl")
}
// ...but the durable constraint still rejects the late retry.
st, fresh := s.Settle(fp, base.Add(500*time.Millisecond))
if fresh || st != StateDelivered {
t.Fatalf("late retry = (%v, %v), want (Delivered, false)", st, fresh)
}
if got := s.Stats(); got.StoreDup != 1 || got.Granted != 1 {
t.Fatalf("stats = %+v, want StoreDup=1 Granted=1", got)
}
}
这个测试里有一个关键的断言顺序:先证明窗口已经忘了,再证明仍然没发第二次奖。如果只写后半句,测试在「窗口其实没忘」的实现下也会通过,就掩盖了「第②层到底有没有生效」这个真正的问题。
由这个边界可以推出一条容量纪律:持久去重记录的存活时间,必须长于「客户端可能重发的最长时间」。重试窗口可以取 10 秒(内存里省),但持久记录不能跟着 10 秒过期——玩家的客户端可能离线一小时后重连补发,人工客服也可能手动重推一条请求。对「钱和道具」这类东西,去重记录的寿命应该绑在业务对象本身上:只要那张结算单还在,它的唯一键就不能被复用。实践中常犯的错是给持久去重记录也设一个短 TTL,于是「窗口过期」和「持久记录过期」同时发生,两层一起失效——这正是本节要防的那类 bug。
真跑与实测结论
除了上面的边界测试,还有两个断言。TestSettleExactlyOnceUnderReplay 把窗口压到 1ms、连发 200 次同一请求,验证不管窗口命中还是落库,总授予次数恰好是 1;TestSettleConcurrentReplay 让 64 个 goroutine 同时投递同一指纹,用 -race 验证并发下也只有一次授予:
// snippet: internal/idem/reward_test.go(节选)
func TestSettleExactlyOnceUnderReplay(t *testing.T) {
s := NewSettler(NewWindow(time.Millisecond, 4), NewMemStore())
fp := FingerprintOf(0x0201, 42, 7, 100, 200)
granted := 0
for i := 0; i < 200; i++ {
// Deliver the same request once per millisecond; the 1ms window
// expires almost immediately, so most of these reach the store.
if _, fresh := s.Settle(fp, base.Add(time.Duration(i)*time.Millisecond)); fresh {
granted++
}
}
if granted != 1 {
t.Fatalf("granted %d times, want exactly 1", granted)
}
}
func TestSettleConcurrentReplay(t *testing.T) {
s := NewSettler(NewWindow(time.Minute, 8), NewMemStore())
fp := FingerprintOf(0x0301, 42, 7, 77)
var grants int64
var wg sync.WaitGroup
for g := 0; g < 64; g++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, fresh := s.Settle(fp, base); fresh {
atomic.AddInt64(&grants, 1)
}
}()
}
wg.Wait()
if grants != 1 {
t.Fatalf("granted %d times under concurrency, want exactly 1", grants)
}
}
本机(Go 1.26,macOS arm64)实测,同机相对量级,绝对数值不可移植:
| 指标 | 实测值 | 说明 |
|---|---|---|
Settle 命中窗口(重复) | 37 ns/op,0 allocs | BenchmarkSettlerHit,纯内存路径 |
Settle 首次(落 store) | 321 ns/op,151 B/op | BenchmarkSettlerFresh,含 map 插入 |
| 200 次重放授予次数 | 1 | 窗口 1ms,绝大多数走第②层 |
| 64 并发同指纹授予次数 | 1 | -race 下通过 |
| 全量测试 | 11 passed,go vet 干净 | go test -race ./... 通过 |
结论有两条。其一,把重试挡在持久层之前是划得来的:命中窗口只要 37ns,而落一次持久唯一约束(真机上是一次 Redis 往返或一条 SQL)要几百微秒——两者差四个数量级。所以窗口不是可有可无的优化,它决定了「高峰期有多少请求会真的去碰持久层」。其二,「恰好处理一次」是可以被测试钉死的:只要唯一约束的 Begin 是原子的、状态机是幂等的,那么无论请求来 1 次还是 200 次、串行还是 64 路并发,授予次数恒为 1,而且这一点可以在 CI 里用真跑的测试守住。
最后一句总结这一章:幂等不是某一层的事,而是「窗口 + 唯一约束 + 状态机」三层的组合。窗口负责快,唯一约束负责对,状态机负责能从崩溃里恢复。少任何一层,都会在某个具体的边界上漏——而这些边界,都能用一条 go test 复现出来。
阅读导航:上一节:11.2 命令编号与业务指纹 · 下一节:12.1 状态同步与帧同步的服务端视角 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。