《MMO 服务器开发》8.2 消息序号、去重与顺序保证

TCP 只保证字节有序,不保证你的业务命令有序。本节给每条连接加一个 32 位单调序号,用 TCP 同款的滑窗位图做去重,再写一个乱序缓冲把先到的高序号暂存到缺口补齐,并说清顺序保证的三条边界,最后量化每帧去重的成本——本机实测约 7 纳秒、零分配。

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) 是有意的:调用方一次拿到「现在能处理哪些帧」「还压着几帧」「有没有撑爆」。第三个返回值最重要——它是一条必须被处理的信号,不是可忽略的日志。

这里必须把边界讲清楚,否则乱序缓冲会被当成万能药:

  1. 它不重传。 缓冲区只是暂存,不会主动向对端要那条缺失的帧。如果缺口永远补不上(客户端那帧真丢了),缓冲会一直压着后续帧。所以必须有超时:超过 N 毫秒仍未补齐,就丢弃缓冲并通知上层重新同步(发一次全量快照,而不是傻等)。
  2. 它只在有真实乱序来源时才需要。 单线程读循环 + TCP 的场景,帧本来就是有序的,加一个 ReorderBuffer 只是徒增一次 map 操作。它真正的用武之地是第 5 章的 KCP 通道、以及把帧分发到 worker 池之后需要「按连接串行化」的场合。
  3. 顺序保证的粒度是「每条连接」,不是「每个实体」。 两个客户端分别操作同一只怪物,它们各自的连接内有序,但跨连接仍然要由业务层(例如 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/opAccept 全部是整数位运算
每帧分配0 B / 0 allocs窗口是值类型,不碰堆

结论很直接:去重的成本可以忽略。7 纳秒一帧,即便 1 万 QPS 也只吃掉 0.007% 的一个核。所以「要不要做去重」从来不是性能问题,而是正确性问题——不做,就会被重放和重复提交打穿。真正有成本的是乱序缓冲里的 map 操作和它的内存上限,那才是需要设 cap 和超时的地方。

本节小结

序号是一套三层机制,别混为一谈:seq 字段给每帧一个身份(客户端保证单调、重发复用),SeqWindow 回答「这条我见过没有」,ReorderBuffer 回答「现在能不能处理它」。第一层是协议约定,第二层是去重,第三层是排序,各有各的边界——尤其是乱序缓冲不重传、只按连接排序,别指望它解决跨连接的一致性。

下一节把视角从「一条连接」拉到「一万条连接」:同一条消息要发给 N 个玩家时,怎么发才不浪费。

阅读导航:上一节:8.1 路由表与请求-响应-推送 · 下一节:8.3 广播与扇出 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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