《MMO 服务器开发》11.2 命令编号与业务指纹

上一节的窗口搭好了,但「拿什么当键」还没解决。本节说清传输身份(命令号加会话序号)为什么不可靠,给 aegis 写一套业务指纹:只对定义意图的字段做哈希,把序号、时间戳、连接号排除在外,并算清 64 位指纹的碰撞概率与对抗性边界,附一张字段进出的清单。

11.2 命令编号与业务指纹

上一节把窗口这个容器写好了,但故意留了一个洞:往里面放什么键? 这是整个幂等方案里最容易做错、也最难事后补救的一步——键选错了,窗口再精巧也只是在错误的地方去重。

直觉答案有两个,而且都能说服人:用「命令号 + 会话序号」,因为 aegis 的帧头里就带着 cmd(2 字节) 和 seq(4 字节),拿来即用;或者干脆让客户端生成一个自增请求 ID,服务端只认它。两个答案都有致命缺陷,这一节把它们讲透,然后给出一套真正可判定的方案——业务指纹:对「这个请求到底想干什么」做哈希,而不是对「这个请求是怎么传过来的」做哈希。

传输身份:命令号加会话序号,为什么不够

先看帧头里那两个字段到底代表什么。按第 3 章定死的 wire 格式,一帧是 length(4) | cmd(2) | seq(4) | flags(1),其中 seq 是会话级的单调序号。它有三个性质决定了它不能当幂等键:

  • 重连就归零。玩家掉线重连,新连接从 seq=1 重新开始。此时服务端若还用 (cmd, seq) 去重,会把新会话的第一条命令误判成旧会话某条命令的重复;反过来,旧会话里没处理完的命令重发时又带着旧 seq,可能被新会话的窗口「顶掉」。
  • 跨进程不唯一。一个玩家可能今天连网关 A、明天连网关 B,两个网关各自维护序号空间。同一对 (cmd, seq) 在不同网关上指的是完全不同的请求。
  • 客户端不可信。seq 由客户端填,它可以跳号、回绕、伪造。服务端拿它做唯一依据,等于把「是否重复发奖」的判定权交给客户端。

更根本的一点是:seq 标识的是传输实例,而我们要防重的是业务意图。一次「购买」重试时,客户端完全可能生成一个全新的 seq(因为它以为上次根本没发出去),这时 (cmd, seq) 一看就是两条不同的命令——窗口形同虚设。所以键必须描述业务意图,而不是传输。

业务指纹:对「同一件事」做可判定的识别

业务指纹的思路是:把一条命令里所有能区分「这是哪一件事」的字段挑出来,拼成一个规范字节串,哈希成一个 64 位整数。关键在「挑」——挑错了要么漏(同一件事算出两个指纹),要么误杀(两件事算出一个指纹)。判据只有一条:这个字段在一次重试里会不会变? 会变的(序号、时间戳、连接号、客户端本地时间)一律排除;不变的(命令号、玩家、业务参数、以及一个客户端铸造的幂等令牌)保留。

下面这段是 L1,go test 可以直接跑:

// file: internal/idem/fingerprint.go
// tier: L1
// verify: go test ./internal/idem/ -run 'TestFingerprint|TestDeduper' -v

package idem

import (
	"encoding/binary"
	"hash/fnv"
	"time"
)

// CommandID is the transport identity of one command instance: which player,
// which command, and the per-session sequence number carried in the aegis frame
// header (cmd 2 bytes | seq 4 bytes, per the wire spec in chapter 3).
type CommandID struct {
	Actor uint64
	Cmd   uint16
	Seq   uint32
}

// Fingerprint identifies the business intent of a command, independent of the
// transport that carried it. Two retries of "buy item 1001 for 50 gold" share a
// fingerprint even if they carry different Seq values or arrive on different
// connections.
type Fingerprint uint64

// FingerprintOf hashes the semantic fields of a command into a Fingerprint.
// Only fields that define the intent go in: the command, the actor, a
// client-minted idempotency token, and the business arguments. Sequence
// numbers, timestamps and connection ids deliberately do not, because they
// change on every retry.
func FingerprintOf(cmd uint16, actor, token uint64, args ...uint64) Fingerprint {
	h := fnv.New64a()
	var buf [8]byte
	binary.BigEndian.PutUint16(buf[:2], cmd)
	_, _ = h.Write(buf[:2])
	binary.BigEndian.PutUint64(buf[:], actor)
	_, _ = h.Write(buf[:])
	binary.BigEndian.PutUint64(buf[:], token)
	_, _ = h.Write(buf[:])
	for _, a := range args {
		binary.BigEndian.PutUint64(buf[:], a)
		_, _ = h.Write(buf[:])
	}
	return Fingerprint(h.Sum64())
}

// Deduper is the window from 11.1 keyed by business fingerprint instead of by
// transport sequence. It is the cheap first filter: a retry that lands inside
// the window never touches durable storage.
type Deduper struct {
	win *Window
}

// NewDeduper returns a deduper whose window remembers fingerprints for ttl
// across n buckets.
func NewDeduper(ttl time.Duration, n int) *Deduper {
	return &Deduper{win: NewWindow(ttl, n)}
}

// Observe hashes a request and reports whether it is a duplicate. The
// fingerprint is returned so the caller can log it or persist it.
func (d *Deduper) Observe(cmd uint16, actor, token uint64, now time.Time, args ...uint64) (Fingerprint, bool) {
	fp := FingerprintOf(cmd, actor, token, args...)
	return fp, d.win.Seen(uint64(fp), now)
}

// Window exposes the underlying window for lookups and tests.
func (d *Deduper) Window() *Window { return d.win }

编码里有三个刻意的选择。第一,用固定宽度的 big-endian 拼接,而不是 fmt.Sprintf 或结构体内存布局。fmt 会分配、会把 1 和 01 编成不同串;内存布局则带 padding、且随编译器/平台变。固定宽度拼接保证「同样的语义字段 → 同样的字节串」,跨进程、跨版本稳定。第二,长度敏感:(100, 200) 和 (100, 200, 0) 会算出不同指纹,因为多了一个 8 字节的零块——这正是我们要的,参数个数本身也是语义。第三,令牌(token)必须参与哈希。它由客户端为「一次用户意图」铸造一次,重试时复用同一个值。没有它,玩家连点两次「买同一件道具」会被误判成重复,第二次购买直接丢失。

令牌还有一个很漂亮的性质:它的误用方向对服务端有利。客户端忘了在重试时复用令牌 → 服务端多扣一次钱,受害的是玩家自己;客户端反复复用同一个令牌 → 服务端把后续请求当重复丢弃,玩家自己丢操作。所以客户端有动力正确使用它,服务端不需要信任它也能保证「不会因为伪造令牌而多拿奖励」。

把「哪些字段进哈希」列成一张表,评审时逐条对照:

字段进哈希?理由
cmd(命令号)是「买」和「卖」是不同的事
actor(玩家 ID)是同一条命令,不同玩家是不同的事
token(幂等令牌)是区分「同一玩家的两次相同意图」
业务参数(道具 ID、数量、目标)是定义了这件事的实质
seq(会话序号)否重连归零、跨网关不唯一
时间戳 / 客户端本地时间否每次重试都不同
连接 ID / 网关 ID否重试可能换连接
服务端接收时间否重试必然不同

表里最容易被写错的是时间戳。很多客户端会把 client_ts 塞进请求做排序或超时判断,顺手也进了指纹——于是每一次重试都算出新指纹,窗口彻底失效。判据始终是那一句:这个字段在一次重试里会不会变?

令牌由客户端在「用户按下按钮」的那一刻生成一次,之后无论重发多少次都复用。最简单可靠的做法是一个 64 位随机数,token = rand.Uint64()。它不需要全局唯一,只需要在一个玩家的重试窗口内唯一——因为指纹里还带着 actor 和 cmd,不同玩家的随机数撞了也不会互相影响。想省掉随机数也可以用一个本地计数器加会话种子,关键是计数器只在用户意图发生时自增、重发时不增。把它和 seq 摆在一起看最清楚:seq 是「发了几次包」,token 是「用户想干几件事」,两者在重试时的行为恰好相反。

这里有个常见误区:让客户端把「请求 ID」算好上报,服务端只存这个 ID、自己不复算。功能上看似等价,实际上把正确性整个交给了客户端——客户端把两个不同意图算成同一个 ID,服务端就会静默丢掉一次操作,而且服务端无从发现。让服务端从原始业务字段复算指纹,好处是键始终可以被独立验证:拿到日志里的一枚指纹,顺着同一份参数就能把它复现出来;而客户端上报的 ID 无法复现,排障时只是一串看不懂的数字。

为什么不能只靠自增 ID 与碰撞概率

把四种候选键摆在一起,差距一目了然:

候选键重连后稳定跨网关唯一描述业务意图服务端可复算结论
(cmd, seq)否否否是会漏、会误杀
客户端自增请求 ID否否否否与 seq 同病
客户端上报的随机 ID是是否否能用,但排障时不可复现
服务端复算的业务指纹是是是是本节采用

「让客户端给每个请求一个自增 ID」是上面方案的近亲,但差在两点。其一,自增 ID 的语义是「第几条」,不是「哪件事」:它不携带任何业务信息,服务端无法用它反查「这条重复到底对应哪次操作」,日志和排障都会很痛苦;而指纹至少能拿业务参数复算。其二,自增 ID 的空间由客户端掌控,回绕、跳号、跨设备同步全是问题,而指纹是函数式的——同一件事,谁算都是同一个值,不需要任何中心分配器。

真正需要认真算的是碰撞:两个不同的业务意图算出同一个 64 位指纹,窗口就会把第二条误判成重复。对 64 位哈希,n 个不同指纹里至少撞一次的概率约为 n² / 2⁶⁵。代进去:

指纹总数 n至少撞一次的概率场景
5 × 10⁴≈ 7 × 10⁻¹¹窗口内的活动键(r × ttl)
1 × 10⁸≈ 2.7 × 10⁻⁴一天的请求量级
1 × 10⁹≈ 2.7 × 10⁻²持久表全量指纹

这个表说明了两个不同的工程判断。在窗口里,碰撞可以忽略:窗口的活动键上界是 r × ttl,几万个量级,碰撞概率是百亿分之一,比硬件出错的概率还低。但一旦指纹被当成持久表的唯一键,population 变成全量历史,概率就不可忽略了——1 亿条记录时约万分之三,1 亿次请求里大概有几百次潜在碰撞。

举个更贴近线上的数:假设一个区服日活 10 万、人均每天发 200 条写命令,一天就是 2 × 10⁷ 条。代入公式,全量指纹至少撞一次的概率约 1 × 10⁻⁵——也就是大概每几个月会撞上一次。单看数字不高,但撞上的后果是「两个玩家的两次不同操作被当成同一次」,而且极难从现象反推原因。所以持久层绝不能只靠裸指纹,正确的唯一键是复合键:发奖用 (fingerprint, settlement_id),购买用 (fingerprint, order_id)。即使指纹撞了,业务主键也不同,两条记录都能落库,只有真正同一件事才会被唯一约束挡住。这个复合键就是 11.3 要落地的东西。

最后一条边界:FNV-1a 是非加密哈希,能被人为构造碰撞。如果指纹的用途只是「服务端内部去重」,这不构成威胁;但如果它要用来防「恶意重放」,就必须换成带密钥的哈希(SipHash、xxh3 带密钥)或直接上加密哈希,让攻击者无法离线凑碰撞。本节的场景(客户端重试、网关重发)不在对抗模型里,所以标准库的 hash/fnv 足够。

真跑与实测结论

测试要证的就一件事:同样的语义 → 同样的指纹,任一语义字段变了 → 指纹就变。后半句尤其重要,它防的是「参数顺序写反了但指纹一样」这种静默错误:

// snippet: internal/idem/fingerprint_test.go(节选)

func TestFingerprintStableAndSeparating(t *testing.T) {
	a := FingerprintOf(0x0201, 42, 7, 100, 200)
	if b := FingerprintOf(0x0201, 42, 7, 100, 200); a != b {
		t.Fatalf("fingerprint not stable: %d != %d", a, b)
	}
	cases := map[string]Fingerprint{
		"token ignored": FingerprintOf(0x0201, 42, 8, 100, 200),
		"actor ignored": FingerprintOf(0x0201, 43, 7, 100, 200),
		"arg order":     FingerprintOf(0x0201, 42, 7, 200, 100),
		"cmd ignored":   FingerprintOf(0x0202, 42, 7, 100, 200),
		"arity ignored": FingerprintOf(0x0201, 42, 7, 100, 200, 0),
	}
	for name, got := range cases {
		if got == a {
			t.Fatalf("%s: fingerprint collided with the base request", name)
		}
	}
}

指纹只是「算键」,还得证明它接进窗口之后行为正确。TestDeduperWithinWindow 覆盖三个方向:同一意图的重试被判重、不同令牌被判成两件事、滑出窗口后同一意图不再被判重:

// snippet: internal/idem/fingerprint_test.go(节选)

func TestDeduperWithinWindow(t *testing.T) {
	d := NewDeduper(100*time.Millisecond, 4)
	fp1, dup := d.Observe(0x0201, 42, 7, base, 100, 200)
	if dup {
		t.Fatal("first observe reported a duplicate")
	}
	fp2, dup := d.Observe(0x0201, 42, 7, base.Add(5*time.Millisecond), 100, 200)
	if !dup || fp1 != fp2 {
		t.Fatalf("retry not deduped: fp %d vs %d, dup=%v", fp1, fp2, dup)
	}
	// A different token is a different intent, not a retry.
	if _, dup := d.Observe(0x0201, 42, 8, base.Add(5*time.Millisecond), 100, 200); dup {
		t.Fatal("distinct token reported as duplicate")
	}
	// Past the window the deduper has forgotten the intent entirely.
	if _, dup := d.Observe(0x0201, 42, 7, base.Add(time.Second), 100, 200); dup {
		t.Fatal("window should have forgotten the fingerprint")
	}
}

接进 aegis 的路径很短:网关从帧头取出 cmd 和 actor(actor 由登录态绑定,不取客户端自称的值),从 payload 解出 token 与业务参数,算出指纹后调 Deduper.Observe。命中重复时不重新执行 handler,而是回放上一次的响应——这就要求响应本身也被缓存起来(可以就放在窗口那个 uint64 值槽里,指向一个响应缓存)。注意 actor 必须来自服务端的会话,绝不能来自客户端字段,否则玩家 A 可以伪造 actor=B 把 B 的请求「顶」成重复。

// snippet: internal/gateway/dedup.go(接线示意,随第 17 章落地)

// onCommand handles a mutating command. It binds the actor from the
// authenticated session, recomputes the fingerprint, and either replays the
// cached response or runs the handler exactly once.
func (g *Gateway) onCommand(sess *Session, f *proto.Frame) (*proto.Frame, error) {
	actor := sess.ActorID()        // server-side identity, never a client field
	token, args := decodeIntent(f) // token + business args from the payload
	fp, dup := g.deduper.Observe(f.Cmd, actor, token, time.Now(), args...)
	if dup {
		return g.responses.Get(uint64(fp)), nil // replay, do not re-execute
	}
	resp, err := g.dispatch(sess, f)
	if err == nil {
		g.responses.Put(uint64(fp), resp, time.Now())
	}
	return resp, err
}

本机(Go 1.26,macOS arm64)实测:

指标实测值说明
FingerprintOf(2 参数)≈ 34 ns/op,0 allocshash/fnv 全在栈上,不逃逸
重复探测(窗口命中)37 ns/op,0 allocs见 11.1 的 BenchmarkSettlerHit
首次探测(落 store)321 ns/op,151 B见 11.3

结论很直接:指纹不是瓶颈。一次 FingerprintOf 大约 34ns、零分配,比它之后还要做的窗口探测(134ns)还便宜,和任何一次网络往返(微秒级起)比更是可以忽略。所以不要为了省这点开销去用「更简单」的键——用 (cmd, seq) 省下的那点哈希成本,换来的是 11.1 里那个洞:滑出后重放、重连后补发全都漏。真正的取舍在别处:指纹的字段选择决定了正确性,而正确性错了是省不回来的。

下一节把这一节和 11.1 拼起来:用指纹当键、用窗口当第一层、用持久唯一约束当第二层,给出「至少一次投递下也不重复发奖」的完整组合,并专门处理「窗口已经过期、请求却真的重发了」这个边界。

阅读导航:上一节:11.1 请求幂等窗口 · 下一节:11.3 至少一次投递下的正确性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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