【不停服更新】概述
看到一个帖子在讨论王者怎么实现不停服更新:王者荣耀不停服更新的技术方案是怎么样的?,主要讨论有状态服怎么实现不停服。其中提到了 基于共享内存resume的原地更新 和 基于双版本的蓝绿更新,各有各的优点和挑战,借此发挥一下。
游戏服务器的更新主要包括:
- res更新:指策划配置导出服务器的json
- cfg更新:指服务器内部的配置
- 代码更新:代码逻辑的修改
一、res和cfg
res和cfg的更新很容易,替换对应的资源和配置后,再发信号reload。服务器的res和cfg层reload后,业务重新获取到的就是最新的值。如果业务对res或cfg做了缓存,还需要在reload的回调中重建对应的缓存。
听起来操作容易,实际上还是有一定的挑战:
很多res 服务器和客户端共用。对于线上更新的场景,如果服务器的reload早于客户端更新,可能会出现不一致的问题。但是服务器和客户端的更新不可能在同一时间推送;而且客户端往往要重登才会触发更新,所以对于存量玩家这个情况一定存在。
每次配置更新需要评估是否兼容旧客户端,就是评估是否会出现上述不一致的问题。但是矛盾点是,配置提交人是策划,而是否兼容涉及到逻辑,需要程序来评估,在res的提交流程里面,需要加入这样一个步骤:相关人员评估某条res更新的影响面。
更麻烦的是,是否兼容的评估还耦合了运维流程。如果不兼容,是否可以从运维流程上规避?如对应的服务是否可以做双版本?这里还需要程序+运维二次评估。
cfg都是服务器自己使用,风险最小,几乎不会存在问题。
二、代码更新
出现代码更新的情况:
- lua、js这类解释执行的脚本代码可以直接热更
- cpp、go的修改需要重新编译,必须要更新进程
讨论第二种需要更新进程的情况。如果是无状态服,选个人少的时候,直接按批次滚动更新就行。比较麻烦的是有状态服,有下面三种更新方案:
1. 基于共享内存resume的原地更新
原理:
- 服务运行时,所有关键有状态数据(如玩家数据等)存储在操作系统的共享内存(shm)里
- 更新时,旧进程退出,新进程启动后 attach 同一块共享内存,直接读取数据resume
而如何保证共享内存能正确resume?
- 选择合适的共享内存方案:共享内存兼容性本质是二进制内存布局的兼容性,直接使用操作系统原生的共享内存attach,只要结构有修改就不兼容,局限性太大。业内的方案主要有基于块描述表和shmalloc堆两种
- 确保共享内存结构里面没有使用非共享内存指针。需要程序在开发阶段关注。如果引用了非共享内存指针,resume后指向一个非法地址(野指针),访问会导致进程coredump。
- 确保共享内存前后兼容:和1中选择的方案有关,但是两种方案都不允许删除。
基于共享内存resume的原地更新的操作:
- 第一步:替换可执行文件(或者so)
- 第二步:
kill -9+start with resume
进程在杀掉和拉起之间存在间隔。release包一般能在秒级拉起,客户端层面不会触发断线重连。但是仍然不可避免逻辑有损:
- 进程退出时,业务逻辑可能正处于一个中间状态(事务执行到一半)
除非业务代码专门设计了幂等性和断点续做机制,否则这类中间态会导致数据不一致。但是会导致开发成本非常高,大多数情况只能容忍。旧进程执行流程: Step1: 扣除玩家金币 ✅(已写入共享内存) Step2: 发放道具 ← 进程在这里退出了 Step3: 记录日志 新进程 Resume 后: 共享内存里:金币已扣,道具未发 新进程不知道自己处于"事务中间态" → 金币扣了但道具没发 - 逻辑不兼容修改,旧逻辑产生的"历史状态"被新逻辑继承,新旧版本对同一份数据的"理解"可能已经不同。
- 新功能的初始化逻辑被跳过,需要额外考虑resume时候逻辑的初始化:
1 2 3 4 5 6 7 8 9 10 11void login() { // 正常启动走这里 new_feature.enabled = false; new_feature.config = load_default_config(); } void resume() { // 从共享内存恢复,new_feature 这块内存是什么就是什么 // 如果是新增的字段,可能是 0/随机值 // new_feature.config 没有被正确初始化 } - resume前跑的定时器,有的需要resume,需要在框架层面建设有resume能力的定时器。业务开发的时候需要根据需求判断是否使用带resume能力的定时器。
除了更新,共享内存resume天然可以处理容灾问题,使得进程coredump的损失降到最小。eg:玩家服按照定时器回写的方式保存DB,如果进程在中途宕掉,玩家数据可能丢失。把玩家数据写到共享内存,重新拉起的时候以resume的方式启动,玩家的重要数据也不会丢失。
2. 基于有状态对象迁移的原地更新
有状态对象的迁移可以基于rpc或者DB实现。就算新版本有迭代,也可以在新版本的反序列化逻辑中处理和旧版本数据结构的映射关系,天然支持字段增删,更加通用。
旧进程序列化:{ hp: 100, mp: 50 }
新进程反序列化:{ hp: 100, mp: 50, new_field: <默认值> } 滚动更新流程:
- 给要更新的进程发SCALING_DOWN信号,启动排空流程
- 注销路由,防止旧进程上继续创建新的有状态对象
- 依次对当前存量的有状态对象执行迁移逻辑,迁移过程中打回点对点路由的rpc,业务自行做错误处理。新进程收到后创建对象,注册对象路由
- 替换可执行文件/so,重新拉起进程
假设滚动批次按照10%/40%/50%,迁移流程:
- 10%先迁移到剩余的90%,然后这10%更新为新版本
- 40%迁移到其他60%,然后这40%更新为新版本
- 50%迁移到其他50%,更新到新版本
最后所有的都是新版本,但是会导致负载不均衡,需要在人少的时候操作。如果有两倍的硬件资源,可以先完全起好新版本,再迁移旧版本。
问题:
- 在更新流程中新旧版本混跑,路由层没有做新旧版本隔离,很难保证请求在新旧版本间震荡不会出问题
- 事务中间态执行迁移被中断,还是没办法完全解决。如果下一步是对象路由,迁移后会路由到新进程,可以继续处理事务逻辑;非对象路由或者进程内部的事务就无法继续执行。
3. 双版本蓝绿更新
原地更新的缺陷
前两种方案都需要考虑服务的更新顺序,一般是被依赖的服务先更新(rpc服务端)。但是如果服务相互依赖,就没法确定谁先更新,一定有损。假设修改的内容中,A依赖B,B依赖A,不管先更新哪一个,一定会存在新访问旧和旧访问新两种情况:
大前提是版本始终保持向后兼容,新版本完全兼容旧版本。
新访问旧:
- 修改的是req,旧服务会截断req新增的字段
- 修改的是rsp,旧服务不会填rsp中新增字段,默认是类型零值
旧访问新:
- 修改的是req,req中新增字段都是类型零值
- 修改的是rsp,旧服务会截断rsp中新增字段
上述情况在向后兼容的前提下都是符合预期的。但是版本迭代很难保证完全向后兼容,协议的向后兼容不等于逻辑的向后兼容:
case1:req 中新增必要字段
A v1 调用 B v2:
B v2 新增了一个字段 required_token,用于鉴权
旧 A 不知道这个字段,发送的 req 中 required_token = 零值(空字符串)
新 B 收到空 token → 鉴权失败 → 请求被拒绝 case2:语义发生修改
B v1:rsp { code: 0 表示成功 }
B v2:rsp { code: 0 表示成功, error_detail: "具体错误原因" }
同时修改了语义:code=0 但 error_detail 非空 表示"部分成功"
旧 A 调用新 B:
新 B 返回 code=0, error_detail="xxx"(部分成功)
旧 A 只看到 code=0 → 认为完全成功 → 逻辑错误 case3:逻辑重构。新版本需要根据rpc版本号走新旧两套逻辑,开发的心智负担大,维护成本高
旧 A → 新 B:req { damage=100, physical_dmg=0, magic_dmg=0 }
新 B 只读 physical_dmg 和 magic_dmg → 都是 0 → 逻辑错误 | |