《MMO 服务器开发》11.1 请求幂等窗口

客户端重试、网关超时重发、断线重连补发,都会让同一条命令在服务端出现两次。本节给 aegis 从零写一个滑动幂等窗口:时间桶怎么切、内存上界为什么是 rate×ttl、窗口滑出之后重放还安不安全,并用真跑的 L1 测试把这几条边界钉死,附本机实测的内存与耗时。

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/key500k 活键的 HeapAlloc 增量 / 键数
100k 键、ttl=100ms 后的活键100恰好等于 r × ttl = 1000 键/秒 × 100ms
Window.Seen134 ns/op,0 allocs单次探测(含一次 map 查找/插入)
内存估算r × ttl × 38 B5000 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 命令编号与业务指纹 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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