8.2 消息序号、去重与顺序保证
上一节的路由表回答了「这条帧交给谁」,但没回答「这条帧该不该处理」。在 MMO 里这不是小事:客户端因为丢包重发一次移动请求,服务端不该让角色多走一格;一条 CmdChatSend 因为网络抖动到达两次,聊天频道不该刷出两条一样的消息。
第 3 章定下的固定头里有一个 seq(4, BE) 字段,当时只是「占位」。这一节把它变成真正干活的字段:给每条连接一个单调递增的 32 位序号,服务端用一个滑动窗口判断「这条是不是已经见过」,再用一个乱序缓冲把「先到的高序号」暂存起来,等缺口补齐后再按序交给路由表。
一个常见的误解要先破除:TCP 已经保证字节有序,为什么还需要序号? 因为字节有序不等于业务有序。第一,一旦你把帧从读循环丢进 worker 池并发处理,处理完成的顺序就乱了;第二,第 5 章的可靠 UDP(KCP)在应用层交付时本来就允许乱序;第三,即便单线程读,客户端也可能因为超时重发同一帧,重复同样要挡。序号解决的是帧的身份与次序,和传输层是否可靠是两回事。
序号从哪里来:每连接单调递增
序号的作用域是每条连接,不是全局。连接断开重连后,序号从头开始(第 7 章的重连票据会带上一次的最后确认序号,用于衔接)。这一点很关键:如果用全局序号,一个客户端断线重连就得对齐一个它根本不知道的全局计数器,代价和复杂度都不划算。
服务端给每条连接维护两个方向:
- 入站:记客户端发来的
seq,用去重窗口判断重复。 - 出站:服务端自己发出去的每一帧也带一个递增
seq,客户端据此判断自己收到的响应是否重复。
入站序号不需要是「连续的整数」,只需要是「单调不减、且客户端保证同一条连接内不跳号」。实践中客户端从 1 开始,每发一帧加一;重发的帧复用原来的 seq,这正是去重窗口能识别出「这是重发」的原因。如果重发时换了新序号,服务端只会当成一条新命令,去重形同虚设。
去重窗口:一块滑窗位图
最朴素的做法是记一个 map[uint32]bool,但那样内存会无限增长,而且每帧一次 map 查找在热路径上不便宜。更好的做法直接抄 TCP 的接收窗口:记住「见过的最大序号」high,再用一个 64 位的位图标记 high 往下 64 个序号里哪些已经见过。下面这段是 L1,go test 可以直接跑:
// file: internal/router/seq.go
// tier: L1
// verify: go test ./internal/router/
package router
// SeqWindow tracks the highest seq accepted on one connection plus a 64-bit
// sliding window of the seq values seen just below it. It is a faithful port of
// the TCP receive-window trick: a replayed or too-old frame is rejected, and
// all comparisons are wrap-safe because seq is a uint32 counter.
type SeqWindow struct {
size uint32 // window width in bits, 1..64
high uint32
bitmap uint64 // bit i set means high-i has been seen
seen bool
}
// NewSeqWindow returns a window of the given width (clamped to 1..64).
func NewSeqWindow(size uint32) *SeqWindow {
if size < 1 {
size = 1
}
if size > 64 {
size = 64
}
return &SeqWindow{size: size}
}
// Accept records seq and reports whether it is new. It returns false for a
// duplicate (already in the window) or a frame so old it fell out of the window.
func (w *SeqWindow) Accept(seq uint32) bool {
if !w.seen {
w.seen = true
w.high = seq
w.bitmap = 1
return true
}
// int32 subtraction makes the comparison wrap-safe: seq 0 right after
// 0xffffffff is +1 ahead, not a huge negative number.
diff := int32(seq - w.high)
if diff > 0 {
if uint32(diff) >= w.size {
w.bitmap = 1
} else {
w.bitmap = (w.bitmap << uint(diff)) | 1
}
w.high = seq
return true
}
back := uint32(-diff)
if back >= w.size {
return false // too old to judge; treat as duplicate to stay safe
}
bit := uint64(1) << back
if w.bitmap&bit != 0 {
return false // already seen: a replay
}
w.bitmap |= bit
return true
}
// ReorderBuffer holds frames whose seq is ahead of the expected one and releases
// them in order once the gap fills. Its job is to give a connection an in-order
// view of a stream that arrived out of order; it does not retransmit, so a gap
// that never fills eventually forces a resync from the caller.
type ReorderBuffer struct {
next uint32
cap int
pending map[uint32]Frame
seen bool
}
// NewReorderBuffer returns a buffer holding at most cap out-of-order frames.
func NewReorderBuffer(cap int) *ReorderBuffer {
if cap < 1 {
cap = 1
}
return &ReorderBuffer{cap: cap, pending: make(map[uint32]Frame)}
}
// Push feeds one frame in. It returns the frames that became deliverable in seq
// order, how many frames are still buffered behind a gap, and whether the
// buffer overflowed (the caller should resync when it did).
//
// The first frame anchors the baseline: whatever seq arrives first is treated as
// the expected one, because there is nothing to compare against yet.
func (b *ReorderBuffer) Push(f Frame) (ready []Frame, buffered int, overflow bool) {
if !b.seen {
b.seen = true
b.next = f.Seq
}
switch {
case f.Seq == b.next:
ready = append(ready, f)
b.next++
for {
g, ok := b.pending[b.next]
if !ok {
break
}
delete(b.pending, b.next)
ready = append(ready, g)
b.next++
}
case int32(f.Seq-b.next) < 0:
// Older than expected: already delivered, drop it.
default:
if len(b.pending) < b.cap {
b.pending[f.Seq] = f
} else {
overflow = true
}
}
return ready, len(b.pending), overflow
}
// Pending reports how many frames are buffered behind a gap.
func (b *ReorderBuffer) Pending() int { return len(b.pending) }
两个细节。第一,比较必须用 int32 减法。序号是 uint32 会回绕,0xffffffff 的下一个合法序号是 0。如果直接比大小,0 < 0xffffffff 会被误判成「太旧」。int32(seq - high) 得到的差值天然处理了回绕:0 - 0xffffffff 在 32 位里是 1,即「领先一格」。第二,超出窗口的旧序号直接判为重复。序号落后 high 超过 64 位,说明这条帧要么是极老的延迟包,要么是伪造的——两种都不该处理,返回 false 是安全侧。
窗口「滑动」的过程用一个小例子最清楚。设宽度 8,已经收到 1..5:
high=5 bitmap=0b00011111 bit0..bit4 置 1,表示 5,4,3,2,1 都见过
收到 seq=8(领先 3 格):
bitmap = (0b00011111 << 3) | 1 = 0b11111001, high=8
bit0=1(8 见过);bit1、bit2 仍为 0(7、6 还没到)
收到 seq=6(落后 2 格):bit2 为 0 → 接受,置 1
收到 seq=6 再来一次: bit2 已为 1 → 判为重复,丢弃
所以窗口不是「记 64 个布尔值」,而是「一个高位 + 一块位图」,SeqWindow 整个结构体恒定 12 字节,与收到过多少帧无关。
乱序缓冲与顺序保证的边界
去重窗口只管「是不是新的」,不管「顺序对不对」。乱序缓冲补上后半段:如果 seq=12 比 seq=11 先到,12 不能立刻交给路由表,得先存起来;等 11 到了,11 和 12 一起按序放出去。
上面 ReorderBuffer.Push 的返回值设计成 (ready, buffered, overflow) 是有意的:调用方一次拿到「现在能处理哪些帧」「还压着几帧」「有没有撑爆」。第三个返回值最重要——它是一条必须被处理的信号,不是可忽略的日志。
这里必须把边界讲清楚,否则乱序缓冲会被当成万能药:
- 它不重传。 缓冲区只是暂存,不会主动向对端要那条缺失的帧。如果缺口永远补不上(客户端那帧真丢了),缓冲会一直压着后续帧。所以必须有超时:超过 N 毫秒仍未补齐,就丢弃缓冲并通知上层重新同步(发一次全量快照,而不是傻等)。
- 它只在有真实乱序来源时才需要。 单线程读循环 + TCP 的场景,帧本来就是有序的,加一个
ReorderBuffer只是徒增一次 map 操作。它真正的用武之地是第 5 章的 KCP 通道、以及把帧分发到 worker 池之后需要「按连接串行化」的场合。 - 顺序保证的粒度是「每条连接」,不是「每个实体」。 两个客户端分别操作同一只怪物,它们各自的连接内有序,但跨连接仍然要由业务层(例如 Actor 邮箱,见第 10 章)去串行化。序号帮不了跨连接的一致性问题。
L1 真跑与成本
// snippet: internal/router/seq_test.go(节选)
func TestSeqWindowRejectsDuplicate(t *testing.T) {
w := NewSeqWindow(64)
if !w.Accept(10) {
t.Fatal("first accept of 10 should succeed")
}
if w.Accept(10) {
t.Fatal("replay of 10 must be rejected")
}
}
func TestSeqWindowWrapAround(t *testing.T) {
w := NewSeqWindow(64)
w.Accept(0xfffffffe)
w.Accept(0xffffffff)
if !w.Accept(0) { // 回绕后 0 是「下一个」,不是「很久以前」
t.Fatal("seq 0 after 0xffffffff should be accepted")
}
}
func TestReorderBufferEmitsInOrder(t *testing.T) {
b := NewReorderBuffer(8)
b.Push(Frame{Seq: 10}) // 第一帧锚定基线
ready, buffered, _ := b.Push(Frame{Seq: 12})
if len(ready) != 0 || buffered != 1 {
t.Fatalf("out-of-order 12: ready=%d buffered=%d; want 0,1", len(ready), buffered)
}
ready, _, _ = b.Push(Frame{Seq: 11}) // 缺口补齐,11 和 12 一起放行
if len(ready) != 2 || ready[0].Seq != 11 || ready[1].Seq != 12 {
t.Fatalf("gap fill emitted out of order: %+v", ready)
}
}
本机实跑(Go 1.26,macOS arm64),同机相对量级,绝对数值不可移植:
$ go test ./internal/router/ -run '^$' -bench 'BenchmarkSeqWindowAccept' -benchmem -count=3
BenchmarkSeqWindowAccept-10 152227797 7.259 ns/op 0 B/op 0 allocs/op
BenchmarkSeqWindowAccept-10 166896390 7.367 ns/op 0 B/op 0 allocs/op
BenchmarkSeqWindowAccept-10 164834041 7.313 ns/op 0 B/op 0 allocs/op
| 指标 | 实测值 | 说明 |
|---|---|---|
| 单帧去重判定 | 约 7.3 ns/op | Accept 全部是整数位运算 |
| 每帧分配 | 0 B / 0 allocs | 窗口是值类型,不碰堆 |
结论很直接:去重的成本可以忽略。7 纳秒一帧,即便 1 万 QPS 也只吃掉 0.007% 的一个核。所以「要不要做去重」从来不是性能问题,而是正确性问题——不做,就会被重放和重复提交打穿。真正有成本的是乱序缓冲里的 map 操作和它的内存上限,那才是需要设 cap 和超时的地方。
本节小结
序号是一套三层机制,别混为一谈:seq 字段给每帧一个身份(客户端保证单调、重发复用),SeqWindow 回答「这条我见过没有」,ReorderBuffer 回答「现在能不能处理它」。第一层是协议约定,第二层是去重,第三层是排序,各有各的边界——尤其是乱序缓冲不重传、只按连接排序,别指望它解决跨连接的一致性。
下一节把视角从「一条连接」拉到「一万条连接」:同一条消息要发给 N 个玩家时,怎么发才不浪费。
阅读导航:上一节:8.1 路由表与请求-响应-推送 · 下一节:8.3 广播与扇出 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。