《MMO 服务器开发》附录 D 常见错误与 FAQ

附录 D 把卷一各章真跑出来的坑收成一张速查表:帧头长度、载荷越界、varint 边界、KCP 重传、时间轮级联、邮箱背压、幂等窗口与帧同步 Digest 漂移,逐条给出根因、修法与出处;再回答十二个读者最常问的问题,并附一段把错误写法写成失败测试的 L1 对照代码。

附录 D 常见错误与 FAQ

卷一从头到尾在强调一件事:每一段代码都要有断言把它钉住,因为这一层的错误大多属于「写错了也能编译、也能跑,只是行为不对」的类型。这个附录把各章真跑时反复出现的坑收成一张表,再回答读者最可能问的十二个问题。

表里每一条都能在本卷正文里找到出处——「出自」一列给的是节号。FAQ 里的回答同样只引本卷结论,不引入正文没验证过的东西。

常见错误速查表

现象根因修法出自
解出的载荷比实际多 7 字节length 被当成载荷长度,忘了它统计 cmd+seq+flags+payloadpayloadLen = length - (HeaderSize-4),即 length = 7 + N3.2
声明 length = 0xFFFFFF00 的包打满内存按不可信的长度字段分配缓冲分配前先比 MaxPayload,超限返回 ErrPayloadTooLarge3.2、16.1
收到半帧就丢弃或错切假设一次 Read 拿到一整帧,未用 io.ReadFull 补齐读固定头与载荷全程走 io.ReadFull3.2
length 小于 7 时越界uint32 相减先下溢,转 int 成巨大值先判 length < HeaderSize-4,返回 ErrShortFrame16.1
客户端谎报字节块长度致进程崩溃GetBytes 拿到长度前缀直接 buf[pos:pos+n] 切片切片前先比 Remaining(),不足返回 ErrShortBuffer4.1
varint 边界算错、值被截断以为 32 位只占 4 字节LEB128 每字节 7 位,2^31 占 ceil(32/7)=5 字节4.1
全 0x80 的 varint 把解码器拖死没有移位上限移位数到 64 仍未终止即返回 ErrVarintOverflow4.1
KCP 重传迟迟不收敛RTO 写死或估错Jacobson:srtt += (rtt-srtt)/8、`rttvar += (rtt-srtt
弱网下 P99 高只等 RTO 超时重传快速重传:fastack >= fastresend(默认 2)立即重传5.2
长定时器被提前触发place 用绝对格数取模用「剩余格数」ticks = 剩余时间/interval,扫到时再判一次是否到期(级联)9.2
回调里注册新定时器自死锁回调在锁内执行,sync.Mutex 不可重入先把到期任务摘到切片、Unlock,再在锁外执行回调9.2
背压失效、内存无限涨邮箱无界有界邮箱 + RejectBlock,在途上界 Σcap + 110.2
消息乱序以为「消息驱动」等于顺序无所谓同发送者到同接收者 FIFO:发送方顺序 Send + 邮箱 FIFO;跨发送者无全序10.2
幂等窗口内存说不清map 逐条扫过期项时间桶:活键上界 r × ttl,整格 clear11.1
窗口滑出后重复请求被当新请求窗口只记得「最近」这是窗口定义;真正的「绝不重复」要靠持久化唯一约束11.1、11.3
帧同步 Digest 漂移float32 加法不满足结合律,且遍历 map 顺序随机按排序后的 ID 切片遍历;Step 先应用全部输入再积分12.1
和解后位置每次多走一格裁剪用 >=,留下了 ack 本身用 > 裁掉所有 Seq <= ack13.2
和解完全失效、预测一直不准先重放再复位,结果被覆盖复位必须在重放之前13.2
平滑层永远收不到校正量before 在复位之后取,err 恒为 0before 取旧的预测位置13.2
服务端为玩家追帧致瞬移积压输入一次性全算完固定每 tick 的消费上限,积压排队13.2
客户端改 speed 加速服务端采信了客户端的 speed字段级信任契约:Derived 字段一出现即违规15.1
千连接压测随机 RSTmacOS kern.ipc.somaxconn = 128错峰连接(每连一条 sleep 一下)6.2、16.2
1000 个 1ms ticker 吃掉一个核每连接一个看门狗 ticker共享节拍或改走时间轮6.2、9.2

按症状定位

排障通常是从现象反推根因。下面这张 ASCII 是卷一的「症状 → 该查哪一节」入口,按现象从上往下找:

收到畸形包 → 解析器崩 / OOM
   ├─ 越界 panic        → len(buf) < HeaderSize 是否在切片之前(3.2 / 16.1)
   ├─ 内存暴涨          → 是否按 length 声明分配、有没有 MaxPayload(3.2)
   └─ 解出长度对不上    → length 有没有减掉 7 字节固定开销(3.2)

连接层异常
   ├─ 半开连接堆积      → 心跳与读超时(6.2)
   ├─ 千连接随机 RST    → somaxconn = 128,错峰连接(2.2 / 16.2)
   └─ 连接数涨、吞吐降  → 是排队不是协议栈(2.2,Little 定律)

弱网下体验差
   ├─ 一个丢包卡住后面  → 队头阻塞,考虑 KCP(5.1)
   ├─ P99 高、P50 正常  → 快速重传是否开启(5.2)
   └─ RTO 停在初值      → RTT 样本与 Jacobson 估计(5.2)

逻辑不一致
   ├─ 帧同步 Digest 漂移 → float32 结合律 / map 遍历顺序(12.1)
   ├─ 预测一直不准       → 和解三步的顺序(13.2)
   └─ 同一命令执行两次   → 幂等窗口 + 持久化唯一约束(11.1 / 11.3)

这张图覆盖了卷一绝大多数线上问题。它的用法很简单:先定位到「哪一类」,再翻回对应章节读那一节的可运行产物——每一节的产物都带一条断言,把当时的结论固定住了。

FAQ

Q1 为什么不用 Protobuf / FlatBuffers,而要手写编解码?

因为热路径上真正省下的是「字段名」和「反射」,不是框架本身。4.3 实测同一条移动通知:encoding/json 95 字节、约 296ns 编码、解码 5 次分配;手写 varint 30 字节、约 45ns 编码、解码零分配。Protobuf 用的是「字段号 + wire type」,本质和 4.1 手写的 varint 是同一套思想——4.1 的 PutVarint 就是它编码方式的简化版。所以问题不是「要不要用 Protobuf」,而是「这条消息是不是高频、结构是否固定」:高频固定结构的手写最划算,配置、调试、第三方对接用 JSON 换可读性。详见 4.1 与 4.3 。

Q2 Go 已经有了 netpoll,还需要自己写 Reactor 吗?

要分清两个层次。2.1 证明了 Go 的 net 包就是 runtime 内置的 Reactor:conn.Read 遇到 EAGAIN 走 gopark 挂起 goroutine、把 M 让出去,netpoller 用 kqueue/epoll 报告可读后再唤醒。所以在 Go 里写业务不需要手写 Reactor——同步写法即可。但 2.1 手写那个事件循环的价值在于理解它的三个不变式,其中第三条「回调必须短」是纪律而非结构:任何一次同步查库写进回调,网关 P99 会从 2ms 变 200ms。你自己起的事件循环、或别的语言栈里的 EventLoop,都逃不掉这条。详见 2.1 。

Q3 KCP 与 QUIC 怎么选?

本卷的 KCP 是教学版,不是生产件。5.2 明确砍掉了拥塞控制与会话协商,conv 由上层指定,Recv 按整条消息交付——它保留的是实时对战最需要的那部分语义:ARQ、滑动窗口、快速重传、RTO 估计。选型的判据在 5.1:TCP 有三个硬伤(队头阻塞、重传不区分重要性、拥塞控制不可自定义),要不要换取决于你的消息是否「过期即废」。QUIC 把 TLS、多路复用、连接迁移都做进了协议,适合需要加密与多流的场景;KCP 的优势是可裁剪、可针对弱网调参。站内 TCP / WebSocket / QUIC 的选型文章 讲架构取舍,本卷只负责从零实现与量化。详见 5.1 与 5.2 。

Q4 服务端权威会不会让手感变差?

不会——前提是配上预测与和解。12.1 把两条路线讲清楚了:状态同步的服务端是「唯一跑模拟的权威」,客户端只发意图。为了让操作不跟手,13.1 的客户端预测先走一步,13.2 的和解在服务端答案回来时把本地状态拉回权威基线。关键数字是:大多数时候和解的结果和预测一致(err = 0),玩家感觉不到它的存在;它只在服务端真的不同意时才产生可见修正。手感来自预测,正确性来自权威,两者不冲突。详见 12.1 与 13.2 。

Q5 somaxconn = 128 怎么绕?

绕不过内核参数,只能绕开它的触发条件。macOS 的 kern.ipc.somaxconn 默认 128,是 accept 队列长度——一次性发起上千个 connect,超出队列的 SYN 会被内核直接 RST,表现为随机的 connection reset。做法是错峰连接:2.2 的 2000 连接压测按 64 条一批、批间隔 15ms 拨号;6.2 的 1000 空连接测试每连一条 time.Sleep(time.Millisecond),约 5 秒才建完。要记住 fd 不是瓶颈(本机 ulimit -n = 1048576、maxfilesperproc = 122880),真正卡人的是这个 accept 队列。详见 2.2 与 6.2 。

Q6 本机只有 Java 8,怎么跑 Java 对照?

Java 代码不跑在宿主机上,跑在 Docker 里。2.3 的对照用镜像 eclipse-temurin:21-jdk(JDK 21,虚拟线程、record、模式匹配 switch 都可用),绝不用本机 javac 冒充验证。还有一个必须记住的坑:Colima 只把 $HOME 挂进虚拟机,/tmp 挂不进去,所以 Java 源码要落到 $HOME/.aegis-java/ 下再 docker run -v。顺带说明,本卷对照 Netty 时用的是 JDK 自带的 java.nio.ByteBuffer(slice() / duplicate() 就是零拷贝视图),因为本机没有 Netty jar,也不去下。详见 2.3 与 4.2 。

Q7 数字为什么不能跨机器比较?

因为本卷所有基准都是同机相对量级,绝对数值不可移植。两个直接原因:其一,2.2 的 2000 连接测量期间,这台 10 核机器的 1 分钟负载均值是 57–66,所有数字都是在超载状态下取得的,同一台机器重复跑,基线吞吐在 4159–6685 往返/秒之间波动。其二,2.3 的 Go 组跑在 10 核宿主机、Java 组跑在 6 核容器,只能横着比量级,不能竖着比快慢。可以带走的永远是方向:延迟由在途请求数决定、JSON 比手写二进制慢一个数量级、时间轮挂载与轮内任务数无关。详见 2.2 与 4.3 。

Q8 时间轮只比 time.AfterFunc 快约 1.5 倍,还有必要自己写吗?

有,但收益不在速度。9.2 实测时间轮挂载约 77ns,time.AfterFunc 约 120ns——Go 运行时内部本来就是按 P 分片的 64 槽时间轮,所以 AfterFunc 已经很快。自写的真正价值是确定性与批量控制:回调严格发生在你主动推进的那一拍里、顺序由你决定,可以整表快照、可以按实体批量取消(玩家下线时把他的定时器一次摘掉)。第 10 章的 Actor 邮箱、第 12 章的帧同步、第 13 章的服务器回滚都依赖「同一 tick 里发生了什么」是可重放的,而 time.AfterFunc 的回调在任意运行时线程上触发,天然破坏这个模型。详见 9.2 。

Q9 幂等窗口能保证「绝不重复发奖」吗?

不能,而且这是故意的设计边界。11.1 的窗口只记得「最近一段时间内处理过哪些请求」,TestWindowForgetsAfterTTL 专门验证「窗口滑出后重放会被当成新请求」。它能挡住短期重试(客户端超时重发、网关重试),但挡不住断线半小时后的补发、隔一会儿的重放、或窗口因重启而清空。要真正做到「绝不重复发奖」,窗口之后必须再压一层持久化的唯一约束——这是 11.3 的主题。窗口的价值不是「正确」,而是把绝大多数重试挡在昂贵的持久层之前。详见 11.1 。

Q10 邮箱该多大,满了该丢还是阻塞?

邮箱必须有界,无界队列只是把内存问题推迟到更晚爆发。满了怎么办取决于消息类型,而不是邮箱:10.2 给了四种策略——RejectBlock(不丢,压力回传)、RejectDrop(可丢,靠 Drops() 报警)、RejectDisconnect(跟不上就踢)、RejectDegrade(关键命令等位、非关键直接丢)。位移消息丢一条、下一条补上位置,玩家几乎无感;而 CmdEnterWorldReq 丢一条就是进不去游戏。所以四种策略往往同时存在于一个进程里。要背压就选阻塞:只有阻塞式 Send 才会让压力沿链路回传,在途上界是 Σcap + 1。详见 10.2 。

Q11 帧同步和状态同步,MMO 到底选哪个?

MMO 选状态同步。判据不是「哪个技术更好」,而是玩家数与防作弊需求:12.1 的实测里,帧同步的服务端一次 Step 都不跑(是时钟加转发器),但它把正确性完全托付给客户端的确定性——float32 加法不满足结合律,(2^24+1)+(-2^24) = 0 而 2^24+(1-2^24) = 1,任何一处不确定的累加都是定时炸弹;而且一个玩家卡住,整局都卡住。状态同步的服务端每帧都跑全场,换来客户端无从作弊、迟到者随时可入。MMO 一个副本几百个实体、绝不能让客户端决定自己站在哪,所以落在后者。帧同步适合人数少、可等待的 RTS / MOBA。详见 12.1 。

Q12 fuzz 出崩溃后,为什么要连 testdata 文件一起提交?

因为那条最小化输入是永久回归用例。16.1 的 fuzzer 在 5 秒内跑了约 150 万次变异,新增 interesting 为 0、崩溃为 0,说明三条规则(不 panic、不按声明分配、要么给消耗字节数要么给错误)封住了越界与超量分配。一旦哪天它撞出一个崩溃,Go 会把最小化后的输入写进 internal/proto/testdata/fuzz/——那条文件应当连修复一起提交,让同一个畸形包以后再也不会悄悄回来,而不是修完就删。详见 16.1 。

三个常见误读

速查表和 FAQ 之外,还有三句话最容易被当成真理传播,值得单独纠一下。

第一句是「Go 的 goroutine 很轻,随便开」。轻的是栈——一个空闲连接 2KB 起步,2000 条合计约 4MB,确实可以忽略;贵的是每次 fd 就绪时的那次 gopark / goready,以及并发拉高后的排队。2.2 实测吞吐从 8 连接的 4159 往返/秒掉到 2000 连接的 1008 往返/秒,说明该控制的是并发请求数,不是连接数。

第二句是「上了 UDP 就万事大吉」。多条独立通道确实避开了队头阻塞,但同时也丢掉了可靠性与有序性——谁重传、谁去重、谁保证顺序,全要自己写。5.2 的 KCP 就是从零接管这三件事的最小实现,它有意去掉拥塞控制与会话协商,所以不能直接上生产。

第三句是「把所有逻辑搬上服务端就没外挂了」。15.1 说得很清楚:这样既贵又慢,而且并不能消灭外挂,只会把它推向更隐蔽的面——伪造合法输入干扰 AOI、卡在冷却判定边界疯狂触发。权威边界是「把有限的裁决预算花在影响别人和影响数值的地方」,不是「把一切搬上服务端」。

把错误写成会失败的测试

光看表容易忘。真正把错误钉死的办法,是把它写成一条「错误写法会失败」的测试——这也是本卷每一节的做法。下面这段是 L1,go test ./... 可以直接跑:它把帧头长度、谎报长度、varint 边界三类错误各自配一份「正确写法」与「错误写法」,断言两者结果不同,从而证明缺陷确实存在。

// file: faq_test.go
// tier: L1
// verify: go test ./...
package appendix

import (
	"encoding/binary"
	"io"
	"testing"
)

// 卷一共享约定:固定头 11 字节,length 统计它自身之后的字节数。
const (
	HeaderSize = 11             // length(4) + cmd(2) + seq(4) + flags(1)
	overhead   = HeaderSize - 4 // = 7,length 必须覆盖的最小字节数
)

// encode 按正确约定写帧:length = 7 + N。
func encode(payload []byte) []byte {
	buf := make([]byte, HeaderSize+len(payload))
	binary.BigEndian.PutUint32(buf[0:4], uint32(overhead+len(payload)))
	copy(buf[HeaderSize:], payload)
	return buf
}

// payloadLenCorrect 是 3.2 / 16.1 的写法:先减掉固定开销 7。
func payloadLenCorrect(length uint32) int { return int(length) - overhead }

// payloadLenWrong 是经典错误:把 length 直接当载荷长度,每个帧多算 7 字节。
func payloadLenWrong(length uint32) int { return int(length) }

// putVarint 写 LEB128,返回占用的字节数。
func putVarint(dst []byte, v uint64) int {
	n := 0
	for v >= 0x80 {
		dst[n] = byte(v) | 0x80
		v >>= 7
		n++
	}
	dst[n] = byte(v)
	return n + 1
}

// getBytesGuarded 是 4.1 的正确写法:先比 Remaining() 再切片,越界返回错误。
func getBytesGuarded(buf []byte, pos int) ([]byte, int, error) {
	if pos+4 > len(buf) {
		return nil, pos, io.ErrUnexpectedEOF
	}
	n := int(binary.BigEndian.Uint32(buf[pos:]))
	pos += 4
	if n > len(buf)-pos {
		return nil, pos, io.ErrUnexpectedEOF
	}
	out := make([]byte, n)
	copy(out, buf[pos:pos+n])
	return out, pos + n, nil
}

// getBytesNaive 是错误写法:拿到长度前缀直接切片,不先比 Remaining()。
func getBytesNaive(buf []byte, pos int) []byte {
	n := int(binary.BigEndian.Uint32(buf[pos:]))
	return buf[pos+4 : pos+4+n]
}

// TestFrameLengthIs7PlusN 把「length = 7 + N」钉死:错误写法会得到 N+7。
func TestFrameLengthIs7PlusN(t *testing.T) {
	payload := []byte("dawn-realm")
	got := binary.BigEndian.Uint32(encode(payload)[0:4])
	if n := payloadLenCorrect(got); n != len(payload) {
		t.Fatalf("正确写法解出 %d, want %d", n, len(payload))
	}
	if n := payloadLenWrong(got); n != len(payload)+overhead {
		t.Fatalf("错误写法应多算 7 字节:got %d", n)
	}
}

// TestLiarLengthMustErrorNotPanic:长度前缀谎报 100、实际只有 3 字节。
func TestLiarLengthMustErrorNotPanic(t *testing.T) {
	liar := []byte{0, 0, 0, 100, 'a', 'b', 'c'}
	if _, _, err := getBytesGuarded(liar, 0); err == nil {
		t.Fatal("受保护写法应返回错误,而非构造越界切片")
	}
	defer func() {
		if recover() == nil {
			t.Fatal("裸切片写法应对谎报长度 panic,测试未复现出缺陷")
		}
	}()
	_ = getBytesNaive(liar, 0)
}

// TestVarintBoundaryTakesFiveBytes:2^31 占 5 字节而非 4。
func TestVarintBoundaryTakesFiveBytes(t *testing.T) {
	for _, c := range []struct {
		v     uint64
		bytes int
	}{{0, 1}, {127, 1}, {128, 2}, {1 << 31, 5}} {
		buf := make([]byte, 10)
		if got := putVarint(buf, c.v); got != c.bytes {
			t.Errorf("PutVarint(%d) 占 %d 字节,期望 %d", c.v, got, c.bytes)
		}
	}
}

本机实跑:ok aegis,3 个测试全绿。TestLiarLengthMustErrorNotPanic 最有代表性——它一边断言受保护写法返回错误,一边用 recover() 确认裸切片写法真的会 panic,等于把「为什么必须先比 Remaining()」从一句注释变成了一条可执行的证据。这正是本卷反复用的手法:不靠记忆,靠断言。

一句话收束

卷一所有错误都能归到同一句话上:把不可信输入当成了可信,或者把「写错了也能跑」当成了「写对了」。 前者靠字段级信任契约(15.1)与边界检查封住,后者靠每段代码一条断言封住。带着这两条读第 4 章到第 16 章,表里的坑大多能在踩到之前被认出来。

阅读上一节:附录 C 实测数据汇总

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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