« 群星的经济系统 | 返回首页 | 程序员修炼之道第二版开始翻译了 »

三国志战略版服务器卡顿问题

我们的新作品 三国志战略版 上线有一小段时间了。市场反应不错,获得了许多玩家。随着玩家数量增加,服务器也产生了严重的卡顿问题,在每天高峰期尤其严重。

这个产品的服务器开发团队在立项之初并没有接触过 skynet ,可以说是从头学习起,在很短的时间内就完成了项目,还是很不错的。我没有参加过这个项目的开发,在问题显露的那几天正巧国庆假期在国外度假,远程参与了一些讨论。帮助分析了问题,等休假完毕后,昨天又开了一整天的会,大致了解了游戏的玩法(需求),结合前两天的思考,给了几套不同的改进方案。

目前遇到的直接问题是,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 代码,那是未来的工作。

Comments

这种回合制游戏需要多复杂的 实时计算,规则没有搞清楚而已。。。还独立计算服务器。。
cpu 一秒可以做多少次计算? 回合游戏 呵呵

看了 回复,这个游戏是策划为主 没有在数据结构上,算法上好好设计,简单的东西在没有算法支持复杂化了

其实地图分服务这个想复杂了,这个地图结构表面看来有点像WOW地图,游戏设计层面其实本来就是支持分服务的(云风是没玩游戏吧),没想到程序的实现居然没有分开。

玩法上几大州都是靠关卡互联的,只有占领关卡的阵营(和同盟)才能通过,所以不存在跨区域建筑,也不会在跨区域的瞬间发生战斗,跨关卡的时候再处理跨服务,关卡攻防战再拆单独模块处理。
只要每个州的数据一个服务能hold住,后面可以随时扩展开放更多的地图。

我这里的benchmark结果(N=20):
Lua5.3.5+原版程序: 113.5s
Lua5.3.5+空表改为false: 83s
Lua5.3.5+空表改为false+非空表池化: 46.6s
LuaJIT+原版程序: 27.4s
LuaJIT+空表改为false: 15.2s
LuaJIT+空表改为false+非空表池化: 5.5s
池化基本上可以认为是省掉gc开销的结果,可以看出gc开销还是不小的,LuaJIT的结果也很惊人.

如无历史原因,必须选择动态语言的情况下,个人建议选择生态强大的javascript或python,js在无法限制使用jit的情况也有Fabrice Bellard的 quickjs了

可以理解lua编译器的优化有限吗?

几个字节的改动是很容易被编译器处理的情况,(相比全局数据流分析等优化的难度)

编译器要做的不正是抹平现实业务不同人编程方式(非算法问题)不同而带来的性能差异吗,而需要自动最优化吗,人不值得把精力花在学习特定语言的特定优化上

语言优化在今天是个开放而且很困难的问题,但已经进步巨大,很多人认识到,动态的语言过去的优点都在逐渐变成缺点,难于优化只是相对安全较为其次的问题

btw, 这种 benchmark 除了让语言比较的人自嗨外,基本没有什么参考价值。因为太依赖提交代码的人的代码质量,且无法对应实际的业务。

https://benchmarksgame-team.pages.debian.net/benchmarksgame/program/binarytrees-lua-2.html

lua 这个版本是非常糟糕的,可以优化的地方太多了。我随手改了两行代码,不到 10 个字节。就把 N = 20 的情况,在我的机器上执行时间从 2m7.445s 减少到 1m44.257s ;最大内存从 620105.65234375 减少到 337261.39453125 。

这游戏不错,正在体验玩耍中~

这个例子并不衡量 gc 的什么问题。没有任何参数指标显示 gc 的开销。mem 那项指的占内存数量,只说明 Lua 在处理这样的数据结构时,内存开销比较大。

gc 的算法演进是一个整体的过程,发生在任何活跃的语言上。只要语言开发社区积极,总会演变成当下业界认为更好的算法。Lua 的数据结构简单,更容易做。Lua 5.4 的分代 gc 已经在即时回收临时内存方面做的相当好了。

stackoverflow那篇5年前的问题已经过时了, 现在Java处理超大堆推荐用ZGC获得稳定的低延迟.
另外关于内存分配回收的性能, 这个benchmark就很说明问题了(当然Lua的GC有调优手段,不过这个场合仍然很棘手):
https://benchmarksgame-team.pages.debian.net/benchmarksgame/performance/binarytrees.html

快快快,把他们的代码都删掉。。。

学习了,定时gc step策略。

数据规模不同,需要考虑的东西就不同。这并不是 Lua 特有的。

比如我随手搜了一下,这篇 blog 就谈到 Go 在处理超过 10G 的堆的时候 gc 可能引起的问题:

https://syslog.ravelin.com/further-dangers-of-large-heaps-in-go-7a267b57d487

Java 的注意点:

https://stackoverflow.com/questions/214362/java-very-large-heap-sizes


Erlang 的单个 process 上发生 fullsweep 时,算法并不会比 java 更好

https://stackoverflow.com/questions/32669870/gc-performance-in-erlang

skynet 和 erlang 一样,提倡把业务分到多个 vm ,减少单一 vm gc 可能造成的影响。

是用的问题,不是 Lua 的问题。

Lua 唯一会 stop the world 的是 atomic 阶段,这个项目是错误滥用了弱表。Lua gc 其它的部分步进和堆大小没什么关系。但是你要按项目的实际情况,合理的驱动 gc 。

Lua 从 5.2 开始就设计成可以处理超大的数据的。

问题的本质还是Lua的设计理念和gc算法并不适合大内存堆,虽然最新的5.4版开始尝试分代,也并没有彻底解决这个问题.
有些逻辑系统用单进程管理大数据量确实开发起来方便很多,也不会受一些约束,开发之初就应该考虑到用Lua是否有风险. 即使执意用Lua也应该有云风这样的高手去做整体规划避开潜在的大坑.

战斗计算以及诸多其它计算已经是分开的。

在现有基础上改造的话,拆分地图是最直接的方法。场景服务的问题也不在于业务处理所用 cpu 过高,而是占用内存过大。达到了 20-30 G ,这对 gc 造成了负担,卡顿是 gc 直接导致的。

地图服务感觉没必要拆成多个,完全扛得住。。。
反正战斗是回合的,为何不发生战斗时,产生个战斗请求丢给专门计算战斗的服务计算

Post a comment

非这个主题相关的留言请到:留言本