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 allocs | hash/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 至少一次投递下的正确性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。