目前遇到的直接问题是,skynet 中有个巨大的服务,管理了整个游戏场景的数据,大约有 20G 。所有的地块、部队、建筑对象都在这个服务中。且注册了大量的 timer 用来更新这些对象。最终导致在游戏繁忙时,改服务会以大约每分钟 500M 的速度生成临时数据。

这给 gc 带来的极大的负担。gc 会造成该服务的卡顿。而其它业务逻辑反而不太占用 cpu 。

通过监控数据的分析,我认为,gc 的原子操作阶段时间过长是罪魁祸首。这个阶段是不可分割的,真正的 stop the world 。而导致这个步骤过长的原因是,该服务大量使用了弱表。当弱表项高达几十万时,清理重置被影响的弱表,就需要很长的时间。

而实现中几乎把所有的对象都关联在了弱表中,仅仅是为了追踪每个类型的对象在内存中的存活情况,方便排查内存泄漏。我认为这是对弱表的滥用。在真的有这类需求时,通过遍历 vm 一样方便查找,不必为了监控而加大 gc 的负担。

去掉这些无谓的弱表后,情况得到了改观。

另外,一个意外的发现是,在 gc 的 sweep 阶段,每个 step 消耗的时间是 mark 阶段的 10 倍。这让我颇为不解。因为 sweep 的工作仅仅是遍历 gcobject 的双向链表而已。每轮 gc 大约有 1/6 的垃圾需要回收。最多的时间消耗在遍历已有对象上。我的解释是,lua gc 的步进单位,mark 阶段是用对象大小估算的,而 sweep 阶段,每个对象则是一个固定值(GCSWEEPCOST)。其实,mark 一个对象和 sweep 扫描一个对象的成本其实相差无几,尤其是在消耗内存很大时,内存 cache 几乎无效,此时,sweep 阶段的一个步骤就真的比 mark 阶段多访问了大约 10 倍的内存。

解决这个问题,我认为应该结合我们的实际情况,将 GCSWEEPCOST 调大,平衡 gc step 的停顿时间。

在这个项目中,采用的是定时主动 gc step 的策略,而不是默认用分配内存器推动 gc 。我认为,在内存使用情况有明显规律的情况下,通过调整默认参数效果更好。目前我们服务器总共用了 40G 内存,而硬件配备了 128G 内存,这显然是浪费。不如把 gc pause 调大,减慢 gc 单轮的周期,让长期 gc 的总开销减少:因为,gc 越激进,不断地遍历 vm 是一种浪费。


不过,我认为根本原因是开发者没有好好的设计服务器的结构,制造出一个数十 G 的单个 vm (实现也有极大的优化空间,不过这个需要有长期的 lua 使用经验,没有简单的银弹去优化)是根本问题。在 skynet 的结构下,我们通常倾向于合理的切割服务,避免出现单个负担过重的服务。

经过对游戏规则的了解,我意识到源头是策划设计时的含糊不清。在我看来,这类游戏本质上是一个回合游戏。它的大部分事件都以分钟为单位,和过去的类似游戏不同,这次策划让部队在行军时真正在棋盘格上移动,部队之间可以在路途上相遇发生遭遇战。这有点像一个拖慢了的 RTS ,但行动依然是按数秒左右为单位的。

如果把游戏简化为回合,那么规则上就应该明确出同一回合在同一地点发生的事件如何决定次序。但现在游戏规则是没有定出次序,靠程序在处理时的天然持续决定。我们在实现中使用了大量的定时器,事实上这些次序无法确定。这给测试,业务分割都造成了麻烦。

如果拿桌游比较,任何桌面游戏在行动回合中,都会规定不同的事件的处理次序,结算规则。而这个只是普通战棋的放大版,却没有统一的结算规则。依靠着程序处理的次序来决定,QA 感觉是对的就是对的,感觉有问题就添加一些例外处理。不同服务间同一时刻 timer 消息的先后不一致,业务种类(行军,对战等)处理复杂度的不同,相关服务的不同时期的负载不同,都会引起相同初始量导致的结果不同。


在了解完现状后,我笑道,现在策划其实给了服务器极大的自由,各种结算次序都不太所谓了。只要看起来正确就行。有什么理由不直接把单一场景拆分成多个服务呢?如果这样做,无非面临三个问题:

第一,部队可能在某个时刻从一个场景格移动到另一个场景上的格子。对于这种跨服务行军,简单的修改成服务间远程调用即可。如果不做额外的工作,的确存在一些一致性问题。

比如,如果一只军队从 A 场景服务的边界移动到 B 场景服务;同时另一只军队从它的目的地移动到 A 。两件事情同时发生时,他们就错过了。而在同一场景下,由于移动都是串行处理的,所以不会错过。

但实际上,现在由于使用了大量不确定次序的定时器,以及将寻路,战斗计算等分离,也存在某些边界情况没有考虑。由于策划在游戏规则上并没有严格的按回合推演,其实有大量的小概率不符合规则的例外都被容忍了(原因可能是计算服务的负载过高,或是同时刻定时器的执行次序不确定等)。换句话说,新出现的实际情况和规则的不一致,严重程度并没有超过原有的情况。

当然,如果肯花心思,上面这个问题是可以解决的,这里就不展开。

潜在的更重要的问题是多场景服务的数据落地。如果涉及军队对象的迁移,就可能发生军队对象同时存在在多个服务的情况。但这个问题是好解决的,只需要给军队对象加一个版本号,发生迁移时递增版本就可以防止多份对象数据同时落地的冲突。(落地服务永远以最新版本号为优先)

第二,某些大建筑会覆盖多个地块格,如果恰巧在边界上处理起来会比较麻烦。

比较简单的方法是:在修建跨边界的大建筑时,先由一个场景去另一个场景索要地块的管理权。能修建筑的都是空地,所以不存在数据的迁移,仅仅是所有权的转移。在所有权转移之后,再把建筑盖下去即可。

第三,同盟关系需要共享。

这个很容易解决,只需要相互同步即可。同步时效性也不是那么重要。

总结一下就是,把场景切分开,分到多个服务中并不是太困难的工作。带来的一致性问题会有,但出现最坏的情况并不比现在的设计下的潜在问题更糟糕。所以它是一个可行的,可以很快实施的方案。


不过,如果让我从头做设计,我肯定不喜欢现在的方案。

首先,我认为这个游戏本质上就是一个电子化的战棋类桌游。应该有严谨的结算规则。

比如群星,它的游戏推演规则其实有两个不同的周期。单位的移动和战斗是按天威单位推演的,发展和资源结算是按月结算的。一个月内的数值变化都不会在中途影响结算,而仅以月末的状态来决定。这就是很好的简化,让核心规则可以更加严谨。

我们这个游戏也一样,如果抛开行军,其它机制的结算周期都是以分钟这个数量级为周期的,可以说是推演的非常缓慢。只要我们规则制定清楚,无非就是给每个地块一个事件发生队列,以分钟这个长周期步进。同一回合内,不同的事件应该有严格的结算次序:屯田,占领,战斗……这样有一个规则明确的结算序列,才好做到同一输入可以得到确定的输出,从软件角度讲,也更利于测试;从玩家角度讲,战略规划也更加清楚。

而且,所有的地块都是相互独立的,非常适合切分,独立运算。由于大家都遵循一致的回合数,同步规则也相当清晰:每个回合必须所有地块都处理完,再进入下个回合。少数建筑会影响多个地块,比如防御箭塔,简单复制到受影响的每个地块即可。跨地块的大建筑也可以简单的生成多个副本,仅仅是在摧毁判定时,再累加伤害即可。

关于军队在地图上的运动,我调查过我们现在最活跃的服务器,同时在行军的部队,也不超过 10K 这个数量级。所以,我们完全可以制作一个单独的行军服务,管理所有在运动中的部队。这个服务可以有秒级的心跳,专门处理军队的运动。它最关键的职责在于触发部队的遭遇,而这类事件,仅需要部队 id 和路径即可。所需内存不多,cpu 的开销也不大。

btw, 去掉军队的移动,让行军服务空转,那么这个游戏就退化成类似战略游戏中部队点对点移动方式。游戏还是可玩的。而加上行军服务处理行军过程,并不会增加已有规则地复杂程度。复杂度被限制在该服务内部。


最后,我认为以现在 CPU 能力,以秒级别的周期处理区区 1 M 数量级的对象,游戏有卡顿的体验,这是不太正常的。我认为背后一定有大量质量不高的代码。一定有很大的优化余地。考虑到这个项目完成的速度颇快,这个现状可以理解。目前还没有时间去 review 代码,那是未来的工作。

是白的 我是一个勤奋的爬虫~~
{{uname}}

{{meta.replies}} 条回复
写下第一个评论!

-----------到底了-----------