Skip to content

怎么用这份文档

  • 第一次看:先读第 1 章(术语),不然第 2 章之后会处处卡壳。术语章当字典用,不必背。
  • 想理解原理:第 2~7 章,从「为什么网页那套不能照搬」一路推到「你的推断对不对」。
  • 想动手:第 8 章挑一个开源项目,第 9 章按阶段推进。每一条命令下面都写了「这条命令在干什么、为什么要它」。
  • 本机环境106.75.30.92(2 核 / 7.5G 内存 / 42G 可用磁盘,已跑 Jenkins + k8s 控制平面)。

01 术语与概念详解

这一章把后面会用到的所有名词讲清楚。如果你完全没接触过客户端/服务端软件,这一章是地基——它解释的都是「为什么这类软件天生比网页难发布」。

1.1 网络通信层:一切差异的起点

短连接 vs 长连接

短连接(Short-lived Connection)

客户端发一个请求,服务端返回结果,连接就关掉。浏览器访问网页、你现在的若依微服务、REST API 都是这种。

关键性质:服务端完全不需要「记住」你。任意一个实例都能处理你的下一个请求,所以可以随便增删实例、随便重启——这就是「无状态」的物理基础。

长连接(Persistent / Long-lived Connection)

客户端连上后一直保持不断,服务端可以随时主动推消息给它。网络游戏、IM 聊天、协同编辑、远程桌面都是这种。

关键性质:服务端实例上寄存着会话状态(你是谁、你在哪个房间、你的角色在哪)。这个实例一死,上面所有玩家的连接就全断了,而且状态丢了。

这就是为什么游戏服务端不能像网页那样随便滚动更新——也是整个文档里最核心的一条差异。

对比项短连接(网页/API)长连接(游戏/IM)
连接生命周期毫秒~秒级分钟~小时,甚至整天
服务端内存态无需保存必须保存(会话、房间、实体)
谁主动发消息只能客户端问、服务端答服务端可随时主动推送
重启实例的代价下一次请求换个实例即可,用户无感该实例上所有玩家掉线
能否水平扩容直接加实例即可需要「分线/分服」或状态外置,复杂得多
典型协议HTTP/1.1、HTTP/2、gRPCWebSocket、TCP 裸协议、KCP、ENet、QUIC

传输协议家族(选哪个决定了你的发布有多难)

协议底层可靠性延迟典型用途注意点
TCPTCP可靠、有序较高(丢包会阻塞后续包)登录、支付、存档、IM 文本「队头阻塞」:丢一个包,后面全等着,弱网下体感很差
WebSocketTCP可靠、有序同 TCP网页端实时通信、H5 游戏本质是「HTTP 升级后的 TCP」,穿过代理/防火墙友好
HTTP 长轮询TCP可靠老式实时方案、兼容性兜底服务端压力大、延迟高,属于过渡方案
gRPCHTTP/2可靠服务端之间的内部调用不是给玩家客户端用的;适合微服务互调
UDPUDP不可靠、无序极低实时对战(FPS/MOBA)的位置同步丢包不管,宁可丢也不等——但业务层要自己处理丢包
KCPUDP可靠(重传算法激进)国内弱网手游用带宽换延迟。国内网络环境下的常用选择
ENetUDP可靠+不可靠混合通道MOBA(英雄联盟用的就是它)可以同时开「可靠通道」和「快速通道」,各走各的
rUDPUDP可靠Nakama 的实时通信「reliable UDP」的统称写法
QUICUDP可靠、多路复用新一代 HTTP/3、部分游戏解决了 TCP 的队头阻塞,是趋势

为什么这件事和「发布」有关?

协议一旦上线,老客户端就锁死在这个协议上了。你换了协议(比如 TCP 换 KCP),所有没更新的客户端立刻连不上。所以协议体系通常设计成「可切换」的——ET 框架就明确支持 TCP / KCP / ENet / WebSocket 运行时可切换且不断线,这样服务端换协议时老客户端仍能工作。

三个长连接的必备机制

① 心跳(Heartbeat)

双方定期互发一个小包(比如每 30 秒),用来判断「对面还活着吗」。

为什么必须要有:TCP 连接在中间链路断掉(比如手机切 WiFi、NAT 超时)时,两端可能都不知道,会一直以为连着。心跳就是用来发现这种「假连接」的。
工程要点:心跳要有超时阈值 + 重试次数,超了就判定掉线并触发重连。没有心跳的长连接服务一定会积累一堆僵尸连接。

② 粘包 / 拆包(Sticky & Fragmented Packets)

TCP 是「字节流」,它不保证你发一次、对面就收一次。你连发两个包,对面可能一次收到两个黏在一起;一个大包可能被拆成几次收到。

解决方案:定义帧结构,常见有三种——
· 固定长度头 + 长度字段(最常用)
· 特殊分隔符(如 \n
· 定长包
这是写游戏服务端第一个要解决的问题,也是面试高频题。

③ 断线重连(Reconnect)

连接断了之后,客户端要能自动重连,并且恢复到断线前的状态(比如重新进入原来的房间、恢复角色位置)。

为什么和发布有关:如果服务端只支持「重连后从零开始」,那么每次服务端滚动更新都会让玩家体验很差。成熟的做法是:服务端发布前主动通知客户端「我要重启了,请重连到别的实例」,客户端带着会话令牌重连——这就是会话迁移摘流

状态同步 vs 帧同步(游戏特有的两种架构)

这个概念决定了服务端要存多少状态,从而决定了发布难度:

状态同步(State Sync)帧同步(Lockstep)
做法服务端算出结果,把「谁在哪、血量多少」推给客户端服务端只转发玩家的操作指令,每个客户端各自算
服务端状态量大(要保存整个世界状态)小(只保存指令序列)
典型游戏MMO、SLG、卡牌RTS、MOBA、格斗
发布影响服务端重启会丢失世界状态,需要额外做快照/持久化一局内的指令序列丢了就得重开这局,所以通常要求整局结束后才允许更新该实例
反外挂强(逻辑在服务端)弱(逻辑在客户端,需要额外校验)

1.2 服务端架构层

服务端的分工角色(游戏/IM 类项目通用)

网页项目通常只有「网关 + 业务服务」。游戏类项目会分得更细,因为每类工作的特征完全不同:

角色职责有无状态重启代价发布策略
网关服 / 接入服
(Gateway / Gate)
与客户端保持长连接,做协议加解密、限流、消息转发半状态(只存连接)玩家掉线摘流 + 引导重连
登录服 / 认证服
(Login / Auth)
验证账号、发令牌、分配区服无状态几乎无感蓝绿 / 金丝雀,随便发
逻辑服 / 游戏服
(Game / Logic)
跑游戏核心逻辑,保存房间、战斗、玩家实体强状态该服所有玩家掉线分服灰度,一服一服地升
DB 代理服
(DB Proxy)
统一收口数据库读写,做缓存与批量写无状态无感滚动更新
位置服 / 路由服
(Location / Router)
记录「哪个实体在哪个进程」,实现跨服消息投递轻状态(可重建)短暂不可用先升,或在低峰期升
匹配服
(Matchmaking)
把玩家组成一局轻状态无感滚动更新
聊天服世界/公会/私聊轻状态无感滚动更新

这个分工的工程意义

看出来了吗?「无状态的那部分可以随便发,有状态的那部分才需要小心发」。所以成熟架构会刻意把逻辑从有状态服务里抽出来。

ET 框架的做法更彻底:它用 Location Server 统一记录实体位置,任何服务器只要知道实体 ID 就能给它发消息,不用关心它在哪个进程、哪台机器上。这样逻辑服就可以相对自由地迁移和重启。

Actor 模型 / Fiber / 协程

Actor 模型

把每个「有状态的对象」(一个玩家、一个房间、一个公会)当成一个 Actor。Actor 之间不共享内存,只能通过发消息通信。

好处:天然无锁、天然可分布。因为不共享内存,所以这个 Actor 在哪个进程里都行,理论上可以像搬家一样迁移到别的机器上——这让「重启某个实例」这件事的影响面变得可控

Fiber / 协程(Coroutine)

一种比线程更轻的并发单位。一个线程可以跑成千上万个 Fiber,切换代价极小。

在游戏服务端的用途:让每个玩家的逻辑看起来像「顺序执行」的代码(写起来简单),实际上是并发跑的(性能好)。ET 的 Fiber 还支持父子关系,父 Fiber 销毁时子 Fiber 一起清理——这解决了「玩家掉线后残留任务」的问题。

会话(Session)与会话迁移(Session Migration)

会话

「这个连接属于哪个玩家、已经登录了没有、当前在哪个房间」这组信息的集合。它存在于网关服和逻辑服的内存里。

会话迁移要解决的问题:网关服要升级重启,但上面挂着 5000 个玩家的连接。
做法:新网关实例起来 → 老实例通知客户端「请重连到新地址」→ 客户端带上会话令牌重连 → 新实例从数据库/缓存里恢复会话。
效果:玩家感受到的是「一瞬间的卡顿」,而不是「掉线回登录页」。

分区 / 分服 / 合服

概念含义发布意义
区 / 服(Zone / Server)一套独立运行的逻辑服 + 一份独立的存档数据。玩家登录时被分配到某个区这是游戏服灰度的天然单位——可以「今天升 1~3 区,明天升 4~6 区」
开新服上一个全新的区本质是蓝绿的一种变体:新版本在新服跑,老服不动
合服把几个冷清的老区合并成一个这是最危险的一类发布,涉及大量数据迁移、ID 冲突、排行榜重算。必须停机 + 完整演练
跨服不同区的玩家能一起玩(跨服战场)需要服务端之间协议兼容,等于把兼容窗口从「客户端 ↔ 服务端」扩展到「服务端 ↔ 服务端」

StatefulSet 与 Deployment(K8s 层面的直接区别)

DeploymentStatefulSet
Pod 名字随机后缀(app-7d9f-abc12固定序号(app-0app-1
存储共享或不用,Pod 之间无差别每个 Pod 绑定自己的 PVC
更新顺序并行滚动,随便杀默认从大到小逐个更新,等前一个 Ready 才动下一个
适用无状态服务(你的若依 7 个微服务)有状态服务(数据库、消息队列、分片游戏服)

记住这个结论:你的若依项目用 Deployment 就够了,因为它是无状态的。但一旦开始做游戏服务端,你可能需要 StatefulSet,而「更新顺序受控」正是你想要的——只是你必须自己实现排空逻辑。

PDB(PodDisruptionBudget)

PodDisruptionBudget

一句话:**「最多允许多少个 Pod 同时挂掉」**的约束。

为什么需要:K8s 节点维护(kubectl drain)或集群自动缩容时会「驱逐」Pod。如果没有 PDB,它可能把你某个服务的 3 个副本一次性全驱逐,服务直接归零。

例子:minAvailable: 2 + 3 个副本 → K8s 会保证任何时候至少有 2 个活着,驱逐会排队进行。

1.3 客户端层

launcher / updater / bootstrapper(启动器)

启动器(Launcher / Updater)

你双击游戏图标后,第一个跑起来的那个小程序。它不负责游戏逻辑,只做四件事:
① 检查自己是不是最新(启动器本身极少更新)
② 拉取版本清单,判断要不要更新游戏主体
③ 下载并应用补丁
④ 启动真正的游戏程序

为什么要把启动器单独拆出来:因为它是「唯一能修自己的东西」。如果更新逻辑写在游戏主体里,一旦游戏主程序的更新逻辑崩了,你就再也没有机会修它了——用户只能卸载重装。启动器做得越小越简单,它坏的概率就越低。

现实例子:几乎所有 PC 网游、RustDesk、Discord、以及 Electron 类桌面应用(Joplin)都有这一层。

热更新(Hot Update)的三个层次 —— 理解「客户端少量更新」的关键

层次更新的是什么要不要重启客户端各平台限制典型用途
资源热更图片、音频、模型、动画、配置表通常下次进入场景生效所有平台都允许换皮肤、改文案、上新地图素材
脚本热更用脚本语言写的逻辑(Lua / JS / TypeScript)要重新加载脚本环境所有平台都允许改数值、修活动逻辑、加小玩法。这是手游最主要的更新方式
代码热更编译型语言的逻辑代码(C# / Java / C++)要重启或重载程序集有限制:iOS 审核条款理论上禁止改变 App 主要功能的动态代码下发,实践中通过 IL 解释执行等方案绕行;Android 相对宽松Unity + HybridCLR(ET 框架用的就是这个)、ILRuntime
整包更新整个安装包重新下载安装要走应用商店审核(iOS 1~7 天,不可控)引擎升级、大版本资料片、包结构重构

客户端工程的核心目标:把变更尽量推到上面几层

因为越往上越可控、越快、风险越低。所以成熟团队会:
① 把尽可能多的业务逻辑放在脚本层而不是编译层;
② 把功能开关做成服务端下发(Feature Flag),代码已经发出去了也能关掉;
③ 把资源按分包设计,玩家只下载自己用得到的部分。

业界常说的「包体优化」「热更率」就是在这个框架下讨论的。手游能每周更新好几次而不用提交审核,靠的就是这个分层。

AssetBundle / 资源包 / 分包

  • AssetBundle(AB 包):Unity 的资源打包格式。一个 AB 包里装若干资源(贴图、模型等),可以单独下载、单独更新。其他引擎有类似机制(Godot 的 PCK、自研引擎的自定义格式)。
  • 为什么要分包:整包 2GB 一次下载完体验极差。所以做成「核心包 300MB + 按需下载」——玩家进新手村只下载新手村资源,进新地图再下新的。
  • 对发布的影响:资源包是成千上万个小文件,所以它们的更新不能靠「重新发一个安装包」,必须靠清单文件 + 增量下载。这就是为什么客户端必须有 manifest。

整包 vs 增量包(差量补丁)

整包(Full Package)增量包 / 差量补丁(Delta / Patch)
内容完整的安装包只有新旧版本之间的差异部分
体积大(几十 MB ~ 几十 GB)小(常常是整包的 1%~5%)
生成工具打包工具直接产出bsdiff / xdelta3 / hdiffpatch / 自研
使用方法直接装必须在本地已有正确旧版本的前提下应用
风险较高:补丁应用失败会产生「看起来能用但实际损坏」的包,所以必须校验哈希
何时用用户版本太旧 / 没有对应补丁 / 补丁比整包还大绝大多数日常更新

差量补丁的一个陷阱

假设玩家本地文件被改过(比如自己替换了个贴图),补丁应用后可能得到一个半新半旧的状态,程序能启动但行为诡异。这就是为什么流程里必须有一步:下载完先算 SHA256,跟清单里的值比对,不一致就丢弃并退回整包。这一步省不得。

客户端更新的四种「力度」

这是后面所有讨论的基础。同样叫「客户端更新」,这四种的成本差一个数量级:

力度机制用户感知可控性典型场景
静默热更后台悄悄替换资源/脚本,下次启动生效完全无感完全可控改数值、改文案、修 bug
可选更新启动器提示「有新版本,是否更新?」弹窗,可以点「以后再说」可控新功能、体验优化
强制更新版本闸门校验失败,拒绝进入,必须更新进不去,被迫更新可控但风险高协议不兼容、安全漏洞
整包重装下载完整安装包重新安装重新下载大包、走安装流程最不可控(商店审核)大版本、引擎升级

应用商店审核 / 分阶段发布 / TestFlight

  • 应用商店审核(App Review):iOS 提交新版本后需要 Apple 审核,通常 1~7 天,且可能被拒。这意味着你无法「想发就发」——这是 iOS 与 Android 在发布流程上最大的分叉点。
  • 分阶段发布(Staged Rollout):Google Play 支持「先给 1% 用户,再 5%、20%、100%」;App Store 也有 7 天自动放量的分阶段发布。这是客户端侧的金丝雀
  • TestFlight / 内测:上架前的灰度渠道,可以给指定用户测试。相当于「灰度白名单」。

Feature Flag(功能开关)/ 远程配置

Feature Flag

把新功能的代码随版本一起发出去,但用一个开关控制它是否显示/生效,开关的值由服务端下发。

为什么它是客户端项目的命门:客户端代码一旦发出去就收不回来(用户已经装了)。Feature Flag 是唯一能让「已经发出去的代码」失效的手段——它等效于「零成本回滚」

所以客户端项目有一个铁律:每一个新功能都必须配一个开关。没有开关的新功能 = 出问题只能发新版本 = 用户等好几天。

开关的常见用法还包括:按用户分桶放量(先给 5% 用户开)、AB 测试(一半用户看到 A 版,一半看到 B 版)、紧急止血(线上炸了秒关)。

崩溃上报 / 埋点 / 版本维度观测

  • 崩溃上报(Crash Reporting):客户端崩溃时把堆栈上传(Sentry、Bugly、Firebase Crashlytics)。这是客户端项目唯一能知道「用户那边到底怎么样」的渠道
  • 埋点(Telemetry):记录用户行为(登录成功率、关卡通过率、卡在哪个界面)。
  • 关键要求:必须按客户端版本分组。只看「总崩溃率 0.1%」没意义,要看「1.2.0 版本崩溃率 0.02%,1.1.0 版本崩溃率 0.5%」——这样才能定位是新版本引入的问题还是老版本的历史问题。
  • 而且要注意长尾:1.1.0 的用户虽然只占 2%,但如果他们的崩溃率是 30%,那也是一批正在流失的用户。

1.4 版本与兼容

一个系统里有三套版本号(很多人只想到一套)

版本号含义谁在用例子
客户端版本用户设备上装的那个包的版本启动器、商店、埋点1.2.0(build 20260924)
服务端版本运行在集群里的那套服务的版本K8s 镜像 tag、Argo CDprod-k8s-a3e752d
协议版本客户端与服务端「说话的语法」版本。这才是判断兼容性的依据握手阶段、版本闸门proto 5

为什么必须有独立的「协议版本」

因为客户端版本 1.2.0 和服务端版本之间不是一一对应的。服务端可能连发 20 个版本,协议一直没变;也可能一次改动就断了兼容。

把兼容性判断从「版本号」解耦到「协议版本号」,带来的好处是:服务端可以随便发小版本,只要协议版本不变,老客户端就一直能用。这是让「服务端高频发布」和「客户端低频发布」共存的关键设计。

真实案例(Luanti / Minetest):它把协议版本写死在代码里,官方文档明确写着「当前最低协议版本是 24」。客户端握手时先交换协议版本,不在范围内就拒绝。它甚至出过一个真实的 bug——版本不匹配的客户端把服务端搞崩了,导致所有在线玩家被踢下线。这正好说明:版本闸门做不好是会出大事故的。

版本闸门(Version Gate)

版本闸门

服务端在客户端连接时(握手阶段)做的一道检查:你的版本我支持吗?不支持就拒绝并告知该怎么做

典型响应有三种:
· 放行:协议在兼容窗口内
· 警告但放行:能玩,但推荐更新
· 拒绝:告知最低要求版本,客户端提示强制更新

它放在协议层而不是业务层,是因为这是唯一能保证「所有客户端都过这一关」的地方——你不可能在每个接口里都写一遍版本检查。

兼容窗口(Compatibility Window)/ 多版本共存 / N-2 兼容

客户端有「长尾」:永远有一批用户没升级。所以服务端要同时服务多个客户端版本。业界常见约定:

bash
服务端必须兼容「最近 3 个客户端版本」(N、N-1、N-2)
超出窗口的客户端 通过版本闸门强制更新

这个窗口宽度是团队必须明确写下来的规则,不是自然形成的。
没有这条规则,服务端代码会被历史兼容逻辑淹没;
窗口开得太窄,又会把一批用户挡在门外。

兼容窗口的代价

窗口里每多一个版本,服务端代码里就多一点这种东西:

bash
if (clientProto >= 5) {
 // 新客户端:返回带 newField 的结构
} else {
 // 老客户端:返回旧结构,把新字段丢掉
}

所以窗口宽度是一个工程成本与用户留存之间的权衡,需要定期 review。

懒迁移(Lazy Migration)

懒迁移

问题:玩家的存档格式要改,但玩家可能几个月不上线,你不可能在停机窗口里升完所有人的存档。

解法:玩家登录的那一刻,才把他的存档升级到新格式。这意味着服务端在一段时间内必须能读新旧两种格式

配套要求:需要一个「迁移进度」的可观测指标(还有多少活跃存档是旧格式),等这个数字归零,才能放心删掉旧格式的读取代码。

数据库 expand-contract(扩展-收缩)

数据库结构的变更必须拆成四步、跨多个发布周期完成,才能保证「任何时刻都能安全回滚代码」:

bash
 expand(扩展) 加新列/新表,代码双写(新老结构都写) 此时老代码依然正常
 migrate(迁移) 后台任务把历史数据回填到新结构
 switch(切换) 代码改成只读新结构
 contract(收缩)观察若干个发布周期后,删掉旧列/旧代码

关键:每一步都必须「向后兼容」。
绝对不要在同一个发布里既改表结构又改读法——那样一旦要回滚代码,数据库已经回不去了。

1.5 发布策略

蓝绿 / 金丝雀 / 灰度 / Ring 发布:它们是两个维度

策略本质切换方式一句话记忆
蓝绿 Blue-Green同时维护两套完整环境一次性把流量从 A 切到 B回答「怎么切
金丝雀 Canary同一环境里跑新旧两版比例逐步放量(5%→20%→50%→100%)回答「切给谁
灰度 Gray人群或维度圈定范围按规则(白名单→内部用户→某地区→全量)
Ring 发布把用户分成同心圆环,一圈圈往外放环 0 = 内部,环 1 = 尝鲜用户,环 2 = 大众灰度的组织化形式

它们都归属于 渐进式交付(Progressive Delivery) 这个概念。实践中常组合使用:「灰度人群 + 金丝雀比例」——先给内部白名单 10% 流量,再扩大到全部用户的 50%,最后全量;最终切换可以用蓝绿。

分桶(Bucketing)与哈希分布

分桶

要「给 20% 的用户开放新功能」,怎么决定谁是那 20%?答案是把用户 ID 通过哈希函数映射到 0~99 的桶里,然后判断桶号是否小于 20。

关键要求:同一个用户在每次判断时必须落在同一个桶里(否则用户会一会儿看到新功能一会儿看不到)。这就是为什么用哈希而不是随机数。

一个真实且常见的缺陷:如果哈希函数太简单(比如 h = h*31 + 字符),连续的用户 ID(1001、1002、1003…)会落到连续的桶里。于是「放量 20%」实际只覆盖了 ID 尾号靠前的一小撮用户,流量分布严重倾斜,灰度数据完全失真

正确做法:哈希之后必须做一步雪崩混淆(avalanche / 位混合),让相邻输入的输出差异极大。常用的是 murmur3 的 fmix32。判断标准很简单:取 1000 个连续用户 ID,20% 放量下命中率应该接近 20.0%,且各个十分位分布均匀。

放量百分比(Rollout Percentage)

服务端按分桶结果决定「这个用户是否允许更新到新版本 / 是否看到新功能」。这是 PC 客户端和自有启动器最常用的金丝雀手段,因为这一层完全由你控制,不像应用商店那样受第三方制约。

1.6 部署与流量

摘流(Drain)→ 优雅停机 → 排空

这三个词说的是一件事的三个动作

  1. 摘流:先把实例从「可接收新请求/新连接」的列表里拿掉。新流量不再进来。
  2. 排空:等待已经在处理中的请求完成 / 等待已有连接自然结束。给一个宽限时间。
  3. 优雅停机:排空完成后才真正退出进程。

为什么长连接服务必须做这件事:无状态服务杀 Pod 像关水龙头;长连接服务杀 Pod 等于把正在通话的电话线剪断。摘流做得好,玩家感受到的是「一瞬间的卡顿」;做得不好,就是「掉线回登录页」。

readiness vs liveness 探针(必须分开)

探针回答的问题失败时 K8s 做什么排空期间应该
readiness(就绪)「现在能把流量给它吗?」把 Pod 从 Service Endpoints 摘掉,但容器继续跑返回失败(这本身就是摘流机制
liveness(存活)「它是不是卡死了?」重启容器必须保持成功,否则重启会打断排空
startup(启动)「它启动完了吗?」启动完成前不执行 liveness

把两者写成同一个接口是一个经典错误

如果 readiness 和 liveness 都指向 /healthz,那么排空时 /healthz 一失败,K8s 会认为容器卡死并把重启它——你的排空流程刚走到一半就被打断,玩家全部异常掉线。所以排空时期 readiness 失败、liveness 必须成功。

preStop 钩子与 terminationGracePeriodSeconds

bash
K8s 要删除一个 Pod 时的完整顺序:

 调用 preStop 钩子(阻塞,必须执行完)
 同时开始「摘流」:把 Pod Endpoints 移除
 preStop 执行完 发送 SIGTERM 给容器主进程
 容器自己处理 SIGTERM(执行排空逻辑)
 超过 terminationGracePeriodSeconds 还没退出 SIGKILL 强杀

两个必须注意的点:
· preStop 里通常写「等几秒」或「调一下本机的 /drain 接口」,
 目的是给「Endpoints 摘流生效」留出时间——摘流是异步的,不是瞬间的
· terminationGracePeriodSeconds 必须 > preStop 耗时 + 排空耗时,
 否则第 步的强杀会打断你的优雅停机

Node 必须作为容器里的 PID 1 运行

如果 Dockerfile 写成 CMD node server.js(shell 形式),进程树实际是 /bin/shnode。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 哈希、自定义规则不建议(资源不够)

会话粘性(Sticky Session)

会话粘性

让同一个用户的请求尽量落到同一个实例上。WebSocket 场景下这是必需的——因为连接是「粘」的,如果玩家的两个请求被分到不同实例,第二个实例不认识他。

实现:Ingress 用 Cookie 做亲和(nginx.ingress.kubernetes.io/affinity: cookie)。

与发布的矛盾:有粘性就意味着某个实例上的连接不会主动跑掉,所以你必须靠摘流 + 通知重连来「推」它们走。这也是为什么长连接服务的发布比无状态服务麻烦。

Argo Rollouts 相关名词

名词含义
Rollout(CRD)替代 K8s 原生 Deployment 的资源类型,多了 strategy.canary / strategy.blueGreen
steps发布的步骤序列,如 setWeight: 10pause: 5msetWeight: 50
trafficRouting告诉 Rollouts「通过什么方式切流量」——这里是 nginx,也可以是 Istio / ALB
AnalysisTemplate把「人工判断要不要继续」变成「自动查指标判断」,比如查成功率低于 99% 就自动中止
Stable / Canary ReplicaSet老版本和新版本各对应一个 ReplicaSet,Rollouts 同时管理两者

1.7 制品与分发(中间件与名词)

三类仓库,管三种东西

仓库管什么你环境里的实例访问方式
镜像仓库
(Container Registry)
Docker 镜像Harbor 106.75.29.47:10086Docker Registry v2 协议
制品仓库
(Artifact Repository)
任意文件:安装包、补丁、jar、npm 包、配置表Nexus(你机器上装了但没启动)HTTP(可按目录上传/下载)
对象存储 / CDN海量静态文件的高并发下载(暂无,可用 Nginx 顶替)HTTP(自带大带宽)

为什么要分这么细?

  • 镜像仓库是给 K8s 用的,它只认镜像格式,不能放安装包。
  • 制品仓库是「有版本管理、有权限、有保留策略的网盘」。它不擅长扛大带宽——你总不能把 2GB 的游戏包放在 Nexus 上让几万玩家同时下载。
  • CDN / 对象存储专门解决「大带宽」问题。学习阶段用 Nginx 顶替就够了,生产环境才需要 CDN。

版本清单(Manifest)—— 双端发布的中枢

manifest.json 是网页项目里完全不存在的东西

它是一个 JSON 文件,同时被三方读取:客户端启动器(决定要不要更新)、服务端(决定版本闸门放不放行)、运维(人工查看当前该发什么版本)。

核心字段

  • latest:最新客户端版本
  • minSupported:最低支持版本,低于它 → 强制更新
  • recommended:推荐版本,低于它 → 可选更新
  • protocol:协议兼容窗口(min / current)
  • configVersion:配置表版本,双端必须一致
  • packages[]:整包列表(地址、体积、SHA256)
  • patches[]:差量补丁列表(从哪个版本到哪个版本)
  • rollout.percent:放量比例
  • forceUpdate:按平台分别控制强制更新(应对 iOS 审核延迟)

原子发布(Atomic Publish)

为什么 manifest 必须原子替换

如果客户端正好在你写入 manifest.json 写到一半时来拉取,它会读到半个 JSON,解析失败,然后就卡在更新界面——这是一个真实发生过的线上事故类型。

正确做法:先写临时文件 manifest.json.tmp,写完并 fsync 之后,用 mv 覆盖正式文件。mv 在同一文件系统内是原子操作,客户端要么看到旧文件、要么看到新文件,永远不会看到半个。

这个技巧在整个运维领域都通用(配置下发、证书替换、静态站点发布)。

1.8 配置与数据

配置中心 vs 注册中心(两个容易混的概念)

配置中心注册中心
解决什么「配置怎么下发和热更新」「服务 A 怎么找到服务 B 的地址」
存的东西键值对配置(限流阈值、开关、DB 地址)服务实例列表(IP:端口)
典型产品Apollo / NacosConsul / Eureka / Nacos
你的环境Nacos(106.75.29.47:8848同时提供这两种能力,所以你的若依网关才能用 lb://ruoyi-auth 这种写法

对客户端项目的意义:注册中心在游戏服务端里还有额外用途——它就是「位置服」的原型。ET 框架的 Location Server 本质就是一个为 Actor 服务的注册中心。

配置表 / 导表流水线(游戏项目的命门)

配置表是「一份源、两份产物」,必须原子生效

策划改 Excel → 导表工具 → 同时产出两份:

bash
策划改 Excel(唯一的事实来源)
 导表工具(同一次运行)
 ├── 服务端用:config_server.json 逻辑判定用(伤害 = 100)
 └── 客户端用:config_client.bytes 显示用(显示伤害 = 100)

如果服务端用了新表(伤害 = 100)而客户端还是旧表(显示 = 50),玩家就会看到「打出的伤害和显示的对不上」,直接爆发客诉。

工程解法:给配置打一个 config_version,客户端启动时上报自己的版本,服务端发现不一致就触发强制热更。

序列化(Serialization)

格式可读性体积速度用途
JSON人可读配置、调试、Web 接口
Protobuf需 schema跨语言通信、协议定义
MessagePack半可读较小客户端与服务端通信
MemoryPack / FlatBuffers不可读极快(零 GC)高性能游戏通信(ET 用的 MemoryPack)

与发布的关系:序列化格式决定了协议兼容性。Protobuf 这种带字段编号的格式天然向后兼容(加字段不影响老客户端);JSON 靠字段名,改名就会破坏兼容;而自定义二进制格式最容易出事——必须严格版本化。

存档(Save Data)

玩家的进度数据。它的发布要求和代码完全不同:

  • 不能丢:代码发错了可以回滚,存档丢了这个玩家就永远流失了。
  • 要能向前兼容:老存档要能被新服务端读。
  • 格式变更要走懒迁移(见 1.4)。
  • 要能回滚:大版本上线前必须备份,出问题时能回滚到升级前的存档快照。

1.9 术语速查表(中英对照)

中文English一句话
长连接 / 短连接Persistent / Short-lived Connection连接会不会一直保持
心跳Heartbeat定期小包,检测对面是否还活着
粘包 / 拆包Sticky / Fragmented PacketsTCP 字节流不保证消息边界
断线重连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 ExportExcel 转成服务端与客户端两份配置产物
序列化Serialization对象与字节流之间的转换
分区 / 分服Zone / Shard互相独立的运行单元,天然的灰度单位
合服Server Merge把多个区的数据合并,风险最高
状态同步State Synchronization服务端算结果推给客户端
帧同步Lockstep只转发操作指令,各端各自算
Actor 模型Actor Model不共享内存、只发消息的并发模型
协程 / FiberCoroutine / 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)

02 现状勘察结论

先把「当前真实跑着什么」钉死。下面每一条都是现场读出来的,不是推测。

2.1 节点与集群底座

4 个节点全部通过 Tailscale 组网,kubectl 的 API Server 挂在 Jenkins 那台机器上,它同时兼任控制平面:

节点名角色Tailscale IPCPU / 内存(allocatable)当前内存占用状态
jenkinscontrol-plane(etcd / apiserver / controller-manager / scheduler 全在这)100.68.117.722C / 7.4Gi71%Ready(带 control-plane 污点)
k8s-node1worker100.125.80.984C / 3.5Gi76%Ready
k8s-node2worker100.97.166.904C / 3.5Gi93%Ready(内存最紧张)
k8s-node3worker100.78.249.1062C / 7.4Gi70%Ready(内存余量最大)
组件现状
Kubernetesv1.28.14(kubeadm 部署)
容器运行时containerd 2.3.3;/etc/containerd/certs.d 已按 registry 配置了镜像加速
CNI / 代理Calico(VXLAN),kube-proxy 为 IPVS 模式
Ingressingress-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

2.2 若依微服务的实际形态

应用全部落在 ruoyi 命名空间:7 个 Deployment + 7 个 NodePort Service + 1 个 Ingress。

Deployment容器端口Service NodePort当前镜像 tag
ruoyi-gateway-deploy80803108020260827145338
ruoyi-auth-deploy92003108120260827145033
ruoyi-modules-system-deploy92013109320260827145603
ruoyi-modules-gen-deploy92023009120260827161313
ruoyi-modules-job-deploy92033009020260827162653
ruoyi-modules-file-deploy93003009220260827152043
ruoyi-visual-monitor-deploy91003009520260827191643
  • 镜像仓库:私有 Harbor 106.75.29.47:10086,项目 ruoyi-cloud,tag 是「秒级时间戳」。
  • 拉取凭据harbor-secret / harbor-cred 两个 dockerconfigjson。
  • 配置管理:该命名空间只有一个 ConfigMap ruoyi-gateway-config(以 subPath 挂到 /home/ruoyi/application.yml);其余服务配置打进镜像。Redis 指向 106.75.29.47:6379
  • Ingressruoyi-gateway-ingressingressClassName: nginx,无域名(host*)。
  • 前端不在集群里ruoyi-ui 由宿主机 Nginx 托管 /opt/ruoyi/dist
  • 中间件全在另一台机器106.75.29.47 开放 3306(MySQL)、6379(Redis)、8848/9848(Nacos)、10086(Harbor)、80/8080。

2.3 Jenkins 现状

现状
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-credentialsgitee-enterprise-tokendeploy-ssh-keynexus-admin

现有 Jenkinsfile 的部署逻辑(以 gateway 为例)

groovy
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

这套流程里有 4 个必须改造的点

  1. CI 承担了 CD:Jenkins 持有集群 admin kubeconfig 并直接 kubectl apply,构建与发布耦合,失败无法自动回滚。
  2. sed -i 污染工作区:构建时直接改写仓库里的 k8s/deployment.yaml,导致工作区 git 树长期 dirty,清单文件在本地被改坏。
  3. 镜像 tag 不可追溯:时间戳无法定位到代码提交;还同时推 latest 这种可变标签。
  4. 集群实况与仓库清单已经漂移:线上 ruoyi-gateway-deploy 实际挂载了 ruoyi-gateway-config,但仓库里的 k8s/deployment.yaml 这段是被注释掉的。漏跑一次 CI 或重装一次集群,网关的配置挂载就会丢。

2.4 与本次学习相关的资源现状(2026-09-24 补充勘察)

先说一个会影响所有实验结论的硬事实:这台机器只有 2 个 CPU 核

CPU 核数2
系统负载(空闲时)0.79 / 0.87 / 0.82 —— 也就是空闲状态下就已经用掉约 40% 的 CPU
可用内存约 3.5 GB
Swap0(完全没有)——任何内存尖峰都会直接 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-sshssh-stepsnodejsdocker-workflowgitee没有 Nexus 上传插件、没有 Generic Webhook TriggerCI 编排(推 Nexus 用 curl 即可)
ELK(k8s-elk 节点)filebeat DaemonSet 已在跑按客户端版本分组观测日志

集群版本天花板:k8s v1.28 正在限制你的工具选型

这已经是第二次遇到同一个问题:

  • Argo CD:官方测试矩阵里 3.3/3.4/3.5 只覆盖 k8s v1.32~v1.36,覆盖 v1.28 的最新分支是 2.14 → 只能用 v2.14.21(该分支已停止维护)
  • Argo Rollouts:我直接读了官方仓库的 e2e 测试配置——stable 分支测 1.32~1.35master1.34~1.37都不含 1.28;且 1.8 起明确不再支持 1.28 以下 → 只能选 v1.7.2

结论:把集群升级到 v1.33+ 值得单独排一个项目。在升级之前,你没法用上任何一个最新版云原生工具。

03 为什么网页那套不能照搬

你上一轮做的若依项目是典型网页项目:7 个微服务 + 一个前端静态目录。那套流程里有一些「默认成立」的前提,在客户端软件里一个都不成立

维度网页项目(你的若依)客户端 / 服务端项目
交付产物无状态容器镜像 + 静态文件服务端镜像 客户端安装包 / 资源包 / 配置表(三份,需分别发布)
用户侧版本刷新即最新,全网只有一个版本用户设备上可能装着 3 年前的版本,同时存在 N 个版本
生效方式服务端部署完,下次请求立刻生效(秒级)服务端秒级;客户端要用户下载 + 安装 + 重启(分钟到数月不等)
变更可控性你说了算客户端部分你说了不算:商店审核、用户不点更新
回滚语义回滚 = 换回旧版本,真·恢复服务端可回滚;客户端升级不可逆,只能「再发一个版本改回来」
状态无状态,Pod 随便杀长连接 / 房间 / 会话 / 存档在内存里,重启就是断线
兼容性责任方浏览器天然向前兼容,服务端随意改必须由服务端兜底:新服务端要能服务老客户端
流量单位请求请求 + 连接(连接是「粘」的)
测试难度一套接口测试就够需要客户端版本 × 服务端版本的兼容矩阵测试

3.1 三条最重要的差异

差异一:网页项目的回滚是回滚,客户端项目的「回滚」是再发一版

服务端出问题,argocd app rollback 一秒回旧版本,用户毫无感知。客户端出问题,你没有任何办法把用户设备上那个包变回旧版——只能连夜出新版本,然后祈祷用户愿意更新。

推论:客户端发布的风险是「不可逆」的。因此客户端的质量闸门必须比网页严得多,而且必须配备「服务端开关」作为兜底

差异二:网页项目服务端可以随便改,客户端项目服务端必须「只加不改」

你的若依改个接口返回结构,改完部署,前端下次请求跟着改就行。但客户端项目里,老客户端是你改不动的存量。一旦把字段改名或删掉,所有没升级的老客户端立刻报错、闪退、卡在登录页。

推论:客户端项目的服务端接口必须遵守向后兼容契约——字段只增不减、新增字段要有默认值、旧语义不能改、接口不能直接下线(要先标废弃、观察调用量为零、再等一个发布周期才删)。

差异三:客户端有「长尾」,网页没有

网页项目的版本分布是一条竖线(发布完成 = 100% 新版本)。客户端项目是一条长尾曲线,尾部可能拖几个月到几年。企业内网软件常年停在某个 LTS 版本;手游里总有用户半年不更新;桌面软件里总有人关掉自动更新。

推论:客户端项目的版本管理本质是**「多版本共存治理」**,不是「版本替换」。你要维护的是兼容矩阵,不是版本号。

版本分布对比:同一时刻,服务端只有一个版本,客户端有一堆版本服务端(发布即替换)100%0%v1 = 100%切换v2 = 100%一条竖线:切换完,旧版本就没了客户端(长尾共存)100%0%新版本占比老版本残存(长尾)一条长尾:永远有一批人没升级
图 1 服务端是「替换」,客户端是「共存」。这就是两类项目发布复杂度差异的根源

04 标准流程:三类产物、三条通道

网页项目只有一条交付链路(代码 → 镜像 → 集群)。客户端/服务端项目有三条相互独立、节奏不同的交付通道,这是理解一切差异的框架。

一份源码服务端 + 客户端① 服务端镜像Docker Image② 客户端安装包整包 / 差量补丁③ 资源配置表双端共用,最易出事Harbor + K8sArgo CD / RolloutsNexus + Nginx版本清单 + 增量补丁Nexus(同一份)必须原子性双端生效长连接 / API秒级生效用户设备分钟~月级生效双端同时读取不一致就出事故④ 版本清单(manifest)—— 双端发布的「调度中枢」记录:最新客户端版本 / 最低支持版本 / 协议版本 / 补丁地址 / 校验哈希 / 强制更新级别服务端启动时读它做「版本闸门」;客户端启动时读它决定「要不要更新、更新到什么程度」← 这是网页项目里完全不存在的东西,也是客户端项目 CD 的核心
图 2 三类产物、三条通道,以及把三条通道串起来的「版本清单」。网页项目只有最上面那条

4.1 通用标准流程(7 步)

#环节网页项目的做法客户端/服务端项目的额外要求
1版本号策略Git SHA 或时间戳,能追溯即可需要三套版本号:客户端版本、服务端版本、协议版本。靠协议版本判断兼容性
2构建与打包只出镜像同时产出:服务端镜像、客户端整包、客户端差量补丁、导表产物
3自动化测试接口测试 + 单测额外需要:协议契约测试(新服务端跑老请求样本)、兼容矩阵测试、导表一致性校验
4制品入库只推 Harbor镜像推 Harbor;客户端包与配置表推 Nexus;大包另需 CDN/对象存储
5发布版本清单不存在这一步关键环节:更新 manifest.json原子发布。这一步决定双端的可见性
6发布编排k8s 滚动 / 蓝绿 / 金丝雀服务端同上;客户端是分渠道 / 分地区 / 分用户比例放量,无法「滚动」
7观测与回滚服务端指标 + 一键回滚必须按客户端版本分组观测;客户端回滚靠「服务端开关 + 发新版」

4.2 服务端标准流程(K8s 视角)

bash
代码提交 CI 构建镜像 Harbor 更新 GitOps 清单 Argo CD 同步

 RollingUpdate(默认) / BlueGreen / Canary

服务端特有的两点增强:
 长连接服务必须先「摘流 + 排空」再终止,否则用户断线
 启动时做「版本闸门」:读 manifest 里的 proto 版本,拒绝不兼容的客户端连接

4.3 客户端标准流程(网页项目完全没有的部分)

bash
 构建:出整包 + 生成版本号(写进包内版本文件)
 差量:与上一个线上版本对比,生成增量补丁(xdelta3 / bsdiff)
 入库:整包 + 补丁包推 Nexus,记录 SHA256 与体积
 清单:更新 manifest.json(新版本号、最低支持版本、补丁地址、强制级别)
 放量:分渠道/分桶逐步放开,同时开启服务端 Feature Flag

 观测:各客户端版本的成功率、崩溃率、登录转化率

4.4 配置表 / 静态数据:第三类产物

网页项目里配置改完部署就生效。客户端项目里,配置表是 Excel → 导表工具 → 双端产物,两份产物必须来自同一次导表(详见 1.8 节)。

05 特别流程:8 类软件的差异地图

「客户端/服务端软件」不是一个东西,不同类型差异大到需要不同流程。下面按「标准流程里哪一步被替换掉」来组织。

5.1 手机游戏(热更新为主)

环节与标准流程的差异
审核iOS 必须过 App Store 审核(1~7 天,不可控)。所以 iOS 端的「可热更范围」被严格限制在 Apple 允许的范围内(脚本、资源、配置),这是 iOS 和 Android 发布流程最大的分叉点
渠道Android 有几十个渠道包(应用宝、华为、小米、TapTap…),一个版本要出 N 个包,且各渠道审核节奏不同 → 发布是「多点异步」的
放量商店支持分阶段发布(如 Google Play Staged Rollout:1% → 5% → 20% → 100%),这是客户端侧的金丝雀
回滚无法回滚,只能「发新版」或服务端下开关

5.2 PC 客户端网游

  • 自更新器是核心组件:先启动一个极小的 launcher,launcher 检查 manifest → 下载补丁 → 应用 → 启动主程序。launcher 本身极少更新(它一旦坏了就没法修)。
  • 下载策略:整包 10~50GB,必须用差量补丁;大厂还会用 P2P + 多 CDN 源分摊带宽。
  • 发布窗口:通常选凌晨低峰,配合停机维护公告。玩家对「维护」有预期,这是 PC 网游比手游宽松的地方。

5.3 长连接游戏服务器(最重要的「特别流程」)

核心难点:连接是「粘」的,重启就是断线

网页项目里 Pod 随时可以被杀,因为每个请求都是独立的。但长连接服务里,玩家的整个游戏过程都挂在一条连接上,杀掉 Pod = 所有在线玩家掉线。

标准做法(摘流 + 迁移)

  1. 摘流:从负载均衡/网关把该实例的新连接权重置零(新玩家不再进来)
  2. 排空:给一个宽限期(如 60s),让现有对局自然结束
  3. 引导迁移:主动通知仍在线的客户端「服务即将重启,请重连」→ 客户端重连到其他实例
  4. 优雅退出preStop 钩子里完成上述动作,terminationGracePeriodSeconds 要大于宽限期
  5. 分区发布:一次只滚动一个区/服,其他区照常运营

这也是为什么游戏服务端很少用「一次性蓝绿全切」——切过去那一瞬间,所有在线玩家都会掉线。

5.4 桌面 / 企业内网软件

  • 发布渠道不是应用商店,而是内网分发 + 域策略 / MDM 推送
  • 可以做到真正的「静默升级」,但需要管理员权限,且常受变更窗口约束(只能在维护窗口推送)。
  • 蓝绿在这里有真实对应物:先给 IT 部门灰度,再给财务部,最后全公司——本质是按组织维度的灰度。
  • 常有「LTS 版本」概念:大部分用户停留在 LTS,只有关键补丁才强制推送。

5.5 无状态 API / 微服务(最接近网页项目)

你的若依就属于这一类。标准 k8s 流程可以直接套用,不需要本文的大部分复杂度。差别只在:如果它同时服务客户端,则要额外遵守「向后兼容契约」和「版本闸门」。

5.6 强一致 / 金融类服务

  • 不能出现「新旧版本同时处理同一笔业务」的情况 → 蓝绿切换时需要先排空队列
  • 发布要做对账:切换后跑一次数据一致性校验,不一致就回滚。
  • 数据库变更严格遵守 expand-contract,跨多个发布周期完成。

5.7 IoT / 边缘设备 OTA

  • 设备可能永远在线不稳定、可能断电 → 升级包必须支持断电续传与失败回滚。
  • 硬件上常用 A/B 双分区:新固件写入 B 分区,验证成功后切换引导分区。这是「蓝绿」在硬件层的原始形态——而且它把回滚变成了硬件级的一行操作。
  • 分批放量必须按「设备批次 / 地区」而不是按比例,因为坏固件可能刷砖,无法远程修复。

5.8 数据层 / 数据库

无论是哪类软件,数据库变更都是独立的第四类发布,走 expand-contract 四步(见 1.4 节)。

06 蓝绿 / 金丝雀 / 灰度在哪里体现

6.1 服务端侧(K8s):三者的落地方式

策略用现有能力怎么实现流量入口资源成本你的环境可行性
蓝绿两套 Deployment(-blue/-green)+ 一个 Service,改 Service 的 selector 一次性切换。或用 Argo Rollouts 的 blueGreen 策略Service selector2× Pod可行,但你的集群内存紧张,节点余量不足 2 倍
金丝雀(按比例)Argo Rollouts + 现有 ingress-nginx,用 nginx.ingress.kubernetes.io/canary-weight 注解按权重分流Nginx Ingress1× + 少量可行,推荐作为入门演练
灰度(按人群)Argo Rollouts + ingress-nginx 的 canary-by-header / canary-by-cookie,按请求头或 Cookie 路由Nginx Ingress1× + 少量可行,最适合「先给测试账号放量」
更细粒度Istio / Linkerd 服务网格按用户 ID 哈希分流Sidecar较高不建议(集群资源不够,且引入复杂度)

6.2 客户端侧:三者变得完全不一样

客户端上不存在「蓝绿」

蓝绿的前提是「流量可以瞬间从 A 切到 B」。但客户端已经装在用户设备上了,你没法让用户的手机瞬间变成另一个版本。所以客户端侧只有两类手段:

  • 放量控制(对应金丝雀/灰度):控制「谁能下载到新版本」
  • 功能开关(对应灰度):控制「装了新版本的人能不能用新功能」
策略客户端上的对应物具体做法
金丝雀商店分阶段发布Google Play Staged Rollout:1% → 5% → 20% → 50% → 100%。iOS 用 App Store 的分阶段发布(7 天自动放量)
金丝雀渠道分批先放 TapTap / 官网包(可控),再放应用宝,最后放华为/小米。观察各渠道数据再决定是否继续
金丝雀自有更新器的比例放量服务端按「用户 ID 分桶 < N」决定是否下发「可更新」标记 → 这一层你完全可控,是 PC 端最常用的手段
灰度白名单 / 内测组内部员工账号、测试机、招募的体验服玩家先行
灰度按地区/运营商先开一个小地区,验证 CDN 与网络兼容性后再全国
Feature Flag这才是客户端项目最有力的灰度手段新功能代码已随版本发出,但默认关闭;服务端按用户分桶下发「开启」标记。可以随时关掉,等效于「零成本回滚」
蓝绿双版本并行(变体)老客户端连老逻辑、新客户端连新逻辑,服务端同时维护两条路径。这不是真正的蓝绿,而是「双轨共存」,是过渡期的权宜之计

6.3 一次完整的大版本发布:双端编排表

阶段服务端动作客户端动作用户感知
T-7 天部署新接口(双协议并行,老协议继续服务)新客户端包提审(iOS)
T-3 天保持双协议Android 各渠道包准备完毕
T-1 天上线配置表新版本(双端同一份)
T 日 停机发布维护公告 → 停服 → 升级数据库(expand)→ 部署新服务端停机公告
T 日 开服版本闸门设为「最低支持 proto=5」开放整包下载;老客户端登录时被要求更新强制更新
T+1 天观察各客户端版本的成功率/崩溃率放量到 50%部分用户仍未更新
T+3 天确认老版本流量接近 0放量到 100%
T+14 天下线旧协议代码(contract)

07 逐条验证你的四种推断

你的推断判定一句话的真实情况
服务端更新,客户端无需更新✔ 正确,占比最高日常发布的绝对主力(约 6~7 成)。唯一前提是服务端保持向后兼容——只加不改、不删字段、不改语义。
服务端无更新,客户端更新◐ 方向对,但「无更新」是理想态纯客户端更新(改 UI、换素材、性能优化)确实存在;但现实中服务端通常仍要动一点:更新版本清单、调开关默认值、放开新资源路径。真正的「服务端零改动」只发生在纯资源替换。
服务端更新,客户端少量更新✔ 正确,是「新功能发布」的标准形态服务端加接口 + 客户端资源热更 + Feature Flag 按人群放量。老客户端依然能登录,只是看不到新功能。
服务端大更新,客户端大更新甚至升级✔ 正确,对应「资料片 / 大版本」协议不兼容的破坏性变更。需要停机窗口或分服灰度、强制更新、协议大版本号递增、老客户端被版本闸门拒绝登录。

你缺的 4 个维度

  1. 发布顺序:谁先发?客户端先发还是服务端先发?这一步选错,就是全服事故。
  2. 客户端更新的「力度」:静默热更 / 可选更新 / 强制更新 / 整包重装。这四者对服务端的要求完全不同(见 1.3 节)。
  3. 兼容窗口与多版本共存:服务端要同时服务 N、N-1、N-2 三个版本的客户端。
  4. 数据/存档这个第四维:配置表、存档格式、数据库结构的变更不随双端发布一起生效

7.1 推断一:服务端更新,客户端无需更新

结论:✅ 完全正确,日常发布的绝对主力

占实际工作量的 60%~70%。典型内容:服务端性能优化、后台 bug 修复、运营活动逻辑、风控规则、日志与监控增强。

它成立的技术前提(缺一不可)

  1. 协议只加不改:新增字段老客户端忽略即可;不能改字段类型、不能改字段语义
  2. 接口只增不删:老接口必须继续返回,哪怕新客户端已经不用了
  3. 行为不改变老客户端的可观测结果:可以改服务端内部算法,但不能让老客户端显示的数值变得不合理
  4. 配置表改动必须落在客户端已有字段上,不能新增客户端不认识的列

7.2 推断二:服务端无更新,客户端更新

结论:◐ 方向正确,但「服务端无更新」在现实中很少是字面意义的零改动

真正的「服务端一行不动」只发生在纯资源替换(换张图片、改个文案、字体优化)。只要涉及任何「用户能不能拿到新版本」的判断,服务端就必须动:

  • 至少要更新 manifest.json(新版本下载地址、哈希、体积)
  • 如果要控制放量节奏,服务端要下发「是否允许该用户更新」
  • 如果新客户端有默认开启的新功能,服务端要能关掉它
  • 如果新客户端改了上报格式,服务端要能解析新格式同时兼容旧格式

更关键的一点:这种情况下服务端的兼容压力其实更大,因为它要同时服务「已经升级的新客户端」和「没升级的老客户端」,两边期望不一样。这就是「多版本共存」的真实代价。

7.3 推断三:服务端更新,客户端少量更新

结论:✅ 完全正确,最健康的形态

bash
服务端:新增接口 + 新增逻辑(老接口不变)
客户端:资源包/脚本热更(走「静默热更」或「可选更新」)
开关 :Feature Flag 控制新入口是否显示,按用户分桶放量

效果:老客户端照常玩,只是看不到新入口;
 新客户端能看到新入口,但后台可以随时关掉

为什么这种形态最健康:它把风险完全控制在「可回滚」范围内——出问题关掉开关就行,不需要发新版本,也不需要回滚服务端。

7.4 推断四:服务端大更新,客户端大更新甚至升级

结论:✅ 完全正确,对应「资料片 / 大版本 / 引擎升级」

特征是协议不兼容:新服务端无法正确服务老客户端。特征清单:

  • 协议版本大版本号递增(proto 4 → 5
  • 版本闸门提升「最低支持版本」,老客户端被拒绝登录并提示强制更新
  • 需要停机窗口(或分服分批灰度,一个区一个区地升)
  • 数据库执行 expand 阶段的结构变更
  • 客户端走「整包重装」或大体积补丁
  • iOS 需要提前提审,时间不可控 → 排期时必须预留审核缓冲

风险最高的也是这一类:一旦客户端审核延迟而服务端已经切到新协议,就会出现「玩家进不去游戏,而新客户端还没上架」的灾难。所以实践中通常选择**「服务端双协议并行一段时间」**而不是一刀切。

7.5 缺失维度 1:发布顺序(最容易造成全服事故的变量)

DANGER

这是你四种推断里都没有考虑、但后果最严重的变量。四种组合:

顺序前提后果
服务端先发新服务端必须能服务老客户端安全。前提是服务端严格遵守向后兼容
客户端先发服务端必须能服务新客户端通常需要服务端提前上线新接口(双协议并行)
客户端先发,服务端没准备不成立新客户端调用不存在的接口 → 新用户直接不可用
服务端先发(破坏性变更),客户端还没发不成立最典型的事故:全部老客户端立刻挂掉

工程上的通用法则
① 破坏性变更 → 服务端先上双协议,等新客户端铺开后再下线老协议;
② 更稳妥的做法是「三步走」:服务端支持双协议 → 客户端发布并放量 → 服务端下线老协议。
这个过程的绝对时长由「客户端铺开速度」决定,通常是 2 周到 2 个月

7.6 缺失维度 2:客户端更新的「力度」

你把「客户端更新」当成一件事,但它有四种力度(见 1.3)。同样是「服务端更新,客户端少量更新」,走静默热更和走强制更新,对服务端的要求和风险完全不同:

客户端更新力度服务端需要做什么风险
静默热更只要更新资源清单,几乎不需改动
可选更新需要下发更新提示;需兼容未更新用户
强制更新必须提升版本闸门,且要准备好回退方案中高(用户被挡在门外)
整包重装要协调应用商店/分发渠道,需预留审核期高(时间不可控)

7.7 缺失维度 3:兼容窗口与多版本共存

你的模型隐含了「服务端和客户端一一对应」的假设。真实情况是一对多:任意时刻,服务端要同时正确服务 N、N-1、N-2 三个版本的客户端(详见 1.4 节)。

7.8 缺失维度 4:数据/存档这第四维

你的模型只有「服务端」和「客户端」两个轴,但真实系统里还有第三个独立的版本轴:数据格式(存档结构、数据库 schema、配置表版本)。它的特点是不随双端发布一起生效,而且一旦转换往往不可逆。解法是懒迁移(见 1.4 节)。

7.9 完整状态矩阵:把 4 种推断扩展成 8 种

把「发布顺序」「客户端更新力度」「协议兼容性」「数据变更」四个维度补进来之后,完整状态空间是这样的:

#服务端改动客户端改动协议发布顺序停机回滚能力现实频次
1内部逻辑兼容服务端单发服务端可回滚最高(约 40%)
2纯资源兼容客户端单发靠再发一版
3逻辑/代码兼容(新客户端兼容老服务端)客户端单发靠再发一版/开关
4新增接口热更资源+开关兼容(老客端可用)服务端先关开关即可
5新增接口代码+资源兼容服务端先(双协议)服务端可回滚 + 关开关
6破坏性变更大版本不兼容停机 or 分服极难(双端已切)低(每年 1~4 次)
7数据格式变更视情况兼容(惰性迁移)服务端先(读新旧)谨慎(数据已迁移)
8配置表变更必须同步热更需版本校验原子双端回滚表即可高频(每周)

看第 1 行和第 8 行

日常 90% 的工作量集中在第 1、2、4、8 这四种状态。第 6 行(大版本)虽然只占发布次数的极小比例,却占事故后果的绝大部分。所以工程资源应当这样分配:把第 1/2/4/8 做成完全自动化的流水线,把人力集中在第 6 行的演练与预案上。

7.10 三种真实事故模式

事故模式一:服务端先发,老客户端全军覆没

场景:服务端改了登录接口的返回字段名(userIduid),部署上线。老客户端解析不到 userId,登录失败,所有未更新的用户立刻无法进入。

根因:把「服务端可以随便改接口」这个网页项目的习惯带到了客户端项目。

正确做法:新字段与老字段共存一段时间(双写双读),等老客户端流量趋零再删老字段。时间窗口:至少一个完整的客户端铺开周期

事故模式二:强制更新遇上审核延迟

场景:计划 T 日上线大版本,服务端 T 日切换协议并提升版本闸门。结果 iOS 版本审核被拒,重新提交后再等 3 天。这 3 天里,iOS 用户全部卡在「请更新到最新版本」的提示页上,而新版本根本下载不到

根因:把「客户端可用性」和「服务端发布」的时序耦合在了一起,却忽略了客户端分发有外部依赖。

正确做法:① 版本闸门的提升必须在确认新客户端已在所有渠道可用之后才执行;② 闸门要支持按平台分别设置(iOS 通过审核前,iOS 的闸门不动);③ 预留缓冲期。

事故模式三:配置表只发了一半

场景:导表同学把服务端表更新到了环境并生效,客户端的资源包还在打包中。这期间上线的玩家看到「技能描述写着造成 100 点伤害,实际打了 50 点」,论坛立刻炸锅。

根因:把配置表当成「一份文件」而不是「两份必须同步的产物」。

正确做法:给配置打 config_version;服务端启动和客户端登录时都校验;不一致时优先走强制热更。导表产出必须是一个原子发布单元。

08 开源项目:拿来就能练的真项目

第 9 章的小 demo 能让你 30 分钟理解概念,但它只有几十行代码,看不到「真实项目长什么样」。这一章给你 8 个真实开源项目,按「学习目标」和「你机器的承受能力」分成三档。

选型标准(为什么是这些项目)

  • 必须有「客户端 + 服务端」两端,或者至少能体现「客户端独立于服务端演进」这个特征
  • 服务端能在 Linux 上跑,不需要你去装 Unity/Visual Studio 才能看到效果
  • 国内可获取:他的服务器实测可以 git ls-remote 到全部这些仓库(含 GitHub),也可以用 gh-proxy.com / ghfast.top 加速
  • 有代码可读:不是只给你一个安装包,而是能读它的发布逻辑

8.1 项目矩阵

项目语言两端形态最值得学的东西部署难度资源需求
ColyseusTypeScript7.3k服务端框架 + JS/Unity/Defold 等客户端 SDK房间(Room)生命周期、状态同步、重连极低(npm 一条命令)≈100MB
NakamaGo13.4k单二进制服务端 + 官方客户端 SDK 共 8 种
(Unity/Unreal/Godot/JS/C#/Java/Swift/Defold)
工业级游戏后端:匹配、排行榜、实时多人、
rUDP 协议、内嵌管理控制台
低(官方 docker-compose)≈500MB + 数据库
RustDeskRust124k全平台客户端(自带自动更新)+ 自建中继服务端(hbbs/hbbr)客户端自动更新的完整实现 +
「客户端 + 分发服务端」的最小完整案例
低(单二进制)≈50MB
Luanti
(原 Minetest)
C++13.6k独立客户端 + luantiserver 专用服务端协议版本号 + 版本闸门的最佳教材:
官方文档明确写「最低协议版本 24」,握手时协商
中(要编译或装包)≈200MB
MindustryJava29.1k桌面/移动客户端 + -server 无头服务端「客户端版本与服务端不匹配」的实际处理方式低(一个 jar)≈300MB(JVM)
OpenIMGo16.7k服务端 + Android/iOS/Flutter/Web/PC 全端客户端长连接 IM 的完整工业架构
网关层、消息可靠投递、离线推送、多端消息同步
高(需 Mongo+Redis+Kafka+MinIO+Etcd)≈4GB(你这台机器跑不动)
ETC#9.9kUnity 客户端 + C# 服务端(双端共享同一份协议代码HybridCLR 客户端代码热更 + 服务端 DLL 热重载 +
Location Server 路由 + 中文文档
高(需 Unity + VS2022)开发机需求高
SkynetC14.2k只有服务端框架(无客户端)Actor 调度模型、Lua 层热更新(国内游戏服务端的祖师爷级项目)≈100MB

按你的机器条件给出的建议路线

你的服务器只剩约 3.5GB 内存、2 个 CPU 核、且 tailscaled 常驻吃掉 28% CPU。所以:

  • 能真正跑起来的:Colyseus、RustDesk、Skynet、(Mindustry 也勉强)
  • 建议只读代码不部署的:OpenIM(依赖太重)、ET(要 Unity 环境)
  • Nakama:官方 docker-compose 自带 PostgreSQL,大约要 500MB~1GB。可以跑,但要先把 Nexus 或者别的服务停掉腾内存。

8.2 第一档:能立刻跑起来,并且能看见「两端」

① Colyseus —— 最小可跑的多人游戏框架(推荐第一个练)

为什么第一个选它

它是 Node.js 生态的,和你已经会的 npm 流程完全一致;服务端和客户端 SDK 都是 TypeScript,一套语言看两端;代码量小,能把「房间生命周期」这个概念看透。启动只要一条命令。

对应的知识点:长连接、房间(Room)、状态同步(State Sync)、断线重连、客户端与服务端的消息协议。

把项目拉下来(只读操作,不改任何东西)
bash
cd /root && git ls-remote --heads https://github.com/colyseus/colyseus.git | head -5

这条命令在做什么

这条命令做什么:git ls-remote 只列出远端分支,不下载任何东西

  • 为什么先做这一步:它验证「服务器能不能连上这个仓库」——这正是后面 Argo CD 的 repo-server 要做的事。连通性有问题就要先解决,而不是等到部署时才发现。
  • 输出里的 HEAD 和分支名是哈希值,不是文件名。能看到一堆哈希就说明通了。
正式克隆并看一眼它的结构
bash
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 —— 「客户端自动更新」的最小完整案例

为什么它对你特别有价值

RustDesk 的结构和「游戏客户端 + 服务端」几乎一模一样,但轻得多:

  • 客户端:装在你电脑上的那个程序,自带「检查更新」功能,需要更新时会提示或自动升级
  • 服务端hbbs(信令/ID 服务器)+ hbbr(中继服务器),两个都是单个二进制文件,几十 MB,能在你的机器上跑
  • 发布形态:服务端是镜像/二进制发布,客户端是安装包分发 —— 正是本文讲的两条通道

要读的代码:客户端里搜 update 相关模块,看它怎么「检查新版本 → 下载 → 校验 → 替换自己」。这就是 launcher/updater 的真实实现。

克隆并定位「自动更新」相关代码
bash
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 源码里搜,跳过文档和资源文件。
  • 为什么用这种方式读代码:面对一个十几万行的大项目,不要从头读。先锁定关键词再定位文件,是读陌生代码库最有效的方法。你要找的就是「版本检查」和「下载替换」这两段。
跑一个 RustDesk 中继服务端(可选,需要 Docker)
bash
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 清掉。

8.3 第二档:理解「协议版本」和「版本闸门」

③ Luanti(原 Minetest)—— 协议版本号的最佳教材

为什么它值得专门跑一遍

我在官方文档里核实过:Luanti 的网络协议页明确写着**「当前最低协议版本是 24」**,客户端和服务端在握手的第一时间就交换协议版本,不在范围内直接拒绝。你的环境里「版本闸门」应该长什么样,看它就够了。

更能说明问题的是它的一个真实事故:有用户用 5.5.1 版本的安卓客户端连 5.6.1 的服务端,结果服务端直接崩溃,把所有在线玩家踢下线(该问题后来被修复)。这说明——版本不匹配如果只做了「拒绝」而没有做好错误处理,是会从一个客户端的问题升级成全服事故的。

克隆并阅读协议定义
bash
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(客户端发给服务端的第一个包)看它怎么校验版本、校验失败后怎么回应。
看它的版本号规则(理解「大版本 = 破坏性变更」)
bash
cd /root/luanti-study && grep -n -A12 -i 'version scheme\|versioning' README.md | head -40

这条命令在做什么

**这条命令做什么:**在 README 里找「版本号方案」那一段。

  • Luanti 的规则是:Major 号只有出现破坏性变更时才递增,minor 是新功能,patch 是修 bug。
  • 这就是语义化版本(SemVer)的核心思想,也是为什么「协议版本」和「软件版本」要分开——软件的 patch 号涨了不代表协议变了。

④ Mindustry —— 看它怎么处理客户端与服务端版本不匹配

它适合看什么

Mindustry 是一个 Java 写的塔防 RTS,客户端和专用服务端是同一个 jar 用不同参数启动java -jar server-release.jar)。它会在玩家连接时校验版本,版本不一致会直接提示。

学习点:这是「单体发布物,两种运行角色」的形态——和「一份源码出两个产物」的思路一致,但更简单。适合对比理解。

克隆并找版本校验逻辑
bash
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 是它的服务端网络核心。先找到「核心文件」,再在里面搜关键词,比全仓库搜快得多。
  • 你要找的是:它怎么拿到连进来的客户端的版本、怎么比较、不一致时是断开还是警告。这就是版本闸门的最小实现

8.4 第三档:只读代码,理解工业级架构(不建议部署)

⑤ OpenIM —— 长连接 IM 的完整工业架构

先看依赖,再决定要不要动手

OpenIM 的服务端依赖 MongoDB + Redis + Kafka + MinIO + Etcd 一整套。你这台 2 核 3.5G 可用的机器跑不动,所以我的建议是:只克隆代码读架构,不部署

它值得读的原因是:IM 的架构和游戏服务端几乎同构——都是长连接 + 网关层 + 消息路由 + 离线存储 + 多端同步。而且它客户端覆盖 Android / iOS / Flutter / Web / PC 五端,是观察「多端版本共存」的真实样本。

克隆并摸清服务端模块划分
bash
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 框架 —— 客户端热更新 + 双端共享协议的中文教材

它解决的是本文最核心的一个问题

ET 是国产的 Unity 双端框架,文档全中文。它有三个设计直接对应本文的概念:

  • 客户端代码热更:用 HybridCLR 把 C# 逻辑编译成可以运行时加载的程序集,不用重新提交应用商店就能更新游戏逻辑,连协议、配置、UI 都能热更。这正是 1.3 节「代码热更」的真实实现。
  • 服务端 DLL 热重载:它的组件只有数据、没有方法,所有方法做成扩展方法放在可重载的 DLL 里。重载 DLL 时实体数据保留——这就是「热更服务端逻辑而不丢状态」的办法。
  • 双端共享同一份协议文件:客户端和服务端引用同一份消息定义,加一个消息只需要改一遍。这是避免「双端协议不一致」的工程手段。

它需要 Unity + VS2022 才能跑起来,所以建议读文档和代码结构,不部署。仓库里的 Book/ 目录是中文教程。

克隆并看它的文档目录结构
bash
cd /root && git clone --depth 1 https://github.com/egametang/ET.git ET-study
cd ET-study && ls -1 Book/ | head -30

这条命令在做什么

**这条命令做什么:**列出中文文档目录。

  • Book/ 里是分章节的中文教程(运行指南、热更、网络、服务器架构等),比读代码快得多
  • 建议阅读顺序:先看「运行指南」理解两端怎么启动,再看「热更」章节——那一章就是本文 1.3 节的实践版。

⑦ Skynet —— 国内游戏服务端的祖师爷

云风的 Lua 游戏服务端框架。它没有客户端,所以不适合学「双端发布」,但它是理解 Actor 调度模型Lua 层热更新最好的材料——国内大量商业游戏服务端的架构思路都源自它。

克隆并看它的服务划分
bash
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 跨节点路由等)。

  • harborgate 两个:前者是「跨机器路由」,后者是「客户端接入」。这两个就是 1.2 节讲的「网关服」和「位置服」的最简实现。

8.5 建议的学习顺序

顺序项目花多久你要能回答的问题
1第 9 章的自建 demo半天版本闸门、Feature Flag、摘流 分别解决了什么问题?
2Colyseus1 天一个「房间」的生命周期是怎样的?客户端断线重连后怎么恢复?
3RustDesk1 天客户端怎么知道有新版本?下载完怎么保证没被改坏?
4Luanti1 天协议版本号为什么必须独立于软件版本号?不匹配时应该怎么处理?
5Nakama2 天一个成熟的游戏后端把哪些能力做成了「开箱即用」?
6OpenIM / ET(读代码)各 1~2 天工业级项目的服务端是怎么分层的?热更新在双端分别怎么实现?

09 在你服务器上动手(单条命令)

关于「为什么这份文档里没有一键脚本」

你可能注意到 outputs/demo/ 目录里没有构建脚本、没有发布脚本、没有测试脚手架。这是刻意的:

  • 脚本会把「过程」藏起来。你运行一个 publish.sh,回车,看到「成功」,但中间发生了什么你并不知道。学习阶段最需要的是看见每一步
  • **所以本文所有操作都是单条命令,每条下面都写了「它做什么、为什么需要它」。**你可以一条一条敲,敲错了就知道是哪一步错了。
  • 那 demo 目录里剩下的文件是什么?它们不是自动化脚本,而是「被发布的软件本身」——就像若依项目的 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

9.0 阶段 0:先做环境自检

每次开始动手前,先花 10 秒确认机器状态。这三个数字决定了你今天能做什么、不能做什么。

检查 CPU 核数、负载、可用内存、Swap
bash
nproc; uptime; free -m; df -h /

这条命令在做什么

四个命令分别看什么:

  • nprocCPU 核数。你的机器是 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。
看看谁在吃资源
bash
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 集群做中继。这是你环境里最大的常驻开销,知道它在哪,排查「机器为什么慢」时就不会走错方向。
  • 第二个命令按内存排序,你会看到 Jenkins 占约 1GB。这是正常的(Jenkins 是 JVM 应用),但它意味着这台机器不是「什么都没跑」

9.1 阶段 1:启动 Nexus,建立「客户端产物仓库」

这一阶段的目标

Nexus 在你机器上已经装好但从未启动/opt/nexus,端口 7878,不是默认的 8081;数据目录 /opt/sonatype-work/nexus3 只有 12M,说明初始化过但基本没用)。我们要让它可以上传/下载文件——这就是「客户端产物仓库」。

① 先确认端口没被占用
bash
ss -lntp | grep ':7878' || echo "7878 端口空闲,可以启动"

这条命令在做什么

**为什么先做这一步:**如果端口被别的程序占了,Nexus 启动会失败但错误信息可能藏在日志里,你会白折腾。先检查再启动是运维的通用习惯。

  • ss -lntp-l 只看监听中的、-n 显示数字端口(不解析成服务名)、-t TCP、-p 显示占用进程。
  • || echo ...|| 表示「前面失败才执行后面」。grep 没找到会返回非零退出码,所以就会打印那句话。
② 以 nexus 用户身份启动(Nexus 不允许用 root 跑)
bash
sudo -u nexus /opt/nexus/bin/nexus start

这条命令在做什么

逐条解释:

  • sudo -u nexusnexus 这个系统用户的身份运行。Nexus 出于安全考虑拒绝以 root 启动,直接 /opt/nexus/bin/nexus start 会报错退出。
  • /opt/nexus/bin/nexus:官方的启停脚本。你的机器上没有 systemd 服务单元systemctl list-unit-files | grep nexus 会是空的),所以只能用这个脚本。
  • start:启动。stop / status / restart 同理,脚本支持这些参数。
  • 副作用提醒:Nexus 是 Java 应用,启动约占 1GB 内存(它自带 Temurin JDK 21)。启动前先确认 free -m 的 available 够。
③ 等它起来,然后确认监听
bash
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
④ 拿到初始管理员密码
bash
cat /opt/sonatype-work/nexus3/admin.password

这条命令在做什么

**这条命令做什么:**Nexus 3 首次启动会生成一个随机密码写在这个文件里。第一次登录后它会要求你改密码,改完这个文件会被自动删除(所以只第一次能看到)。

  • 登录地址:http://<服务器IP>:7878,用户名 admin
  • 学习建议:改密码时记下来,后面 curl 上传制品要用到。
⑤ 创建「客户端产物」仓库(用 REST API,不用点界面)
bash
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 表示创建成功
⑥ 验证仓库真的能上传能下载
bash
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)。
  • 下载时不需要认证也能读(raw 仓库默认允许匿名读)。这一点很重要:客户端要能匿名下载安装包。
  • 第二条命令期望输出 hello v1.0.0。能看到就说明**「客户端产物分发通道」已经通了**。
  • 再验证一次 allow_once 的效果:把同一条上传命令再跑一遍,应该返回 400 而不是成功——这就是「已发布的东西不许改」。

阶段 1 验收标准

  • Nexus 在 7878 端口监听,curl 返回 200
  • 仓库 game-client 创建成功(HTTP 201/204)
  • 上传一个文件 → 能匿名下载回来 → 内容一致
  • 重复上传同一路径 → 被拒绝(验证 allow_once 生效)

9.2 阶段 2:认识你的「被发布软件」

先花 20 分钟读一遍 server/server.jsclient/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 始终返回 200readiness 与 liveness 分离(1.6)
drain() 函数优雅停机(1.6)
跑起来看效果(先不涉及 K8s)
bash
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 服务端)。
  • 最后一行:用环境变量启动,这是本 demo 的核心教学点——FEATURES 就是 Feature Flag。改这个值重启,就相当于「服务端下发不同的开关配置」。
  • 观察启动日志:它会打印协议窗口 [4, 5]configVersion、当前开关。这三行就是「服务端能力声明」

Ctrl+C 停止,或者另开一个终端继续下一步。

另开一个终端,用 4 种客户端参数验证 4 个机制
bash
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 在兼容窗口 [4,5] 内,所以放行;但因为它是老协议,服务端不下发新功能。这一条完整演示了「老客户端还能玩,只是看不到新功能」——也就是你推断三的实现。
  • 协议 3 低于 MIN_PROTOCOL=4,服务端返回 reject 并告知最低要求。这就是「强制更新」的技术本质——不是客户端自己想更新,是服务端不让它进。
  • 模拟「配置表只发了一半」的场景(1.8 节的事故模式三)。
验证分桶分布是否均匀(这是容易被忽略的坑)
bash
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'

这条命令在做什么

**这条命令做什么:**跑 100 个连续用户,数一数有多少个「命中放量桶」。grep -c 只输出计数,不输出内容。

  • 为什么要做这个测试:如果分桶哈希写得不好,连续 ID 会落到连续的桶里,你看到的可能是「命中 0 个」或「命中 1 个」这种荒谬结果,而不是接近放量比例的数字。
  • 服务端默认 ROLLOUT_PERCENT=100(全部命中),所以想看到真实分布,要把服务端用 ROLLOUT_PERCENT=20 重启后再测,此时 100 个用户里应该命中约 20 个。
  • 这个坑真实存在:本文 demo 的第一版哈希就没做雪崩混淆,连续 ID 得到的是连续桶号,放量 20% 实际只覆盖了 ID 尾号靠前的一小撮用户。修复后 1000 个连续 ID 的命中率才是 20.1%。
验证摘流 + 优雅排空(长连接服务最关键的一段)
bash
# 终端 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 节的经典错误)。
  • 同时去终端 B 看:客户端应该收到 {"type":"reconnect","message":"服务即将重启..."}而不是异常断开。这就是「用户感受到的是卡顿,而不是掉线」。
  • Linux 上还可以用 SIGTERM 触发同一条路径pkill -TERM -f "node server.js"。它和 /drain 调用的是同一个函数。

9.3 阶段 3:客户端发布链路(全程单条命令)

这一阶段的目标

把「客户端从 1.0.0 更新到 1.2.0」这条链路完整跑通,而且每一步都是你自己敲的:打包 → 生成差量补丁 → 算哈希 → 写清单 → 原子发布 → 客户端自动更新。

① 装差量补丁工具
bash
dnf install -y xdelta

这条命令在做什么

  • xdelta 在 EPEL 源里(你的机器已启用 epel 仓库,实测可安装 xdelta.x86_64 3.1.0-17.el9)。
  • 它是什么:一个生成/应用二进制差量补丁的工具。给两个版本的安装包,它能算出「只有差异部分」的小补丁。
  • 为什么用 xdelta 而不是 bsdiff:bsdiff 在 Rocky 的官方源和 EPEL 里都没有,xdelta3 有。功能用途相同。
  • 验证:xdelta3 -V 能看到版本号就成功了。
② 准备两个版本的「客户端安装包」
bash
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),而不是字节数。
③ 生成差量补丁 —— 观察体积差,这是客户端更新的意义所在
bash
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 的意思。
  • 然后是新文件,最后是输出补丁。顺序不能乱。
  • 真实场景下这里会看到补丁只有整包的百分之几。你这次用随机数据,补丁会接近整包大小——这是正常的,因为随机数据没有重复模式可利用。想看到真实效果,可以把 1.2.0 改成「1.0.0 的副本 + 改几个字节」:cp 1.0.0/full.tar.gz 1.2.0/full.tar.gz && echo change >> 1.2.0/full.tar.gz,这样生成的补丁就只有几 KB。
④ 算 SHA256 和体积 —— 这两个数字要填进清单
bash
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 会四舍五入,清单里需要精确字节数。
  • 为什么必须手工算而不是估算:这两个值是客户端用来判断「下载是否成功」的唯一依据。填错了客户端会认为所有下载都是坏的。
⑤ 写版本清单(先写临时文件)
bash
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' ... EOFheredoc 写法,把两个 EOF 之间的内容写进文件。注意 'EOF' 要加单引号——不加的话 shell 会把内容里的 $ 和反引号当变量展开,JSON 就会被破坏。
  • 为什么叫 .tmp:下一步会解释。这一步是「先写临时文件」。
  • python3 -m json.tool:把 JSON 格式化并校验语法。如果 JSON 写错了(少个逗号、多个括号),这条命令会直接报错并指出行号。你要用这个当「语法检查器」——不要等客户端来发现你的 JSON 是坏的。
  • 每个字段的作用(对照 1.7 节):
  • latest / minSupported / recommended 三个版本号决定客户端是「不更新 / 可选更新 / 强制更新」。
  • protocol 是给服务端做版本闸门用的兼容窗口。
  • configVersion 用来防「配置表只发了一半」。
  • rollout.percent: 20 表示只给 20% 的用户开放更新(分桶决定谁命中)。
  • packages[] 是整包兜底,patches[] 是优先使用的差量补丁。
⑥ 原子发布 —— 这一行是整个客户端发布链路里最重要的一个技术细节
bash
cd /opt/patches/client
mv manifest.json.tmp manifest.json
ls -l manifest.json*

这条命令在做什么

为什么不能直接写 manifest.json

  • 如果客户端正好在你写到一半的时候来拉取,它读到的是半个 JSON → 解析失败 → 卡在更新界面。这是一个真实的线上事故类型。
  • mv 在同一个文件系统内是原子操作:客户端要么看到完整的旧文件、要么看到完整的新文件,不存在中间态
  • ls -l manifest.json* 会显示只剩 manifest.json,临时文件已消失。
  • 这个技巧通用:配置下发、证书替换、静态站点发布都用同一招。
⑦ 把 Nginx 变成「客户端分发通道」
bash
# 先备份,再改(改配置前永远先备份)
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。
⑧ 让客户端走完整更新流程
bash
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 发布的清单地址。
  • 预期输出顺序:拉取 manifest → 打印三个版本号 → 决策(可选更新/未命中放量/已最新)→ 下载差量补丁 → SHA256 校验 → 应用 → 版本变更 → 连接服务端握手。这一整条就是客户端的 CD。
  • 如果分桶没命中percent: 20),换个 --user 值再试,直到命中。这本身就是在体验「分阶段放量」。
⑨ 验证「完整性校验能拦住坏包」
python
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,和清单里的值比对,不一致就拒绝应用

  • 预期输出:[校验] SHA256 不匹配!期望 deadbeef… 实际 29c0d44c… 然后 拒绝应用该包,本地版本仍然是 1.0.0
  • python3 - <<'PY' ... PY:把一段 Python 脚本通过标准输入交给 Python 执行,不落盘成文件。这就是「不需要写脚本文件」的做法——一次性操作直接用 heredoc 喂给解释器。
  • 为什么必须有这一步:差量补丁应用失败会产生「看起来能用但实际损坏」的包。没有哈希校验,这种坏包会直接进到玩家设备上。

9.4 阶段 4:服务端编排(Argo Rollouts)

① 下载安装清单(走国内加速)
bash
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」的第二道保险。
  • 为什么是 v1.7.2:你的集群是 k8s v1.28,而 Argo Rollouts 官方 stable 分支测试的是 1.32~1.35、master 是 1.34~1.37,都不覆盖 1.28。1.8 起已明确不支持 1.28 以下。
② 把镜像搬进自己的 Harbor,并替换清单里的地址
bash
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
  • 为什么要搬进 Harbor 而不是直接用代理:集群对公网没有依赖、镜像有留存、后续升级路径统一。这是上一轮 Argo CD 方案里同样的做法。
  • sed -i "s#旧#新#g":用 # 当分隔符是因为路径里含 /,用 / 当分隔符要转义很麻烦。
  • grep -n 'image:'确认替换干净——输出里不应该再出现 quay.io
③ 安装并钉到内存余量最大的节点
bash
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

这条命令在做什么

  • 为什么要 nodeSelector 钉在 k8s-node3:你的集群里 node1 内存 76%、node2 内存 93%,只有 node3(7.4G 分配)余量够。不钉的话调度器可能把它丢到 node2 上,直接触发 OOM。
  • imagePullSecrets:Harbor 是私有仓库,需要拉取凭据。这个 secret 要先从 ruoyi 命名空间复制过来,或者手工创建。
  • kubectl get crd | grep argoproj确认 CRD 装上了——能看到 rollouts.argoproj.ioanalysistemplates.argoproj.io 就成功了。
④ 发一次金丝雀,全程观察权重推进
bash
# 终端 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 版本,是新版本出问题时的第一反应。
  • 注意流量切分靠的是 ingress-nginx 的 canary 注解,由 Rollouts 自动维护,你不需要手工改 Ingress。

9.5 阶段 5:双端联合演练(把 7.9 节的矩阵跑一遍)

这是最有价值的一步——把上面几个阶段的机制组合起来,逐个复现 8 种状态,并记录每种状态下你需要哪些操作、耗时多久、能不能回滚。演练完你会得到一张属于自己环境的表,比任何文档都值钱。

演练操作观察重点要回答的问题
① 纯服务端发布只更新服务端镜像,客户端不动老客户端连接是否正常我的服务端真的向后兼容吗?
② 纯客户端资源热更只更新资源包 + manifest客户端能否静默更新成功热更失败时能否退回旧资源?
③ 新功能(服务端+热更+开关)服务端加接口,客户端热更,开关先关后开关开关时新入口是否消失开关生效要多久?
④ 可选更新recommended 提升到新版本客户端弹提示、可取消取消后老版本还能正常玩吗?
强制更新minSupported 提升到新版本老客户端被拒绝进入怎么紧急撤回这次强制更新?
⑥ 协议破坏性变更服务端把 MIN_PROTOCOL 改成 5proto 4 客户端全部失败能不能快速切回双协议?
⑦ 配置表不一致只改服务端 CONFIG_VERSION客户端是否检测到不一致不一致时的兜底行为是什么?
⑧ 金丝雀中途 abort发金丝雀,20% 时执行 abort流量回切速度、在线连接是否受影响abort 到完全恢复要多久?
演练⑤:强制更新,以及最重要的「紧急撤回」
python
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,就是雪上加霜。

10 常见误区与速查

10.1 学习路径上的 10 个误区

#误区真相
1「客户端更新了,服务端就不用管」服务端仍要动 manifest、开关、兼容逻辑。而且服务端的兼容压力会变大——要同时服务已升级和未升级的用户
2「服务端可以像网页那样随便改接口」老客户端是你改不动的存量。接口必须只加不改不删
3「客户端出问题回滚一下就行」客户端升级不可逆。只能发新版或服务端关开关
4「客户端也能做蓝绿」客户端做不了蓝绿(用户设备不能瞬间切换)。能做的是放量控制 + 功能开关
5「灰度 = 按比例」按比例是金丝雀;灰度的核心是按人群/维度。两者常组合使用
6「把客户端代码热更当成万能药」热更范围有硬边界(iOS 审核限制、编译层代码、底层协议),且热更失败可能让客户端卡在更新界面
7「配置表是一份文件」它是两份必须同步的产物(服务端用 + 客户端用)。必须做版本校验
8「发布完就结束了」客户端发布完成后才进入真正的观测期。要看按版本分组的成功率/崩溃率。观测是发布的一部分
9「readiness 和 liveness 用一个接口就行」排空时 readiness 失败会让 K8s 重启容器,直接打断你的优雅停机。必须分开
10「hash 取模就是分桶」没有雪崩混淆的哈希会让连续 ID 落到连续桶,放量分布严重倾斜。必须做位混合

10.2 本环境相关命令速查

bash
# ---------- 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 /

10.3 本次实测的镜像与下载地址

以下地址全部在 106.75.30.92 上实测通过(2026-09-24):

用途地址结果
Argo Rollouts 镜像quay.m.daocloud.io/argoproj/argo-rollouts:v1.7.2manifest 200(amd64/arm64)
Argo Rollouts 镜像(备)quay.nju.edu.cn/argoproj/argo-rollouts:v1.7.2200
Argo Rollouts 镜像(备)quay.io/argoproj/argo-rollouts:v1.7.2200(本环境可直连)
install.yaml v1.7.2https://gh-proxy.com/https://raw.githubusercontent.com/argoproj/argo-rollouts/v1.7.2/manifests/install.yaml200 / 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-amd64200 / 126850410 B
xdeltadnf install -y xdelta(EPEL:3.1.0-17.el9源可用
npm 源https://registry.npmmirror.com200
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

10.4 三句话总结

INFO

  1. 你的四条推断方向都对,尤其抓住了「双端版本独立变化」这个关键;缺的是发布顺序、更新力度、兼容窗口、数据维度这四层。
  2. 网页项目优化的是「替换」,客户端项目优化的是「共存」。前者追求快速回滚,后者追求让服务端能兜住所有历史版本,并用 Feature Flag 换取「可回滚」的能力。
  3. 你服务器上的练习条件已经齐了:Nexus(已装未启动)负责客户端产物分发,Argo Rollouts v1.7.2 负责服务端蓝绿/金丝雀/灰度,xdelta 负责差量补丁,Nginx 负责分发通道。第 9 章六个阶段跑完,你就有了别人只能在书上看到的完整经验。

文档说明:本文环境结论来自 2026-09-24 对 106.75.30.92只读勘察(未做任何变更)。文中所有镜像地址、版本可用性、命令存在性均在该服务器上逐条实测。Argo Rollouts 选型依据为:官方 stable 分支 e2e 测试配置覆盖 k8s 1.32~1.35、master 覆盖 1.34~1.37(均已直接读取确认),因此对 k8s 1.28 选择 v1.7.2。第 8 章开源项目数据(语言、星数、最近提交时间)来自 GitHub API 实时查询。

配套目录 outputs/demo/ 只包含「被发布的软件本体」及其构建/部署定义,不包含任何自动化脚本。建议与上一轮《ArgoCD 部署与 CICD 流程改造方案》合并归档,作为同一条 GitOps / 渐进式交付演进路线的两份连续记录。

基于 VitePress 构建 · 内容为个人学习与工程实践笔记