《MMO 服务器开发》13.2 服务器回滚与和解
预测让客户端先走一步,但权威永远在服务端:一旦服务端的答案和本地不一致,客户端必须回滚到权威状态、把未确认的输入重放一遍。本节把 13.1 的输入缓冲接到和解逻辑上,讲清裁剪-复位-重放三步、用一条测试给回滚的代价定上界(重放条数只跟 RTT 有关),并给出和解误差阈值的取法与「小误差平滑、大误差瞬移」的判据。
tag
预测让客户端先走一步,但权威永远在服务端:一旦服务端的答案和本地不一致,客户端必须回滚到权威状态、把未确认的输入重放一遍。本节把 13.1 的输入缓冲接到和解逻辑上,讲清裁剪-复位-重放三步、用一条测试给回滚的代价定上界(重放条数只跟 RTT 有关),并给出和解误差阈值的取法与「小误差平滑、大误差瞬移」的判据。
客户端先走一步能掩盖一个 RTT 的延迟,代价是服务端必须记住每个玩家「已经确认到哪一条输入」。本节给 aegis 的 predict 包写一个真实的输入缓冲:客户端给每次输入编号、本地立刻前进、把未确认的输入排进重放队列,服务端回一个确认号。最后用固定随机种子跑 500 步确定性回放,验证「服务端同意时客户端预测与权威状态逐字节相等」。
发布流程的最后一环是「坏版本由谁第一时间发现」。本节把 TaskHub 的 SLO 落成会响的告警:用本机真算的月度错误预算 43.2 分钟与 14x/4x/1.5x/0.8x 四档燃烧率讲清多窗口告警怎么既不漏报也不吵人,再给出值班手册、升级路径与不追责复盘的落地模板。
新版本一次全量上线是拿全部用户做赌注。本节把 TaskHub 的发布拆成灰度、蓝绿、回滚三条路径:用一段可运行的 Go 反向代理真跑「按权重分流」并给出实测的 9.67% 灰度占比,讲清健康门禁怎么驱动自动回滚,以及为什么「能回滚代码」不等于「能回滚数据」。
把 TaskHub 的「提交即验证、合并即产出镜像」落成一条分阶段流水线:从 lint、build、race 测试到 govulncheck 供应链门禁与镜像构建,每一道关卡的命令都在本机真跑一遍并贴出真实输出;再给出 GitHub Actions 的完整 YAML 与缓存策略,并说明为何流水线门禁必须能「让构建失败」。
一个副本里 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 测试证明实体字段一个锁都不需要,并说清句柄、注册表与路由各自的边界。
给 TaskAPI 做规范的健康检查与指标端点:区分 liveness 与 readiness、用 atomic 计数与计时中间件暴露 Prometheus 文本格式的 /metrics、设计 /healthz 与 /readyz 的语义边界,并给出探针配置与安全暴露的取舍。
给 TaskAPI 挂上运行时观测能力:用 net/http/pprof 一行导入拿到 CPU、堆、goroutine profile,用 go tool pprof 定位热点函数,再用 expvar 暴露自增计数器与自定义指标,并用 runtime/pprof 在离线程序里抓 CPU profile。
给 TaskAPI 接入 log/slog 结构化日志:JSON 与 Text 两种 handler、LevelVar 动态调级、With 携带公共字段、LogValuer 脱敏、自定义 handler 从 context 注入 request_id,并用 ReplaceAttr 裁剪时间格式,最后把 main 与 httpapi 的日志出口统一起来。
世界时钟把不稳定的墙钟时间折算成固定步长的逻辑时间,让「第 N 帧发生了什么」完全确定、可重放。本节从零实现一个逻辑帧时钟:固定步长与累加器、单帧步数上限、追帧 / 丢弃 / 降频三种超支策略,以及时间缩放;并用确定性测试演示「死亡螺旋」是怎么形成的、丢弃策略又是怎么把它掐断的。
时间轮用「一个数组 + 一个游标」把所有定时器塞进固定数量的槽位,把挂载从 O(log n) 压到 O(1),代价是精度退化到一格。本节从零写一个支持任意时长的哈希时间轮:槽位、cursor、级联搬迁、Tick 推进,以及为什么回调必须在锁外执行;再用确定性测试验证跨圈的定时器仍能准时触发。
心跳超时、技能冷却、Buff 到期是三类时间尺度完全不同的定时任务,却常被同一套「一个任务一个 time.Timer」的写法一锅端。本节先厘清三种尺度,再给 aegis 写两个真正省掉定时器的结构:惰性判定的冷却表与单节拍扫描的心跳看门狗,并用本机基准对比每定时器一个堆节点与共享扫描的成本。