对比脚本型和编译型游戏服务器的热更新方案
本文对比游戏服务器中C++搭配脚本语言(Lua、Python)以及纯编译型语言(C++、Golang)来进行开发时,进行线上服务器热更新的方案。
游戏开发模式
在开始下文之前,有必要简单描述一下游戏服务与web服务的区别。
长连接 VS 短连接
游戏服务对外与客户端之间的链接多是长连接形式,而web服务多是短连接。
有状态服务 VS 无状态服务
游戏服务内,需要维持着玩家的状态数据,如玩家属性、位置等,web请求多是无状态服务。
启动时间
由于前面提到的游戏服务是有状态服务,因此游戏服务器启动的时候,需要从持久化存储中将数据加载到内存中,这意味着游戏服务器的启动时间会很长,一般一次需要几分钟,web服务器相对轻量很多,因为需要访问的持久化数据在另外的存储服务器上。
开发周期
游戏服务的开发周期短,有一些游戏一周就需要进行一次维护,这意味着在这一周内策划(对应互联网中的产品经理)提出的需求都要完成上线。
从以上对比可以看到,游戏业务的特点是更新频繁,而启动一个服务器的时间又比较长。在进行开发的过程中,如果使用纯编译型语言进行开发,那么流程就是如下所示:
可以看到,上面是一个比较长的开发功能流程,而如果还考虑到开发周期短这个特点,显然是不能匹配游戏开发这种业务的特征的,此时就需要“热更新”功能才能提高开发效率。
以下就脚本语言与编译型语言如何实现“热更新”展开讨论。
C++搭配脚本语言
这种方案是笔者见过的方案,其一般的做法是:C++来实现底层的框架(网络、与数据库通信等),接收到数据包之后,将数据传递给脚本层,由脚本来处理具体的业务逻辑。
这种也是软件设计中常见的分层方案:底层的模块为上层的模块服务,同时底层模块也变动的较少。
由于嵌入到进程里面的脚本语言引擎,本质上是将脚本语言代码翻译成内存中的Opcode来执行,因此这类型游戏服务器实现“热更新”方案很简单:将新的脚本同步到服务器上,然后给服务器发出一个信号,重新读取脚本代码到内存中即可。
有了这个架构之后,原先的开发模式就变成了下图:
可以看到,前面编译型语言中编译和重启服务器这两部最消耗时间的步骤,变成了热更新脚本,这样就不需要重启服务器来验证功能,开发效率提高了很多。
编译型语言实现热更新
从上面的分析可以看到,因为编译型语言存在需要重启服务器的步骤,导致了以下两个问题:
- 客户端连接需要断开,因为游戏服务是长连接。
- 重启服务器时需要耗费大量的时间将持久化存储的数据加载到内存中,这样启停过程中的客户端请求就会丢失。
下面依次看看如何解决这两个问题。
维护客户端连接,可以再引入一个网关组件,由网关来维护连接,这样服务器重启流程中客户端对内部游戏服务器的启停并无感知。
为了在启动新版本服务器的过程中继续服务客户端请求,并且新版本服务器上线之后能接着当前的玩家属性继续操作,可以考虑将数据存入共享内存中,这样即便进程退出共享内存还存在。这样做的思路是“代码与数据分离”。
如果要实现这个方案,又要做到以下两点:
- 设计一套面向共享内存的数据结构,至少应该能支持常见的链表、数组、字典等类型。
- 数据结构的设计需要考虑可扩展性以及前后兼容性,因为可能出现两个前后版本中,有一些字段不存在或者有一些字段新增的情况。
有了以上的介绍,下图中就是为了支持热更新的编译型语言的架构方案:
其中:
- 网关负责维护与客户端的连接,同时也知道当前访问的是哪个游戏服务器。当新版本服务器启动完毕之后,向网关发送一个指令,让网关在收到这个指令之后的所有客户端请求,都转发到新的游戏服务器上,这样就完成了一个看似没有重启的“热更新”。
- 数据保存在共享内存中,这样即使在启动新版本服务器的时候也能继续服务客户端的请求。另外需要注意的是,启动的时候服务器需要判断一下是否已经有一个进程存在,如果存在进程且有共享内存数据的情况下,不需要再从持久化存储中加载数据到内存中。
方案对比
以下来对比一下两种技术方案的优缺点。
特性 | 脚本型游戏服务器 | 编译型游戏服务器 | 备注 |
---|---|---|---|
开发效率 | 高 | 低 | 脚本语言没有编译步骤 |
性能 | 低 | 高 | 脚本语言执行性能不如编译型语言 |
架构难度 | 低 | 高 | 编译型语言为了实现热更新,需要解决:网关维护连接,代码数据分离,数据存入共享内存等,而脚本型语言只需要实现热更新脚本即可 |
重构难度 | 高 | 低 | “脚本语言一时爽,代码重构火葬场” |