编写网络程序应该注意的几个问题
刘刚
2003年9月19日星期五
2006年7月14日星期五批注
2009年1月22日修订
经过一年多来《天骄》网络游戏的编程工作,有了一些经验和教训,希望在这里跟大家分享。
注:后来我和我的同事编写了天骄II,增加了不少经验,在这里补上。以前我们得出的一些结论到了现在发生了变化,在这里也进行了修正和补充。毕竟我离开目标软件和网络游戏行业一年了,有些经验可以结合着具体的游戏表达出来。
网络程序与单机程序相比难度是成倍增加的
网络程序的编程和单机版相比,难度增加了许多,而且往往是超乎我们的想象的。
对稳定性需求增加
一台游戏的服务器程序需要持续不断的运行,至少要在24小时内不会发生任何问题。这与单机游戏相比,难度增加了。如果希望这款游戏的规模再大些,要求就更高了。基本上要达到电信级的水平。
注:在后来的天骄II里面,我们终于基本上做到无严重BUG,正式运营的游戏服务器可以正常运行一周没有问题,在我们自己的测试服务器上运行时间超过49天。当然这个成绩在我离开目标的时候还没有达到,还存在一个BUG不定期的会导致崩溃,这时候是去年的7月份,天骄II收费2个月。后来在去年年底,这个BUG由接替我工作的同事们找到,值得庆幸的是,导致BUG的代码并不是我们编写的,而是编写网络底层的程序员编写的。这说明我们在防止BUG方面,做得很不错。
对效率要求增加
一台服务器支持的游戏人数是非常多的,每个用户的操作也是多种多样的。中间会有慢速操作和快速操作。就需要我们分离这些操作,提高快速操作的效率,而不能让这些操作为了等一些无关紧要的东西而降低效率。对这部分内容进行优化势必会增加程序的复杂性和出现问题的可能性。
注:一般我们会用多线程来充分利用服务器上的多个CPU。
程序复杂度增加
游戏的用户操作比起那些电信的操作来说要复杂的多了。所可能导致的问题也要多很多。服务器结构的扩充,程序内部模块的划分,模块和线程之间的交互都会使得程序更加容易出现BUG,而且这些BUG更加隐蔽。
注:服务器程序的复杂度,不在于代码量的多少,而在于代码的BUG数量和优化的程度。
版本更新频繁
天骄的服务器程序从2003年2月14日至今就更新了123个版本,平均不到每两天就有一个版本。每次更新都可能包含新的BUG,新的问题。
长时间的维护,同一段代码由不同的人维护,也会增加代码中的隐患。
注:所谓的至今是只截止到我写这篇文字的时候,2003年9月,大约7个月的时间。天骄I至今还在运营,后来更新了多少版我没有统计过。
对错误的处理比对功能的实现更重要
实现功能,当然是程序的主要用途。但是对于服务器程序,我们不光要实现功能和用途,而且要在出现错误的时候还能健壮的处理(对于健壮的定义详见《程序编写规则》)。这几乎占据了90%的编写服务器代码的时间。
当我们写好了功能,要想的是,如果一旦出现了某些极端情况会怎样。而不要想,反正出现了极端情况也是以两年以后的事情了。这是我们服务器程序与客户端程序最大的不同。
我们写程序不能留尾巴。要做到完成一段,就让这段代码完全的Bug Free。不用过几个月,甚至过几年还要回过头来看这些代码。否则我们就永远被这些代码拴住,而无法进步了。
对BUG的监测
那么如何加强对如此复杂的程序的监控呢?下面有一些方法。这些方法对于单机程序而言,可能并不重要,但是对于网络程序而言,就显得比较重要了。这个时候我们写代码,其主要作用就不仅仅是实现游戏的功能,更重要的是如何使我们的代码错误更少,出了错之后更容易被找到。
注:因为网络游戏的特殊性,我们必须不断地更新游戏,所以游戏基本上不可能出现一个阶段,在这个阶段里是完全BUG Free的。很多修改都要求我们在没有完全进行测试的情况下就必须上线,实际上进行所谓的完全测试也基本上是不可能的。所以,这要求我们必须很快的定位错误,并且在错误一开始发生的时候就尽量的收到报警并找到它。
Assert
在每一个模块,线程,函数的参数输入部分,增加Assert()进行参数合法性判定。但是要注意,这个函数的作用仅仅限于Debug版,对于查找那些非常隐蔽的错误用处是不大的。所以一般仅仅在程序开发的前期使用。到了后期,基本上要以后后面的解决办法来查找问题。
assert( strlen(szMsg) < sizeof(szMsg) );
注:在天骄II的时候,因为ASSERT的作用实在是太大了,我们又增加了一个编译选项:Final版。也就是说除了普通的Debug和Release编译选项以外,又增加了一个Final。Final才是最终服务器会用到的版本。而我们在Release版里,重新定义了ASSERT(),而不是原来的简单的把代码设置为NULL。重新定义以后,ASSERT()就不再报对话框,而是直接写log文件,这在公测期间尤为重要。可以随时帮助我们防范错误。
Log文件
因为游戏的服务器经常需要长时间运行,而且大多是Release版的情况下,所以非常有必要对那些出现错误的地方进行随时的记录。对错误的输入要有记录。主要需要记录的内容有:
l 正常错误。程序中可能出现的正常错误,比如资源没有找到,读写磁盘失败,登陆失败等等。
l 非正常错误。不应该出现的错误,比如人物没有找到,道具属性没有找到,怀疑是作弊的行为等等。
对于网络程序,几乎要在任何我们认为可能出现错误的地方写下Log记录。但是因为Log记录的操作非常消耗时间,所以对于那些调用非常频繁的地方不适合用Log来记录,那就需要下面的方法。
WriteLogFile( "easyrpg.log", "Here cannot be a room!\n" );
注:因为log比较慢,所以在天骄I的后期,和天骄II,我们将这些信息输出给另外一个线程,后者是另外一台计算机,然后再保存。这里的用途除了查找BUG以外,另外一个主要的用途是记录玩家的操作,这是为了追查复制等玩家作弊行为,监控游戏的公平性,防止玩家刷钱,洗钱。
Try和Catch
网络服务器程序经常需要运行较长的时间,而且一旦出现错误,无法得知到底是在哪里出现的。Try和catch就帮助我们解决这个问题。Try的作用就是保证如果这部分代码出现的了异常,程序会自动返回到一个catch中的指令里继续执行。而不会出现非法指令的问题。
我们的全部代码都应该被保护在try和catch里,层层嵌套。在现实情况下,错误往往会出现在程序的任何一个地方,可能会在任何时候出现,而且这些错误往往是不能立刻再现的。我们的程序员已经不可能针对每一个错误长时间的进行“再现”尝试和跟踪。这时候,往往需要我们一边用try先把错误跳过去,让程序可以继续运行,另一方面缩小包围圈,逐步找到问题的真正所在。
Try和catch的最大作用是当程序发生了错误,我就可以立刻知道在哪个范围里,哪个函数里发生了错误。虽然发生错误的地方和产生错误的地方可能并不一定是一个地方,但是至少可以帮助我们尽快的确定到底是哪里出了错,并且不让这里继续出错。
在天骄里经常可以遇到几个月以后才最终找到BUG的情况。往往当一个错误发生了,我们并不能很快定位出错的原因。但是如果我们的程序内部有这样一个由log和try这样的纠错机制所组成的“网”的话,会非常有助于我们尽快解决问题。
实际上一般,try和写log都在一起使用,一旦发生了异常,被catch住,就应该写log把相关的信息打印出来。
int aaa = 0;
#ifndef _DEBUG
try
{
#endif
…(实际的代码1)
aaa = 1;
…(实际的代码2)
aaa = 2;
…(实际的代码3)
aaa = 3;
…
#ifndef _DEBUG
}
catch(...)
{
char szMsg[ER_MESSAGE_SIZE];
sprintf( szMsg, "newpersonex() Error : %d <%s %d %d>\n", aaa, szType, nLevel, nSLevel );
WriteLogFile( "easyrpg.log", szMsg );
}
#endif
这里要说明一下,变量aaa的作用就是帮助我们定位到底是在执行哪段代码,哪个函数的时候发生了异常。以便于我们下次在该函数内部写入这样的try和catch的代码,继续跟踪。
注意:对于还没有发布的程序,尤其是单机版游戏,草率的使用try反而是有害的(假如try不能很快定位错误的话)。因为这样会徒然导致许多很奇怪的错误的产生,会掩盖真正出现错误的地方。
注:Try和Catch不能滥用,这里再次强调,尤其在客户端程序,我们不建议使用。甚至我们在天骄II里,基本上很少使用Try和Catch了。一个主要原因是,因为吸取了天骄I的经验,所以天骄II崩溃的机会比天骄I要少了很多。我们基本上没有必要随时保证服务器的稳定运行。另外一个主要原因是我们找到了一个更好的办法。
以前,我们最头疼的问题是,一旦程序崩溃,我们无法知道它是在什么地方崩溃的,函数调用的嵌套关系是怎样的。但是我们利用了Windows提供的dbghelp.dll的功能,可以使我们随时发现相关的代码,这大大提供了我们查找错误的速度,所以Try和Catch的主要作用都可以被替代了。
当然,如果我们遇到的这个BUG,短时间内找不到,利用Try和Catch维持一下服务器的稳定还是必要的。
程序注释
程序注释的重要性不仅仅是在解决BUG方面,在大家一起维护同一段代码和自己长时间维护同一段代码的过程中,都会帮助我们的记忆。一般我们都会要求:
在每修改一个BUG的时候,要写上修改人,日期,BUG的原因以及是怎么改正的。
在每增加一项功能的时候,要写上是什么功能,和什么部分配合。
// BUG FIX : Apr.14.2003, 对中毒以后的人,必须记住是谁下的毒。
//中毒时间结束以后,要删除这个变量
注:这个要求尤其重要。在我们培训新程序员的时候,这是一个基本要求,在写任何代码的时候我们都要求必须这样。因为这是目前我们认为的最有效的程序文档。必须强制执行。相信程序员自己会逐渐认同的,毕竟这对于需要不停阅读代码的他们,有着实际上的好处。
消息和指令要有文档
服务器和客户端之间,甚至服务器内部不同模块之间的交互,往往是通过消息的形式来实现的。模块之间交互往往牵扯到不同的程序员之间,不同的模块之间的程序。所以这些接口部分的内容我们建议用文档来将其规范化。
每增加一个消息,都应当在文档上有所体现。文档上的主要内容是这个消息的作用,消息的参数,以及发出了这个消息后我们希望会收到什么样的消息。
如果每两个模块之间的交互都有这样的文档,那么模块与模块之间的接口就清晰了,发生了问题也比较容易分清责任。我们在设计游戏中某一个功能的时候也能够有一个依托。
打开赛程安排界面 |
94 |
TDS_PLAN |
%d |
此人ID |
|
|
|
|
%d |
场次1-4 |
|
|
|
|
%d |
是否可以参加 |
|
|
|
|
%s |
比赛信息 |
最大人数,时间信息,比赛方1,比赛方2 |
注:这相当于是制定网络协议。这是软件工程里,拆分模块的最有效的方法,这就相当于是模块之间的接口。这个接口定义的越清晰,模块之间的调用和访问就会越简单。在我们的天骄I和天骄II里,我们甚至把服务器端程序都拆分成两个部分:文字服务器和图形服务器,这样大大简化了单个模块的复杂度。尽管这增加了消息通讯之间的复杂度,有些程序员认为效率不高,但是经过了长时间的使用,我相信他们会觉得,简单的程序对他们来讲更重要。
局域网的局限性
我们测试一般都是用公司内部的局域网络进行测试。但是,我们必须要知道,有些错误和问题在局域网上是不能检测出来的。比如天骄的卡机问题。
还有在内测时候出现的服务器停止响应的问题,一些利用网络反应迟缓作弊的问题等等。都是我们在局域网无法预先得知的。
所以在最终的测试之前,一定要经过Internet的实际检验。
注:必须要在真正的Internet环境下真实的测试。测试的问题,一直被认为是最让人头疼的问题之一。在盛大,每次服务器更新都是一场恶仗,由陈天桥带队,所有人员(开发、运营)必须在场,更新以后,必须等待一段时间以后,游戏稳定了,没有玩家抱怨了,所有人才可以撤离。但是我更欣赏九城的方法:建立免费测试区。
目标软件也采取的同样的方法,我们建立专门的免费测试区,这个区一般是由我们的内测演变而来的——这样就不必删除玩家的存档了。新版内容先放在这个区里测试1-3天,确定没有问题了,再扩展到全部服务器。但是在现在免费时代,免费测试区存在的意义似乎就不大了,因为没有了免费的优势。同时,战区的结构需要更复杂些。
对内存使用的关注
服务器要想稳定运行,其内存和Windows资源的占用也是很重要的。一定要注意内存的泄漏(Memory Leak)。尽管Windows提供了很方便的内存管理方式,但是有时候我们也需要自己来管理动态内存。因为这时候我们更加容易检测到内存使用的非法情况。
再提一次天骄II最后的那个BUG,为了查找到底是谁把内存写出界了,我们把所有模块的动态内存分配都放入了一个自己写的内存池(Memory Pool),但是很遗憾的是因为当时我们采用了公司另外一个部门写的网络底层,这部分代码并没有放入pool中,所以这个问题一直没有被发现。后来还是这个底层代码被使用在另外的一个游戏中之后,才被发现的。
所以,我们必须要时刻关注服务器程序的内存占用,这是服务器程序想要稳定运行的必不可少的条件。
对于链表,要写如下的log进行监控:
If( ActList.size()>=1000 && ActList.size()%1000==0)
{
WriteLogFile( “MemCheck.log”, “Too much counts in ActList.\n” );
}
运行超过49天的BUG
在Windows系统里,VC提供一个函数timeGetTime(),得到系统启动累计时间的毫秒数。这个数值是32位的,所以基本上每隔49天就会复位一次。而我们游戏的BUG恰巧在这个时候出现了。这种情况按理说是非常难以出现的,因为大多数正式运行的服务器,都会每周重新启动一次。
出现异常要立即做些什么
尽量要把BUG消灭在刚刚写完的时候。所以一旦发现问题,尽量立即就进行查找。而对于那些一时无法定位的问题,我们就应该增加log和try这样的调试代码。以期望下次这个问题出现的时候,我们距离错误能够更进一步。
注:用一句通俗的话讲,这叫做“贼不走空”。每次我们发现了问题,必须写一些代码更详细的跟踪代码。这样这次服务器的“当机”才更有意义。如果你发现你对这样的当机无计可施,这只能说明你应该换程序员了。
调试方法——排除法
所有的调试方法,都来自于程序员自己的信念。所以我们在查找错误的时候,一定要具有“人定胜天”的必胜信念,如果我们不战胜程序,我们就会被程序所战胜。
排除法。这种方法最常见,就是跳过对错误影响不大的内容,步步为营,一点点缩小包围圈,直至找到问题的所在。这个方法几乎可以应用于任何类型的错误,程序员所需要的仅仅是细心和坚持不懈而已。
要想更好地用排除法调试程序,我们应该尽量(在一段时间内)保留原有的代码,不要急于删除不要的代码。一旦发生问题,我们可以尽快地恢复原始环境,快速定位产生变化的原因。
导致崩溃的元凶
程序的崩溃,对程序来说是最致命的,而且有的崩溃,我们可以很容易找到“抛尸”的地方,却很难找到真正“作案”的地方。这就需要我们对代码严格把关。
对野指针的引用
大量的崩溃来自于对野指针的引用。所谓野指针,是指指针所指向的地址已经被释放,这个指针就被称之为野指针。我们对野指针所在地址进行引用,就会直接导致程序的崩溃。
解决办法,就是尽量不要直接保存别人的指针,而尽量用ID来代替。
五个导致崩溃的函数
经过我们的研究发现,服务器导致崩溃的5个危险函数是:memcpy(), memset(), strcpy(),strcat(),sprintf()(这些函数的变体也包括在内,就不一一列举了)。
解决办法,就是尽量不要用这五个函数,或者用自己的函数把这几个函数包裹起来,对错误的处理放在自己写的函数中,比如:
inline __declspec errno_t strcpy_ss(char* pDst, size_t _DstSize, const char* pSrc)
{
size_t nSrcLen = strlen(pSrc);
if( nSrcLen >= _DstSize )
{
g_FWXLogFile.WriteFormatLog("<strcpy_ss> _DstSize[%d] is small for _SrcSize[%d], pSrc[%s]", _DstSize, nSrcLen, pSrc);
return EINVAL;
}
return strcpy_s(pDst, _DstSize, pSrc);
}
我们可以用CString(或者std::string)来代替char []。只在最需要的时候使用memcpy(),用ZeroMemory()来代替memset()。
一个导致崩溃的数据结构——数组
数组虽然好用,但是如果下标越界,崩溃起来也是很恐怖的。
解决办法,尽量少用数组,用链表吧。
其他
类型转换,除零,都可能导致崩溃,但是因为用得比较少,也比较容易查找,这里就不再赘述。
死循环也会导致服务器的停止响应,这里也不再详细说明了。
提升效率要注意的地方
服务器程序里有一些操作是必须进行序列化的。比如:对客户端发过来的指令的执行,我们一般都只能采用一条流水线来处理(为了减少程序的复杂度)。而这些操作里往往既有很快就可以执行完的操作,比如修改一个属性,也有需要较长时间才能执行完的,比如读取一个场景。如果这些慢速指令影响到那些快速指令,则会导致服务器的效率降低。
读写磁盘的操作
这些操作包括:对图片等资源的读取,对角色档案的读取和写入,对log文件的写入等等。磁盘操作所消耗的时间一般是内存操作的1000倍以上,所以宁肯减少一次读写盘的操作,也比增加一些程序代码要好。当然,具体问题需要具体进行测试和分析。
如果是读写其他计算机上的磁盘的操作,比如通过网络共享来读写,速度就更慢了,这一点我们必须注意。
注:在天骄II,我们用数据库的操作更多的来代替磁盘文件操作,减少了不少出现BUG的可能性,同时性能也有所提升。
调用硬件的次数
对网络,显示的硬件调用次数,会直接影响到程序效率。
消息队列的排队情况
模块之间的调用我们一般用消息(指令)的形式实现。这就需要我们时刻关注这些消息队列的排队情况。如果某个队列排队情况比较严重。则说明有效率低下的情况。我们就可以针对这些情况进行优化。
注:消息的堆积这个问题在天骄II里非常突出。因为天骄II的服务器程序的效率大幅度的提升,我们内部测试的数据可以达到2000人/服务器。但是,因为人数太多,消息的数量曾经在公测期间造成大面积的堆积。这需要我们花费更多的精力来进行监控的优化。我们制作了专门的代码来检查消息队列的长度,以及他们消长的情况。
一般我们会测试出一个消息量的最大值,比如每秒处理5万条消息,一旦超过这个数量,消息就会堆积,这时候我们的服务器必须丢弃一些消息,或者减缓游戏的刷新频率(比如敌人的刷新频率),这样来缓解小溪的压力。同时,我们也获得了一台服务器最大能够支持的用户的数量。
帧速率的平稳
理想的情况下,服务器的帧速率应该是平稳的,即便是每秒12帧,帧与帧之间的间隔应该也是平稳的。而如果我们不能把慢速操作分离出来,就很难做到这种效果。
注:在我们的游戏程序里,为了防止外挂,很多操作是服务器同步模拟的,并且一切都以服务器的为准。所以这时候,服务器一般都会有一个固定的刷新频率。比如刷新敌人,记录人物身上状态的时间,记录技能火球的移动等等。我们在优化的时候应该尽量减少服务器每秒刷新的内容。
多线程的使用
单机游戏可以不使用多线程,但是网络方面的程序一般就都需要多线程技术了。而多线程技术对程序员的水平提出了更高的要求。
最容易出BUG和死锁的情况
多线程的问题最主要的就是资源共享的问题。如果一个资源同时被多个线程修改和访问,就会造成错误。而关键区的嵌套则很容易造成死锁。要想避免或者解决这样的问题,一般可以遵循以下的规则:
l 搞清楚那些内容是要被其他线程共享的,它们被称作资源。尽量减少这些资源。
l 尽量不要用指针来访问那些共享的资源。对于那些可能被释放的动态资源尤其如此,这是非常危险的。
l 可以把资源拷贝一份出来,然后释放关键区,减少关键区包含的范围。关键区包含的范围越大,越会降低多线程的效果,也容易不小心包含了不该包含的内容。
l 关键区内尽量不要包含慢速操作,因为这样会拖住其它的线程。
l 尽量不要嵌套使用关键区。
l 把所有同一个线程的操作写在同一个文件里。这样会帮助你理清头绪。
写线程最省事的办法
一个10分钟就可以为你的程序增加一个线程,却不会出任何关键区错误的方法是这样的:
1. 这个线程和其它线程通讯的唯一途径就是消息。通过一个接受消息链表和一个发送消息链表,来进行本线程和其它线程的通讯。
2. 在这两个链表的添加消息和删除消息的函数里分别设置关键区。
3. 保证只有这两个消息链表才是线程之间的共享资源,其它的所有内容都不是。
CDMList<CSRBN_MsgItem*> gSRBN_RecvList;
CCriticalSection gCS_SRBN;
int SRBN_AddRecvMsg( int nIDNet, int nKey, LPCTSTR szCmd, LPCTSTR szParam )
{
int nCount = gSRBN_RecvList.GetCount();
if( nCount >= 1000 && ((nCount%1000) == 0) )
{
char szMsg[NET_MESSAGE_SIZE];
sprintf( szMsg, "SRBN_AddRecvMsg() : Too Much<%d>\n", nCount );
WriteLogFile( "easyrpg.log", szMsg );
}
gCS_SRBN.Lock();
CSRBN_MsgItem* pmsg = gSRBNPool.NewPtr();
if( pmsg )
{
assert( strlen(szParam) < (int)sizeof(pmsg->m_szMsg) );
sprintf( pmsg->m_szMsg, "%s %s", szCmd, szParam );
pmsg->m_nMsgSize = strlen( pmsg->m_szMsg );
assert( pmsg->m_nMsgSize < (int)sizeof(pmsg->m_szMsg) );
pmsg->m_nIDNet = nIDNet;
pmsg->m_nKey = nKey;
gSRBN_RecvList.AddTail( pmsg );
}
gCS_SRBN.Unlock();
gSRBN_Sema.Unlock(1);// 接收线程可以接收
return 1;
}
int SRBN_RemoveRecvMsg( int* pnIDNet, int* pnKey, char* szMsg, int nSize )
{
int ret = 0;
gCS_SRBN.Lock();
if( !gSRBN_RecvList.IsEmpty() )
{
CSRBN_MsgItem* pmsg = gSRBN_RecvList.RemoveHead();
if( nSize > pmsg->m_nMsgSize )
{
memcpy( szMsg, pmsg->m_szMsg, pmsg->m_nMsgSize );
assert( pmsg->m_nMsgSize < nSize );
szMsg[pmsg->m_nMsgSize] = 0;
*pnIDNet = pmsg->m_nIDNet;
*pnKey = pmsg->m_nKey;
assert( pmsg->m_nKey != -1 );
}
else
{
assert( 0 );
}
gSRBNPool.DeletePtr( pmsg );
static int nloop = 0;
if( nloop % 101 == 0 )
{
gSRBNPool.Fix( 100 );
}
nloop ++;
ret = 1;
}
else
{
ret = 0;
*pnIDNet = -1;
*pnKey = -1;
}
gCS_SRBN.Unlock();
return ret;
}
当然,这样的写法仅仅对一部分线程有效,对于那些还需要共享其它资源的线程是没有太大用处的。
注:要用这里的算法请注意,消息的拷贝将可能是你的程序消耗CPU的罪魁祸首。
硬件环境
在编写和测试多线程程序的时候,一个双CPU的计算机对你会有非常大的帮助。一个在单CPU计算机运行一宿才可能出现的问题,在一个双CPU的计算机上一个小时就可能出现了。这样可以节省你大量的测试时间。
注:值得庆幸的是,在Windows系统下,你不用自己去管你的线程应该会用在哪个CPU上。
文件操作中容易出错的地方
如果程序对一个文件既有读操作,又有写操作。那么对这个文件和整个程序而言是极为危险的。因为如果一旦写入文件失败,则会影响到读的操作,而读操作的失败则可能导致整个程序的崩溃。如果这个文件是玩家的存档,则很容易造成档案数据的永久性损坏。
注:还是那个建议:尽量用数据库来代替文件系统,又快又不容易出错,更好查询和修改。天骄I的问题是因为这是一个从单机游戏改装过来的网络游戏,你总不能让用户在玩一款单机游戏的时候还要再安装一个数据库吧?
多个服务器共享文件
在天骄中,会出现多台服务器程序读写同一个文件的情况。比如门派系统的存盘文件。读文件无所谓,但是对于写文件,如果出现多个进程同时写入一个文件,则会导致这个文件的数据混乱。这时候我们必须自己建立一个跨计算机的“关键区”。
目前我们所采取的方法是建立“文件锁”的方法。在写操作开始前,判断是否有文件锁,如果没有则创建一个文件锁,然后写入,写入结束以后,删除这个文件锁。
写盘操作最不容易出错的写法
在读写玩家存档这样的操作中。为了防止在写入文件时失败导致的文件不可读。我们一般采取下面的做法:
先把文件写入到一个临时文件中。如果最后成功,再把这个文件改名成为正式文件。
当然,文件的结构也是非常重要的,如何既可以随时修改和扩充,又可以随时校验读写的正确与否,都是我们要考虑的。
最后,我们必须判断所有的读写函数的返回值。如果出现错误必须能够及时发现,及时处理,以确保如果读取了出了错的文件不会对整个系统造成致命的影响。因为这毕竟不同于单机游戏,网络游戏里即使读了错误的文件也不能使系统崩溃。
用数据库代替文件操作
用数据库来代替文件操作是个不错的想法,既解决了资源共享的问题,又保证不会出现错误。而且,在速度上也会比较高。
数据库操作应该注意的问题
数据库使用起来很方便,也不容易出错。但是这里很容易成为一个效率上的瓶颈,所以经常是我们优化的重点,一些额外的代码也是为了数据库而准备的。
一个表中只有一个Primary Key,而且只通过这个Key 来访问记录。
给每一个表建立一个unique key,这个key的取值在每条记录中都是不重合的。相同的key会降低查询的速度。
不通过key来访问一条记录是非常慢的。在MySQL下,超过10万条记录消耗的时间就可以长达几秒。
组合字段的查询效率也非常低,也要尽量减少这样的操作。
尽量一个玩家的信息在一个表中只有一条记录,一次查询(Select)出多条记录,速度也是很慢的。
必要的时候可以采用二进制Buffer来存储一些琐碎的数据。
数据库操作应该作为慢速操作而进行优化。
数据库的访问速度再快,也是毫秒级的。所以,如果可能,尽量减少访问数据库的次数,甚至将其放入另外的流水线中进行操作。
注意数据库的问题往往不出现在刚写的时候
数据库出现问题,往往是当记录的条目多了以后,所以当我们刚写完代码的时候,基本上不会出现问题。而当运行了一段时间以后,比如几个月,这个问题才会逐渐显现。所以在我们写代码的时候,尤其要注意其严谨性。比如,如果已经无效的记录,一定要删除,而不能留在Table里。
测试的时候,我们要尽可能模拟大数据量的情况。一旦我们发现程序响应速度大幅度下降,要想到有可能是数据库出现了问题。
机器人的使用
机器人就是我们用来模拟客户端操作的一种程序。在网络时代,一台服务器支持成百上千的客户端是家常便饭。但是由此而来的测试问题就摆在我们的面前。
尽管有内测和公测的阶段,但是,如果我们的程序有太多的问题,会给公众造成非常不好的影响。所以,我们尽量还是应该能够自己进行比较广泛的测试。
机器人就是我们最有效的办法。
注:这一点在天骄II得到了充分的利用。我们用机器人模拟了尽可能多的用户操作,从简单的聊天说话,到选用指定的技能进行战斗,甚至穿戴装备。用技能战斗和使用穿戴道具,是常见的两类服务器功能性BUG。
尤其在机器人制作的过程中,如果能在一个进城里模拟出更多的机器人,也是很重要的。这样我们利用少量的几台机器就可以模拟出多大上千的机器人。我们测试的2000人/服务器的最大负载就是利用机器人实现的。
在天骄II的机器人产生程序里,所能产生的机器人的数量取决于Windows所允许的一个进程里同时能打开的线程的数量,和机器的内存。毕竟一个虚拟的客户端至少也需要几M的空间来存放必要的客户端数据。
利用海量的机器人长时间测试过的服务器程序,会大大增强你的信心。
实现稳定性测试
机器人的一大特点,就是可以针对某一类操作进行高频率的测试,以求在最短的时间里达到最好的测试结果。比如我们在天骄里的登录测试,切换场景测试和组队测试等。都是让机器人频繁的发送一些指令,来专门测试这些指令的执行情况。
我们在天骄的稳定性测试的时候,一般是以一宿为单位(10小时),如果在一宿的时间里运行正常,则被认定是稳定的。
实现压力测试
机器人的另外一大好处是占用的资源较少,我们目前天骄使用的机器人占的内存只有6MB,远远小于一个真正的客户端的需求。这样我们可以在一台计算机上同时启动多个机器人,将服务器的人数充满。
如果机器人模拟的足够真实,我们在内测以前就可以得知服务器的最大负载将是多少人。但是,很遗憾,天骄里到目前为止还没有真正实现这第二个功能,一般我们仅以几十来模拟几百人的效果。
杜绝外挂
实践证明,我们是完全有能力杜绝游戏中外挂的出现的。原理其实很简单,就是一个原则:不信任客户端。
注:这句话后来很遗憾的被证明是错误的,至少严格来讲是不完全正确的。这样做只能完全杜绝恶性外挂。也就是说,最多能够防止玩家作弊,但是不能防止玩家的“类机器人”外挂。
不过现在网游进入了免费时代,那种消耗时间的机器人外挂存在的价值已经不大了,所以外挂的威胁对网络游戏而言,已经逐渐在缩小。对于我们更重要的是防止恶性外挂就足够了。所以从这个层面上来讲,这段文字的意义还存在。
所有重要的计算都放在服务器端进行
这些最主要的操作可能包括:装备道具,攻击计算,移动,NPC的智能等等。
天骄的实现方法是在服务器端也运行着一个游戏世界,而客户端仅仅是接受服务器传来的一些必要消息。当然,这种做法也导致服务器负载过重,一台服务器支持的人数收到限制。
注:天骄I就有严重的此类问题。因为游戏的服务器是从单机游戏简单的修改过来的,所以,服务器端程序真实的模拟了所有客户端的操作,比如一个火球在飞行,服务器端也会产生一个火球在一帧帧的飞行。但是,其实因为服务器不是玩家可见的,所以完全没有必要这样,只要做一个近似的模拟就可以了。比如天骄II就进行了此类的优化,服务器性能大幅度提高。
审核客户端来的消息参数
对任何客户端发过来的消息(指令)都要进行参数的合法化校验。如果发过来的参数不足以证明其合法性,就要在服务器端额外设置变量进行验证。
比如,学习技能。客户端可能发送过来任意一个技能。但是,服务器必须临时设置一个变量,来存储此人能不能学习这个技能。
// 判断作弊
CERString strToLearn = pPlayer->GetStr("SKL_ToLearn");
int nFind = strToLearn.Find( szParam );
if( nFind < 0 )
{ // 这项技能不属于你能学习的范围,你不能学习这项技能。
sprintf( szMsg, ER_LoadString(1322) );
LogUpdateEx( pPlayer, 16, -1, szMsg );
return FALSE;
}
pPlayer->DeleteStr("SKL_ToLearn");
注:这是防止黑客攻击的基本方法。与上面的问题结合起来考虑,有这样一条规则:如果你无法审核这条指令的合法性,那么这就是重要的计算,你必须把它放到服务器端来进行。另外一个特别强调的是:所有的指令。
对消息包进行简单加密
如果我们已经不折不扣的执行了上面的方法。下面的方法即使不用,也足以防止外挂的出现。下面的方法主要是为了当我们的系统还不是很完善的时候,替我们抵挡一阵,也为了让那些外挂的制作者们做起事情来不是那么轻而易举。
l 应该对消息包进行简单的加密。这可以把一大批制作外挂的菜鸟拒之门外。如果全部是明码的话,让他们解的也太容易了,反正做这件事对我们而言也不太费事。加密的算法只要保证速度就可以了,简单一些也无所谓。
l 服务器发送给客户端的消息,与客户端发送给服务器的消息加密算法最好不一样。而且,客户端没有服务器端的解密算法。这样可以防止人们对客户端发送消息的解密。
l 在消息包中增加一个消息计数器。防止人们复制消息包对我们的服务器进行轰炸。
l 对消息包的合法性进行处理和甄别。防止人们用垃圾消息对我们进行轰炸。也要注意接受非法消息所导致的程序崩溃。
l 客户端定时向服务器发送时钟消息。服务器一旦检测不到,则立即剔出此客户端。防止客户端被调试。
l 用一些伪指令扰乱跟踪者的视线。这个方法对付那些菜鸟还是绰绰有余的。
_asm{
JMP _Begin13 + 2
_Begin13:
_EMIT 0x8D
_EMIT 0x5E
}
注:你要明白的一点就是:外挂制造者并不会直接去解你的封包,他们唯一要做的就是找到你的加密或者解密的函数入口,获取你加密前或者解密后的数据。所以,目前比较有效的对付破解的办法就是给客户端加壳,就像我们早年在PC单机版游戏做的那样。然后依靠不断的更新修改,拖垮外挂制作者。
任何外挂的传言都是游戏中的漏洞
只要做到上面三点,就可以防范除了按键精灵以外的任何形式的外挂了。但是,为什么关于外挂的传言仍然到处都有呢?
因为游戏中的漏洞。比如我们忘了对某条指令的参数进行审核,忽略了网络速度迟缓对系统的影响,游戏中的BUG等等。
这种传言永远都会有,我们只要谨慎言行,小心取证就可以了。
注:对于这个结论,我们只要增加一个注脚就可以了:恶性。我们只要确保自己完全可以防范恶性外挂。
对于“类机器人”外挂的防范
我们在天骄II曾经与类机器人的外挂做过一段时间的斗争,其实效果并不理想。对付这类外挂,目前我们只有一条路,这也是当年文字MUD时代对付机器人的手段。就是想尽一切办法,识别操作这个客户端的到底是不是真人。比如,GM会找到这个人通过对话问他一些问题,如果他的回答表现得的确“像”个真人那么作罢;再比如:游戏中不定期的弹出一个对话框,让用户回答一个问题,选择一个图案或者数字,就像网页上的安全码。
但是所有的这些做法,省事的很容易会被破解,麻烦的我们自己的代价又太大,所以最后基本上只能放弃。像网易的提问图片,有时候就是真人你都不一定能选择正确。
当然,在网游的免费时代,挂机练级的必要性越来越小,尤其是花钱给外挂练级。许多网游公司都宁肯让这些玩家花钱给自己快速升级。所以这个问题在未来应该不算是问题了。