先大致描述一下场景,客户端按登陆流程成功登陆后,开始按各个模块加载所需数据,待收到消息返回后cache在内存中,只有当所有模块全部加载完成之后,客户端才算真正登陆成功,这时玩家才可以进行各种操作。在玩家操作过程中,客户端不断与服务器进行消息交互,并始终与服务器数据保持一致(这里是指状态一致,不是指所有数据全部一致,服务端存储的数据与客户端所需要的数据并不完全相同)。
由于模块过多,每个模块都是分别独立加载,因此从用户点击登陆按钮开始到真正可以操作所经过的时间显得有些漫长。客户端同学提出,由于客户端内存中的数据与服务器保持一致,那么在客户端退出时将cache中的模块数据存储为文件。下次登陆直接跳过模块的网络数据加载过程,直接从本地加载。
刚听到这个想法,我被其惊艳到了,随后心里却隐隐感觉不妥,但是并没有发现有什么问题。
仔细思考后,终于知道哪里不妥了,玩家的某一个ID可能同时会登陆多个客户端,那么理论上每一个客户端都会有一份这个ID的cache,就需要所有客户端与服务器在玩家数据上保持一致。这明明是一个典型的分布式一致性问题,那么由CAP定理,问题不可能这么容易被解决。
后来他们又围绕着这个机制进行了进一步加强,服务器为每个玩家ID的cache落地后数据维护一个版本号,这个ID每登陆一次,客户端就对其自增并发往服务器进行存储。这确实会把问题概率降低好几个数量级,但是我觉得可靠性依然不够。
比如客户端将数据写入文件的过程中crash了,服务器在向数据库记录版本号时crash了等等都有可能导致数据的不一致性。虽然这个概率很低,但并不是没有可能。
最近受paxos影响颇深,就想着有没有办法在任意糟糕的网络状况下和数据落地可能出错(比如写文件或DB时crash了)的情况下保证数据的一致性。
先说一下登陆流程。
客户端持帐户名向LoginServer进行帐户认证,如果认证成功LoginServer会拿着帐号ID向GameServer索取一个session。当LoginServer成功获取session之后将uid和session一同返回给客户端。客户端再持uid和session到GameServer端进行登陆。其中session在GameServer端分配,规则为一直自增。
大致的想法是:
在GameServer登陆之后,服务端向客户端返回GameServer的启动时间戳StartTime和下次从LoginServer登陆时将会拿到的session(这里称为next_session),next_session在GameServer整个启动周期内(从启动服务器进程到进程退出)有效。客户端在收到登陆成功消息之后将next_session和StartTime存入内存,当进程退出时首先所有数据落地完成之后,再将next_session和StarTime字段一起进行落地。
下次在从LoginServer认证成功,获取到session后,与本地存储的next_session进行比较,如果一致则说明中间没有人登陆过,那么数据一定是一致的。由于session是在GameServer整个启动周期内有效,所以如果中间服务器重启session会0开始分配。因此除了需要比较LoginServer返回的session与上次获取到的next_session是否一致之外还需要比较本次GameServer返回的StartTime与上次落地的StarTime是否一致。
整个算法的一致性可以很容易进行证明。
因为每次登陆成功在GameServer端是一个原子操作,而session的分配是直接在内存完成的,不存在操作数据库的行为,也一定是原子的。登陆成功和分配next_session在一个消息中被处理,因此整个操作一定是原子的,这保证了session的有效性。即然保证了服务端session的正确性,那么客户端只要保证在所有模块数据落地之后再落地next_session和StartTime就可以保证数据的一致性。不管是服务器和客户端在哪一步crash其实都无关紧要。
正如CAP定理所说的一样,这个算法虽然保证了一致性,但是会有两个缺陷。
1. 由于session是个4字节无符号整型,因此如果每秒有1000个人登陆,24天后就会回绕。因此如果一个客户端上的数据24天内都没能动过,不管session是否一致,都需要在登陆时重新向服务器拉取新数据。
2. 为了保证session分配的原子性和有效性,session是不存DB的(如果对DB进行存储则有两种方案,一种是等DB成功返回之后, session才分配成功,这样会增加登陆时间,第二种是将存储session的消息发向DB直接返回分配session成功, 这样会有数据不一致的风险),因此每次停服维护之后,玩家第一次登陆一定是需要拉取全新数据。
理清上述算法逻辑之后,对比最初客户端同学提出的算法,其实只要将Server端版本号的改成纯内存版,就可以解决Server端crash引起的不一致问题,将客户端版本号在最后所有模块落地之后再落地,则可以解决客户端存储时crash数据不一致问题。那么看来我之所以没在原版基础上去思考改进,可能是因为我当时正在看登陆逻辑的代码:D
绕了一大圈,不管哪种算法都挺累的。
回过去再看看,最根本的问题其实是模块过多加载数据过慢,而缓存落地只是为了解决它,又引申出来的一系列问题。
那么我们真的非要落地才可以解决模块加载慢的问题么,我不这么认为,分布式数据同步一定是复杂的,所以如果有其他办法来解决加载慢的问题,分布式肯定不是首选。
站在服务端的角度,只要客户端登陆成功了,不管客户端是否需要发消息加载数据,服务端都必须要把这个玩家的所有数据加载入内存。因此玩家登陆慢与游戏服务器的数据加载没有一点关系。
再看一下加载过程可能出现的问题。
1. 频繁发包,导致系统调用过多,开销过大
2. 各模块数据加载之间可能有数据依赖,即必须等一个模块返回后,才能请求另一个模块
3. 模块加载的数据过多,流量过大,带宽不足,所以网络传输时间长
从之前的经验来看,一般客户端登陆时拉取的都是一些最基础的信息,这些信息每个模块都很小(几十到几百个字节不等)。只有真正点开模块的UI时才会加载更详细的数据。因此问题3不存在。
问题1肯定是存在的,至于到底对加载过慢产生了多少影响,需要经过仔细评测才能得出结论。
不管怎么样,先把所有模块的加载协议合并,对比一下最初的加载过程,看看有多大差距。
在游戏服务器判断,此连接有合法Session之后,收集所有模块的基础信息,将其打包与登陆成功消息一起返回到客户端。客户端不再为每个模块单独请求数据。
下面来估算一下修改前和修改后的开销。假设游戏有30个模块,每个模块的基础信息是100字节。
30个模块单独加载数据,请求数据和回应数据至少需要30次write和30次read,一共60次系统调用,同样会有30个请求包和30个回应包,一共60个包。(在没开启nagle算法的情况下)
所有模块数据在登陆成功之时,由服务器收集并将其跟随登陆成功消息一起返回给客户端,不需要再多调用一次系统调用。30个模块的数据总大小为30 * 100 = 3000字节,而一个MTU大概为1500个字节左右,估算下来大概只有3个包左右。
优化前后,系统调用个数比是30比0,数据包个数比是60比3。而数据包越多,在网络上迷路的概率就越大。
事情到这里还没有结束,再分析一下客户端从LoginServer认证到从GameServer的登陆过程。
客户端到LoginServer会经历一次公网Connect时间和一次公网RTT。到GameServer会再经历一次公网Connect时间和一次公网RTT。如果客户端从LoginServer认证,LoginServer到GameServer之间会再经历一次内网RTT。
如果客户端从LoginServer认证并在GameServer登陆成功之后将得到的session存储,下次启动时从外存中加载session并按某种约定算法(比如将session自增,GameServer每次认证成功后也将此uid所关联的session自增)生成新的session, 直接向GameServer认证。就可以省掉一次公网Connect时间一次公网RTT和一次内网RTT。
其中公网Connect是非常慢的,通过都会在几十到几百毫秒,而一次公网RTT也至少要10毫秒以上。在网络情况一般的情况下,就这一项优化都可以减少掉数百毫秒的开销。
相比那个分布式的方案,我个人更喜欢这个,简单而有效而不会引入过于复杂的问题。