梦幻西游服务器的优化
在历史工程上修补是件麻烦的事情。
前两天说起梦幻西游服务器的优化。这几天我到广州住下来,打算专门花一周时间搞定这件事。由于以前都是网上聊天,只有坐到一起才能真正理解问题。
目前,梦幻西游,只使用单台机器,最高配置 8 个 CPU ,配置 8G 内存。就算最热闹的服务器,也用不完这些资源(大约只用满了 3 个 CPU ,一半的内存)。核心程序差不多就是 10 年前写的,从大话西游延续至今。这两年一直在享受免费的午餐,随着硬件配置提升,现在单台服务器同时在线容量达到一万两千人。观察服务器回应速度的图表可以发现,目前的问题在于,定期会出现反应迟钝的现象。周期性的,服务器回应时间会超过 1000ms 。查得原因在于那个时候,磁盘 IO 非常拥塞。有定期保存玩家数据的服务对 IO 的占用,以及 SA 做的定期备份数据的脚本占用了大量的 IO 时间。最终造成了机器负荷过重。
IO 负荷过重最终怎样影响到游戏服务的性能,这个暂时不过于深入探讨。我这两天主要是分析以有的系统结构,并想一下改进方案。
其实老的系统并不复杂,代码量也相当之小。相关的服务代码仅仅数千行干净的 C 代码而已。一直没有人动它,因为事关重大,牵扯着数百万用户的数据,以及记费流程。无论设计是好是坏,实现的性能有无问题,都让位于稳定。“历史原因”造成的种种,也只能在闲聊时抱怨一句,如果重新设计,肯定不会这样写了。近两年,我越发的对重构这件事情显的兴趣漠然,为何不这样做,为何不那样? 更多的时候都只是程序员们饭局上的聊资。每个系统一旦编写完成,就充满了种种的遗憾。如果它能用,最大的可能就是它就将一直用下去。一切的新想法,留给下一次吧。
对于已经稳定运行了很多年的陈旧的系统,找到好的方法去改造的意义不大。最重要的是,如何对已有系统影响最小的增加一些东西,提高性能。模块间清晰的划分显得相当重要。服务的独立性也是必要的。现在运行的数据服务和记费以及用户鉴权服务居然放在一个服务程序中恐怕是一个大失误。它使得我们把数据读写剥离出来非常困难。
数据服务采用的是一个 C/S 结构。但没有使用数据库,而是直接使用的本地文件系统。整个设计算是良好,但数据服务本身的机制却很糟糕。C 和 S 之间采用共享内存交换数据,这是为了提高 IPC 性能。C 只有一个,就是游戏主进程,而 S 可以有多个。可以并发的提供服务。多个 S 和 C 之间用管道传输命令,用共享内存交换数据。本意是好的,但协议设计是有问题的。因为 C 直接操控数据区,而有唯一性,结果设计时,把数据区的区块管理放在了 C 上,而不是由 S 提供。
举例来说,如果游戏进程(C) 需要加载一个用户的数据,它自己先寻找数据区中的空位,然后通知 S 把这个用户的数据加载到它指定的数据位置。数据区的清理工作同样是由 C 这边做的。这使得 S 不能直接在数据区上做 Cache ,如果需要 Cache 暂时不用的数据(比如一个玩家离线)就得由 C 自己来做。或者额外的再做一个 Cache 服务(这需要多出一倍的内存,以及内存复制的操作)当初这么实现恐怕是考虑到有多个 S 同时为一个 C 服务的需求,但我只能认为是设计欠佳。
结果就是,整个数据服务,无论是读还是写,都是无 Cache 的。Cache 仅仅依赖 OS 来做。对于当初低一个数量级的时候,这没有问题。但在线人数从千级达到万级后,问题就显露出来了。毕竟你为最终需求最更多的定制,越能充分发挥硬件的性能。
下面记录一下我已经实现好的内存 Key/Value 数据库的设计思路。
要实现前几天想好的,只保存差异信息的策略(经实测,可以减少 90% 的写 IO 操作),必须先统一数据读写服务的位置。不能依赖本地文件系统做数据交换。我之前考察过若干内存数据库,比如 Redis ,最终决定自己实现一个。因为我已经非常了解需求,可以高度定制算法,最大发挥硬件的能力。代码量也不会太大。(控制在 500 行 C 代码之内,最后实际写下来,不过 300 行 C 程序)
我们的需求是这样的:服务程序每周会停机一次。每周总共涉及的玩家数据 10 万组。每组数据 4k 到 32K 之间。都是文本数据。可以看成一个 id 到数据串的 key/value 数据储存服务。经估算,总数据可以全部放入内存。数据会频繁更新,更新后长度会改变。
我花了一天实现这个 k/v 内存数据服务。为了最大利用内存,并同时保证效率,以及代码实现的简洁。我采用预先分配好整块内存的方案,把内存切割成 1K 为单位的区块。并用单向链表串起来。考虑到内存 cache 的命中效率。链表指针本身和数据储存区分离。(大多数时候,我们只需要访问链表指针,而不需要访问具体数据)
链表指针采用序号,而非内存地址。这样即使在 64bit 系统上,依然使用 4 字节索引(可以最大可管理 4T 数据,足够了)。单向链表可以比双向链表节省一半的指针操作以及节约少量内存。代价是代码写起来繁杂一点。
所有内存区块分成两部分:空闲区块和已用区块。一开始全部空间都是空闲。一旦向内放入一段数据,就从空闲链表上摘下够用的区块,放到已用链表的尾部。如果 cache 空间满,则从已用区块链表头部移掉一些空间还给空间区块(这些数据区是长久未访问过的)。每次读取一段数据,都将其调整到已用链表的末尾,保证最后才清理。
另外做一个 hash 表,从 id 映射到在 cache 中区块段的头(由于是单向链表,具体实现时应保存上一个节点)。这样可以用 O(1) 时间查询指定 id 对应的数据区,
保存在 cache 中的数据不必在地址上完全连续,这好比磁盘的分簇管理。和磁盘不同,内存的随机访问性能和顺序访问性能差异更小。这样有利于内存空间利用效率。