« 开发笔记(14) : 工作总结及玩家状态广播 | 返回首页 | 开发笔记(16) : Timer 和异步事件 »

开发笔记(15) : 热更新

这几天我的工作是设计未来游戏服务器的热更新系统。

这部分的工作,我曾经在过去的一个项目中尝试过 。这些工作,在当时一段时间与广州网易其他项目交流时,也对网易其他项目的设计产生过一些影响,之后,也在实战中,各个项目组逐步发展出许多热更新的系统来。

我最近对之前所用到的一些方案,如修改 lua module 的加载策略,增加一些间接层,来达到热更新代码的系统设计做了一些思考。感觉在处理热更新这个问题时,还不够严谨。经过两天的思考,我按我的构思实现了新系统的雏形。

在函数式编程语言中,热更新通常比较容易实现。erlang , lisp 都把热升级做为核心特性之一。函数副作用越小的语言,越容易做热升级:你只需要简单的把新写的函数替换回去就好了。

对于纯粹的请求回应式的服务,做热升级也相对容易。比如大多数 web server ,采用 REST 风格的协议设计的网站,重启服务器,通常对用户都是透明的。中间状态保存在数据库中,改写了代码,把旧的服务停掉,启动新版的服务,很少有出错的。

按我们现在的游戏服务器设计,大多数服务也会遵循这个结构,所以许多底层的子模块简单重启进程就可以了。但和游戏逻辑相关的一些东西却可能要考虑更多东西。

我想把我们的系统分步设计实现,先实现最简单的热更新功能,再逐步完善。如果一开始就指望系统的任何一个部分都可以不停机更新掉老版本的代码是不太现实的,考虑的太多,容易使系统变的过于复杂不可靠。

那么第一步,我们要实现的就仅仅是游戏逻辑有关的代码热更新。而不考虑更新服务器框架有关的模块。我想把这部分称为热修复,而不是热升级。主要用来解决运行时,不停机去修复一些 bug ;而不是在不停机的状态下,更新新版本。在这个阶段,也不考虑可以更新服务间的通讯协议,只考虑更处理这些协议的代码逻辑。

做以上限制后,热更新系统实现起来就非常简单了。

首先,我们的每个服务就是一个包分发器。主干代码在启动时,将一组组函数按模块加载入框架。框架分发消息包,经由分发器调用消息处理函数处理。热更新其实也是由一个消息来触发的。由于我们用 lua coroutine 的方式来分发每个消息处理包,在大多数情况下,收到热更新请求时的那一刻,之前的消息包都已经处理完毕了。只需要把消息处理函数的分发器重置,重新把新版的消息处理函数塞进去就可以了。但是,也有一些例外。

我们的系统按功能拆分出了许多服务模块,大量依赖 RPC 来协同工作。所以在一个特定的消息包处理中,如果它通过 RPC 调用了另一个模块中的方法,那么这个消息处理函数是被框架挂起的。直接忽略掉这个处理函数的运行是可能有副作用的(如果没有副作用,我们可以简单的把函数参数记录下来,用这些参数重新调用新版函数即可)。那么更合理的方案是等待所有这些挂起的函数运行完毕,再启动热更新流程。

实现起来并不难,在收到热更新请求时,替换掉消息分发器,截流住所有新的服务请求,记录在队列中。过滤出那些之前的 RPC 请求的返回包,交给框架处理。检测到所有旧请求完成后,切换回正常的消息分发器,并用新版本代码更新所有消息处理函数。

当然这只是很理想的状况,是有可能因为同时更新几个服务,而服务间相互有 RPC 请求造成死锁。我的观点是,暂时先不考虑这些非常状况,等遇到问题了再一个个解决,让系统逐渐演化。这并非是不能一开始就设计出没有问题的系统,而是把系统的复杂度维持在一个可控范围内的举措。而且把热更新的设计目的限制在热修复 bug 的范畴,我认为是可以接受的。

关于 timer 这种特殊的相应函数,我更倾向于直接停止所有 timer callback 回调,让重新启动的新版服务的初始化过程重新注册新的 timer ,这样比较干净。

第二个重要点在于状态数据的继承。

我们的模块在初始化时,往往会构造出一些环境表。消息处理过程是可以带状态的,状态信息就储存在这些状态表中。比如前面提到的 agent 的关注列表。

所以我们应该避免直接在初始化代码中创建 table 出来,而应该调用框架提供的 API 创建这些表,并给它们起上名字。通过这些名字,可以在热更新时直接继承这些表,而不用重新创建。

日后的需求可能是更多样的,因为版本更新后,原来的数据结构发生的改变,当发生数据结构改变时,我们需要在热更新的初始化阶段更新这些数据结构。

对于这类问题,我的看法还是一样。先不必在设计实现热更新系统时包含入这些考量,而在真正遇到这个需求时,再仔细考虑如何演化这个系统。有更为实际的需求,比空想更容易评估怎样做的更好。

第三个问题是逻辑层编写时,由于代码规模的需要,分拆出来的子模块代码。

这些子模块可能和消息分发模式无关,单纯解决某些子问题,又不适合拆分成独立服务。

我这次不希望去修改 lua 的模块加载机制。因为从 lua 5.1 到 5.2 的发展路线来看, lua 5.1 的模块加载机制本身就被过度设计了。我想我会单独为我们的系统设计一套简单的适应热更新的模块机制,而不是直接用 lua 基础库里的 require 。从 API 层面就把不可更新的模块和可更新的模块区分开。不可热更新的模块,比如系统框架等,用 lua 基础库里的 require 机制;可以热更新的则使用自己的。


今天花了半天时间把以上想法实现,明天尝试合并入主干代码。

Comments

以前搞热更新,就是把 package.loaded[moduleName] = nil;require(moduleName); 最怕ModuleB中 local shortFunc = ModuleA.longFunc 然后下面就用shortFunc调用函数了 这样重新加载ModuleA也没用。。。 求教新的机制是怎么解决这种问题的?
"我们的系统按功能拆分出了许多服务模块,大量依赖 RPC 来协同工作。" 使用RPC,就是为了让开发的时候感觉接口简单吧? 但是“接口简单”往往没有看上去的那么重要吧? COM的设计目标之一也要使用简单,但是我记得当年学习COM的时候,国内某著名COM专家书里有一句话:COM是如此的复杂,以至于很多COM专家都无法掌握它的所有技术细节。。。(虽然这句话在当年对我是一种激励,但是现在看来不愿意再接触类似这种复杂的技术了。) 当COM的实现问题百出的时候,已经几乎没有人记得它当初是为什么而创立的。。 我总觉得某个问题的复杂度是一定的,如果你把80%(甚至90%,99%)的复杂度都封装到你的模块里面,然后剩下的20%(10%,1%)的复杂度让你的模块的使用者来面对,很可能导致模块的内部实现过于复杂。 声明:以上纯属YY。^_^
支持大哥
云风大哥,你的博客是用movabletype做的吧?
云风大哥,你的博客是用movabletype做的吧?
忘了说,普通工作机器,i5的cpu。 服务器的话,也差不多吧,高不到那里去,除非单核性能强的u
luajit性能很强大的。现在项目中测试结果是一个进程,若只跑一个地图,则地图中500玩家绝对流畅(NPC、怪物一共80左右),每帧最大不超过150ms。 ps.地图很小,基本上等于视野大小。 怪物、NPC开启ai。 一个进程就算500人。 也够用了; 若是600人,则不超过200ms。 当时测试性能时,没用分析器,直接通过屏蔽一些代码的法子,还有就是对某个功能函数的前后记录当前微秒值来查问题。 最后发现assert这玩意开销很大哈。
不知道云风对于lua性能分析方面有什么好的工具或思路可以推荐的?我这边的项目是使用luajit2做的MMOARPG,现在项目进入中后期了,在性能上一直有问题,尝试过用debug api等方式写profile工具,收效甚微,主要是这些工具会导致虚拟机运行速度直线下降,很难收集有用的数据。
大部分游戏设计时都会考虑提供类似的热更新。这对修复一些紧急的bug而又不影响在线和体验关系重大。一般都修改的是逻辑层的一点小问题才会使用热更新吧。要是新功能新系统上线,我觉得停机维护时更新,稳妥而简单。 不然考虑东西太多,易出问题:)如本文所说的事务,定时器等
“我想我会单独为我们的系统设计一套简单的适应热更新的模块机制,而不是直接用 lua 基础库里的 require ” 关于这个,写好了会开源不。嘿嘿。
MARK~~等下细看

Post a comment

非这个主题相关的留言请到:留言本