《MMO 服务器开发》11.3 至少一次投递下的正确性

「只投递一次」在传输层根本做不到,能做的是「处理一次」。本节把 11.1 的窗口和 11.2 的指纹拼起来,加上持久唯一约束与发奖状态机,给出 at-least-once 投递下不重复发奖的完整组合,并专门处理「窗口已过期、请求却真的重发了」这个边界。

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 allocsBenchmarkSettlerHit,纯内存路径
Settle 首次(落 store)321 ns/op,151 B/opBenchmarkSettlerFresh,含 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 状态同步与帧同步的服务端视角 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标