附录 D 常见错误与 FAQ
卷一从头到尾在强调一件事:每一段代码都要有断言把它钉住,因为这一层的错误大多属于「写错了也能编译、也能跑,只是行为不对」的类型。这个附录把各章真跑时反复出现的坑收成一张表,再回答读者最可能问的十二个问题。
表里每一条都能在本卷正文里找到出处——「出自」一列给的是节号。FAQ 里的回答同样只引本卷结论,不引入正文没验证过的东西。
常见错误速查表
| 现象 | 根因 | 修法 | 出自 |
|---|---|---|---|
| 解出的载荷比实际多 7 字节 | length 被当成载荷长度,忘了它统计 cmd+seq+flags+payload | payloadLen = length - (HeaderSize-4),即 length = 7 + N | 3.2 |
声明 length = 0xFFFFFF00 的包打满内存 | 按不可信的长度字段分配缓冲 | 分配前先比 MaxPayload,超限返回 ErrPayloadTooLarge | 3.2、16.1 |
| 收到半帧就丢弃或错切 | 假设一次 Read 拿到一整帧,未用 io.ReadFull 补齐 | 读固定头与载荷全程走 io.ReadFull | 3.2 |
length 小于 7 时越界 | uint32 相减先下溢,转 int 成巨大值 | 先判 length < HeaderSize-4,返回 ErrShortFrame | 16.1 |
| 客户端谎报字节块长度致进程崩溃 | GetBytes 拿到长度前缀直接 buf[pos:pos+n] 切片 | 切片前先比 Remaining(),不足返回 ErrShortBuffer | 4.1 |
| varint 边界算错、值被截断 | 以为 32 位只占 4 字节 | LEB128 每字节 7 位,2^31 占 ceil(32/7)=5 字节 | 4.1 |
全 0x80 的 varint 把解码器拖死 | 没有移位上限 | 移位数到 64 仍未终止即返回 ErrVarintOverflow | 4.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 + 1 | 10.2 |
| 消息乱序 | 以为「消息驱动」等于顺序无所谓 | 同发送者到同接收者 FIFO:发送方顺序 Send + 邮箱 FIFO;跨发送者无全序 | 10.2 |
| 幂等窗口内存说不清 | map 逐条扫过期项 | 时间桶:活键上界 r × ttl,整格 clear | 11.1 |
| 窗口滑出后重复请求被当新请求 | 窗口只记得「最近」 | 这是窗口定义;真正的「绝不重复」要靠持久化唯一约束 | 11.1、11.3 |
| 帧同步 Digest 漂移 | float32 加法不满足结合律,且遍历 map 顺序随机 | 按排序后的 ID 切片遍历;Step 先应用全部输入再积分 | 12.1 |
| 和解后位置每次多走一格 | 裁剪用 >=,留下了 ack 本身 | 用 > 裁掉所有 Seq <= ack | 13.2 |
| 和解完全失效、预测一直不准 | 先重放再复位,结果被覆盖 | 复位必须在重放之前 | 13.2 |
| 平滑层永远收不到校正量 | before 在复位之后取,err 恒为 0 | before 取旧的预测位置 | 13.2 |
| 服务端为玩家追帧致瞬移 | 积压输入一次性全算完 | 固定每 tick 的消费上限,积压排队 | 13.2 |
客户端改 speed 加速 | 服务端采信了客户端的 speed | 字段级信任契约:Derived 字段一出现即违规 | 15.1 |
| 千连接压测随机 RST | macOS 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 实测数据汇总
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。