聊聊Golang游戏服务器的热更

从Erlang过渡到Golang以来,一直陆续会有同学问: “Golang能不能做代码热更呢?”,”游戏服务器不能热更会不会经常停服?”之类的问题,我的一贯看法是,抛开成本和风险谈收益的意义是不大的,所以在回答以上问题之前,先聊聊主流的Golang热更方案,它们都是基于Go Plugin机制的,这里讨论其中的两种,这里我将其称为plugin package swap方案和plugin function patch方案。

plugin package swap

该方案的思路是,将业务package编译为plugin,动态加载和替换,再通过plugin.Lookup来动态查找和使用函数。这也是最主流的Go Plugin应用方案,不过该方案有以下缺点:

  1. 受 Go Plugin 本身的限制,如第三方依赖需要一致、不能引用插件package、插件内数据类型共享、插件无法释放、调试相对困难、跨平台问题等
  2. 对业务代码侵入式较强,包括 plugin main package限制、Lookup调用方式、发布流程等

Go Plugin从2016年发布以来一直不温不火,Go官方对Plugin的维护升级更谈不上上心(两者互为因果),对于大部分开发者而言,面临Plugin的诸多限制,还是要花一些时间踩坑的。因此,有一套弱侵入性的热更方案,减少对已有框架代码的影响,减轻对开发者的理解负担(比如不要为热更单独写业务代码),并且提供快速切换静态编译和热更版本的支持,在我看来对线上服务是比较重要的。有一些开源库能解决部分问题,如hotswap能解决 main package 限制(通过正则临时替换)、不同版本plugin数据类型问题、Plugin生命周期管理问题等,并且也提供了动态链接(走plugin Lookup)和静态链接(对外暴露相同的接口,不过底层不走plugin Lookup,而是package函数调用)两种集成方式,一定程度降低了热更的侵入性。

更进一步降低侵入式的方案是使用条件编译来区分静态编译版本和热更版本,对静态编译版本而言,它除了多个几个no-op hook外,不耦合任何热更相关的东西。将插件相关代码对框架的侵入式降到最低。每次发布主体或插件,基于同一套代码同时构建热更主体、热更插件以及静态编译版本,如此在需要时,可以很方面地切换或者对照。

另一种较为Geek的方案是不使用条件编译对静态编译和热更版本做编译期区分,而是做运行时区分。服务器每次都以静态编译启动(无需so插件),但是开启插件热更机制,一旦自动检测或手动启动热加载,就用so插件的代码替换掉原本的package实现,从而实现热更。这种方案的优点是在服务器没有BUG无需热更的情况下,它的运行时表现和静态编译版本基本一致。缺点是代码层面对热更机制有一定的耦合。

另外,Go Plugin 的诸多限制也影响了该方案的应用范围,对游戏服务器而言,首要就是得有合理的package拆分,这方面可以借助DDD和洋葱架构对业务领域进行拆分和隔离,理想中的plugin package需要满足:

  1. 无全局变量
  2. 不被其他package依赖
  3. 所有数据和接口依赖,都通过外部依赖注入
  4. 无后台goroutine
  5. 尽量不要暴露plugin内的数据类型

满足如上条件的package其实更偏向提供类似纯函数的Handler实现,这较大程度限制了Plugin代码热修复的覆盖度和能力。

plugin function patch

另一种在其他项目应用过的方案也是基于Plugin,但不是将Plugin作为整个package的可替换实现,而是只通过Plugin实现要替换的函数补丁:

1
2
3
4
5
6
7
8
func GetPatchFuncs() map[string]string {
//map的key是新函数在本补丁文件中的名称(以便通过plugin.Lookup找到该函数地址)
//map的value是旧函数在旧可执行文件中的名称(应该用nm来查,以便通过CGO dlsym找到该函数地址)
list := map[string]string{
"TestHandlerHotFix": "main.TestHandler",
}
return list
}

在加载Plugin后,借助plugin.Lookup("GetPatchFuncs")拿到Patch映射,再通过plugin.LookupCGO dlsym分别找到新旧函数地址,最后借助mprotect+memcpy+hardcode asm修改旧函数地址入口内容为: jmpq 新函数地址

这套方案借鉴经典的C函数补丁热更方案,它的好处是,业务代码不需要大的调整,缺点也有不少:

  1. 相较package swap,函数补丁灵活性没有那么高
  2. 需要为了热更,暴露package过多的类型和函数
  3. Patch函数修复后,还需要在业务逻辑中再修复一次
  4. 由于是函数地址替换,因此还会受到编译器内联优化的影响
  5. 由于用了C和汇编,与底层耦合过重,跨平台和跨系统可移植性只能自己保证

代码热更的价值

在大概了解Go热更的两种实现方式和相对应的成本和风险之后,现在来聊聊代码热更的价值和收益。首要仍然要强调的是,代码热更如同设计模式中的适配器,本质都是一种补救措施。如果一个线上服务器频繁使用代码热更来修复BUG,这不能说明代码热更很有用,反倒是说明服务器的线上交付质量有问题,工作重心应该更多地考虑保障线上交付质量。同时,为热更付出的开发成本以及带来的风险性都要谨慎评估,否则就会有点舍本逐末了(提升可用性的措施,引入了新的风险或花了过高的成本反而降低了可用性)。这也是我前面提到非侵入性和共存方案的初衷之一。

现在回到最开始的两个问题:

  1. “Golang能否做代码热更?”: 能支持一定程度的代码热更,但目前已知的代码热更方案,都有一定的技术风险(坑),限制也比较多,并且只能覆盖部分业务代码,可能还需要做前置代码重构
  2. “没有热更会不会经常停服?”: 在我们近年的Golang服务器实践经验中,能否代码热更还不算是服务器的可用性瓶颈,服务器停服的原因包括版本更新、意外故障以及紧急修复等,代码热更只能解决紧急修复中的一部分BUG,这个”部分”取决于BUG复杂程度、是否需要停服止损、修复数据、是否被热更覆盖等

综上,代码热更是否值得做,最终还是取决于对各项目对这一块的成本风险和收益的权衡,而每个项目在这一块上面临的技术挑战和风险是不一样的。对我们而言,由于之前借助一些DDD思想对业务领域进行了拆分和重组(基于可维护、可测试、可扩展性考量),目前的业务package粒度和组织方式,都比较适合hotswap方案,因此目前我们开始尝试 hotswap + domain package + 条件编译 + 并存构建的热更方案,其前置业务重构成本、侵入性、风险性都很低,目前的想法是小范围逐步推进,比如先用在活动这类最常出问题的系统上。

非代码热修复

一个完整的有状态游戏服务器主要包含三部分: 代码、数据和配置,因此除了代码热更之外,这里顺便提一下配置热更和数据热修复。

配置问题是游戏服务器线上问题的主要来源之一,大部分的游戏服务器都会提供一定的配置热更能力,这个实现起来不难,由于我们使用全容器部署,为了做到宿主机隔离,配置热更是通过将配置导入到DB然后Reload来实现的,并借助atomic.Value保证配置的并发读写安全性。对于逻辑层而言,尽可能不要缓存配置,而是每次都从配置中读取最新值。

至于数据热修复,SLG游戏服务器基于性能、响应延迟、逻辑耦合强等各种原因,通常都是有状态+定时落地的,尽管我们尽可能从防御性编程、架构可靠性、线上监控、快速部署恢复等手段来尽可能提升服务器可用性,但各种预期之外的错误和故障仍然可能导致处理流程中断,引发各种数据不一致性问题。而按照经验,处理这些故障导致的数据修复,往往比服务恢复更可能成为”可用性瓶颈”。因此,为了最大程度提升玩家体验,一种或多种不停服修复数据方案是需要被考虑并长期维护的。按照我们的经验,大概可以从以下几个维度来考虑:

  • Normal Fix: 对于常见的数据不一致,做一套Fix流程,并且手动(如通过GM)或者自动(如服务器启动、玩家上线时)开启
  • Lua Fix: 接入Lua(如gopher-lua)并暴露核心的数据API以便通过Lua做一些临时的数据诊断和修复
  • Reflect Fix: 基于Go Reflect实现一套简单的DSL,支持结构体嵌套字段的读取和赋值
  • DB Fix: 强制带LRU的数据刷盘,修改DB,最后Reload

由于这一块和业务框架耦合较重,不再展开,仅仅提供一些思路。