最近整理了过去一年发生过的bug,包含跟其他项目组程序朋友交流的例子, 都是大家发生过的真实营运事故。
游戏服务器程序,很多bug的原因都是共通的。抽象出了以下10点启示, 作为checklist, 写下来以后写程序review时自检:
1. 安全边界问题 对于有界限的东西(数值,buffer空间,队列或一切对象容器),一定要考虑越界判断。
启示:用snprint, strncpy等限制长度. 永远都要考虑超过边界的情况
数值加法和乘法:考虑上限溢出;
减法:考虑负数; 除法,判断分母
2. 输入参数非法
case1: 扣钱逻辑,减去一个负数,变成了加钱。
case2: int型大负数相加,负溢出变成大正数
启示:test case要全覆盖输入参数范围, 处理各种可能的情况
3. 上下文改变错误
共享变量/全局变量被外部改变,这似乎很常见,而且有时很隐蔽。在异步回调的情况下更常见。
check A变量
call func_B()
....
A变量被func_B改变了, 但继续信任A变量check的结果。
启示:白盒复查代码时,注意检查调用后的变化。
减少共享变量和全局变量的使用
外部接口调用后,注意共享变量的更新和恢复
启示:在最接近执行的地方,检查上下文变量。不信任调用者,如果效率不关键,多一遍冗余检查没有坏处
4. 执行中断
动态脚本抛异常,或者引擎层面的EINTR中断信号,都有可能中断代码执行,需要考虑函数的重入性问题。
启示:要检查一致性,有些逻辑不允许多次被执行(比如发奖励),需要有状态变量确保只执行1次(避免出刷bug)
推广到异步环境(多线程,多进程,各种回调),事务的中断也有一个重入性问题,解决方法也只有一个:用一个唯一可辨认的状态变量,保证某些逻辑不会被多次执行(比如购物应用中,用唯一订单号来识别,状态改变是一次性的,当逻辑运行多次,也不会重复加物品,或者重复扣钱了)
5. 终止条件问题--死循环
case: 异步环境中,RPC远程调用,调用成环,逻辑一直不结束。
启示:while或递归的终止条件,逻辑全覆盖检查,避免死循环。较深层次的互相调用,要注意是否出现了递归,是否有可能死循环。
6. 关联数据操作的不一致
例子:Employee对象有company变量, Company中有employee变量,
如果操作改变其中一方,而另一方没有改变,则造成数据不一致。
(数据库表可以指定constrain, 关联表删除, 但代码变量中需要程序员自己实现)
双向引用的数据一致性问题,要特别注意。
为什么要双向引用?为了查找效率,而避免遍历其中一方.
这个问题本质是数据一致性问题,编程中遇到的很多bug也归结到这个问题,比如野指针,就是因为数据结构相互引用的操作不一致造成的。
处理这个问题,个人经验是,他们的attach,detach操作尽可能在同一个模块,不要分散在多个地方随意修改,所有修改都集中在同一级接口做。
同理适用于new, delete, malloc, free这些分配,释放,都集中在同一层的接口/模块文件中做,debug起来也容易;非常反感在一个地方new, 然后不知道哪个模块去delete, 很容易泄漏或者野指针, 无论如何,想办法传递这些指针,一直传到分配他所在的模块文件中释放,而且new和delete的接口代码要靠近,方便查找问题。
7. 涉及多玩家,防止笔误传错参数
经典错误: foreach(uid in team) some_func(usernum, xxx)
经典错误:有usernum和target两个对象,调用函数搞混了。review时要仔细检查
8. 特殊分支忘了return
异常判断等if分支忘了return。导致逻辑继续往下走。这属于笔误问题,测试期间未必能留意的到。
9. 异步返回没清变量
对于异步操作,如果在返回时清变量,这时如果不能保证把变量清掉(比如期间玩家下线无法离线修改该变量),就会出刷。
启示:对于已奖励标记,一定要保证各种情况下领奖后能正确记录。
10. 瞬爆容量上限
case1: 网络待发送队列,因为瞬间大量请求,塞满抛异常,导致流程受影响。
case2: 大量连接请求,listen的accept没有规定单次读事件的accept,用了while(true), 导致爆机
在listen fd的读事件回调中, 通常会accept所有新的连接请求,如果用while(true)而不设一个上限,就有可能被攻击(想象一下客户端也用一个死循环来做connect)。
一方面要限制单次接受的socket次数, 另外各个状态要有超时机制,踢掉不寻常的连接,以防被攻击占尽资源。
case3: 异步情况下,要限制操作者连续频繁的操作。(比如在请求入口处增加最少时间间隔限制,避免玩家狂点,形成雪崩效应)
(同时要考虑用户体验,不要让玩家死等,可以做一个提示跳转,或者等候的动画)
参考资料:
附上最近看的一篇文章
<Writing-reliable-online-game-services> 作者曾是魔兽争霸和星际争霸,battle.net的开发者,
里面讲的point也是游戏里经常遇到的可靠性问题。
http://www.codeofhonor.com/blog/wp-content/uploads/2012/04/Patrick-Wyatt-Writing-reliable-online-game-services.pdf
by Daly
网游服务器程序优化要解决的最主要矛盾无非就是在保证流畅游戏体验(响应时间在可接受范围)的前提下,容纳更多的玩家,当然还要保证开发的便捷性。一个靠谱的MMOG游戏服务器基本上都是多线程或多进程的架构, 利用多个CPU核把串行处理变成并行处理,以容纳更大的并发玩家规模。
然而并行处理程序会使开发的复杂度增加,一不小心很容易出一些诡异bug。为什么这样说呢?实际环境的大部分程序,函数的执行结果与状态数据相关(外部状态,全局数据),并且函数执行可能会改变这些状态。如果把处理模块拆成多进程,进程间的这些状态数据的一致性和处理时序,会影响到结果的正确性。多进程状态数据的管理,读写和同步更新机制,便是本文要探讨的主要问题。
如果函数能变成无状态的(结果只与输入参数相关),则分拆成多进程毫无压力。于是业界开始探讨erlang这种函数式编程语言,并有已有实际游戏项目(参看:http://www.qingliangcn.com/) 。不过笔者觉得,erlang的无状态,本质上是把状态数据通过函数参数传递,这样意味着频繁而大量的数据复制和传递,是否更适合于MMORPG开发很难说,本文不予讨论,可见文章末尾参考资料。下面探讨一下状态数据在多进程之间的问题。
为了容易描述,整个架构如下图
G
client <--->║ <------> A
║ <------> B
其中G表示接入网关,负责把client协议分发到内网对应处理进程,A,B是负责不同功能的处理进程,client表示客户端,玩家状态数据只有个v和w两个。用reqA,reqB分别表示client对A, B的处理请求,respA, respB表示A,B返回给client的处理结果。
游戏逻辑大部分情况下需要保证状态数据的强一致性,基于过期的数据进行处理会得到错误的结果(分布式数据一致性的工程问题见文末的参考资料)。举个有点蹩脚的例子,假设client先后发出reqA, reqB两个请求,reqA是换武器,reqB是发起攻击,变量v是攻击输出量(dps)。reqB在reqA之后发出,攻击理应是按穿上武器后的dps数值来计算的。但多进程情况下,却有可能reqB先于reqA处理(比如A进程很忙),这时reqB的逻辑会基于还没穿上装备时的变量v来计算结果。下面分别讨论几种解决数据一致性问题的方案。
模式一:共享内存
适合于单机多进程或多线程的模式。
优点:数据只有一份,可以保证强一致性。
缺点:进程无法扩展到多台服务器;
需要加锁,加锁相当于把处理串行化,还是有可能被某一个较忙的进程卡住。如果精心设计和划分数据,减少锁的粒度可以提高性能,但细粒度的锁(设计成类似MySQL的行级锁),在涉及多个玩家数据的交互逻辑时,稍有不慎又容易导致死锁。随手写一个:
假设进程A和B同样执行以下类似的逻辑
foreach( user in mapA) {
lock(user);
lock(user‘s friend);
do_something();
unlock(user's friend);
unlock(user_id);
}
由于遍历的是map, 进程A和B中的user顺序有可能交叉, 假设交叉的两个user互为friend,就可能死锁了。
参考资料[4]采用了这种模式的方案。
模式二:状态数据只由一个进程管理
把状态数据根据游戏逻辑进行划分,比如变量v只由A读写, 变量w只由B读写。假如A逻辑需要用到w,则通过异步请求B获取w。
优点:保证强一致性;数据只有一份,无需进程间复制更新。
缺点:异步请求增加了响应时间(嗯,又从并行变成了串行); 异步写起来的代码有点ugly,到处是callback, 回来要检查上下文,不然又是诡异bug.
适用范围:如果状态数据能比较好的划分(即绝大多数情况下,某个数据只会在某个进程的逻辑中用到),用这种方案比较适合,因为简单。比如玩家位置只由AOI进程管理,玩家好友由聊天进程管理。
模式三:多个writer, 类似MVCC方案
这是完全的分布式设计。每个进程有自己版本的状态数据,进程间可互相同步更新, 状态数据v分别在A,B都有一份。互相update时,根据版本信息进行merge。
这种方案不能保证强一致性,而且merge时会有可能发生冲突,需要逻辑开发者仲裁这种冲突(比如按时间先后)。不同于互联网应用,游戏需要较强的数据一致性和实时性,这种方案比较复杂且不太可控。
模式四:Master-Slave模式
这个是对模式二的一个扩展,某个状态数据还是只由一个进程进行写操作,但其他进程会维持一份cache进行读操作,比如变量v由进程A管理,v的更新会同步到进程B,进程B逻辑如果要用到v,直接读自己的cache就可以了。对于变量v
特点:这种方式也是不能保证强一致性,只能保证最终一致性。作为模式二的补充,有些数据不需要保证更新时序,根据过期数据进行处理也可以接受(这个是代价,需要权衡玩家体验),可以采取这种方式。而对于不能接受的,走模式二。某些需求reqA,reqB虽然先后发出,如果respA还没反馈回来的话,即使逻辑上reqB先于reqA处理,在玩家体验上也是可以接受的。比如reqA穿装备, 然后reqB攻击,但是respA还没返回,客户端还是看作是没穿上装备,这时候按照老的属性计算攻击值是可接受的。广域网几百毫秒的延迟,reqB要晚于reqA + respA这种概率很小了,如果真的发生,服务器已经很卡了。
又比如聊天进程,reqA离开场景,然后reqB发聊天消息往当前场景频道,需要知道当前场景的玩家列表(假设场景玩家列表在AOI进程管理),如果reqB先到达聊天进程,拿到旧的场景玩家列表, 那么这个广播就不准确了。这种不一致性的代价可以忍受的话就没问题(在这个聊天栏例子,在跳场景的瞬间发错人了也可以忍),实际情况,进程间通信几个毫秒,发生这种处理时序反转的几率其实非常小了。
综上,如果要设计多进程结构,个人比较推崇模式四。这时又引申出几个问题:状态数据如何合理划分?何时更新?同步给谁?
如何划分?
有些功能很好划分。比如聊天进程,状态数据只与好友列表有关,这个需求可以忍受过期数据,好友关系由主进程修改,同步到聊天进程。玩家position, 由AOI进程管理,修改同步到主进程,主进程几乎没有需要用到position的逻辑。
但有些数据就可能很纠结,比如背包数据。玩家交易,在线奖励,战斗都需要修改背包物品数据,而且必须保证强一致性,否则就可能出现丢失或物品复制,该由谁做这个数据的管理者呢?如果AOI进程管理,物品使用效果可以马上生效,但是交易和在线奖励也需要验证背包物品,这些逻辑也放到AOI进程么,如果放,则又牵扯出更多的变量,如果不放,则需要退化成模式2的异步请求。如果放主进程,则使用物品后产生的效果不能立刻同步到AOI进程。可以经过仔细对比,AOI与背包数据交互的频率远高于主进程,于是背包数据可由AOI进程管理。
何时更新?
两种选择:一有修改立马发送更新给其他进程;队列buffer住所有更新,定时送出去(比如每2秒同步一次);既然是无法保证强一致性,后者性能容易优化些。比如AOI进程中的位置信息变化很频繁,但主进程对位置实时性不敏感(比如只用于持久化,掉线重上后的位置恢复),则更新间隔可以长一些,否则会有频繁而大量的位置数据更新;定时更新也利于同步间隔内数据修改的合并,减少同步量。
同步给谁?
某类数据有修改时,需要通知哪些进程,意味着要维持一个映射表。可以在编码阶段,在数据定义时静态写死某类数据要通知哪一类功能进程; 也可以在运行期设计成pub-sub模式(或者叫observer模式), 动态增删订阅者。笔者觉得前者可控一点,因为进程要用到哪些数据,在编码阶段是可以清楚规划的,根据这个原则把数据划分成一个个模块,比如玩家数据分为基本角色属性,avatar, 位置/朝向, 好友数据.... 然后决定归属。
多进程可以提升系统并发规模,但同时有各种异步调用和数据一致性问题,带来的代价就是bug的风险增加(尤其团队水平不能保证个个都很高的情况下,一个菜鸟程序员就够受了,还很难跟踪),开发难度增大。这个需要仔细profile和实验确定瓶颈在哪,真的跑满CPU或者卡IO才有必要分出去,想当然的把模块拆分很多进程,设计看上去很优雅也很牛逼,往往是麻烦的开始 ——> 开发效率降低,出bug意味着啥?加班,加班,深夜运维的夺命追魂call... ...
参考资料
替代系统自带的malloc/new原因无非两个:
reason 1. 做内存profile或查找问题
reason 2. 自定义的分配方案提高性能
不过文章[1]中说明了,替代全局new不是一个好做法. 其实要达到以上两点目的,笔者认为用valgrind工具链就可以了。
解决方案:
1. 用valgrind和massif
valgrind的memcheck做内存泄露和bug的查找, 里面的massif工具包做内存性能profile, 足矣。比自己山寨的一个profiler要好。
注意:tcmalloc目前还不能很好支持valgrind, 实测中jemalloc可以
2. linux下C的程序可以用wrap的方式(相当于python的decorator)
编译加上选项:gcc -Wl,-wrap,malloc
可以做到对malloc这个函数,linker会调用__wrap_malloc代替之, 若要调用原来的malloc函数__real_malloc
缺点:依赖于编译器支持; 对c++的new不起作用 --> 不实用
启示:这个方法作为function装饰器,对于调试别的问题倒有帮助。(例如不改变函数的情况下,wrap一层,输出些调试信息)
3. 用__malloc_hook
#include <malloc.h>
void *(*__malloc_hook)(size_t size, const void *caller);
缺点:依赖GNU编译工具链; 容易死循环(想利用原有malloc,要参考例子中,把原__malloc_hook变量保存起来使用,并恢复现场)
4. LD_PRELOAD注入.so ,替代原
环境变量LD_PRELOAD指定程序运行时优先加载的动态连接库,这个动态链接库中的符号优先级是最高的。标准C的各种函数都是存放在libc.so.6的文件中,在程序运行时自动链接。使用LD_PRELOAD后,自己编写的malloc的加载顺序高于glibc中的malloc,这样就实现了替换。用法 LD_PRELOAD=" ./mymalloc.so"
缺点:在生产环境不现实。因为LD_PRELOAD相当于库注入,有安全性问题,是必须禁止的。(生产环境很多时候用-static连接)
5. 用宏或另外的函数替代new/malloc
比如定义一个宏或者指定的函数,规定所有的分配释放都调用他。这样相当于给项目引入了额外的代码规则(而且是一立项就要遵循这个规则,否则该方法无效),不能很自然的new/delete, 如果分配和释放调用得不一致,会产生问题的。某产品组就是用宏,然后加上__FILE__, __LINE__之类的信息。
有时候valgrind的效率是个问题(尤其生产环境),这种方案有其价值所在, 就是代码看上去比较ugly罢了
用宏的例子:
#define _New(Type, Catergory) (Type*)MyMemController::New((new Type), #Type, 1, sizeof(Type), Catergory, __FILE__, __LINE__, false)
#define _NewArray(Type, N, Catergory) (Type*)MyMemController::New((new Type[N]), #Type, N, sizeof(Type)*(N), Catergory, __FILE__, __LINE__, true)
MALLOC的替代品:
自己写一个malloc其实很复杂,要考虑线程安全等各种问题,性能到头来可能更差。google 的tcmalloc, facebook使用的jemalloc. 多线程下性能较好,可以考虑使用。
缺点:笔者尝试过。tcmalloc不能正确用valgrind,只能用自带gperftools(运行中会core)
jemalloc可以使用valgrind,不过还没完全验证是否都准确。
tcmalloc相关:
在64位系统上要装libunwind, 对x86-64架构使用还有些问题
源码包的INSTALL文档里面也提到了这个问题。
CAUTION: if you install libunwind from the url above, be aware that
you may have trouble if you try to statically link your binary with
perftools: that is, if you link with 'gcc -static -lgcc_eh ...'.
This is because both libunwind and libgcc implement the same C++
exception handling APIs, but they implement them differently on
some platforms. This is not likely to be a problem on ia64, but
may be on x86-64.
主要是64位机frame-pointer的影响, 他的profile工具里的backtrace用libunwind这个库,这个库又有版本问题,各种囧啊....
笔者试过系统x86-64, freebsd,用静态链接。实际用了一下,问题很多很折腾,等他fix了再说吧.
windows下可以参考:
jemalloc暂时未发现有什么兼容性问题,运行得挺好的。
最近组内发表一篇小论文,是关于改进游戏储存系统的IO性能思路。老大原来早有相同的想法,并且已经实现了大部分模块,后来和老大一同努力,新的储存引擎终于逐步完善。在外服环境跑了两个多月,性能和可靠性得到了明显的提升。具体的细节就不方便发表了,实践证明,用binlog来做MMORPG的数据储存是行得通的。
几个事实:
1. 磁盘IO的瓶颈在寻道,顺序写性能比随机写性能高一个数量级。
目前典型硬盘的顺序写入速度大约是60MB/s , 而寻道时间在5~8ms (200次/秒)。可以看到硬盘IO的主要瓶颈在于磁头寻道,也就是随机写。在linux开发服(非虚拟机,Xeon 3.0G 4核/16G内存)上做了一个benchmark。
顺序写50MB: 700ms
写5000个文件,每个10KB(共50MB): 12秒
10000次随机写,每次1KB(共10MB): 21秒
2. 游戏数据都是K-V数据,关系查询需求极少;k-v数据的update很频繁(实测是每玩家每5秒一次修改)
3. MMORPG单服的玩家同时在线数量是10K级别, 这个数量级可以有效估算binlog的规模,使得方案可行。
一般MMORPG系统的存盘策略: 定时存盘。就是过一段时间(比如5分钟)把在线有修改过的玩家数据,整个snapshot存下去(mysql也好,文件系统也好)。这样有两个主要问题:一到保存点,IO随机写暴增,玩家卡机;如果系统down机, 数据就会有几分钟的回档。而性能和数据可靠性两则是矛盾的,存盘间隔过小,玩家卡机,过大,故障后数据回档时间长。需知现在的MMORPG,贵价武器价值都成千上万RMB,数据可靠性对游戏营运影响还是很大的。
so, 可以用定制的binlog来记录玩家数据,也就是说,不记录整个snapshot,而是每个k-v变化时记录opcode马上写入binlog文件, binlog的格式根据游戏情况可以高度定制,尽量减少空间。由于是顺序写,性能可以非常高。如果down机,可以根据binlog来恢复,基本上没有回档。不过要解决一个问题:binlog增长过大 --> 崩溃恢复时间过程 & binlog文件本身损坏的风险增大 & 磁盘空间用光。因此binlog需要有rotate机制, rotate的时候需要存一次在线玩家数据的snapshot, 这样旧的binlog就可以存到远处或者丢弃。rotate的过程中需要考虑恢复时玩家数据一致性和完备性等等一系列细节问题,后来一一解决了。
这是最近做的成就感的事。几年没写blog了,笔记都记在evernote里,最近又想在公开的地方写点东西,发个文纪念一下。
摘要: 实习做的两个小游戏,山寨版的雷电和网络对战的休闲游戏。
阅读全文
摘要: 很多程序都需要处理一系列定时事件, 本文就见过的程序中,几种实现Timer的方法。用到的数据结构一般有链表, 堆, RB树,hash table等,还有一些比较优化的方法。
阅读全文
摘要: 总结了几种资源和内存管理的实现思路。包括buddy算法,STL中的allocator实现思路,游戏中的资源管理
阅读全文