这个框架在我心里已经推演了将近四年了,一直因为其巨大的工作量,让我难以下定决心去实际验证一下它的正确性。
直到今年三月份,我在工作中发现,大模型已经可以做长程任务时,觉得是时候将之前的想法变成现实,来验证一下它的正确性了。
自此,我购买了chatGPT会员,并将所有周末都投入进去了。虽然目前的代码还是粗胚,后续还需要古法重构,但整个想法已经被验证为可行。
趁着对整个过程还有点记忆,赶紧先写篇 Blog 记录一下,不然估计很快就要忘光了。
整个框架的起源要从 2018 年的八九月份说起。
当时我去一家公司面试,和面试官聊游戏服务器架构时,面试官提到他们的一个设计:他们的游戏服合服只需要打一个 Tag,除此之外几乎什么都不需要做。哪几个欧系服应该合到一起,只要改一下 Tag 就行了。
我当时并没有觉得这个方案有多大用处。在我以前的开发经历中,合服并不算是一个特别复杂的逻辑:只要在设计之初规划好 ID 不要冲突,在合服时,只需要把几个服务器的数据库合到一起,处理一下玩家名、排行榜之类的冲突,事情基本也就结束了。
并且保持单服的纯粹性,不管是对于稳定性还是复杂度来讲,都是有益处的。所以我只是单纯地好奇他是怎么做的,并不是很看好这个方案。
最终阴差阳错,我进入了另一家公司,这家公司和我以前的经验完全相反。
策划们在设计时,更倾向于使用跨服而不是合服来维持服务器生态。当然,一个进程一个游戏服的设计和我以前并没有什么不同。
在开发过程中,我们经常会遇到本来是一个本服玩法,后面因为生态的需要,需要扩展成跨服模式。这就需要重新写一套相似度 99%,但又不能复用的代码。
不能复用的原因不仅是数据库的不同,更关键的是玩家自身的数据修改和跨服数据的修改中间隔着一层网络。
而分布式总是复杂的,因此需要在各种接口层编写各种容错逻辑,这才是逻辑不能复用的元凶。
在反复经历几次本服玩法扩展成跨服玩法后,我就开始思考:有没有一种可能,我们从一开始做出来的就是跨服玩法?
如果它现在只服务于一个服务器,那就为每个服务器分别维护一份数据;如果以后需要跨服,就让几个服务器共享同一份数据。也就是将本服玩法看成一种特殊的跨服。这样就不需要每个玩法都做两遍了。
更近一步,我们甚至可以使用这个机制直接实现合服功能。
推演到这里,我突然发现这不就是 2018 年那次面试时,面试官提到的 Tag 方案么。
果然每一种方案都是有其存在的必要性的。
作为一个小服卡牌游戏,不停开新服是一种必然选择。在项目上线一到两年之后,往往会有上千个游戏服,甚至更多。
此时,即使在线并不高,庞大的进程数量以及每个进程所占用的静态资源,尤其是配置表的固定内存,都会成为一个不可忽视的成本。
即使在Tag方案下,每个游戏服还是会存在一个物理进程来承载这个游戏服的玩家操作。
因此Tag方案只能节省开发负担,并不是减少云服务器的成本。
此时我们面临两个选择:开始合服,此时合服的目的变成了压缩成本;尽可能优化单进程的开销,主要是内存,因为配置表非常庞大。
然而,在设计之初,所有系统都没有考虑合服的情况,增加合服功能是一件异常艰难的事情。
因此,我们在第二条路上走了相当长一段路程。
我们甚至还经历过启动内存峰值的问题导致维护重启时,进程被 OOM kill。
经过各种努力,单进程的内存开销已经被压缩到了极限,我甚至尝试过使用 mmap 这种歪招。
但随着游戏服数量继续增加,最终还是撑不住而进行了合服。
此时,我意识到,要解决进程成本问题,只能让进程数和同时在线有关,而不是和游戏服数量有关。
而如果让进程数和同时在线有关,就需要摒弃进程和游戏服之间的强绑定。
也就是说,不管哪个游戏服的玩家,他都可以在任意一个进程上操作自己的数据。
这就是我设计这个框架的初衷。
纸上得来终觉浅,绝知此事要躬行。
最终的实现方案和刚开工时的设想已经完全不同了。
这其中经历了各种反复,我会尽可能把关键节点记录下来。
我最开始的设计思路是将进程分为两类:role 和 gameplay。
role 用于处理玩家自己的数据,比如抽卡、养成等。所有游戏服(玩家看到的是 1 服、2 服)的玩家都可以被分配到任意一个 role 进程上去处理。
所有的交互性玩法全部被抽象为 gameplay,在 gameplay 进程上进行服务。
每个 gameplay 进程都会有许多 zone 对象,一个 zone 会关联一个或多个游戏服 ID,代表这些游戏服在同一个 zone 中进行交互。
role 只需要和 gameplay 进行连接即可。role 和 role 之间不互联,gameplay 和 gameplay 之间不互联。
这个约定是为了解决进程过多、连接爆炸的问题。
在和别的同学进行头脑风暴时,很快发现了这个约定的弊端。
我把所有非玩家自身数据全部归类为 gameplay,比如好友、联盟等。
但是有一部分玩法是建立在社交关系之上的,比如 GVG 等玩法是以联盟为基础来运行的。
这就会导致一个问题:GVG 怎么来获取联盟数据?
在现有的约束下,似乎只有通过 role 进程中转的方案来实现。
这个实现并不是不行,我们以前线上就这么干过。但是实话说,它并不符合我的审美。
于是我抽象出了第三种进程:social。所有的社交关系,比如好友、联盟,甚至聊天都放在这种进程中。
social 可以和 role、gameplay 进行连接,但是 social 之间不互联。
之后,我把所有能想到的玩法全套了一遍,发现基本满足需求。
之所以用“基本”二字,是因为在我的职业生涯中,真的碰到过一个玩法依赖另一个玩法的排行数据。
在当时跨服战区不能互联的情况下,使用游戏服来进行了绕行。所以这种情况也并非不能解决。
第一版方案暂时就这么敲定了。
下一步就是先把框架写出来,再挑几个以前做过的有代表性的玩法来验证一下。
正在这时,我犹豫了。我突然觉得,如果开头就有这么多掣肘,那作为一个框架来讲,它可能就是失败的,因为它很难应对未来灵活的变化。
最终,我放弃了限制同种进程不能互连的约束。但链接爆炸的问题依然需要解决。
此时有两种方案,使用router进程或消息队列中间件进行收敛或使用惰性链接。
消息队列中间件会增加很多不必要的开销,而且游戏中的大部分消息都有实时性,召如果当时没有生效,后期也没有必要再性效了。这和消息队列的特性是冲突的。
NATS虽然有一种模式可以用来充当router来收敛链接,但是他会在链接不断的情况下因为高负载丢弃部分消息,这会让我们失去背压的特性。所以选择NATS还不如自己写一个router更具备可定制性。
最终我选择了惰性连接,即首次调用rpc.call时再去链接。
这样,如果我们需要减少链接数量,可以能通过改变业务所在的进程来实现。比如把相互依赖的两种服务部署在没有交集的两个组进程上。
当然,凡事皆有代价。
一直以来的设计都是两个进程之间只会建立一条连接,这是为了在极端情况下可以利用 TCP 的 FIFO 特性。
在之前的设计中,启动时就知道准来负责链接。
改为惰性连接后,因为有可能两个进程相互call的可能。想要实现惟一链接,就需要实现一个类似 Erlang 那样的连接仲裁机制,不过我认为这个复杂度是值得的。
在整个取舍过程中,我反复思考了 K8s 对集群的抽象和 Erlang 对进程的抽象。
所以在解决完链接的问题之后,我想要的就更多了。
我想把整个框架做成一个类似 K8s 一样的调度引擎。
正如 K8s 调度的单位是 Pod 一样。是否可以做一个调度业务单元的引擎或框架?比如一个战区、一个玩家的数据管理。
我们可以暂时把这种业务单元称为:逻辑单元。
我们还可以顺便将整个网络层抽象掉。
表面上,逻辑单元通过 cluster.call(target, cmd, args) 来调用任意另外一个逻辑单元,看起来和网络通信没有什么区别。但是,框架可以根据是否在同一个进程来决定是通过网络,还是直接调用对方的函数。
当然,业务层面必须要处理超时,因为业务单元所在的进程是可以被调度到任意的进程上。
到这一步之后,我意外地发现整个框架多了一个不停机更新和动态迁移的能力。
这两个能力的逻辑其实是一样的:先暂存未处理的消息,然后将当前数据落地,在当前进程或另外的进程新建一个逻辑单元并加载数扬剧库,之后继续处理这些消息即可。粗暴一点,甚至我们可以考虑直接丢弃这些消息,让发起方重试即可。
于是最终的方案变成了下面这个样子:
flowchart TB
player["玩家(游戏服 ID:1)"] --> gateway["gateway"]
gateway --> agent["agent(玩家私有数据)"]
agent -->|cluster.call| gameplay["gameplay 逻辑单元"]
gameplay --> db["共享 DB 集群"]
server["游戏服 ID"] --> zone["战区"] --> gameplay
controlplane["controlplane"] --> etcd["etcd"]
etcd -. "节点发现、部署与路由配置" .-> agent
etcd -. "节点发现、部署与路由配置" .-> gameplay
整个集群共享一个 DB 集群,不再像以前的小服模式一样,每个游戏服配置一个数据库。
这种存算分离不管是从成本还是可维护性上都会更方便。
所有集群节点通过 etcd 管理。
所有 etcd 的操作都必须通过一个叫做 controlplane 的进程代理。因为,必要时 controlplane 需要通过两阶段提交的方式保证集群的状态和Etcd的强一致性。
etcd 一共承担三个职责:配置逻辑单元部署在哪个进程节点上;配置战区(从抽象的角度来看,就是玩家消息会在 cluster.call 时被路由到哪个逻辑单元);节点间相互发现对方的监听地址。
每个节点都有一个独一无二的名字,所有的静态配置都是根据节点名字来配置的,而监听地址是在进程启动时动态注册的。
为了简化实现,我将逻辑单元拆成了两种类型:agent 和 gameplay。
agent 就是以前的 role 进程,专门处理玩家的私有数据。之所以换名字,是因为我想把 role 这个名字留给业务层使用。
除此之外的逻辑单元都运行在 gameplay 进程中。
所有的 gameplay 都是以战区的方式进行组织的,如果它是单服玩法,那就是一个服的战区。
这样拆分有个好处:agent 可以根据负载均衡算法更充分地使用 CPU,和 gateway 之间的通信也更直观。
还有一些特性无法做到框架里。但我都提供了可选插件。
第一个,如何解决本服资源扣除、gameplay 失败之后资源回滚的问题。这是跨服开发经常碰到的一种情况:我扣了资源,去跨服打 Boss 时它死了,此时恰好Boss死掉了,你怎么把资源还给玩家?
在以前的开发中,我们要么先扣除,失败之后尽可能回滚;要么就是加个协议锁防止重复发协议,rpc.call成功后再扣资源。
两种方案都不完美,一种是玩家可能会吃亏,一种是可能产生刷资源的漏洞,因为你可以防止玩家重发这个协议,但你不能防止玩家通过别的协议消耗掉你要扣的资源。
得益于所有进程共享 DB 集群,针对这种情况,我提供了一个 user_mq 的抽象。
我们可以在玩家请求时,就先将资源扣除,然后将扣除的资源数量带到gameplay去,如果此时玩家无法玩成这个操作,就通过user_mq将玩家资源返还即可。
同样类似的逻辑,比如活动结算发奖、充值订单发货等,都可以使用 user_mq 来实现。
第二, gameplay 逻辑单元如何向玩家主动推送消息。
初步的想法是可以做一个固定的 serverzone(gameplay),玩家登录时向 serverzone 注册当前所在的 agent。
当任何 gameplay 需要推送给玩家消息时,直接通过 serverzone 来转发即可。
当然这只是一种可能,还有一种实现我正在考虑它的必要性。
以 1 服玩家打跨服 Boss 为例,整个流程可以理解为:
- 玩家登录后,请求先经由 gateway 被转发到任意一个 agent;agent 处理玩家私有数据,并在扣除资源时记录扣除的数量。
- agent 通过
cluster.call调用 1 服所属战区的 gameplay 逻辑单元,由它处理跨服 Boss 的交互逻辑。 - 如果 gameplay 处理失败,就通过 user_mq 将资源返还给玩家;如果处理成功,结果则沿原调用链返回。
- 当 gameplay 需要主动通知玩家时,通过 serverzone 找到玩家当前所在的 agent,再由 agent 将消息推送给玩家。
第三,上面说的热更新其实有个瑕疵。
如果当前业务单元正在 rpc.call 的过程中被强行切换到新代码,除非代码非常健壮,不然可能会触发一些意料之外的问题。
当需要热更这个逻辑单元时,框架会先缓存后面进来的消息,同时需要逻辑单元自己确认没有任何正在处理的 rpc.call 和 timer 函数。
框架提供了一个包装函数来实现这个目的。