游戏服务器难在哪?

不知不觉做游戏服务器四年多,期间有些同事跟我讲游戏服务器很简单,就是写点逻辑保存下玩家数据,也有同事觉得游戏服务器很复杂,需要学习的东西很多。本文谈谈我对这个问题的一些理解,或者是说,如果说游戏服务器开发很难,那么它究竟难在哪里?

状态性

游戏服务器是后端,做后端的,每天耳濡目染横向扩展,自动伸缩等炫酷的特性,要说放在以前,这些特性还是巨头的”专利”,我们想要自己实现这些东西挑战性是比较大的,但近几年有了容器生态如k8s的加持,只要你实现了一个无状态应用,你几乎马上就可以得到一个可伸缩的集群,享受无状态本身带来的各种好处,机器挂了自动重启,性能不够了就自动扩展等等。而作为一名游戏服务器开发者,自然也想充分享受容器时代的红利,所以我们来捋捋无状态游戏服务器的可行性。

我们将游戏服务器的状态性分为连接状态性和数据状态性。

连接状态性

连接的状态性比较好理解,即我们通常所说的长连接和短连接,游戏服务器通常使用TCP长连接,长连接有如下好处:

  • 时序性: 指对请求的顺序性保证,即客户端先发出的请求会被先处理,如果服务器是顺序一致性的,那么响应也满足顺序性。
  • 状态性: 在连接建立时进行鉴权,之后这个连接的所有消息都附带上下文(如玩家ID,权限等),而不用每次请求都带 Header。
  • 服务器推送: 这个对游戏来说还是比较重要的,邮件/聊天/广播等功能都依赖于服务器主动推送。

长连接也有一些问题:

  • 弱网体验: 通常需要做专门的断线重连流程
  • 限制了后端的扩展性: 如服务器透明重启,横向扩展等

简单来讲,由于客户端与服务器的连接是绑定且应用层有感知的,不存在中间层,因此灵活性会受限,需要应用层做更多地事情,如心跳检测,断线重连,消息负载均摊等。

那么 GS 与客户端可以用短链接吗?在我看来是可以的:

  1. 时序性: 客户端对消息时序性的要求没那么高,并且 HTTP 内部通常也是间隙性长连接的。
  2. 状态性: 这个也比较好解决,将认证Token,玩家ID这些上下文放到 HTTP Header,每几分钟重新认证一次。
  3. 推送: 这个可能稍微麻烦点,一种方案是借助 websocket 来实现。

数据状态性

数据状态性是指,游戏服务器本身是否包含数据状态。通常 GS 都是会先将数据更新到内存中,再定期存盘,这意味着服务器内存数据状态和数据库中的数据状态有一定的不一致窗口,这就是所谓的数据状态。

从实现上来讲,无数据状态服务器的逻辑节点本身只是 Handler,真正的数据放到DB(或Redis缓存)等数据服务中,由于逻辑服务通常是不稳定的,而数据服务通常是相对稳定的,并且更容易做扩展或者主从,因此逻辑节点挂掉不会造成数据丢失或不一致,并且可以透明重启(暂不考虑连接状态)。

那么游戏服务器能否做成无数据状态呢,假设我们将所有的玩家/地图等数据全部存放到外部数据服务如Redis中,每次逻辑处理的时候从Redis 读出数据处理然后再写回。来看看可能产生什么问题。

数据耦合

游戏服务器的特性之一就是数据关系灵活多变,比如一个使用道具的请求就可能涉及到道具,Buff,任务,活动等数据表的更新,即数据耦合重和单次请求相关数据量大,分表或者范式化只能针对前者做优化,而数据量大则会更多地影响服务器吞吐量以及数据服务的压力。

数据不一致

无状态服务器的处理模型无非两种:

  • 串行模型: 所有的地图相关逻辑在一个线程中处理,优点是数据的一致性,缺点是串行瓶颈。这里其实还分同步和异步: 同步数据存取吞吐量不可接受,而异步数据存取写起来很痛苦,并且可能失去数据的一致性(依赖外部数据服务的一致性)。
  • 并行模型: 每个请求在单独的线程中执行,优点是并发性和可扩展性(包括节点级的扩展),缺点是数据一致性。当多个线程在读写同一块数据的时候,可能出现数据不一致,写丢失等问题。

不用多说,并行处理模型是最优的方向,它能够充分运用无状态特性进行节点级的动态伸缩。但就游戏服务器而言,这一块通常是很难做的,因为游戏有很多类似地图,联盟这类并发访问的数据。而反观大多数的HTTP服务器,绝大部分时候都只是对自己的数据进行增删查改,需要并发读写的数据相对较少,并且在处理这些并发数据块的时候,通常的解决方案要么就是对这部分请求串行化,要么就是用数据服务提供的事务机制。但就游戏服务器而言,这类公共数据块太多,并且对性能的要求也较高,两种方案都不是很适合,需要逻辑层做更多的优化。

总结一下,游戏服务器的无状态主要受限于连接状态性和数据状态性,连接状态性比较好解决,数据状态则难得多,这是游戏数据的特性决定的,也是游戏服务器和传统HTTP服务器不同的地方。

由于有状态,那么扩展性就受限,就需要在逻辑层用并发,异步等各种手段来保证服务器的负载能力。而无法利用很多现成的中间件。

由于有状态,也就难以在意外宕机时透明重启,宕机后果也很严重: 数据不一致,补偿,玩家流失等。

由于有状态,数据调试也相对麻烦一些,比如要临时在线上执行一些数据修复或实时地数据整合。

架构设计

节点拓扑

通常的游戏服务器架构可以大致分为大服和小服两种,小服架构即按照逻辑服务器的概念来划分架构拓扑,最常见的情形是每个小服一个节点,DB也相互隔离。小服架构的优点是容易扩展,当量级起来后也不容易引发各种瓶颈问题。缺点是后期几百上千个服务器节点的运维成本高一些(尽管一些老服可能只有一两百个活跃玩家),同时做一些批量操作不是很方便。大服架构是指从架构拓扑层面没有逻辑服的概念,而是更多地从功能划分的角度来纵向划分,如地图节点,联盟节点等,逻辑服只是运行于这些节点上的逻辑概念,就像玩家一样。大服架构的好处是运维成本更低,能够更好地利用物理资源,同时做跨服功能更方便,比如合服,开新服,跨服活动等,但缺点是大服架构通常更复杂,因为它面临的量级不一样,会更多地考虑瓶颈问题,当整个游戏有几万同时在线时,会对DB,单点带来不小的考验。

但不管是大服还是小服,随着游戏后期各种跨服活动,跨服战场开启,节点数量逐渐增多,节点间的连接也越来越多,节点拓扑变得复杂,以下问题就要纳入考虑:

  • 全联通: 从架构设计上来说,全联通是应该尽量避免的,因为全联通可能导致节点职责和关系不明确,调试困难,并且在某些情况下可能出现雪崩效应。在具体设计中,比如服务器A需要获取服务器B某个玩家的数据,不要让服务器A直连服务器B,而是让它通过第三方节点(跨服节点)去取,或者将这类跨服功能做到跨服节点上,该节点可能根据其业务特性还需要考虑扩展性以避免单点(比如做逻辑切割或设计成无状态的)。节点与节点之间的连接尽可能复用,可以借助grpc/HTTP2这类技术。另一方面,如果节点间的拓扑实在复杂且易变,也可以考虑借用MQ这类中间件来解耦节点依赖,同时也可以避免全联通。另外,在使用一些分布式组件时,也要考虑其全联通特性,如之前用Erlang做大服架构的时候用到了Mnesia,就会导致节点全联通,在架构上是一个隐患。
  • 节点依赖: 全联通是拓扑复杂之后的连接状态,而从逻辑层的角度来说,这其实也映射出节点依赖问题,如活动服务区依赖玩家服务,玩家服务依赖联盟服务,联盟服务依赖玩家服务等,依赖有强弱之分,但不管是那种,环形依赖是不允许的,各类节点应该有清晰的依赖关系,以方便处理如启服停服这类操作的一致性问题。如果节点间是弱依赖,MQ可以一定程度简化拓扑关系,避免维护复杂的依赖状态。另外,尽可能地拆分那些无状态的服务,将其做成可扩展HTTP服务也是一个思路,毕竟不能全局适用无状态,在局部上用用也能得到一些好处。

有些同学可能会说,为什么节点拓扑不在架构设计之初就考虑周全,确定下来。确实,一个好的架构师应该尽可能地保留架构的弹性和扩展性,但另一方面,游戏本身的多变性本身也是架构设计的一大挑战,今天策划跟你说我们按小服实现就可以,明天为了拉付费就设计一个跨服功能或活动也是常有的事。特别是对SLG这类游戏,到了后期,除了跨服玩法,通常很难刺激玩家的新鲜感。游戏玩法很多时候都是根据各种运营数据在调整。

节点内部

节点内部而言,通常是多线程(或轻量级线程)的,就线程交互而言,常用的是以下几种并发模式:

  • 并行: 交互性不强的逻辑尽可能并行,如每个玩家的网络线程
  • 扇入: 如逻辑线程会接收来自Timer,网络,事件,以及其它线程投来的消息,类似多路复用
  • 串行: 交互性强的逻辑通常是串行的,如地图线程
  • 扇出: 如任务派发,发布订阅者模式等

在线程交互上,也有一些实践细则,如:

  • 线程与线程之间慎用同步调用
  • 尽可能将阻塞的事情放到单独的线程中去做,如网络IO,文件读写等
  • 线程之间投递消息注意值语义,如果用共享内存则注意单写入者原则

这类的设计细则跟具体情形相关,不再赘述,总的而言,主要是要有异步并发思维。

另外,不管对于节点间还是节点内,都尽量地异步交互,异步思维是后端开发的必备思维,特别对于游戏服务器来说,变更快速,测试很难完全覆盖,系统峰值难以预估,玩家(外挂)行为不可预测等都会带来不确定因素。我在这里有详细的讨论。异步编程对开发者的要求本身也更高,同时也会进一步放大数据不一致性。

快速迭代

游戏的需求变更和版本迭代速度很快,很多时候是一天一个版本,为了保证服务器的稳定性,我们需要:

  1. 通过代码规范,测试流程,代码Review等提升代码质量
  2. 通过容器化,CI/CD,DevOps 来提升交付速度
  3. 做好相关的调试工具,监控措施,修复脚本等,接入完善的监控体系,并且具备基础的运维知识,保证对异常情况的快速响应

这三个方面的内容这里不打算细讲,有的穿插在我的其它文章中,有的后续可能会单独谈谈。总的来讲,游戏后端开发需要掌握的知识栈更广一些,一方面要保证版本的快速迭代,另一方面也要保证交付质量。毕竟前面提到的状态性的问题导致服务器很脆弱,如果出现一些逻辑上的BUG,停服维护的代价是很大的,特别对于静态语言而言,有状态+无热更=如履薄冰。做很多功能的时候一方面要尽可能保证其正确性,另一方面也要考虑到其容错性,如果出错了如何监控/调试/修复。别到时候服务器出现问题了,重启一次来打印Log/上下文,再重启一次来修复Bug。或者是等到玩家已经利用该漏洞刷了大量道具,然后客服找到你,修了Bug还要修数据。

总结

本文从状态性,架构设计和快速迭代三个方面简单谈了谈自己的一些理解,其中状态性讲得比较多,因为我认为状态性是导致游戏服务器比常规HTTP服务器更复杂的直接原因之一,游戏服务器的另一个特性就是需求变更变速,版本迭代快,这需要更灵活地架构设计,更严格的软件工程实践。用一句经常跟同事开玩笑的话来说:唯一不变的就是变化,唯一可信的就是”这个地方不要写死,可能会改”。