11.1 请求幂等窗口
先想清楚一件事:「只发一次」这个假设在真实网络里从来不成立。玩家点了一次「购买」,客户端把购买请求发出去,然后可能发生这些事:客户端自己等不到响应、超时后重发;网关转发时下游超时、网关重试;服务端处理到一半进程重启,客户端断线重连后按本地队列补发;甚至只是玩家手指抖了一下,UI 连点了两次。
这些路径都会让同一条业务命令在服务端出现两次。对读操作无所谓,对「扣 50 金币换 1 件道具」就是灾难——第一次扣钱发货,第二次再扣一遍。MMO 里这类命令满地都是:购买、领奖、抽卡、进副本、使用道具、公会捐献。
处理它的第一道也是最便宜的一道防线,是一个请求幂等窗口:服务端记住「最近一段时间内处理过哪些请求」,再来一次同样的请求就直接判定为重复。这一节就把它从零写出来,重点是三件事:窗口怎么切才既不漏也不爆内存、内存上界到底由谁决定、以及窗口滑出之后重放还安不安全——最后这一点会直接决定第 11.3 节要补什么。
与站内实战文章的分工
站内 game/server/practice/ 专题里有几篇直接讲幂等与去重架构的文章。它们负责「线上怎么设计、怎么取舍、怎么排障」,本节负责「从零写出可运行的那一层」,两者的分工如下:
| 站内既有文章 | 它负责的问题 | 本节负责的部分 |
|---|---|---|
/game-server-command-deduplication-architecture/ | 命令去重与业务幂等的整体架构、编号与窗口的关系 | 只把「窗口」这一个数据结构从零写出来 |
/battle-settlement-service-prevent-duplicate-rewards/ | 结算侧如何用状态机与流水避免重复发奖 | 本节只提供窗口,落库与状态机见 11.3 |
/game-server-cooldown-token-service-architecture/ | 冷却令牌的发放、消费与续期治理 | 不做令牌语义,只做「同一请求出现两次」的判定 |
/game-server-data-consistency-architecture/ | 哪些数据必须强一致、哪些可补偿 | 窗口是内存里的弱一致第一层,持久层见 11.3 |
一句话:要看架构取舍去读那四篇,要一份能 go test 通过的窗口实现读本节。
时间窗:内存上界是 rate × ttl
朴素做法是 map[key]time 加一个后台协程定期扫过期项。这个做法能跑,但内存上界很难说清:扫描间隔多长?扫的时候持锁吗?高峰积压多久?我们要的是一个上界可计算的结构。
答案是时间桶(time bucket)。把 ttl 切成 n 个等长小格,每个小格是一个 map;新键永远写进「当前格」;时间往前走时,整格整格地丢弃,而不是逐条判断过期。这样:
- 活动键恰好覆盖
ttl这段时间(n格 ×ttl/n); - 在到达速率
r下,活动键数上界是r × ttl,与「历史上见过多少个键」无关; - 淘汰是 O(1) 的整格
clear,没有后台扫描线程,也没有锁竞争。
下面这段是 L1,go test 可以直接跑:
// file: internal/idem/window.go
// tier: L1
// verify: go test ./internal/idem/ -run 'TestWindow|TestCountWindow' -v
package idem
import (
"sync"
"time"
)
// Window is a sliding dedup window: it maps a uint64 key to an opaque uint64
// value and forgets the pair once the key has been silent for ttl.
//
// Memory is bounded by construction. Keys live in one of n time buckets, and a
// bucket is dropped whole when the window slides past it, so the live set
// always spans exactly ttl: with arrival rate r the entry count stays near
// r*ttl instead of growing with the number of keys ever seen.
type Window struct {
mu sync.Mutex
ttl time.Duration
step time.Duration
cur int
total int
evicted int
buckets []bucket
}
type bucket struct {
start time.Time
set map[uint64]uint64
}
// NewWindow builds a window that remembers keys for ttl across n buckets
// (n >= 2). A larger n makes eviction finer grained (granularity ttl/n) at the
// cost of one extra map lookup per probe.
func NewWindow(ttl time.Duration, n int) *Window {
if n < 2 {
n = 2
}
w := &Window{
ttl: ttl,
step: ttl / time.Duration(n),
buckets: make([]bucket, n),
}
for i := range w.buckets {
w.buckets[i].set = make(map[uint64]uint64)
}
return w
}
// Put records key->val at now and returns the value that was live before, if
// any. existed=true is the duplicate signal: the key was already in the window.
func (w *Window) Put(key, val uint64, now time.Time) (prev uint64, existed bool) {
w.mu.Lock()
defer w.mu.Unlock()
w.advance(now)
for i := range w.buckets {
if v, ok := w.buckets[i].set[key]; ok {
w.buckets[i].set[key] = val
return v, true
}
}
w.buckets[w.cur].set[key] = val
w.total++
return 0, false
}
// Lookup reads key without recording anything. It still advances the window,
// so a stale bucket can never answer a fresh probe.
func (w *Window) Lookup(key uint64, now time.Time) (uint64, bool) {
w.mu.Lock()
defer w.mu.Unlock()
w.advance(now)
for i := range w.buckets {
if v, ok := w.buckets[i].set[key]; ok {
return v, true
}
}
return 0, false
}
// Seen is the set-only view for plain dedup.
func (w *Window) Seen(key uint64, now time.Time) bool {
_, existed := w.Put(key, 0, now)
return existed
}
// Stats returns the live key count and the number of slots reclaimed so far.
func (w *Window) Stats() (live, evicted int) {
w.mu.Lock()
defer w.mu.Unlock()
return w.total, w.evicted
}
func (w *Window) advance(now time.Time) {
if w.buckets[w.cur].start.IsZero() {
w.buckets[w.cur].start = now
return
}
// A long stall or a clock jump would otherwise walk the ring once per step;
// past a whole ttl there is nothing left to keep.
if now.Sub(w.buckets[w.cur].start) >= w.ttl {
w.reset(now)
return
}
for now.Sub(w.buckets[w.cur].start) >= w.step {
next := (w.cur + 1) % len(w.buckets)
w.drop(next)
w.buckets[next].start = w.buckets[w.cur].start.Add(w.step)
w.cur = next
}
}
func (w *Window) drop(i int) {
n := len(w.buckets[i].set)
w.evicted += n
w.total -= n
clear(w.buckets[i].set)
}
func (w *Window) reset(now time.Time) {
for i := range w.buckets {
w.drop(i)
w.buckets[i].start = time.Time{}
}
w.cur = 0
w.buckets[0].start = now
}
三个细节值得单独说。第一,now 是显式传进来的,不是内部调 time.Now()。这不是洁癖,而是让测试能直接把时钟拨到 500ms 之后,去验证「窗口滑出」这件事——不然你只能靠 time.Sleep 把测试拖慢。第二,探测要遍历全部 n 格,因为一个键可能落在任意一格;n 取 4~16 都行,超过之后收益递减。第三,advance 里那段「跨过整个 ttl 就直接 reset」不是优化而是必需:时钟一次性跳很远时,按格推进的循环会一格一格走几千次,直接清空才能把复杂度钉在 O(n)。
计数窗与滑出后的安全性
时间窗不是唯一选择。另一种常见结构是计数窗:只记「最近 N 条命令」,用固定大小的环形数组 + 集合实现,内存是死的 N 项,与速率无关。它的核心方法就这几行:
// snippet: internal/idem/countwindow.go(节选)
// Seen records key and reports whether it was among the last n keys.
func (w *CountWindow) Seen(key uint64) bool {
w.mu.Lock()
defer w.mu.Unlock()
if _, ok := w.set[key]; ok {
return true
}
if w.full {
delete(w.set, w.ring[w.pos])
}
w.ring[w.pos] = key
w.set[key] = struct{}{}
w.pos++
if w.pos == w.n {
w.pos = 0
w.full = true
}
return false
}
两种窗口的取舍很清楚:
| 维度 | 时间窗 | 计数窗 |
|---|---|---|
| 内存上界 | r × ttl,随速率变 | 固定 N 项 |
| 判定依据 | 「多久以前」 | 「隔了多少条」 |
| 突发流量 | 免疫:窗口按时间走 | 脆弱:一波 N 条命令能把几毫秒前的重试挤出去 |
| 适合场景 | 防重试(重试由超时驱动,天然是时间量) | 防同一连接的序号回绕 |
对「防重试」这个需求,时间窗是对的:重试的触发条件本来就是超时,用时间度量它才自然。
但两种窗口有一个共同的、必须写进文档的性质:窗口滑出之后,重复请求会被当成新请求。这不是 bug,是窗口的定义——它只记得住「最近」。所以窗口只能挡住「短期内」的重放,挡不住这些情况:客户端断线半小时后从本地队列补发一条旧请求、攻击者隔一会儿重放一次、或者窗口因为重启而清空。要真正做到「绝不重复发奖」,窗口不够,后面必须再压一层持久化的唯一约束——那就是 11.3 的主题。
真跑与实测结论
测试不 mock 时间:now 是参数,直接喂。三个关键断言——窗口内去重、滑出后遗忘、内存有界:
// snippet: internal/idem/window_test.go(节选)
func TestWindowForgetsAfterTTL(t *testing.T) {
w := NewWindow(100*time.Millisecond, 4)
w.Seen(7, base)
if _, ok := w.Lookup(7, base.Add(50*time.Millisecond)); !ok {
t.Fatal("key vanished before ttl")
}
// Past a full ttl the key is gone; a very late repeat looks new again.
if _, ok := w.Lookup(7, base.Add(500*time.Millisecond)); ok {
t.Fatal("key survived past ttl")
}
if w.Seen(7, base.Add(500*time.Millisecond)) {
t.Fatal("late repeat still reported as duplicate")
}
}
func TestWindowMemoryBounded(t *testing.T) {
// 100k keys, one per millisecond. ttl is 100ms, so at most ~100 keys can be
// live at once no matter how many have been seen in total.
const total = 100_000
w := NewWindow(100*time.Millisecond, 4)
for i := 0; i < total; i++ {
w.Seen(uint64(i), base.Add(time.Duration(i)*time.Millisecond))
}
live, evicted := w.Stats()
if live > 110 {
t.Fatalf("live keys = %d, want <= 110", live)
}
if live+evicted != total {
t.Fatalf("live+evicted = %d, want %d", live+evicted, total)
}
}
本机(Go 1.26,macOS arm64)实测数字如下,同机相对量级,绝对数值不可移植:
| 指标 | 实测值 | 说明 |
|---|---|---|
| 单键内存 | 37.8 bytes/key | 500k 活键的 HeapAlloc 增量 / 键数 |
| 100k 键、ttl=100ms 后的活键 | 100 | 恰好等于 r × ttl = 1000 键/秒 × 100ms |
Window.Seen | 134 ns/op,0 allocs | 单次探测(含一次 map 查找/插入) |
| 内存估算 | r × ttl × 38 B | 5000 cmd/s × 10s ≈ 1.9 MB |
两个结论。其一,上界真的可以被算出来:TestWindowMemoryBounded 往窗口里塞了 10 万个键,但结束时活着的只有 100 个——ttl=100ms、速率 1000 键/秒,r × ttl = 100,与实测分毫不差(另外 99900 个都被整格回收了)。这意味着容量规划变成一道乘法题:先定「重试窗口多长」(由客户端超时与网络抖动决定,MMO 里 5~30 秒常见),再乘峰值命令速率,就是内存需求。其二,窗口本身几乎不要钱:单次探测 134ns、零分配,比一次 Redis 往返(几百微秒量级)便宜三个数量级。所以窗口的价值不在于「正确」,而在于把绝大多数重试挡在持久层之前,让昂贵的唯一约束只在真正必要的时候才被触达。
最后留一个钩子:TestWindowForgetsAfterTTL 的最后一行断言「滑出后重放会被当成新请求」,这是我们故意验证的窗口缺陷。下一节先解决「拿什么当键」——也就是命令编号与业务指纹——再在 11.3 用持久层把这个洞补上。
阅读导航:上一节:目录 · 下一节:11.2 命令编号与业务指纹 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。