面向「只做过浏览器网页项目发布、第一次接触客户端/服务端软件」的学习者。所有操作都拆成单条命令并逐条解释,你可以完全自主地跑完每一个实验。
106.75.30.92(2 核 / 7.5G 内存 / 42G 可用磁盘,已跑 Jenkins + k8s 控制平面)。这一章把后面会用到的所有名词讲清楚。如果你完全没接触过客户端/服务端软件,这一章是地基——它解释的都是「为什么这类软件天生比网页难发布」。
客户端发一个请求,服务端返回结果,连接就关掉。浏览器访问网页、你现在的若依微服务、REST API 都是这种。
关键性质:服务端完全不需要「记住」你。任意一个实例都能处理你的下一个请求,所以可以随便增删实例、随便重启——这就是「无状态」的物理基础。
客户端连上后一直保持不断,服务端可以随时主动推消息给它。网络游戏、IM 聊天、协同编辑、远程桌面都是这种。
关键性质:服务端实例上寄存着会话状态(你是谁、你在哪个房间、你的角色在哪)。这个实例一死,上面所有玩家的连接就全断了,而且状态丢了。
这就是为什么游戏服务端不能像网页那样随便滚动更新——也是整个文档里最核心的一条差异。
| 对比项 | 短连接(网页/API) | 长连接(游戏/IM) |
|---|---|---|
| 连接生命周期 | 毫秒~秒级 | 分钟~小时,甚至整天 |
| 服务端内存态 | 无需保存 | 必须保存(会话、房间、实体) |
| 谁主动发消息 | 只能客户端问、服务端答 | 服务端可随时主动推送 |
| 重启实例的代价 | 下一次请求换个实例即可,用户无感 | 该实例上所有玩家掉线 |
| 能否水平扩容 | 直接加实例即可 | 需要「分线/分服」或状态外置,复杂得多 |
| 典型协议 | HTTP/1.1、HTTP/2、gRPC | WebSocket、TCP 裸协议、KCP、ENet、QUIC |
| 协议 | 底层 | 可靠性 | 延迟 | 典型用途 | 注意点 |
|---|---|---|---|---|---|
| TCP | TCP | 可靠、有序 | 较高(丢包会阻塞后续包) | 登录、支付、存档、IM 文本 | 「队头阻塞」:丢一个包,后面全等着,弱网下体感很差 |
| WebSocket | TCP | 可靠、有序 | 同 TCP | 网页端实时通信、H5 游戏 | 本质是「HTTP 升级后的 TCP」,穿过代理/防火墙友好 |
| HTTP 长轮询 | TCP | 可靠 | 差 | 老式实时方案、兼容性兜底 | 服务端压力大、延迟高,属于过渡方案 |
| gRPC | HTTP/2 | 可靠 | 中 | 服务端之间的内部调用 | 不是给玩家客户端用的;适合微服务互调 |
| UDP | UDP | 不可靠、无序 | 极低 | 实时对战(FPS/MOBA)的位置同步 | 丢包不管,宁可丢也不等——但业务层要自己处理丢包 |
| KCP | UDP | 可靠(重传算法激进) | 低 | 国内弱网手游 | 用带宽换延迟。国内网络环境下的常用选择 |
| ENet | UDP | 可靠+不可靠混合通道 | 低 | MOBA(英雄联盟用的就是它) | 可以同时开「可靠通道」和「快速通道」,各走各的 |
| rUDP | UDP | 可靠 | 低 | Nakama 的实时通信 | 「reliable UDP」的统称写法 |
| QUIC | UDP | 可靠、多路复用 | 低 | 新一代 HTTP/3、部分游戏 | 解决了 TCP 的队头阻塞,是趋势 |
协议一旦上线,老客户端就锁死在这个协议上了。你换了协议(比如 TCP 换 KCP),所有没更新的客户端立刻连不上。所以协议体系通常设计成「可切换」的——ET 框架就明确支持 TCP / KCP / ENet / WebSocket 运行时可切换且不断线,这样服务端换协议时老客户端仍能工作。
双方定期互发一个小包(比如每 30 秒),用来判断「对面还活着吗」。
为什么必须要有:TCP 连接在中间链路断掉(比如手机切 WiFi、NAT 超时)时,两端可能都不知道,会一直以为连着。心跳就是用来发现这种「假连接」的。
工程要点:心跳要有超时阈值 + 重试次数,超了就判定掉线并触发重连。没有心跳的长连接服务一定会积累一堆僵尸连接。
TCP 是「字节流」,它不保证你发一次、对面就收一次。你连发两个包,对面可能一次收到两个黏在一起;一个大包可能被拆成几次收到。
解决方案:定义帧结构,常见有三种——
· 固定长度头 + 长度字段(最常用)
· 特殊分隔符(如 \n)
· 定长包
这是写游戏服务端第一个要解决的问题,也是面试高频题。
连接断了之后,客户端要能自动重连,并且恢复到断线前的状态(比如重新进入原来的房间、恢复角色位置)。
为什么和发布有关:如果服务端只支持「重连后从零开始」,那么每次服务端滚动更新都会让玩家体验很差。成熟的做法是:服务端发布前主动通知客户端「我要重启了,请重连到别的实例」,客户端带着会话令牌重连——这就是会话迁移和摘流。
这个概念决定了服务端要存多少状态,从而决定了发布难度:
| 状态同步(State Sync) | 帧同步(Lockstep) | |
|---|---|---|
| 做法 | 服务端算出结果,把「谁在哪、血量多少」推给客户端 | 服务端只转发玩家的操作指令,每个客户端各自算 |
| 服务端状态量 | 大(要保存整个世界状态) | 小(只保存指令序列) |
| 典型游戏 | MMO、SLG、卡牌 | RTS、MOBA、格斗 |
| 发布影响 | 服务端重启会丢失世界状态,需要额外做快照/持久化 | 一局内的指令序列丢了就得重开这局,所以通常要求整局结束后才允许更新该实例 |
| 反外挂 | 强(逻辑在服务端) | 弱(逻辑在客户端,需要额外校验) |
网页项目通常只有「网关 + 业务服务」。游戏类项目会分得更细,因为每类工作的特征完全不同:
| 角色 | 职责 | 有无状态 | 重启代价 | 发布策略 |
|---|---|---|---|---|
| 网关服 / 接入服 (Gateway / Gate) | 与客户端保持长连接,做协议加解密、限流、消息转发 | 半状态(只存连接) | 玩家掉线 | 摘流 + 引导重连 |
| 登录服 / 认证服 (Login / Auth) | 验证账号、发令牌、分配区服 | 无状态 | 几乎无感 | 蓝绿 / 金丝雀,随便发 |
| 逻辑服 / 游戏服 (Game / Logic) | 跑游戏核心逻辑,保存房间、战斗、玩家实体 | 强状态 | 该服所有玩家掉线 | 分服灰度,一服一服地升 |
| DB 代理服 (DB Proxy) | 统一收口数据库读写,做缓存与批量写 | 无状态 | 无感 | 滚动更新 |
| 位置服 / 路由服 (Location / Router) | 记录「哪个实体在哪个进程」,实现跨服消息投递 | 轻状态(可重建) | 短暂不可用 | 先升,或在低峰期升 |
| 匹配服 (Matchmaking) | 把玩家组成一局 | 轻状态 | 无感 | 滚动更新 |
| 聊天服 | 世界/公会/私聊 | 轻状态 | 无感 | 滚动更新 |
看出来了吗?「无状态的那部分可以随便发,有状态的那部分才需要小心发」。所以成熟架构会刻意把逻辑从有状态服务里抽出来。
ET 框架的做法更彻底:它用 Location Server 统一记录实体位置,任何服务器只要知道实体 ID 就能给它发消息,不用关心它在哪个进程、哪台机器上。这样逻辑服就可以相对自由地迁移和重启。
把每个「有状态的对象」(一个玩家、一个房间、一个公会)当成一个 Actor。Actor 之间不共享内存,只能通过发消息通信。
好处:天然无锁、天然可分布。因为不共享内存,所以这个 Actor 在哪个进程里都行,理论上可以像搬家一样迁移到别的机器上——这让「重启某个实例」这件事的影响面变得可控。
一种比线程更轻的并发单位。一个线程可以跑成千上万个 Fiber,切换代价极小。
在游戏服务端的用途:让每个玩家的逻辑看起来像「顺序执行」的代码(写起来简单),实际上是并发跑的(性能好)。ET 的 Fiber 还支持父子关系,父 Fiber 销毁时子 Fiber 一起清理——这解决了「玩家掉线后残留任务」的问题。
「这个连接属于哪个玩家、已经登录了没有、当前在哪个房间」这组信息的集合。它存在于网关服和逻辑服的内存里。
会话迁移要解决的问题:网关服要升级重启,但上面挂着 5000 个玩家的连接。
做法:新网关实例起来 → 老实例通知客户端「请重连到新地址」→ 客户端带上会话令牌重连 → 新实例从数据库/缓存里恢复会话。
效果:玩家感受到的是「一瞬间的卡顿」,而不是「掉线回登录页」。
| 概念 | 含义 | 发布意义 |
|---|---|---|
| 区 / 服(Zone / Server) | 一套独立运行的逻辑服 + 一份独立的存档数据。玩家登录时被分配到某个区 | 这是游戏服灰度的天然单位——可以「今天升 1~3 区,明天升 4~6 区」 |
| 开新服 | 上一个全新的区 | 本质是蓝绿的一种变体:新版本在新服跑,老服不动 |
| 合服 | 把几个冷清的老区合并成一个 | 这是最危险的一类发布,涉及大量数据迁移、ID 冲突、排行榜重算。必须停机 + 完整演练 |
| 跨服 | 不同区的玩家能一起玩(跨服战场) | 需要服务端之间协议兼容,等于把兼容窗口从「客户端 ↔ 服务端」扩展到「服务端 ↔ 服务端」 |
| Deployment | StatefulSet | |
|---|---|---|
| Pod 名字 | 随机后缀(app-7d9f-abc12) | 固定序号(app-0、app-1) |
| 存储 | 共享或不用,Pod 之间无差别 | 每个 Pod 绑定自己的 PVC |
| 更新顺序 | 并行滚动,随便杀 | 默认从大到小逐个更新,等前一个 Ready 才动下一个 |
| 适用 | 无状态服务(你的若依 7 个微服务) | 有状态服务(数据库、消息队列、分片游戏服) |
记住这个结论:你的若依项目用 Deployment 就够了,因为它是无状态的。但一旦开始做游戏服务端,你可能需要 StatefulSet,而「更新顺序受控」正是你想要的——只是你必须自己实现排空逻辑。
一句话:「最多允许多少个 Pod 同时挂掉」的约束。
为什么需要:K8s 节点维护(kubectl drain)或集群自动缩容时会「驱逐」Pod。如果没有 PDB,它可能把你某个服务的 3 个副本一次性全驱逐,服务直接归零。
例子:minAvailable: 2 + 3 个副本 → K8s 会保证任何时候至少有 2 个活着,驱逐会排队进行。
你双击游戏图标后,第一个跑起来的那个小程序。它不负责游戏逻辑,只做四件事:
① 检查自己是不是最新(启动器本身极少更新)
② 拉取版本清单,判断要不要更新游戏主体
③ 下载并应用补丁
④ 启动真正的游戏程序
为什么要把启动器单独拆出来:因为它是「唯一能修自己的东西」。如果更新逻辑写在游戏主体里,一旦游戏主程序的更新逻辑崩了,你就再也没有机会修它了——用户只能卸载重装。启动器做得越小越简单,它坏的概率就越低。
现实例子:几乎所有 PC 网游、RustDesk、Discord、以及 Electron 类桌面应用(Joplin)都有这一层。
| 层次 | 更新的是什么 | 要不要重启客户端 | 各平台限制 | 典型用途 |
|---|---|---|---|---|
| 资源热更 | 图片、音频、模型、动画、配置表 | 通常下次进入场景生效 | 所有平台都允许 | 换皮肤、改文案、上新地图素材 |
| 脚本热更 | 用脚本语言写的逻辑(Lua / JS / TypeScript) | 要重新加载脚本环境 | 所有平台都允许 | 改数值、修活动逻辑、加小玩法。这是手游最主要的更新方式 |
| 代码热更 | 编译型语言的逻辑代码(C# / Java / C++) | 要重启或重载程序集 | 有限制:iOS 审核条款理论上禁止改变 App 主要功能的动态代码下发,实践中通过 IL 解释执行等方案绕行;Android 相对宽松 | Unity + HybridCLR(ET 框架用的就是这个)、ILRuntime |
| 整包更新 | 整个安装包 | 重新下载安装 | 要走应用商店审核(iOS 1~7 天,不可控) | 引擎升级、大版本资料片、包结构重构 |
因为越往上越可控、越快、风险越低。所以成熟团队会:
① 把尽可能多的业务逻辑放在脚本层而不是编译层;
② 把功能开关做成服务端下发(Feature Flag),代码已经发出去了也能关掉;
③ 把资源按分包设计,玩家只下载自己用得到的部分。
业界常说的「包体优化」「热更率」就是在这个框架下讨论的。手游能每周更新好几次而不用提交审核,靠的就是这个分层。
| 整包(Full Package) | 增量包 / 差量补丁(Delta / Patch) | |
|---|---|---|
| 内容 | 完整的安装包 | 只有新旧版本之间的差异部分 |
| 体积 | 大(几十 MB ~ 几十 GB) | 小(常常是整包的 1%~5%) |
| 生成工具 | 打包工具直接产出 | bsdiff / xdelta3 / hdiffpatch / 自研 |
| 使用方法 | 直接装 | 必须在本地已有正确旧版本的前提下应用 |
| 风险 | 低 | 较高:补丁应用失败会产生「看起来能用但实际损坏」的包,所以必须校验哈希 |
| 何时用 | 用户版本太旧 / 没有对应补丁 / 补丁比整包还大 | 绝大多数日常更新 |
假设玩家本地文件被改过(比如自己替换了个贴图),补丁应用后可能得到一个半新半旧的状态,程序能启动但行为诡异。这就是为什么流程里必须有一步:下载完先算 SHA256,跟清单里的值比对,不一致就丢弃并退回整包。这一步省不得。
这是后面所有讨论的基础。同样叫「客户端更新」,这四种的成本差一个数量级:
| 力度 | 机制 | 用户感知 | 可控性 | 典型场景 |
|---|---|---|---|---|
| 静默热更 | 后台悄悄替换资源/脚本,下次启动生效 | 完全无感 | 完全可控 | 改数值、改文案、修 bug |
| 可选更新 | 启动器提示「有新版本,是否更新?」 | 弹窗,可以点「以后再说」 | 可控 | 新功能、体验优化 |
| 强制更新 | 版本闸门校验失败,拒绝进入,必须更新 | 进不去,被迫更新 | 可控但风险高 | 协议不兼容、安全漏洞 |
| 整包重装 | 下载完整安装包重新安装 | 重新下载大包、走安装流程 | 最不可控(商店审核) | 大版本、引擎升级 |
把新功能的代码随版本一起发出去,但用一个开关控制它是否显示/生效,开关的值由服务端下发。
为什么它是客户端项目的命门:客户端代码一旦发出去就收不回来(用户已经装了)。Feature Flag 是唯一能让「已经发出去的代码」失效的手段——它等效于「零成本回滚」。
所以客户端项目有一个铁律:每一个新功能都必须配一个开关。没有开关的新功能 = 出问题只能发新版本 = 用户等好几天。
开关的常见用法还包括:按用户分桶放量(先给 5% 用户开)、AB 测试(一半用户看到 A 版,一半看到 B 版)、紧急止血(线上炸了秒关)。
| 版本号 | 含义 | 谁在用 | 例子 |
|---|---|---|---|
| 客户端版本 | 用户设备上装的那个包的版本 | 启动器、商店、埋点 | 1.2.0(build 20260924) |
| 服务端版本 | 运行在集群里的那套服务的版本 | K8s 镜像 tag、Argo CD | prod-k8s-a3e752d |
| 协议版本 ⭐ | 客户端与服务端「说话的语法」版本。这才是判断兼容性的依据 | 握手阶段、版本闸门 | proto 5 |
因为客户端版本 1.2.0 和服务端版本之间不是一一对应的。服务端可能连发 20 个版本,协议一直没变;也可能一次改动就断了兼容。
把兼容性判断从「版本号」解耦到「协议版本号」,带来的好处是:服务端可以随便发小版本,只要协议版本不变,老客户端就一直能用。这是让「服务端高频发布」和「客户端低频发布」共存的关键设计。
真实案例(Luanti / Minetest):它把协议版本写死在代码里,官方文档明确写着「当前最低协议版本是 24」。客户端握手时先交换协议版本,不在范围内就拒绝。它甚至出过一个真实的 bug——版本不匹配的客户端把服务端搞崩了,导致所有在线玩家被踢下线。这正好说明:版本闸门做不好是会出大事故的。
服务端在客户端连接时(握手阶段)做的一道检查:你的版本我支持吗?不支持就拒绝并告知该怎么做。
典型响应有三种:
· 放行:协议在兼容窗口内
· 警告但放行:能玩,但推荐更新
· 拒绝:告知最低要求版本,客户端提示强制更新
它放在协议层而不是业务层,是因为这是唯一能保证「所有客户端都过这一关」的地方——你不可能在每个接口里都写一遍版本检查。
客户端有「长尾」:永远有一批用户没升级。所以服务端要同时服务多个客户端版本。业界常见约定:
服务端必须兼容「最近 3 个客户端版本」(N、N-1、N-2)
超出窗口的客户端 → 通过版本闸门强制更新
这个窗口宽度是团队必须明确写下来的规则,不是自然形成的。
没有这条规则,服务端代码会被历史兼容逻辑淹没;
窗口开得太窄,又会把一批用户挡在门外。
窗口里每多一个版本,服务端代码里就多一点这种东西:
if (clientProto >= 5) {
// 新客户端:返回带 newField 的结构
} else {
// 老客户端:返回旧结构,把新字段丢掉
}
所以窗口宽度是一个工程成本与用户留存之间的权衡,需要定期 review。
问题:玩家的存档格式要改,但玩家可能几个月不上线,你不可能在停机窗口里升完所有人的存档。
解法:玩家登录的那一刻,才把他的存档升级到新格式。这意味着服务端在一段时间内必须能读新旧两种格式。
配套要求:需要一个「迁移进度」的可观测指标(还有多少活跃存档是旧格式),等这个数字归零,才能放心删掉旧格式的读取代码。
数据库结构的变更必须拆成四步、跨多个发布周期完成,才能保证「任何时刻都能安全回滚代码」:
① expand(扩展) 加新列/新表,代码双写(新老结构都写) ← 此时老代码依然正常
② migrate(迁移) 后台任务把历史数据回填到新结构
③ switch(切换) 代码改成只读新结构
④ contract(收缩)观察若干个发布周期后,删掉旧列/旧代码
关键:每一步都必须「向后兼容」。
绝对不要在同一个发布里既改表结构又改读法——那样一旦要回滚代码,数据库已经回不去了。
| 策略 | 本质 | 切换方式 | 一句话记忆 |
|---|---|---|---|
| 蓝绿 Blue-Green | 同时维护两套完整环境 | 一次性把流量从 A 切到 B | 回答「怎么切」 |
| 金丝雀 Canary | 同一环境里跑新旧两版 | 按比例逐步放量(5%→20%→50%→100%) | 回答「切给谁」 |
| 灰度 Gray | 按人群或维度圈定范围 | 按规则(白名单→内部用户→某地区→全量) | |
| Ring 发布 | 把用户分成同心圆环,一圈圈往外放 | 环 0 = 内部,环 1 = 尝鲜用户,环 2 = 大众 | 灰度的组织化形式 |
它们都归属于 渐进式交付(Progressive Delivery) 这个概念。实践中常组合使用:「灰度人群 + 金丝雀比例」——先给内部白名单 10% 流量,再扩大到全部用户的 50%,最后全量;最终切换可以用蓝绿。
要「给 20% 的用户开放新功能」,怎么决定谁是那 20%?答案是把用户 ID 通过哈希函数映射到 0~99 的桶里,然后判断桶号是否小于 20。
关键要求:同一个用户在每次判断时必须落在同一个桶里(否则用户会一会儿看到新功能一会儿看不到)。这就是为什么用哈希而不是随机数。
一个真实且常见的缺陷:如果哈希函数太简单(比如 h = h*31 + 字符),连续的用户 ID(1001、1002、1003…)会落到连续的桶里。于是「放量 20%」实际只覆盖了 ID 尾号靠前的一小撮用户,流量分布严重倾斜,灰度数据完全失真。
正确做法:哈希之后必须做一步雪崩混淆(avalanche / 位混合),让相邻输入的输出差异极大。常用的是 murmur3 的 fmix32。判断标准很简单:取 1000 个连续用户 ID,20% 放量下命中率应该接近 20.0%,且各个十分位分布均匀。
服务端按分桶结果决定「这个用户是否允许更新到新版本 / 是否看到新功能」。这是 PC 客户端和自有启动器最常用的金丝雀手段,因为这一层完全由你控制,不像应用商店那样受第三方制约。
为什么长连接服务必须做这件事:无状态服务杀 Pod 像关水龙头;长连接服务杀 Pod 等于把正在通话的电话线剪断。摘流做得好,玩家感受到的是「一瞬间的卡顿」;做得不好,就是「掉线回登录页」。
| 探针 | 回答的问题 | 失败时 K8s 做什么 | 排空期间应该 |
|---|---|---|---|
| readiness(就绪) | 「现在能把流量给它吗?」 | 把 Pod 从 Service Endpoints 摘掉,但容器继续跑 | 返回失败(这本身就是摘流机制) |
| liveness(存活) | 「它是不是卡死了?」 | 重启容器 | 必须保持成功,否则重启会打断排空 |
| startup(启动) | 「它启动完了吗?」 | 启动完成前不执行 liveness | — |
如果 readiness 和 liveness 都指向 /healthz,那么排空时 /healthz 一失败,K8s 会认为容器卡死并把重启它——你的排空流程刚走到一半就被打断,玩家全部异常掉线。所以排空时期 readiness 失败、liveness 必须成功。
K8s 要删除一个 Pod 时的完整顺序:
① 调用 preStop 钩子(阻塞,必须执行完)
② 同时开始「摘流」:把 Pod 从 Endpoints 移除
③ preStop 执行完 → 发送 SIGTERM 给容器主进程
④ 容器自己处理 SIGTERM(执行排空逻辑)
⑤ 超过 terminationGracePeriodSeconds 还没退出 → SIGKILL 强杀
两个必须注意的点:
· preStop 里通常写「等几秒」或「调一下本机的 /drain 接口」,
目的是给「Endpoints 摘流生效」留出时间——摘流是异步的,不是瞬间的
· terminationGracePeriodSeconds 必须 > preStop 耗时 + 排空耗时,
否则第 ⑤ 步的强杀会打断你的优雅停机
如果 Dockerfile 写成 CMD node server.js(shell 形式),进程树实际是 /bin/sh → node。K8s 发的 SIGTERM 给到 /bin/sh,会被它吞掉,你的排空逻辑永远不会执行,Pod 到宽限期上限被 SIGKILL 硬杀,在线玩家全部异常掉线。
必须写成 exec 形式:CMD ["node", "server.js"]。这个细节决定了你的优雅停机是真的还是假的。
| 手段 | 能按什么切 | 复杂度 | 你环境的可行性 |
|---|---|---|---|
| Service selector 切换 | 全切(蓝绿用) | 极低 | 可以 |
Ingress 权重注解 canary-weight | 按比例(金丝雀) | 低 | 可以,推荐入门用这个 |
Ingress Header/Cookie 注解 canary-by-header | 按请求头/Cookie(灰度) | 低 | 可以,适合「指定账号先看新版」 |
| Argo Rollouts + ingress-nginx | 以上全部 + 自动推进/回滚 | 中 | 可以(需装 v1.7.2) |
| 服务网格(Istio/Linkerd) | 按用户 ID 哈希、自定义规则 | 高 | 不建议(资源不够) |
让同一个用户的请求尽量落到同一个实例上。WebSocket 场景下这是必需的——因为连接是「粘」的,如果玩家的两个请求被分到不同实例,第二个实例不认识他。
实现:Ingress 用 Cookie 做亲和(nginx.ingress.kubernetes.io/affinity: cookie)。
与发布的矛盾:有粘性就意味着某个实例上的连接不会主动跑掉,所以你必须靠摘流 + 通知重连来「推」它们走。这也是为什么长连接服务的发布比无状态服务麻烦。
| 名词 | 含义 |
|---|---|
Rollout(CRD) | 替代 K8s 原生 Deployment 的资源类型,多了 strategy.canary / strategy.blueGreen |
steps | 发布的步骤序列,如 setWeight: 10 → pause: 5m → setWeight: 50 |
trafficRouting | 告诉 Rollouts「通过什么方式切流量」——这里是 nginx,也可以是 Istio / ALB |
AnalysisTemplate | 把「人工判断要不要继续」变成「自动查指标判断」,比如查成功率低于 99% 就自动中止 |
| Stable / Canary ReplicaSet | 老版本和新版本各对应一个 ReplicaSet,Rollouts 同时管理两者 |
| 仓库 | 管什么 | 你环境里的实例 | 访问方式 |
|---|---|---|---|
| 镜像仓库 (Container Registry) | Docker 镜像 | Harbor 106.75.29.47:10086 | Docker Registry v2 协议 |
| 制品仓库 (Artifact Repository) | 任意文件:安装包、补丁、jar、npm 包、配置表 | Nexus(你机器上装了但没启动) | HTTP(可按目录上传/下载) |
| 对象存储 / CDN | 海量静态文件的高并发下载 | (暂无,可用 Nginx 顶替) | HTTP(自带大带宽) |
它是一个 JSON 文件,同时被三方读取:客户端启动器(决定要不要更新)、服务端(决定版本闸门放不放行)、运维(人工查看当前该发什么版本)。
核心字段:
latest:最新客户端版本minSupported:最低支持版本,低于它 → 强制更新recommended:推荐版本,低于它 → 可选更新protocol:协议兼容窗口(min / current)configVersion:配置表版本,双端必须一致packages[]:整包列表(地址、体积、SHA256)patches[]:差量补丁列表(从哪个版本到哪个版本)rollout.percent:放量比例forceUpdate:按平台分别控制强制更新(应对 iOS 审核延迟)如果客户端正好在你写入 manifest.json 写到一半时来拉取,它会读到半个 JSON,解析失败,然后就卡在更新界面——这是一个真实发生过的线上事故类型。
正确做法:先写临时文件 manifest.json.tmp,写完并 fsync 之后,用 mv 覆盖正式文件。mv 在同一文件系统内是原子操作,客户端要么看到旧文件、要么看到新文件,永远不会看到半个。
这个技巧在整个运维领域都通用(配置下发、证书替换、静态站点发布)。
| 配置中心 | 注册中心 | |
|---|---|---|
| 解决什么 | 「配置怎么下发和热更新」 | 「服务 A 怎么找到服务 B 的地址」 |
| 存的东西 | 键值对配置(限流阈值、开关、DB 地址) | 服务实例列表(IP:端口) |
| 典型产品 | Apollo / Nacos | Consul / Eureka / Nacos |
| 你的环境 | Nacos(106.75.29.47:8848)同时提供这两种能力,所以你的若依网关才能用 lb://ruoyi-auth 这种写法 | |
对客户端项目的意义:注册中心在游戏服务端里还有额外用途——它就是「位置服」的原型。ET 框架的 Location Server 本质就是一个为 Actor 服务的注册中心。
策划改 Excel → 导表工具 → 同时产出两份:
策划改 Excel(唯一的事实来源)
↓ 导表工具(同一次运行)
├── 服务端用:config_server.json ← 逻辑判定用(伤害 = 100)
└── 客户端用:config_client.bytes ← 显示用(显示伤害 = 100)
如果服务端用了新表(伤害 = 100)而客户端还是旧表(显示 = 50),玩家就会看到「打出的伤害和显示的对不上」,直接爆发客诉。
工程解法:给配置打一个 config_version,客户端启动时上报自己的版本,服务端发现不一致就触发强制热更。
| 格式 | 可读性 | 体积 | 速度 | 用途 |
|---|---|---|---|---|
| JSON | 人可读 | 大 | 中 | 配置、调试、Web 接口 |
| Protobuf | 需 schema | 小 | 快 | 跨语言通信、协议定义 |
| MessagePack | 半可读 | 较小 | 快 | 客户端与服务端通信 |
| MemoryPack / FlatBuffers | 不可读 | 小 | 极快(零 GC) | 高性能游戏通信(ET 用的 MemoryPack) |
与发布的关系:序列化格式决定了协议兼容性。Protobuf 这种带字段编号的格式天然向后兼容(加字段不影响老客户端);JSON 靠字段名,改名就会破坏兼容;而自定义二进制格式最容易出事——必须严格版本化。
玩家的进度数据。它的发布要求和代码完全不同:
| 中文 | English | 一句话 |
|---|---|---|
| 长连接 / 短连接 | Persistent / Short-lived Connection | 连接会不会一直保持 |
| 心跳 | Heartbeat | 定期小包,检测对面是否还活着 |
| 粘包 / 拆包 | Sticky / Fragmented Packets | TCP 字节流不保证消息边界 |
| 断线重连 | Reconnect | 断了自动连回来并恢复状态 |
| 会话 | Session | 「这个连接是谁」的信息集合 |
| 会话迁移 | Session Migration | 把在线会话从老实例搬到新实例 |
| 摘流 | Drain | 先把实例从流量入口摘掉 |
| 排空 | Graceful Shutdown | 等已有请求/连接自然结束 |
| 版本闸门 | Version Gate | 握手时校验版本,不兼容就拒绝 |
| 协议版本 | Protocol Version | 判断兼容性的依据,独立于软件版本号 |
| 兼容窗口 | Compatibility Window | 服务端要兼容最近几个客户端版本 |
| 热更新 | Hot Update / Live Update | 不重新安装就更新资源或逻辑 |
| 差量补丁 | Delta / Binary Patch | 只含新旧版本差异的补丁文件 |
| 启动器 / 自更新器 | Launcher / Updater | 负责检查和下载更新的小程序 |
| 功能开关 | Feature Flag / Feature Toggle | 服务端控制已发布功能是否生效 |
| 分桶 | Bucketing | 把用户稳定地映射到某个百分比桶 |
| 放量 | Rollout Percentage | 开放给多少比例的用户 |
| 灰度 | Gray Release / Ring Rollout | 按人群范围放量 |
| 金丝雀 | Canary Release | 按比例渐进放量 |
| 蓝绿 | Blue-Green Deployment | 两套环境,一次性切流量 |
| 渐进式交付 | Progressive Delivery | 蓝绿/金丝雀/灰度的总称 |
| 版本清单 | Manifest | 客户端该更新到哪个版本的权威描述 |
| 原子发布 | Atomic Publish | 要么全生效要么不生效,不会读到中间态 |
| 懒迁移 | Lazy Migration | 用户实际访问时才升级其数据格式 |
| 扩展-收缩 | Expand-Contract | 数据库结构变更的四步安全流程 |
| 导表 | Config Export | Excel 转成服务端与客户端两份配置产物 |
| 序列化 | Serialization | 对象与字节流之间的转换 |
| 分区 / 分服 | Zone / Shard | 互相独立的运行单元,天然的灰度单位 |
| 合服 | Server Merge | 把多个区的数据合并,风险最高 |
| 状态同步 | State Synchronization | 服务端算结果推给客户端 |
| 帧同步 | Lockstep | 只转发操作指令,各端各自算 |
| Actor 模型 | Actor Model | 不共享内存、只发消息的并发模型 |
| 协程 / Fiber | Coroutine / Fiber | 比线程更轻的并发单位 |
| 崩溃上报 | Crash Reporting | 客户端崩溃堆栈上报(必须按版本分组) |
| 埋点 | Telemetry / Analytics | 记录用户行为用于分析 |
| 分阶段发布 | Staged Rollout | 应用商店提供的按比例放量能力 |
| 对象存储 | Object Storage | 存海量静态文件(S3/OSS/MinIO) |
| 内容分发网络 | CDN | 把静态文件缓存到离用户近的节点 |
| 制品仓库 | Artifact Repository | 带版本管理的文件仓库(Nexus/Artifactory) |
| 配置中心 | Config Center | 集中下发配置(Nacos/Apollo) |
| 注册中心 | Service Registry | 服务发现(Nacos/Consul/Eureka) |
先把「当前真实跑着什么」钉死。下面每一条都是现场读出来的,不是推测。
4 个节点全部通过 Tailscale 组网,kubectl 的 API Server 挂在 Jenkins 那台机器上,它同时兼任控制平面:
| 节点名 | 角色 | Tailscale IP | CPU / 内存(allocatable) | 当前内存占用 | 状态 |
|---|---|---|---|---|---|
jenkins | control-plane(etcd / apiserver / controller-manager / scheduler 全在这) | 100.68.117.72 | 2C / 7.4Gi | 71% | Ready(带 control-plane 污点) |
k8s-node1 | worker | 100.125.80.98 | 4C / 3.5Gi | 76% | Ready |
k8s-node2 | worker | 100.97.166.90 | 4C / 3.5Gi | 93% | Ready(内存最紧张) |
k8s-node3 | worker | 100.78.249.106 | 2C / 7.4Gi | 70% | Ready(内存余量最大) |
| 组件 | 现状 |
|---|---|
| Kubernetes | v1.28.14(kubeadm 部署) |
| 容器运行时 | containerd 2.3.3;/etc/containerd/certs.d 已按 registry 配置了镜像加速 |
| CNI / 代理 | Calico(VXLAN),kube-proxy 为 IPVS 模式 |
| Ingress | ingress-nginx(IngressClass 名 nginx),NodePort 30080(HTTP) / 30443(HTTPS) |
| 存储 | 无 StorageClass、无 PV、无 PVC——全集群无持久化卷 |
| 可观测 | metrics-server 已装;kube-system 有 filebeat DaemonSet 对接 k8s-elk 节点;Kuboard v4 以容器方式跑在宿主机 |
| 已有 CRD | 仅 Calico + Kuboard,没有 Argo CD / Argo Rollouts / Prometheus Operator |
应用全部落在 ruoyi 命名空间:7 个 Deployment + 7 个 NodePort Service + 1 个 Ingress。
| Deployment | 容器端口 | Service NodePort | 当前镜像 tag |
|---|---|---|---|
ruoyi-gateway-deploy | 8080 | 31080 | 20260827145338 |
ruoyi-auth-deploy | 9200 | 31081 | 20260827145033 |
ruoyi-modules-system-deploy | 9201 | 31093 | 20260827145603 |
ruoyi-modules-gen-deploy | 9202 | 30091 | 20260827161313 |
ruoyi-modules-job-deploy | 9203 | 30090 | 20260827162653 |
ruoyi-modules-file-deploy | 9300 | 30092 | 20260827152043 |
ruoyi-visual-monitor-deploy | 9100 | 30095 | 20260827191643 |
106.75.29.47:10086,项目 ruoyi-cloud,tag 是「秒级时间戳」。harbor-secret / harbor-cred 两个 dockerconfigjson。ruoyi-gateway-config(以 subPath 挂到 /home/ruoyi/application.yml);其余服务配置打进镜像。Redis 指向 106.75.29.47:6379。ruoyi-gateway-ingress,ingressClassName: nginx,无域名(host 为 *)。ruoyi-ui 由宿主机 Nginx 托管 /opt/ruoyi/dist。106.75.29.47 开放 3306(MySQL)、6379(Redis)、8848/9848(Nacos)、10086(Harbor)、80/8080。| 项 | 现状 |
|---|---|
| Jenkins Home | /var/lib/jenkins(rpm 安装、systemd 托管) |
| 执行器 | numExecutors=2 |
| Job 形态 | 全部是 Multibranch Pipeline,Jenkinsfile 在各仓库根目录 |
| 代码来源 | Gitee:gitee.com/pang_le/ruoyi-*.git,分支过滤 prod*(实际分支 prod-k8s),未配置凭据 → 公开仓库 |
| 构建工具 | Maven-3.8.9 + JDK-17;前端用 npm + registry.npmmirror.com |
| 凭据清单 | harbor-credentials、gitee-enterprise-token、deploy-ssh-key、nexus-admin |
stage('代码编译打包') → mvn clean package -Dmaven.test.skip=true
stage('构建 Docker 镜像') → docker build -t ${HARBOR_URL}:latest . && docker tag ... :$VERSION
stage('推送镜像到 Harbor') → docker push ...:latest && docker push ...:$VERSION
stage('部署到 K8s') → sed -i 's/:latest/:$VERSION/g' k8s/deployment.yaml
kubectl apply -f k8s/deployment.yaml -n ruoyi
kubectl apply,构建与发布耦合,失败无法自动回滚。sed -i 污染工作区:构建时直接改写仓库里的 k8s/deployment.yaml,导致工作区 git 树长期 dirty,清单文件在本地被改坏。latest 这种可变标签。ruoyi-gateway-deploy 实际挂载了 ruoyi-gateway-config,但仓库里的 k8s/deployment.yaml 这段是被注释掉的。漏跑一次 CI 或重装一次集群,网关的配置挂载就会丢。| CPU 核数 | 2 |
| 系统负载(空闲时) | 0.79 / 0.87 / 0.82 —— 也就是空闲状态下就已经用掉约 40% 的 CPU |
| 可用内存 | 约 3.5 GB |
| Swap | 0(完全没有)——任何内存尖峰都会直接 OOM,没有缓冲 |
| 磁盘 | 99G 用 57G,剩 42G |
| CPU 占用第一名 | tailscaled 28.3%(它同时在为整个 k8s 集群做中继,累计流量已到 TB 级) |
| 内存占用前列 | Jenkins 1018MB、kube-apiserver 404MB、tailscaled 251MB、dockerd 138MB |
这个结论很重要:任何在这台机器上新增的常驻服务,都要先问「还剩多少 CPU 给我」。它也是后面「代码仓库选型」建议的核心依据。
| 能力 | 现状 | 本次学习的用途 |
|---|---|---|
| Nexus 3.92.3 | 已安装但未启动。位置 /opt/nexus,端口 7878(不是默认的 8081),自带 Temurin JDK 21,数据目录 /opt/sonatype-work/nexus3(12M,说明初始化过但基本没用),没有 systemd 单元文件 |
客户端产物仓库(raw hosted repo)——本次最关键的现成资源 |
| Harbor | 运行中,106.75.29.47:10086 | 服务端镜像仓库 |
| Nginx 1.20.1 | 运行中,已有 ruoyi.conf + jenkins.wanfeng.com.conf | 补丁分发通道(加一个 location /patches/) |
| Argo CD | 方案已出,尚未安装 | 服务端 CD |
| Argo Rollouts | 未安装 | 蓝绿/金丝雀/灰度编排。需装 v1.7.2 |
| xdelta | 未安装,EPEL 源里可装(xdelta.x86_64 3.1.0-17.el9) | 生成客户端差量补丁 |
| Node.js v25.8.0 + npm 11.11.0 | 在 /root/node/bin | 跑第 9 章的最小 demo |
| Jenkins 插件 | 有 publish-over-ssh、ssh-steps、nodejs、docker-workflow、gitee;没有 Nexus 上传插件、没有 Generic Webhook Trigger | CI 编排(推 Nexus 用 curl 即可) |
| ELK(k8s-elk 节点) | filebeat DaemonSet 已在跑 | 按客户端版本分组观测日志 |
这已经是第二次遇到同一个问题:
stable 分支测 1.32~1.35,master 测 1.34~1.37,都不含 1.28;且 1.8 起明确不再支持 1.28 以下 → 只能选 v1.7.2结论:把集群升级到 v1.33+ 值得单独排一个项目。在升级之前,你没法用上任何一个最新版云原生工具。
你上一轮做的若依项目是典型网页项目:7 个微服务 + 一个前端静态目录。那套流程里有一些「默认成立」的前提,在客户端软件里一个都不成立。
| 维度 | 网页项目(你的若依) | 客户端 / 服务端项目 |
|---|---|---|
| 交付产物 | 无状态容器镜像 + 静态文件 | 服务端镜像 + 客户端安装包 / 资源包 / 配置表(三份,需分别发布) |
| 用户侧版本 | 刷新即最新,全网只有一个版本 | 用户设备上可能装着 3 年前的版本,同时存在 N 个版本 |
| 生效方式 | 服务端部署完,下次请求立刻生效(秒级) | 服务端秒级;客户端要用户下载 + 安装 + 重启(分钟到数月不等) |
| 变更可控性 | 你说了算 | 客户端部分你说了不算:商店审核、用户不点更新 |
| 回滚语义 | 回滚 = 换回旧版本,真·恢复 | 服务端可回滚;客户端升级不可逆,只能「再发一个版本改回来」 |
| 状态 | 无状态,Pod 随便杀 | 长连接 / 房间 / 会话 / 存档在内存里,重启就是断线 |
| 兼容性责任方 | 浏览器天然向前兼容,服务端随意改 | 必须由服务端兜底:新服务端要能服务老客户端 |
| 流量单位 | 请求 | 请求 + 连接(连接是「粘」的) |
| 测试难度 | 一套接口测试就够 | 需要客户端版本 × 服务端版本的兼容矩阵测试 |
服务端出问题,argocd app rollback 一秒回旧版本,用户毫无感知。客户端出问题,你没有任何办法把用户设备上那个包变回旧版——只能连夜出新版本,然后祈祷用户愿意更新。
推论:客户端发布的风险是「不可逆」的。因此客户端的质量闸门必须比网页严得多,而且必须配备「服务端开关」作为兜底。
你的若依改个接口返回结构,改完部署,前端下次请求跟着改就行。但客户端项目里,老客户端是你改不动的存量。一旦把字段改名或删掉,所有没升级的老客户端立刻报错、闪退、卡在登录页。
推论:客户端项目的服务端接口必须遵守向后兼容契约——字段只增不减、新增字段要有默认值、旧语义不能改、接口不能直接下线(要先标废弃、观察调用量为零、再等一个发布周期才删)。
网页项目的版本分布是一条竖线(发布完成 = 100% 新版本)。客户端项目是一条长尾曲线,尾部可能拖几个月到几年。企业内网软件常年停在某个 LTS 版本;手游里总有用户半年不更新;桌面软件里总有人关掉自动更新。
推论:客户端项目的版本管理本质是「多版本共存治理」,不是「版本替换」。你要维护的是兼容矩阵,不是版本号。
网页项目只有一条交付链路(代码 → 镜像 → 集群)。客户端/服务端项目有三条相互独立、节奏不同的交付通道,这是理解一切差异的框架。
| # | 环节 | 网页项目的做法 | 客户端/服务端项目的额外要求 |
|---|---|---|---|
| 1 | 版本号策略 | Git SHA 或时间戳,能追溯即可 | 需要三套版本号:客户端版本、服务端版本、协议版本。靠协议版本判断兼容性 |
| 2 | 构建与打包 | 只出镜像 | 同时产出:服务端镜像、客户端整包、客户端差量补丁、导表产物 |
| 3 | 自动化测试 | 接口测试 + 单测 | 额外需要:协议契约测试(新服务端跑老请求样本)、兼容矩阵测试、导表一致性校验 |
| 4 | 制品入库 | 只推 Harbor | 镜像推 Harbor;客户端包与配置表推 Nexus;大包另需 CDN/对象存储 |
| 5 | 发布版本清单 | 不存在这一步 | 关键环节:更新 manifest.json 并原子发布。这一步决定双端的可见性 |
| 6 | 发布编排 | k8s 滚动 / 蓝绿 / 金丝雀 | 服务端同上;客户端是分渠道 / 分地区 / 分用户比例放量,无法「滚动」 |
| 7 | 观测与回滚 | 服务端指标 + 一键回滚 | 必须按客户端版本分组观测;客户端回滚靠「服务端开关 + 发新版」 |
代码提交 → CI 构建镜像 → 推 Harbor → 更新 GitOps 清单 → Argo CD 同步
↓
RollingUpdate(默认) / BlueGreen / Canary
服务端特有的两点增强:
① 长连接服务必须先「摘流 + 排空」再终止,否则用户断线
② 启动时做「版本闸门」:读 manifest 里的 proto 版本,拒绝不兼容的客户端连接
① 构建:出整包 + 生成版本号(写进包内版本文件)
② 差量:与上一个线上版本对比,生成增量补丁(xdelta3 / bsdiff)
③ 入库:整包 + 补丁包推 Nexus,记录 SHA256 与体积
④ 清单:更新 manifest.json(新版本号、最低支持版本、补丁地址、强制级别)
⑤ 放量:分渠道/分桶逐步放开,同时开启服务端 Feature Flag
↓
观测:各客户端版本的成功率、崩溃率、登录转化率
网页项目里配置改完部署就生效。客户端项目里,配置表是 Excel → 导表工具 → 双端产物,两份产物必须来自同一次导表(详见 1.8 节)。
「客户端/服务端软件」不是一个东西,不同类型差异大到需要不同流程。下面按「标准流程里哪一步被替换掉」来组织。
| 环节 | 与标准流程的差异 |
|---|---|
| 审核 | iOS 必须过 App Store 审核(1~7 天,不可控)。所以 iOS 端的「可热更范围」被严格限制在 Apple 允许的范围内(脚本、资源、配置),这是 iOS 和 Android 发布流程最大的分叉点 |
| 渠道 | Android 有几十个渠道包(应用宝、华为、小米、TapTap…),一个版本要出 N 个包,且各渠道审核节奏不同 → 发布是「多点异步」的 |
| 放量 | 商店支持分阶段发布(如 Google Play Staged Rollout:1% → 5% → 20% → 100%),这是客户端侧的金丝雀 |
| 回滚 | 无法回滚,只能「发新版」或服务端下开关 |
网页项目里 Pod 随时可以被杀,因为每个请求都是独立的。但长连接服务里,玩家的整个游戏过程都挂在一条连接上,杀掉 Pod = 所有在线玩家掉线。
标准做法(摘流 + 迁移):
preStop 钩子里完成上述动作,terminationGracePeriodSeconds 要大于宽限期这也是为什么游戏服务端很少用「一次性蓝绿全切」——切过去那一瞬间,所有在线玩家都会掉线。
你的若依就属于这一类。标准 k8s 流程可以直接套用,不需要本文的大部分复杂度。差别只在:如果它同时服务客户端,则要额外遵守「向后兼容契约」和「版本闸门」。
无论是哪类软件,数据库变更都是独立的第四类发布,走 expand-contract 四步(见 1.4 节)。
| 策略 | 用现有能力怎么实现 | 流量入口 | 资源成本 | 你的环境可行性 |
|---|---|---|---|---|
| 蓝绿 | 两套 Deployment(-blue/-green)+ 一个 Service,改 Service 的 selector 一次性切换。或用 Argo Rollouts 的 blueGreen 策略 |
Service selector | 2× Pod | 可行,但你的集群内存紧张,节点余量不足 2 倍 |
| 金丝雀(按比例) | Argo Rollouts + 现有 ingress-nginx,用 nginx.ingress.kubernetes.io/canary-weight 注解按权重分流 |
Nginx Ingress | 1× + 少量 | 可行,推荐作为入门演练 |
| 灰度(按人群) | Argo Rollouts + ingress-nginx 的 canary-by-header / canary-by-cookie,按请求头或 Cookie 路由 |
Nginx Ingress | 1× + 少量 | 可行,最适合「先给测试账号放量」 |
| 更细粒度 | Istio / Linkerd 服务网格按用户 ID 哈希分流 | Sidecar | 较高 | 不建议(集群资源不够,且引入复杂度) |
蓝绿的前提是「流量可以瞬间从 A 切到 B」。但客户端已经装在用户设备上了,你没法让用户的手机瞬间变成另一个版本。所以客户端侧只有两类手段:
| 策略 | 客户端上的对应物 | 具体做法 |
|---|---|---|
| 金丝雀 | 商店分阶段发布 | Google Play Staged Rollout:1% → 5% → 20% → 50% → 100%。iOS 用 App Store 的分阶段发布(7 天自动放量) |
| 渠道分批 | 先放 TapTap / 官网包(可控),再放应用宝,最后放华为/小米。观察各渠道数据再决定是否继续 | |
| 自有更新器的比例放量 | 服务端按「用户 ID 分桶 < N」决定是否下发「可更新」标记 → 这一层你完全可控,是 PC 端最常用的手段 | |
| 灰度 | 白名单 / 内测组 | 内部员工账号、测试机、招募的体验服玩家先行 |
| 按地区/运营商 | 先开一个小地区,验证 CDN 与网络兼容性后再全国 | |
| Feature Flag | 这才是客户端项目最有力的灰度手段 | 新功能代码已随版本发出,但默认关闭;服务端按用户分桶下发「开启」标记。可以随时关掉,等效于「零成本回滚」 |
| 蓝绿 | 双版本并行(变体) | 老客户端连老逻辑、新客户端连新逻辑,服务端同时维护两条路径。这不是真正的蓝绿,而是「双轨共存」,是过渡期的权宜之计 |
| 阶段 | 服务端动作 | 客户端动作 | 用户感知 |
|---|---|---|---|
| T-7 天 | 部署新接口(双协议并行,老协议继续服务) | 新客户端包提审(iOS) | 无 |
| T-3 天 | 保持双协议 | Android 各渠道包准备完毕 | 无 |
| T-1 天 | 上线配置表新版本(双端同一份) | — | 无 |
| T 日 停机 | 发布维护公告 → 停服 → 升级数据库(expand)→ 部署新服务端 | — | 停机公告 |
| T 日 开服 | 版本闸门设为「最低支持 proto=5」 | 开放整包下载;老客户端登录时被要求更新 | 强制更新 |
| T+1 天 | 观察各客户端版本的成功率/崩溃率 | 放量到 50% | 部分用户仍未更新 |
| T+3 天 | 确认老版本流量接近 0 | 放量到 100% | — |
| T+14 天 | 下线旧协议代码(contract) | — | 无 |
| 你的推断 | 判定 | 一句话的真实情况 |
|---|---|---|
| 服务端更新,客户端无需更新 | ✔ 正确,占比最高 | 日常发布的绝对主力(约 6~7 成)。唯一前提是服务端保持向后兼容——只加不改、不删字段、不改语义。 |
| 服务端无更新,客户端更新 | ◐ 方向对,但「无更新」是理想态 | 纯客户端更新(改 UI、换素材、性能优化)确实存在;但现实中服务端通常仍要动一点:更新版本清单、调开关默认值、放开新资源路径。真正的「服务端零改动」只发生在纯资源替换。 |
| 服务端更新,客户端少量更新 | ✔ 正确,是「新功能发布」的标准形态 | 服务端加接口 + 客户端资源热更 + Feature Flag 按人群放量。老客户端依然能登录,只是看不到新功能。 |
| 服务端大更新,客户端大更新甚至升级 | ✔ 正确,对应「资料片 / 大版本」 | 协议不兼容的破坏性变更。需要停机窗口或分服灰度、强制更新、协议大版本号递增、老客户端被版本闸门拒绝登录。 |
占实际工作量的 60%~70%。典型内容:服务端性能优化、后台 bug 修复、运营活动逻辑、风控规则、日志与监控增强。
它成立的技术前提(缺一不可):
真正的「服务端一行不动」只发生在纯资源替换(换张图片、改个文案、字体优化)。只要涉及任何「用户能不能拿到新版本」的判断,服务端就必须动:
manifest.json(新版本下载地址、哈希、体积)更关键的一点:这种情况下服务端的兼容压力其实更大,因为它要同时服务「已经升级的新客户端」和「没升级的老客户端」,两边期望不一样。这就是「多版本共存」的真实代价。
服务端:新增接口 + 新增逻辑(老接口不变)
客户端:资源包/脚本热更(走「静默热更」或「可选更新」)
开关 :Feature Flag 控制新入口是否显示,按用户分桶放量
效果:老客户端照常玩,只是看不到新入口;
新客户端能看到新入口,但后台可以随时关掉
为什么这种形态最健康:它把风险完全控制在「可回滚」范围内——出问题关掉开关就行,不需要发新版本,也不需要回滚服务端。
特征是协议不兼容:新服务端无法正确服务老客户端。特征清单:
proto 4 → 5)风险最高的也是这一类:一旦客户端审核延迟而服务端已经切到新协议,就会出现「玩家进不去游戏,而新客户端还没上架」的灾难。所以实践中通常选择「服务端双协议并行一段时间」而不是一刀切。
这是你四种推断里都没有考虑、但后果最严重的变量。四种组合:
| 顺序 | 前提 | 后果 |
|---|---|---|
| 服务端先发 | 新服务端必须能服务老客户端 | 安全。前提是服务端严格遵守向后兼容 |
| 客户端先发 | 服务端必须能服务新客户端 | 通常需要服务端提前上线新接口(双协议并行) |
| 客户端先发,服务端没准备 | 不成立 | 新客户端调用不存在的接口 → 新用户直接不可用 |
| 服务端先发(破坏性变更),客户端还没发 | 不成立 | 最典型的事故:全部老客户端立刻挂掉 |
工程上的通用法则:
① 破坏性变更 → 服务端先上双协议,等新客户端铺开后再下线老协议;
② 更稳妥的做法是「三步走」:服务端支持双协议 → 客户端发布并放量 → 服务端下线老协议。
这个过程的绝对时长由「客户端铺开速度」决定,通常是 2 周到 2 个月。
你把「客户端更新」当成一件事,但它有四种力度(见 1.3)。同样是「服务端更新,客户端少量更新」,走静默热更和走强制更新,对服务端的要求和风险完全不同:
| 客户端更新力度 | 服务端需要做什么 | 风险 |
|---|---|---|
| 静默热更 | 只要更新资源清单,几乎不需改动 | 低 |
| 可选更新 | 需要下发更新提示;需兼容未更新用户 | 低 |
| 强制更新 | 必须提升版本闸门,且要准备好回退方案 | 中高(用户被挡在门外) |
| 整包重装 | 要协调应用商店/分发渠道,需预留审核期 | 高(时间不可控) |
你的模型隐含了「服务端和客户端一一对应」的假设。真实情况是一对多:任意时刻,服务端要同时正确服务 N、N-1、N-2 三个版本的客户端(详见 1.4 节)。
你的模型只有「服务端」和「客户端」两个轴,但真实系统里还有第三个独立的版本轴:数据格式(存档结构、数据库 schema、配置表版本)。它的特点是不随双端发布一起生效,而且一旦转换往往不可逆。解法是懒迁移(见 1.4 节)。
把「发布顺序」「客户端更新力度」「协议兼容性」「数据变更」四个维度补进来之后,完整状态空间是这样的:
| # | 服务端改动 | 客户端改动 | 协议 | 发布顺序 | 停机 | 回滚能力 | 现实频次 |
|---|---|---|---|---|---|---|---|
| 1 | 内部逻辑 | 无 | 兼容 | 服务端单发 | 否 | 服务端可回滚 | 最高(约 40%) |
| 2 | 无 | 纯资源 | 兼容 | 客户端单发 | 否 | 靠再发一版 | 高 |
| 3 | 无 | 逻辑/代码 | 兼容(新客户端兼容老服务端) | 客户端单发 | 否 | 靠再发一版/开关 | 中 |
| 4 | 新增接口 | 热更资源+开关 | 兼容(老客端可用) | 服务端先 | 否 | 关开关即可 | 高 |
| 5 | 新增接口 | 代码+资源 | 兼容 | 服务端先(双协议) | 否 | 服务端可回滚 + 关开关 | 中 |
| 6 | 破坏性变更 | 大版本 | 不兼容 | 停机 or 分服 | 是 | 极难(双端已切) | 低(每年 1~4 次) |
| 7 | 数据格式变更 | 视情况 | 兼容(惰性迁移) | 服务端先(读新旧) | 否 | 谨慎(数据已迁移) | 中 |
| 8 | 配置表变更 | 必须同步热更 | 需版本校验 | 原子双端 | 否 | 回滚表即可 | 高频(每周) |
日常 90% 的工作量集中在第 1、2、4、8 这四种状态。第 6 行(大版本)虽然只占发布次数的极小比例,却占事故后果的绝大部分。所以工程资源应当这样分配:把第 1/2/4/8 做成完全自动化的流水线,把人力集中在第 6 行的演练与预案上。
场景:服务端改了登录接口的返回字段名(userId → uid),部署上线。老客户端解析不到 userId,登录失败,所有未更新的用户立刻无法进入。
根因:把「服务端可以随便改接口」这个网页项目的习惯带到了客户端项目。
正确做法:新字段与老字段共存一段时间(双写双读),等老客户端流量趋零再删老字段。时间窗口:至少一个完整的客户端铺开周期。
场景:计划 T 日上线大版本,服务端 T 日切换协议并提升版本闸门。结果 iOS 版本审核被拒,重新提交后再等 3 天。这 3 天里,iOS 用户全部卡在「请更新到最新版本」的提示页上,而新版本根本下载不到。
根因:把「客户端可用性」和「服务端发布」的时序耦合在了一起,却忽略了客户端分发有外部依赖。
正确做法:① 版本闸门的提升必须在确认新客户端已在所有渠道可用之后才执行;② 闸门要支持按平台分别设置(iOS 通过审核前,iOS 的闸门不动);③ 预留缓冲期。
场景:导表同学把服务端表更新到了环境并生效,客户端的资源包还在打包中。这期间上线的玩家看到「技能描述写着造成 100 点伤害,实际打了 50 点」,论坛立刻炸锅。
根因:把配置表当成「一份文件」而不是「两份必须同步的产物」。
正确做法:给配置打 config_version;服务端启动和客户端登录时都校验;不一致时优先走强制热更。导表产出必须是一个原子发布单元。
第 9 章的小 demo 能让你 30 分钟理解概念,但它只有几十行代码,看不到「真实项目长什么样」。这一章给你 8 个真实开源项目,按「学习目标」和「你机器的承受能力」分成三档。
git ls-remote 到全部这些仓库(含 GitHub),也可以用 gh-proxy.com / ghfast.top 加速| 项目 | 语言 | ★ | 两端形态 | 最值得学的东西 | 部署难度 | 资源需求 |
|---|---|---|---|---|---|---|
| Colyseus | TypeScript | 7.3k | 服务端框架 + JS/Unity/Defold 等客户端 SDK | 房间(Room)生命周期、状态同步、重连 | 极低(npm 一条命令) | ≈100MB |
| Nakama | Go | 13.4k | 单二进制服务端 + 官方客户端 SDK 共 8 种 (Unity/Unreal/Godot/JS/C#/Java/Swift/Defold) |
工业级游戏后端:匹配、排行榜、实时多人、 rUDP 协议、内嵌管理控制台 |
低(官方 docker-compose) | ≈500MB + 数据库 |
| RustDesk | Rust | 124k | 全平台客户端(自带自动更新)+ 自建中继服务端(hbbs/hbbr) | 客户端自动更新的完整实现 + 「客户端 + 分发服务端」的最小完整案例 |
低(单二进制) | ≈50MB |
| Luanti (原 Minetest) | C++ | 13.6k | 独立客户端 + luantiserver 专用服务端 |
协议版本号 + 版本闸门的最佳教材: 官方文档明确写「最低协议版本 24」,握手时协商 |
中(要编译或装包) | ≈200MB |
| Mindustry | Java | 29.1k | 桌面/移动客户端 + -server 无头服务端 |
「客户端版本与服务端不匹配」的实际处理方式 | 低(一个 jar) | ≈300MB(JVM) |
| OpenIM | Go | 16.7k | 服务端 + Android/iOS/Flutter/Web/PC 全端客户端 | 长连接 IM 的完整工业架构: 网关层、消息可靠投递、离线推送、多端消息同步 |
高(需 Mongo+Redis+Kafka+MinIO+Etcd) | ≈4GB(你这台机器跑不动) |
| ET | C# | 9.9k | Unity 客户端 + C# 服务端(双端共享同一份协议代码) | HybridCLR 客户端代码热更 + 服务端 DLL 热重载 + Location Server 路由 + 中文文档 |
高(需 Unity + VS2022) | 开发机需求高 |
| Skynet | C | 14.2k | 只有服务端框架(无客户端) | Actor 调度模型、Lua 层热更新(国内游戏服务端的祖师爷级项目) | 中 | ≈100MB |
你的服务器只剩约 3.5GB 内存、2 个 CPU 核、且 tailscaled 常驻吃掉 28% CPU。所以:
它是 Node.js 生态的,和你已经会的 npm 流程完全一致;服务端和客户端 SDK 都是 TypeScript,一套语言看两端;代码量小,能把「房间生命周期」这个概念看透。启动只要一条命令。
对应的知识点:长连接、房间(Room)、状态同步(State Sync)、断线重连、客户端与服务端的消息协议。
cd /root && git ls-remote --heads https://github.com/colyseus/colyseus.git | head -5
git ls-remote 只列出远端分支,不下载任何东西。
HEAD 和分支名是哈希值,不是文件名。能看到一堆哈希就说明通了。cd /root && git clone --depth 1 https://github.com/colyseus/colyseus.git colyseus-study
cd colyseus-study && ls -1 | head -20 && cat package.json | head -30
--depth 1:浅克隆,只拿最新一次提交,不拉全部历史。学习用途足够,能省大量时间和磁盘。ls -1:每行一个地列目录。先看它怎么分层——这一步的目的是找出「服务端代码」和「客户端 SDK」分别在哪个目录,这是你理解两端关系的第一步。cat package.json:看它的 workspaces 字段。Monorepo 项目会把多个包放在一个仓库里,看清这个结构你就知道发布时哪些包需要各自打版本。RustDesk 的结构和「游戏客户端 + 服务端」几乎一模一样,但轻得多:
hbbs(信令/ID 服务器)+ hbbr(中继服务器),两个都是单个二进制文件,几十 MB,能在你的机器上跑要读的代码:客户端里搜 update 相关模块,看它怎么「检查新版本 → 下载 → 校验 → 替换自己」。这就是 launcher/updater 的真实实现。
cd /root && git clone --depth 1 https://github.com/rustdesk/rustdesk.git rustdesk-study
cd rustdesk-study && grep -ril 'check_update\|check_update_version\|update_from_url' --include=*.rs . | head -20
grep -ril:-r 递归、-i 忽略大小写、-l 只输出文件名(不输出匹配内容,避免刷屏)。--include=*.rs:只在 Rust 源码里搜,跳过文档和资源文件。ss -lntp | grep -E ':21115|:21116|:21117' || echo "端口空闲,可以起服务"
docker run -d --name hbbs --restart=unless-stopped --net=host \
rustdesk/rustdesk-server:latest hbbs
docker logs --tail 30 hbbs
ss -lntp | grep ...:先检查端口有没有被占用。21115/21116/21117 是 RustDesk 服务端的默认端口。先检查再启动是个好习惯,能避免「启动了但连不上」的困惑。docker run -d:后台运行。--restart=unless-stopped:除非手动停,否则开机自启。--net=host:直接使用宿主机网络(RustDesk 需要大量 UDP 端口,桥接模式很麻烦)。docker logs --tail 30:看最近 30 行日志确认启动成功。服务端启动后第一件事永远是看日志。注意:这条命令会真的启动一个容器。不想留就 docker rm -f hbbs 清掉。
我在官方文档里核实过:Luanti 的网络协议页明确写着「当前最低协议版本是 24」,客户端和服务端在握手的第一时间就交换协议版本,不在范围内直接拒绝。你的环境里「版本闸门」应该长什么样,看它就够了。
更能说明问题的是它的一个真实事故:有用户用 5.5.1 版本的安卓客户端连 5.6.1 的服务端,结果服务端直接崩溃,把所有在线玩家踢下线(该问题后来被修复)。这说明——版本不匹配如果只做了「拒绝」而没有做好错误处理,是会从一个客户端的问题升级成全服事故的。
cd /root && git clone --depth 1 https://github.com/luanti-org/luanti.git luanti-study
cd luanti-study && grep -rn 'LATEST_PROTOCOL_VERSION\|MIN_PROTOCOL_VERSION\|PROTOCOL_VERSION' \
src/network/networkprotocol.h | head -20
networkprotocol.h 这个头文件里,所以直接去那里找。PROTOCOL_VERSION 类常量是编译期常量——也就是「协议版本」是写死在代码里的,改一次就要重新编译发版。这正是「协议变更 = 不可热更」的物理原因。TOSERVER_INIT(客户端发给服务端的第一个包)看它怎么校验版本、校验失败后怎么回应。cd /root/luanti-study && grep -n -A12 -i 'version scheme\|versioning' README.md | head -40
Mindustry 是一个 Java 写的塔防 RTS,客户端和专用服务端是同一个 jar 用不同参数启动(java -jar server-release.jar)。它会在玩家连接时校验版本,版本不一致会直接提示。
学习点:这是「单体发布物,两种运行角色」的形态——和「一份源码出两个产物」的思路一致,但更简单。适合对比理解。
cd /root && git clone --depth 1 https://github.com/Anuken/Mindustry.git mindustry-study
cd mindustry-study && grep -rn 'version' core/src/mindustry/core/NetServer.java | head -20
NetServer.java 是它的服务端网络核心。先找到「核心文件」,再在里面搜关键词,比全仓库搜快得多。OpenIM 的服务端依赖 MongoDB + Redis + Kafka + MinIO + Etcd 一整套。你这台 2 核 3.5G 可用的机器跑不动,所以我的建议是:只克隆代码读架构,不部署。
它值得读的原因是:IM 的架构和游戏服务端几乎同构——都是长连接 + 网关层 + 消息路由 + 离线存储 + 多端同步。而且它客户端覆盖 Android / iOS / Flutter / Web / PC 五端,是观察「多端版本共存」的真实样本。
cd /root && git clone --depth 1 https://github.com/openimsdk/open-im-server.git openim-study
cd openim-study && ls -1 cmd/ && echo "--- 网关/长连接相关 ---" && ls -1 internal/ 2>/dev/null | head -20
ls -1 cmd/:Go 项目的惯例是每个可执行程序在 cmd/ 下有一个目录。看这里就知道它拆了几个服务进程——这就是 1.2 节讲的「服务端分工」的真实样子。ls -1 internal/:内部实现包。看目录名能看出它的分层(网关、消息、推送、存储…)。ET 是国产的 Unity 双端框架,文档全中文。它有三个设计直接对应本文的概念:
它需要 Unity + VS2022 才能跑起来,所以建议读文档和代码结构,不部署。仓库里的 Book/ 目录是中文教程。
cd /root && git clone --depth 1 https://github.com/egametang/ET.git ET-study
cd ET-study && ls -1 Book/ | head -30
Book/ 里是分章节的中文教程(运行指南、热更、网络、服务器架构等),比读代码快得多。云风的 Lua 游戏服务端框架。它没有客户端,所以不适合学「双端发布」,但它是理解 Actor 调度模型和Lua 层热更新最好的材料——国内大量商业游戏服务端的架构思路都源自它。
cd /root && git clone --depth 1 https://github.com/cloudwu/skynet.git skynet-study
cd skynet-study && ls -1 service/ | head -30
service/ 目录。Skynet 里每个「服务」就是一个 Actor 进程单元(gate 网关、logger 日志、harbor 跨节点路由等)。
harbor 和 gate 两个:前者是「跨机器路由」,后者是「客户端接入」。这两个就是 1.2 节讲的「网关服」和「位置服」的最简实现。| 顺序 | 项目 | 花多久 | 你要能回答的问题 |
|---|---|---|---|
| 1 | 第 9 章的自建 demo | 半天 | 版本闸门、Feature Flag、摘流 分别解决了什么问题? |
| 2 | Colyseus | 1 天 | 一个「房间」的生命周期是怎样的?客户端断线重连后怎么恢复? |
| 3 | RustDesk | 1 天 | 客户端怎么知道有新版本?下载完怎么保证没被改坏? |
| 4 | Luanti | 1 天 | 协议版本号为什么必须独立于软件版本号?不匹配时应该怎么处理? |
| 5 | Nakama | 2 天 | 一个成熟的游戏后端把哪些能力做成了「开箱即用」? |
| 6 | OpenIM / ET(读代码) | 各 1~2 天 | 工业级项目的服务端是怎么分层的?热更新在双端分别怎么实现? |
你可能注意到 outputs/demo/ 目录里没有构建脚本、没有发布脚本、没有测试脚手架。这是刻意的:
publish.sh,回车,看到「成功」,但中间发生了什么你并不知道。学习阶段最需要的是看见每一步。ruoyi-gateway.jar 一样。没有它们就没有东西可发布。| 文件 | 它是什么 | 类比若依项目 |
|---|---|---|
server/server.js | 服务端程序(要被部署到集群里的那个东西) | ruoyi-gateway.jar |
server/Dockerfile | 怎么把服务端打成镜像 | 同样有一个 Dockerfile |
client/launcher.js | 客户端程序(要被分发给用户的那个东西) | 网页项目里没有对应物 |
manifest/manifest.json | 版本清单模板(你要照着填自己的真实数值) | 网页项目里没有对应物 |
k8s/rollout-canary.yaml | 服务端的部署清单(Apply 给 K8s 的) | k8s/deployment.yaml |
每次开始动手前,先花 10 秒确认机器状态。这三个数字决定了你今天能做什么、不能做什么。
nproc; uptime; free -m; df -h /
nproc → CPU 核数。你的机器是 2。uptime → 最后的三个数字是 1/5/15 分钟平均负载。判断标准是「负载 ÷ 核数」:2 核机器上负载 0.8 就意味着 40% 已被占用。你的机器空闲时就是 0.79/0.87/0.82,所以要记得给自己留余量。free -m → 看 available 那一列(真正能用的),不是 free。同时看 Swap——你的机器 Swap 是 0,意味着内存一紧张就会直接被 OOM Killer 杀进程,没有缓冲。所以起新服务前一定先看 available。df -h / → 根分区剩余空间。你的机器剩 42G。ps aux --sort=-%cpu | head -6; echo '---'; ps aux --sort=-%mem | head -6
ps aux --sort=-%cpu:按 CPU 占用降序排列(- 表示降序),head -6 只看前 5 行 + 表头。tailscaled 占 28% CPU——它同时在为整个 k8s 集群做中继。这是你环境里最大的常驻开销,知道它在哪,排查「机器为什么慢」时就不会走错方向。Nexus 在你机器上已经装好但从未启动(/opt/nexus,端口 7878,不是默认的 8081;数据目录 /opt/sonatype-work/nexus3 只有 12M,说明初始化过但基本没用)。我们要让它可以上传/下载文件——这就是「客户端产物仓库」。
ss -lntp | grep ':7878' || echo "7878 端口空闲,可以启动"
ss -lntp:-l 只看监听中的、-n 显示数字端口(不解析成服务名)、-t TCP、-p 显示占用进程。|| echo ...:|| 表示「前面失败才执行后面」。grep 没找到会返回非零退出码,所以就会打印那句话。sudo -u nexus /opt/nexus/bin/nexus start
sudo -u nexus:以 nexus 这个系统用户的身份运行。Nexus 出于安全考虑拒绝以 root 启动,直接 /opt/nexus/bin/nexus start 会报错退出。/opt/nexus/bin/nexus:官方的启停脚本。你的机器上没有 systemd 服务单元(systemctl list-unit-files | grep nexus 会是空的),所以只能用这个脚本。start:启动。stop / status / restart 同理,脚本支持这些参数。free -m 的 available 够。sleep 30; ss -lntp | grep ':7878'; curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:7878/
sleep 30:Nexus 是 JVM 应用,启动要 20~40 秒。不是它卡住了,是 JVM 要初始化。ss -lntp | grep ':7878':确认端口真的在监听。看到 LISTEN 那行就说明成功了。curl -s -o /dev/null -w 'HTTP %{http_code}':-s 静默(不打印进度)、-o /dev/null 丢弃响应体、-w 只打印我们关心的格式。这是检查 HTTP 服务是否存活的通用写法,比 curl 出来一整页 HTML 干净得多。期望看到 HTTP 200。cat /opt/sonatype-work/nexus3/admin.password
http://<服务器IP>:7878,用户名 admin。curl -u admin:'你刚设的新密码' -X POST http://127.0.0.1:7878/service/rest/v1/repositories \
-H 'Content-Type: application/json' -d '{
"name": "game-client",
"format": "raw",
"type": "hosted",
"online": true,
"storage": {
"blobStoreName": "default",
"strictContentTypeValidation": false,
"writePolicy": "allow_once"
}
}' -w '\nHTTP %{http_code}\n'
-u admin:'密码':HTTP Basic 认证。注意单引号——密码里有 @ 这类特殊字符时,不加引号会被 shell 解释掉。-X POST:这是「创建」操作,不是查询。"format": "raw":关键字段。raw 类型表示「不按 Maven/npm 等格式解析,就当一个普通文件仓库」——正适合放安装包和补丁。"type": "hosted":本地托管(我们上传到它这里),区别于 proxy(代理别人)和 group(聚合) 。"writePolicy": "allow_once":这是本仓库最重要的一条策略——同一个路径只能写一次,不允许覆盖。为什么:产物一旦发布就不许改。如果允许覆盖,「1.2.0 版安装包」在不同时间下载到的内容可能不一样,客户端校验哈希就会失败,而且你永远说不清线上跑的是哪一个包。strictContentTypeValidation: false:不校验 MIME 类型(补丁文件是自定义二进制格式,校验会误拦)。-w '\nHTTP %{http_code}\n':最后打印状态码。201 或 204 表示创建成功。echo "hello v1.0.0" > /tmp/test.txt
curl -u admin:'密码' --upload-file /tmp/test.txt \
http://127.0.0.1:7878/repository/game-client/client/1.0.0/test.txt -w 'upload HTTP %{http_code}\n'
curl -s -u admin:'密码' http://127.0.0.1:7878/repository/game-client/client/1.0.0/test.txt
--upload-file:curl 的上传参数,直接把本地文件 PUT 到目标 URL。URL 里的路径就是仓库里的目录结构(client/1.0.0/test.txt)。hello v1.0.0。能看到就说明「客户端产物分发通道」已经通了。400 而不是成功——这就是「已发布的东西不许改」。curl 返回 200game-client 创建成功(HTTP 201/204)allow_once 生效)先花 20 分钟读一遍 server/server.js 和 client/launcher.js。它们不是给你「运行一下就完事」的脚本,而是要发布的两个产物。读的时候对照第 1 章的概念:
在 server.js 里找 | 对应 1.x 节的哪个概念 |
|---|---|
MIN_PROTOCOL / CURRENT_PROTOCOL | 协议兼容窗口(1.4) |
握手时检查 msg.protocol 并返回 reject | 版本闸门(1.4) |
握手时检查 msg.configVersion | 配置表版本校验(1.8) |
FEATURE_FLAGS 环境变量 | Feature Flag(1.3) |
bucketOf() 函数 | 分桶与哈希雪崩(1.5) |
ROLLOUT_PERCENT | 放量百分比(1.5) |
ready 变量 + /healthz 返回 503 | 摘流(1.6) |
/livez 始终返回 200 | readiness 与 liveness 分离(1.6) |
drain() 函数 | 优雅停机(1.6) |
export PATH=/root/node/bin:$PATH
cd /root/ArgoCD-study/demo/server 2>/dev/null || cd ~/demo/server
npm config set registry https://registry.npmmirror.com
npm install
FEATURES=new_map,new_shop SERVER_VERSION=1.2.0 node server.js
export PATH=/root/node/bin:$PATH:把 Node 加进 PATH。你的 Node 装在 /root/node/bin,不是系统默认路径。npm config set registry https://registry.npmmirror.com:把 npm 源换成国内镜像。你的 Dockerfile 里也做了这一步,所以构建镜像时不会因为拉不到包而失败。npm install:安装依赖(这个项目只依赖一个 ws 包,用来做 WebSocket 服务端)。FEATURES 就是 Feature Flag。改这个值重启,就相当于「服务端下发不同的开关配置」。[4, 5]、configVersion、当前开关。这三行就是「服务端能力声明」。按 Ctrl+C 停止,或者另开一个终端继续下一步。
export PATH=/root/node/bin:$PATH && cd ~/demo/client
node launcher.js --user 1001 --protocol 5 # ① 新客户端:应通过,并拿到 2 个功能
node launcher.js --user 1001 --protocol 4 # ② 老客户端:应通过,但功能列表为空
node launcher.js --user 1001 --protocol 3 # ③ 太老的客户端:应被版本闸门拒绝
node launcher.js --user 1001 --config cfg-x # ④ 配置表版本不一致:应要求先热更
[4,5] 内,所以放行;但因为它是老协议,服务端不下发新功能。这一条完整演示了「老客户端还能玩,只是看不到新功能」——也就是你推断三的实现。MIN_PROTOCOL=4,服务端返回 reject 并告知最低要求。这就是「强制更新」的技术本质——不是客户端自己想更新,是服务端不让它进。export PATH=/root/node/bin:$PATH && cd ~/demo/client
for u in $(seq 1000 1099); do node launcher.js --user $u --no-update 2>/dev/null; done \
| grep -c '是否命中放量桶:true'
grep -c 只输出计数,不输出内容。
ROLLOUT_PERCENT=100(全部命中),所以想看到真实分布,要把服务端用 ROLLOUT_PERCENT=20 重启后再测,此时 100 个用户里应该命中约 20 个。# 终端 A:起服务端(把排空宽限期设短一点,方便观察)
export PATH=/root/node/bin:$PATH && cd ~/demo/server
DRAIN_GRACE_MS=3000 node server.js
# 终端 B:跑一个客户端连上
export PATH=/root/node/bin:$PATH && cd ~/demo/client
node launcher.js --no-update
# 终端 C:确认排空前 readiness 正常,然后触发摘流
curl -s http://127.0.0.1:3000/healthz; echo
curl -s 'http://127.0.0.1:3000/drain?grace=3000'; echo
curl -s http://127.0.0.1:3000/healthz; echo # ← 现在应该是 503(= 摘流生效)
curl -s http://127.0.0.1:3000/livez; echo # ← 仍然是 200(= 没被判定为卡死)
DRAIN_GRACE_MS=3000:把排空宽限期从默认 20 秒改成 3 秒,这样你不用等太久。curl /healthz 返回 503 是故意的——readiness 失败 → K8s 会把 Pod 从 Endpoints 摘掉 → 新连接不再进来。这就是「摘流」的实现方式:不是去调负载均衡的 API,而是让自己的就绪探针失败。curl /livez 仍然是 200——这是为了告诉 K8s「我活着,别重启我」。如果这两个探针用同一个接口,排空会被 K8s 的重启打断(1.6 节的经典错误)。{"type":"reconnect","message":"服务即将重启..."},而不是异常断开。这就是「用户感受到的是卡顿,而不是掉线」。pkill -TERM -f "node server.js"。它和 /drain 调用的是同一个函数。把「客户端从 1.0.0 更新到 1.2.0」这条链路完整跑通,而且每一步都是你自己敲的:打包 → 生成差量补丁 → 算哈希 → 写清单 → 原子发布 → 客户端自动更新。
dnf install -y xdelta
xdelta 在 EPEL 源里(你的机器已启用 epel 仓库,实测可安装 xdelta.x86_64 3.1.0-17.el9)。xdelta3 -V 能看到版本号就成功了。mkdir -p /opt/patches/client/1.0.0 /opt/patches/client/1.2.0
cd /opt/patches/client
# 用随机数据模拟两个体积不同的包(真实项目里这里是你的构建产物)
dd if=/dev/urandom of=1.0.0/full.tar.gz bs=1k count=2000
dd if=/dev/urandom of=1.2.0/full.tar.gz bs=1k count=2080
ls -lh 1.0.0/full.tar.gz 1.2.0/full.tar.gz
mkdir -p:-p 表示父目录不存在就一起建,已存在也不报错。dd if=/dev/urandom of=... bs=1k count=2000:生成 2000KB(约 2MB)的随机文件,用来模拟安装包。bs=1k count=2000 就是 1000 字节 × 2000 块。真实项目里这一步是「执行构建脚本」,这里只是造个有体积的假包。ls -lh:-h 让体积以人类可读形式显示(KB/MB),而不是字节数。cd /opt/patches/client
xdelta3 -e -s 1.0.0/full.tar.gz 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch
ls -lh 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch
-e:encode,表示「生成补丁」(对应的 -d 是 decode,即应用补丁)。-s 1.0.0/full.tar.gz:源文件(玩家本地已有的那个版本)。-s 是 source 的意思。cp 1.0.0/full.tar.gz 1.2.0/full.tar.gz && echo change >> 1.2.0/full.tar.gz,这样生成的补丁就只有几 KB。cd /opt/patches/client
sha256sum 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch
stat -c '%n %s bytes' 1.2.0/full.tar.gz 1.0.0-to-1.2.0.patch
sha256sum:输出「64 位十六进制哈希 + 文件名」。它的用途是完整性校验——客户端下载完文件后重算一次,和清单里的值比对,不一致就说明文件损坏或被篡改。stat -c '%n %s bytes':-c 表示自定义输出格式。%n 是文件名,%s 是文件字节数。不要用 ls -l 去抄体积,它显示的是 KB/MB 会四舍五入,清单里需要精确字节数。cd /opt/patches/client
cat > manifest.json.tmp <<'EOF'
{
"latest": { "client": "1.2.0" },
"minSupported": { "client": "1.0.0" },
"recommended": { "client": "1.2.0" },
"protocol": { "min": 4, "current": 5 },
"configVersion": "cfg-20260924-01",
"rollout": { "enabled": true, "percent": 20 },
"forceUpdate": { "ios": false, "android": false },
"packages": [
{ "platform": "linux-x64", "version": "1.2.0", "type": "full",
"url": "http://127.0.0.1/patches/1.2.0/full.tar.gz",
"size": 把上一步的字节数填这里,
"sha256": "把上一步的哈希填这里" }
],
"patches": [
{ "platform": "linux-x64", "from": "1.0.0", "to": "1.2.0", "type": "delta",
"url": "http://127.0.0.1/patches/1.0.0-to-1.2.0.patch",
"size": 把上一步的字节数填这里,
"sha256": "把上一步的哈希填这里" }
]
}
EOF
python3 -m json.tool manifest.json.tmp
cat > 文件 <<'EOF' ... EOF:heredoc 写法,把两个 EOF 之间的内容写进文件。注意 'EOF' 要加单引号——不加的话 shell 会把内容里的 $ 和反引号当变量展开,JSON 就会被破坏。.tmp:下一步会解释。这一步是「先写临时文件」。python3 -m json.tool:把 JSON 格式化并校验语法。如果 JSON 写错了(少个逗号、多个括号),这条命令会直接报错并指出行号。你要用这个当「语法检查器」——不要等客户端来发现你的 JSON 是坏的。latest / minSupported / recommended 三个版本号决定客户端是「不更新 / 可选更新 / 强制更新」。protocol 是给服务端做版本闸门用的兼容窗口。configVersion 用来防「配置表只发了一半」。rollout.percent: 20 表示只给 20% 的用户开放更新(分桶决定谁命中)。packages[] 是整包兜底,patches[] 是优先使用的差量补丁。cd /opt/patches/client
mv manifest.json.tmp manifest.json
ls -l manifest.json*
mv 在同一个文件系统内是原子操作:客户端要么看到完整的旧文件、要么看到完整的新文件,不存在中间态。ls -l manifest.json* 会显示只剩 manifest.json,临时文件已消失。# 先备份,再改(改配置前永远先备份)
cp /etc/nginx/conf.d/ruoyi.conf /etc/nginx/conf.d/ruoyi.conf.bak
# 在 ruoyi.conf 的 server 块内、location / 之前插入一段:
# location /patches/ {
# alias /opt/patches/client/;
# autoindex on;
# expires 1h;
# }
nginx -t # ← 先测试语法,不要直接 reload
nginx -s reload # ← 语法没问题才生效
cp ... .bak:改任何配置文件前先备份。改坏了 cp ruoyi.conf.bak ruoyi.conf && nginx -s reload 就能回滚。alias /opt/patches/client/:把 URL 路径 /patches/ 映射到磁盘目录。注意 alias 会把 location 之后的部分直接拼到目录后面。autoindex on:允许列出目录内容。学习阶段方便你确认文件在不在,生产环境建议关掉(会暴露文件结构)。nginx -t:只检查配置语法,不生效。这一步至关重要——如果配置写错直接 reload,Nginx 会拒绝加载新配置(老配置继续跑,服务不断),但你会困惑「为什么我的修改没效果」。nginx -s reload:平滑重载(不中断现有连接)。curl -s http://127.0.0.1/patches/manifest.json | python3 -m json.tool 应该能打印出格式化的 JSON。export PATH=/root/node/bin:$PATH && cd ~/demo/client
echo -n "1.0.0" > local/version.txt # 把本地版本重置为 1.0.0
node launcher.js --user 1001 --auto --url http://127.0.0.1/patches/manifest.json
echo -n "1.0.0" > local/version.txt:模拟「用户设备上装的是 1.0.0 版本」。-n 表示不追加换行符。--auto:自动接受可选更新(不传的话,弹窗类的更新会被跳过)。--url:指向我们刚刚用 Nginx 发布的清单地址。percent: 20),换个 --user 值再试,直到命中。这本身就是在体验「分阶段放量」。cd /opt/patches/client
# 故意把清单里的补丁哈希改错
python3 - <<'PY'
import json
p = "/opt/patches/client/manifest.json"
m = json.load(open(p))
m["patches"][0]["sha256"] = "deadbeef" * 8 # 换成一个错误的哈希
json.dump(m, open(p + ".tmp", "w"), indent=2)
PY
mv manifest.json.tmp manifest.json # 原子替换(还是用这一招)
echo -n "1.0.0" > ~/demo/client/local/version.txt
cd ~/demo/client && node launcher.js --user 1001 --auto --url http://127.0.0.1/patches/manifest.json
[校验] SHA256 不匹配!期望 deadbeef… 实际 29c0d44c… 然后 拒绝应用该包,本地版本仍然是 1.0.0。python3 - <<'PY' ... PY:把一段 Python 脚本通过标准输入交给 Python 执行,不落盘成文件。这就是「不需要写脚本文件」的做法——一次性操作直接用 heredoc 喂给解释器。mkdir -p /root/argo-rollouts && cd /root/argo-rollouts
curl -fL --retry 3 -o install-v1.7.2.yaml \
https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.7.2/manifests/install.yaml
wc -c install-v1.7.2.yaml # 期望 929730
-f:HTTP 出错时返回非零退出码(否则 curl 会「成功」地保存一个错误页面)。-L:跟随重定向。--retry 3:失败重试 3 次。gh-proxy.com/https://raw.githubusercontent.com/...:这是把 GitHub 的原始链接套一层国内代理。实测下载 929730 字节。备用通道:ghfast.top 前缀、cdn.jsdelivr.net/gh/argoproj/argo-rollouts@v1.7.2/manifests/install.yaml。wc -c 校验字节数,确认下载完整——这是防止「下载了一半的 YAML」的第二道保险。stable 分支测试的是 1.32~1.35、master 是 1.34~1.37,都不覆盖 1.28。1.8 起已明确不支持 1.28 以下。HARBOR=106.75.29.47:10086
docker login ${HARBOR} -u admin -p '@Pl000000'
docker pull quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2
docker tag quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2 ${HARBOR}/infra/argo-rollouts:v1.7.2
docker push ${HARBOR}/infra/argo-rollouts:v1.7.2
cd /root/argo-rollouts
sed -i "s#quay.io/argoproj/argo-rollouts:#${HARBOR}/infra/argo-rollouts:#g" install-v1.7.2.yaml
grep -n 'image:' install-v1.7.2.yaml
quay.m.daocloud.io:DaoCloud 提供的 quay.io 国内代理,实测 manifest 可拉取(含 amd64)。备用:quay.nju.edu.cn/argoproj/argo-rollouts。sed -i "s#旧#新#g":用 # 当分隔符是因为路径里含 /,用 / 当分隔符要转义很麻烦。grep -n 'image:':确认替换干净——输出里不应该再出现 quay.io。kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f install-v1.7.2.yaml
kubectl -n argo-rollouts patch deployment argo-rollouts --type=strategic -p '{
"spec":{"template":{"spec":{
"nodeSelector":{"kubernetes.io/hostname":"k8s-node3"},
"imagePullSecrets":[{"name":"harbor-secret"}]
}}}}'
kubectl -n argo-rollouts rollout status deploy/argo-rollouts
kubectl get crd | grep argoproj
imagePullSecrets:Harbor 是私有仓库,需要拉取凭据。这个 secret 要先从 ruoyi 命名空间复制过来,或者手工创建。kubectl get crd | grep argoproj:确认 CRD 装上了——能看到 rollouts.argoproj.io 和 analysistemplates.argoproj.io 就成功了。# 终端 A:实时看发布进度
kubectl argo rollouts get rollout demo-game-server -n ruoyi --watch
# 终端 B:换镜像触发新一次发布
kubectl argo rollouts set image demo-game-server \
server=106.75.29.47:10086/ruoyi-cloud/demo-game-server:1.1.0 -n ruoyi
# 想提前推进:kubectl argo rollouts promote demo-game-server -n ruoyi
# 想中止并回滚:kubectl argo rollouts abort demo-game-server -n ruoyi
--watch:持续刷新状态。你会看到 setWeight 从 10% → 30% → 60% → 100%,中间有 pause 等待窗口。set image:改镜像 tag,这就是触发一次新发布的动作。在 GitOps 流程里这一步由 Argo CD 改清单完成,这里手工触发是为了让你看到过程。abort:这个命令请务必练熟。它会把流量切回 Stable 版本,是新版本出问题时的第一反应。这是最有价值的一步——把上面几个阶段的机制组合起来,逐个复现 8 种状态,并记录每种状态下你需要哪些操作、耗时多久、能不能回滚。演练完你会得到一张属于自己环境的表,比任何文档都值钱。
| 演练 | 操作 | 观察重点 | 要回答的问题 |
|---|---|---|---|
| ① 纯服务端发布 | 只更新服务端镜像,客户端不动 | 老客户端连接是否正常 | 我的服务端真的向后兼容吗? |
| ② 纯客户端资源热更 | 只更新资源包 + manifest | 客户端能否静默更新成功 | 热更失败时能否退回旧资源? |
| ③ 新功能(服务端+热更+开关) | 服务端加接口,客户端热更,开关先关后开 | 关开关时新入口是否消失 | 开关生效要多久? |
| ④ 可选更新 | 把 recommended 提升到新版本 | 客户端弹提示、可取消 | 取消后老版本还能正常玩吗? |
| ⑤ 强制更新 | 把 minSupported 提升到新版本 | 老客户端被拒绝进入 | 怎么紧急撤回这次强制更新? |
| ⑥ 协议破坏性变更 | 服务端把 MIN_PROTOCOL 改成 5 | proto 4 客户端全部失败 | 能不能快速切回双协议? |
| ⑦ 配置表不一致 | 只改服务端 CONFIG_VERSION | 客户端是否检测到不一致 | 不一致时的兜底行为是什么? |
| ⑧ 金丝雀中途 abort | 发金丝雀,20% 时执行 abort | 流量回切速度、在线连接是否受影响 | abort 到完全恢复要多久? |
cd /opt/patches/client
# 第一步:提升最低支持版本 → 触发强制更新
python3 - <<'PY'
import json
m = json.load(open("/opt/patches/client/manifest.json"))
m["minSupported"]["client"] = "1.2.0" # 低于 1.2.0 的客户端一律拒绝
json.dump(m, open("/opt/patches/client/manifest.json.tmp", "w"), indent=2)
PY
mv manifest.json.tmp manifest.json
# 第二步:从 1.0.0 的客户端发起 —— 应该被强制更新(无需 --auto)
echo -n "1.0.0" > ~/demo/client/local/version.txt
cd ~/demo/client && node launcher.js --user 1001 --url http://127.0.0.1/patches/manifest.json
# 第三步(关键):紧急撤回强制更新 —— 把最低支持版本改回去
python3 - <<'PY'
import json
m = json.load(open("/opt/patches/client/manifest.json"))
m["minSupported"]["client"] = "1.0.0" # 撤回
json.dump(m, open("/opt/patches/client/manifest.json.tmp", "w"), indent=2)
PY
mv manifest.json.tmp manifest.json
echo "已撤回,重新跑一次客户端应能正常进入"
minSupported 改回旧值 → 原子替换 manifest → 确认老客户端能重新登录。这个动作要练到 3 分钟内能完成。.tmp + mv 的原子替换。这种场景下尤其不能省——你在撤回的时候如果写坏了 manifest,就是雪上加霜。| # | 误区 | 真相 |
|---|---|---|
| 1 | 「客户端更新了,服务端就不用管」 | 服务端仍要动 manifest、开关、兼容逻辑。而且服务端的兼容压力会变大——要同时服务已升级和未升级的用户 |
| 2 | 「服务端可以像网页那样随便改接口」 | 老客户端是你改不动的存量。接口必须只加不改不删 |
| 3 | 「客户端出问题回滚一下就行」 | 客户端升级不可逆。只能发新版或服务端关开关 |
| 4 | 「客户端也能做蓝绿」 | 客户端做不了蓝绿(用户设备不能瞬间切换)。能做的是放量控制 + 功能开关 |
| 5 | 「灰度 = 按比例」 | 按比例是金丝雀;灰度的核心是按人群/维度。两者常组合使用 |
| 6 | 「把客户端代码热更当成万能药」 | 热更范围有硬边界(iOS 审核限制、编译层代码、底层协议),且热更失败可能让客户端卡在更新界面 |
| 7 | 「配置表是一份文件」 | 它是两份必须同步的产物(服务端用 + 客户端用)。必须做版本校验 |
| 8 | 「发布完就结束了」 | 客户端发布完成后才进入真正的观测期。要看按版本分组的成功率/崩溃率。观测是发布的一部分 |
| 9 | 「readiness 和 liveness 用一个接口就行」 | 排空时 readiness 失败会让 K8s 重启容器,直接打断你的优雅停机。必须分开 |
| 10 | 「hash 取模就是分桶」 | 没有雪崩混淆的哈希会让连续 ID 落到连续桶,放量分布严重倾斜。必须做位混合 |
# ---------- Nexus(客户端产物仓库)----------
sudo -u nexus /opt/nexus/bin/nexus start|stop|status
ss -lntp | grep 7878
cat /opt/sonatype-work/nexus3/admin.password # 首次启动才有
curl -u admin:'密码' http://127.0.0.1:7878/service/rest/v1/repositories
curl -u admin:'密码' --upload-file 本地文件 http://127.0.0.1:7878/repository/game-client/路径/文件名
# ---------- 差量补丁 ----------
dnf install -y xdelta
xdelta3 -e -s 旧版本包 新版本包 输出.patch # 生成补丁
xdelta3 -d -s 旧版本包 补丁文件 还原出的新包 # 应用补丁
# ---------- 客户端产物:算哈希与体积 ----------
sha256sum 文件
stat -c '%n %s bytes' 文件
python3 -m json.tool manifest.json # 校验 JSON 语法
# ---------- 原子发布(通用技巧)----------
mv manifest.json.tmp manifest.json # 同文件系统内是原子操作
# ---------- Nginx(分发通道)----------
nginx -t && nginx -s reload
curl -s http://127.0.0.1/patches/manifest.json | python3 -m json.tool
# ---------- Argo Rollouts ----------
kubectl -n argo-rollouts get pods
kubectl get crd | grep argoproj
kubectl argo rollouts list rollouts -A
kubectl argo rollouts get rollout <name> -n ruoyi --watch
kubectl argo rollouts set image <name> server=镜像:tag -n ruoyi
kubectl argo rollouts promote <name> -n ruoyi # 推进到下一步
kubectl argo rollouts abort <name> -n ruoyi # 中止并回滚
kubectl argo rollouts undo <name> -n ruoyi # 回退到上一版本
# ---------- 长连接服务自检 ----------
curl -s http://127.0.0.1:3000/healthz # readiness:排空时返回 503
curl -s http://127.0.0.1:3000/livez # liveness:始终 200
curl -s 'http://127.0.0.1:3000/drain?grace=3000' # 手动触发摘流+排空
pkill -TERM -f "node server.js" # 走 SIGTERM 路径(Linux)
# ---------- 环境自检 ----------
nproc; uptime; free -m; df -h /
以下地址全部在 106.75.30.92 上实测通过(2026-09-24):
| 用途 | 地址 | 结果 |
|---|---|---|
| Argo Rollouts 镜像 | quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2 | manifest 200(amd64/arm64) |
| Argo Rollouts 镜像(备) | quay.nju.edu.cn/argoproj/argo-rollouts:v1.7.2 | 200 |
| Argo Rollouts 镜像(备) | quay.io/argoproj/argo-rollouts:v1.7.2 | 200(本环境可直连) |
| install.yaml v1.7.2 | https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.7.2/manifests/install.yaml | 200 / 929730 B |
| install.yaml(备) | https://ghfast.top/https://raw.githubusercontent.com/… | 可用 |
| install.yaml(备) | https://cdn.jsdelivr.net/gh/argoproj/argo-rollouts@v1.7.2/manifests/install.yaml | 可用 |
| kubectl 插件 | https://gh-proxy.com/https://github.com/argoproj/argo-rollouts/releases/download/v1.7.2/kubectl-argo-rollouts-linux-amd64 | 200 / 126850410 B |
| xdelta | dnf install -y xdelta(EPEL:3.1.0-17.el9) | 源可用 |
| npm 源 | https://registry.npmmirror.com | 200 |
| git 加速(克隆) | https://gh-proxy.com/https://github.com/<user>/<repo>.git | 支持 git ls-remote |
| 第 8 章项目仓库 | 从你的服务器实测 git ls-remote |
|---|---|
github.com/colyseus/colyseus.git | 通 |
github.com/rustdesk/rustdesk.git | 通 |
github.com/luanti-org/luanti.git | 通 |
github.com/Anuken/Mindustry.git | 通 |
github.com/openimsdk/open-im-server.git | 通 |
github.com/egametang/ET.git | 通 |
github.com/cloudwu/skynet.git | 通 |
github.com/heroiclabs/nakama.git | 通 |