《MMO 服务器开发》13.1 输入缓冲与本地预测
客户端先走一步能掩盖一个 RTT 的延迟,代价是服务端必须记住每个玩家「已经确认到哪一条输入」。本节给 aegis 的 predict 包写一个真实的输入缓冲:客户端给每次输入编号、本地立刻前进、把未确认的输入排进重放队列,服务端回一个确认号。最后用固定随机种子跑 500 步确定性回放,验证「服务端同意时客户端预测与权威状态逐字节相等」。
category
客户端先走一步能掩盖一个 RTT 的延迟,代价是服务端必须记住每个玩家「已经确认到哪一条输入」。本节给 aegis 的 predict 包写一个真实的输入缓冲:客户端给每次输入编号、本地立刻前进、把未确认的输入排进重放队列,服务端回一个确认号。最后用固定随机种子跑 500 步确定性回放,验证「服务端同意时客户端预测与权威状态逐字节相等」。
一个副本里 100 万实体,每个客户端真正看得见的只有周围几百个——兴趣过滤把每帧的增量裁到只剩这一小块。本节用统一网格从零实现 AOI,并按基准三纪律(进程隔离、预热到稳定、工作集超过缓存)实测三种半径下的每客户端字节每秒与扇出放大,数字标明不可移植。
全量快照每帧都要把整个场景编码一遍,增量只发变了的字段——但增量一旦丢包就会和基线错位。本节从零写字段级脏标记与基线窗口,用同一段变更序列实测全量 691 KB 对增量 2.7 KB 的 254 倍差距,并给出基线失配后回退全量的恢复规则与触发条件。
同一个《晨曦大陆》,RTS 选帧同步、MMO 选状态同步,差别不在客户端而在服务端要做什么。本节用一份确定性的 World 分别实现 LockstepServer 与 StateSyncServer,真跑 200 帧证明前者一次 Step 都不用跑、后者每帧都要跑全量模拟,并给出两条路线各自的字节开销与失效条件。
「只投递一次」在传输层根本做不到,能做的是「处理一次」。本节把 11.1 的窗口和 11.2 的指纹拼起来,加上持久唯一约束与发奖状态机,给出 at-least-once 投递下不重复发奖的完整组合,并专门处理「窗口已过期、请求却真的重发了」这个边界。
上一节的窗口搭好了,但「拿什么当键」还没解决。本节说清传输身份(命令号加会话序号)为什么不可靠,给 aegis 写一套业务指纹:只对定义意图的字段做哈希,把序号、时间戳、连接号排除在外,并算清 64 位指纹的碰撞概率与对抗性边界,附一张字段进出的清单。
客户端重试、网关超时重发、断线重连补发,都会让同一条命令在服务端出现两次。本节给 aegis 从零写一个滑动幂等窗口:时间桶怎么切、内存上界为什么是 rate×ttl、窗口滑出之后重放还安不安全,并用真跑的 L1 测试把这几条边界钉死,附本机实测的内存与耗时。
同样是「一个实体一个邮箱」,Go 的 channel 邮箱和 Erlang/Skynet 那种无界队列 + 单线程消费的 Actor,语义与代价并不相同。本节用 Go 实现两种变体,在本机同条件实测吞吐、验证 selective receive 的差异,并说清 Erlang 与 Skynet 的语义各自换来了什么。
消息驱动把锁消掉了,却把问题推给了邮箱:满了到底怎么办?本节给 aegis 写一个有界邮箱,实现阻塞、丢弃、断开、降级四种拒绝策略,用 4 发送者 × 1000 条真跑证明同发送者到同接收者的 FIFO,并用一条 A→B→C 的链路实测背压如何一路顶回源头。
实体不该被多条 goroutine 同时改。本节把《晨曦大陆》的玩家实体改成消息驱动的 Actor:状态只由自己的循环推进,外界只拿到一个句柄;再用 8 个发送者、1.6 万条消息的 -race 测试证明实体字段一个锁都不需要,并说清句柄、注册表与路由各自的边界。
世界时钟把不稳定的墙钟时间折算成固定步长的逻辑时间,让「第 N 帧发生了什么」完全确定、可重放。本节从零实现一个逻辑帧时钟:固定步长与累加器、单帧步数上限、追帧 / 丢弃 / 降频三种超支策略,以及时间缩放;并用确定性测试演示「死亡螺旋」是怎么形成的、丢弃策略又是怎么把它掐断的。
时间轮用「一个数组 + 一个游标」把所有定时器塞进固定数量的槽位,把挂载从 O(log n) 压到 O(1),代价是精度退化到一格。本节从零写一个支持任意时长的哈希时间轮:槽位、cursor、级联搬迁、Tick 推进,以及为什么回调必须在锁外执行;再用确定性测试验证跨圈的定时器仍能准时触发。
心跳超时、技能冷却、Buff 到期是三类时间尺度完全不同的定时任务,却常被同一套「一个任务一个 time.Timer」的写法一锅端。本节先厘清三种尺度,再给 aegis 写两个真正省掉定时器的结构:惰性判定的冷却表与单节拍扫描的心跳看门狗,并用本机基准对比每定时器一个堆节点与共享扫描的成本。
把同一条消息发给一万个玩家,成本到底花在哪?本节给 aegis 写一个扇出器:证明「编一次发 N 次」比「编 N 次」快得多,用批量写把 K 次系统调用压成一次,并说清广播时锁该锁多细,最后给出本机实测的两组基准对比数字,解释为什么只能报相对量级。
TCP 只保证字节有序,不保证你的业务命令有序。本节给每条连接加一个 32 位单调序号,用 TCP 同款的滑窗位图做去重,再写一个乱序缓冲把先到的高序号暂存到缺口补齐,并说清顺序保证的三条边界,最后量化每帧去重的成本——本机实测约 7 纳秒、零分配。
一条帧到达服务端之后要去哪里?本节给 aegis 写一张真正的路由表:用命令号把请求、推送与通知分派到各自的 handler,定义可测试的 handler 上下文,让未知命令和 handler 报错都退化成错误通知而不是 panic,最后用一组 L1 测试真跑验证请求-响应与推送两条路径。
网关是玩家看到的第一层,也是最容易被塞进玩法逻辑的一层:一旦它开始解析 payload、缓存血量、计算伤害,就再也拆不掉了。本节给 aegis 写一个只做四件事的 Gateway——准入、鉴权前置、限流、按命令号路由——并用一个「路由函数拿不到 payload」的签名,把边界从纪律变成机械约束。
同账号第二次登录,该不该踢掉旧连接?踢了之后,旧连接迟到的断开事件又会不会把新会话一起删掉?本节给 aegis 写 Registrar,把「先摘旧、后关旧、再通知」的顺序钉死,用 go test -race 复现那个最经典的顶号竞态,并讲清「踢人」与「迁移」两种语义的区别。
一个账号、一条连接、一份会话,三者一旦不同步就会出顶号误删、幽灵在线这类经典事故。本节给 aegis 写一张连接与账号的双向索引表:单锁版本、64 分片版本,以及顶号时「先摘旧、后关旧」的原子替换顺序,全部用 go test -race 真跑验证,并给出本机并发的实测数字。
玩家掉线后必须能秒回游戏,但又不能凭一个账号名就免密登入。本节给 aegis 设计一张重连票据:用 HMAC-SHA256 签发、绑定账号与连接序号、带过期时间、且只能使用一次。我们用标准库把它从零写出来,并用测试逐一证明正常校验、签名被篡改、过期、以及同一票据二次使用这四种路径的行为。