先阐明一下观点,可以使用单体(单线程)应用程序解决的问题,都不应该使用分布式系统来解决,因为分布式真的很复杂。
在游戏服务器中,我们做服务拆分,大部分情况下都是为了可伸缩,而不是为了高可用(这里暂不考虑那些使用WEB模式实现游戏服务器的思路。我认为这种思路,逻辑的复杂度和实时性都不能保证,而且还需要处理并发问题。)
以前我就说过,游戏服务器的开发更像是在开发数据服务。
现在,我觉得可以更明确一点。
游戏服务器的开发,其实就是针对某种业务逻辑开发的专用数据库。 而玩家的客户端就真的是我们开发的数据库的客户端,来进行“增删改查"。
之所以我认为游戏服务器开发过程中,使用分布式不是为了高可用。是因为,在整个游戏服务器中,每个服务都是单点不可替代的。如果某个服务挂了,在它还没有被启动起来之前,所有与之相关系的业务都会出现异常。除非每个服务都会有对应的候补进程,然后将数据实时冗余在候补进程中。
如果使用“最终一致性”,冗余就会有同步延时。而在游戏服务器这种实时“数据库”领域,有延时就代表崩溃时会有数据丢失,这也谈不上高可用了。
如果使用“强一致性”, 虽然同步没有延时,但是会出现网络请求链路过长,性能和请求的实时性不能保证。
因此,可伸缩往往也是大多数游戏服务器最终的目的。虽然我们一般不要求高可用,但是我们在部分服务Crash的情况下,也要保证不能产生错误的结果(可以产生异常,而终止某条逻辑)。
虽然说“如无必要,勿增实体”。然而,我们在开发之初往往很难评估到我们的单体应用是否可以满足“策划”们突如其来的需求。
因此,除非极其明确的游戏类型,否则往往在设计之始,都不得不预留一些分布式设计。以免增加某个需求之后,需要大规模重构代码。
以我目前的认知,一个通用分布式游戏服务器框架,最多可以帮助业务程序员解决服务发现、服务依赖、RPC机制、集群健康监控等一些服务级别的管理。
而最重要的一环服务拆分,则留给了我们人类来做。
在服务拆分过程中, 我们往往需要关注服务间的数据依赖关系、服务的内聚性、服务间的交互频率、每个客户端请求所经过的链路长度等指标。
同时,遵循“如无必要,勿增实体”原则,服务的拆分应该尽可能的少,这不但会减少链路长度,同时还会降低整个分布式系统出现故障的概率。
这是因为,每个服务都是单点。如果某个服务异常可能导致其余所有服务都产生异常。因此整个分布式系统出现故障的概率,是所有服务出现故障的概率之和而不是积。
事实上, 当单体应用程序变成分布式之后,整个逻辑的复杂度都会有相当程度上的提升,尤其在数据一致性上。
在关系型数据库,如Mysql中,有一项功能叫“外键约束”,用于保证数据完整性。然而随着各种Mysql分布式方案的出现,这项功能被越来越少的使用。其原因就是因为在分布式系统中,“外键约束”很难实现,需要应用逻辑自己来保证。
在我们游戏服务设计中,也存在同样的问题。
假如有一个“联盟服务”,有一个“城池服务”。联盟可以借助占有的城池成为国家,“城池”服务则管理着城池相关的归属问题,比如复杂的领土争夺。如果城池丢失,则国家需要变回联盟。
这时,一般会有两种实现方案:
1) “城池服务”在城池丢失时,直接推送给“联盟服务”进行处理, 并不在意“联盟服务”是否收到消息。
2) “城池服务”在城池丢失时,通过RPC请求等待“联盟服务”处理完“国家变联盟”逻辑之后, 再修改城池归属。
即使在不考虑网络问题的情况下,这两种方案也会存在数据不一致的情况。
比如方案1,在“城池服务”发送给“联盟服务”消息之后,“联盟服务”Crash掉。
比如方案2,在"城池服务”收到“联盟服务”的成功返回后,“城池服务”还没有写入数据库,就Crash掉。
借用数据库的理论,如果需强的一致性就需要2PC,3PC来解决,然而就算2PC,3PC也不能完全解决个别极端情况。
因此,在服务启动时,必须要检查数据约束是否满足,并修正不满足约束的数据。
由于我们需要在启动时进行“数据溯源”(即A需要依赖B来检查约束,B需要依赖C来检查逻辑约束)来修正约束,就势必会产生“服务间依赖”,这时最好不要有循环依赖。
如果我们在拆分服务时,服务的内聚性不够好(比如将联盟和国家数据拆分成“联盟服务”和“国家服务”。由于国家是依托联盟而成存在,如果联盟不存在了,则国家必然不存在了),则会产生更复杂的依赖链,同时会加大数据不一致的概率。
解决了“服务的内聚性”,我们可能会迎来一个新的矛盾“服务间交互频率”。 而且极大概率,这两者是相互冲突的。这需要我们做出取舍,软件设计就是这样,按下葫芦起了瓢,我们永远需要做trade-off。
举个例子, 比如我们“城池服务”中的逻辑和国家数据耦合很紧密,如果我们把联盟和国家数据都放在“联盟服务”中。有可能会导致每处理一条客户端请求,“城池服务”和“联盟服务”之间要通信十数次。这会大大增大调用链的长度。
调用链的变长,会导致浪费很多CPU在网络处理和协议序列化上。同时也会降低系统的稳定性,增加客户端请求的RTT。
如果这个开销在整个系统中难以承受。我们就需要考虑,违反“服务内聚”原则将国家数据,挪到“城池服务”中,即使这会使“城池服务”和“联盟服务”变成循环引用。
至于什么程度是“难以承受”, 又到底要怎么变通,这取决于个人的经验以及对整个业务系统的认知程度。
上面一直在说分布式的复杂性, 还没有提到如何做到“高可伸缩”。并不是拆成分布式系统之后,就一定能做到高可伸缩。
先来描述一个简化的业务场景。
整个世界是由数百万个正方形格子无缝拼接而成。玩家出生后,会随机分配一个空闲格子作为出生点。
玩家在整个世界的主要任务就是打格子,然后形成势力,并最终占领整个服务器。
每个玩家拥有有限个英雄和10支队伍,每支队伍可以上阵三个英雄。
玩家以队伍为单位去占领格子,队伍的出发点,永远是玩家的出生点。
队伍从出发点经过有限时间后到达目标点,经历战斗并最终占领格子。
队伍出发之后到达目标之前称为行军中。行军中的队伍,如果会路过当前客户端显示的世界区域,则会将路线显示出来。
行军中的队伍在到达目标点之前,不会再参与任何逻辑计算。
只要目标点周围两圏范围内有自己的格子,就可以直接行军过去占领,与行军所经过的路线无关。
最初我认为,这样的业务场景,应该会有Role,League,World,Scene这4种服务。
Role用于处理玩家英雄相关数据和请求,可以水平部署多份,有伸缩性。
League用于处理联盟相关数据和请求,全服只有一份,无伸缩性。
World用于管理队伍和格子数据,及队伍相关请求,全服只有一份, 无伸缩性。
Scene用于镜像World的格子数据,供客户端只读拉取,可以水平部署多份,无伸缩性。
为League服务增加可伸缩性并不难。因此随着数据规模的增加,整个分布式系统最终的瓶颈将是World服务。整个分布式系统的伸缩性都将被World的伸缩性所限制。
而我之所以这么分,是因为我对整个业务场景没有清晰的认知,而且有点性能强迫症所致。
当时的思路是这样的:
队伍和格子数据关系很密切,因此需要将队伍和格子数据放在一个服务中处理。这样,客户端来一个请求“占领某格子”,队伍的“出发”,“到达”,“战斗”,“占领”,“返回” 全都在一个服务中搞定了,可以极大的减少服务间的交互。
当我把队伍相关数据放在World服务之后,限制就出现了。
如果我把World服务按地图区域切分,水平部署多份,那么相关的队伍信息,到底应该以怎样的方式分布在相关的World服务中呢,无解。
如果World无法水平部署,那么怎么分摊客户端不停拖屏,所带来的查询压力呢。
只有增加Scene只读服务,用于实时镜像World服务中的格子数据和队伍的行军路线。供客户端拖屏查询使用。
现在重新看待这个问题,我认为应该分为Role,League,World这3种服务。
Role用于处理玩家英雄和队伍的相关数据和请求,可以水平部署多份,有伸缩性。
League用于处理联盟相关数据和请求,全服只有一份,无伸缩性。
World用于管理格子数据,及战斗规则实现,按区域切分,可以水平部署多份, 有伸缩性。
当我们把队伍相关逻辑放入Role服务之后,很多逻辑都会变得更清晰,代价是会多出几次低频率的RPC请求。大概流程如下:
客户端发送“占领某格子”的请求时,Role服务向World服务发出RPC请求,校验目标地的合法性。
World服务返回合法,Role服务为当前操作的队伍设置到达定时器。并再次通过RPC请求路线相关的World服务,设置行军路线供客户端查询使用。
队伍到达目标点之后,Role服务向World服务发出RPC请求,进行战斗并占领行为。World服务向Role服务返回战斗成功。
Role服务再次为队伍设置返回定时器。
队伍到达出生点之后,Role服务通过RPC请求路线相关的World服务,取消行军路线。
从上面流程可以看到,虽然增加了5次RPC请求,但是瞬时RPC请求数量并不高。
虽然在设置和取消行军路线时,需要消耗CPU来计算行军路线会经过哪些World服务,但是这些消耗是常量,随着服务的水平伸缩,很快就被抵消了。
同时还会有两处额外的开销,也需要能通过水平伸缩来抵消。
1) 在客户端拉取当前屏幕地块信息时,有可能需要收集1个以上的World服务中的地块信息
2)在客户端拉取行军路线的队伍信息时,也需要向1个以上Role服务拉取相关的队伍信息
但是不管怎样,整个分布式系统都是以常量的开销, 获得了高可伸缩的能力。
我使用这两个方案进行对比,是想说明分布式系统中服务的拆分,非常依赖于个人对整个业务模式理解,这一点真的很难。
再说一些细节问题。
在设计分布式系统之初, 我为了减少“服务间的交互”, 常常使用缓存技术。
经过一段时间的思考,我认为这是不正确的,因为冗余数据往往是滋生Bug的温床。少量的RPC交互并不会产生性能热点。
如果已经确定了某些交互频率确实过高影响性能。应该首先尝试将交互过多的数据放在同一个服务中,如果确定这样做会产生bad taste,再考虑缓存技术。
在实时游戏服务器中,服务间会经常产生消息推送。在我们不使用缓存技术的情况下,某些业务逻辑会产生竞争问题。
还是以联盟立国为例,客户端调用“联盟服务”选定国都C1进行立国,“联盟服务”通过RPC调用“城池服务”检查是否为自己城池。
“城池服务”收到这条消息,返回消息M1,告知当前城池还是属于此联盟。之后城池突然别被的联盟打掉,然后“城池服务”给“联盟服务”推送了一条消息M2,告知当前城池所有者已经变为了另一个联盟。
由于“联盟服务”调用“城池服务”使用的链接和“城池服务”向“联盟服务”推送的链接不是同一条,所以M1和M2会有一个竞争问题。
如果M2先于M1到达,则“联盟服务”必须要抛弃M1的结果,因为M1是不准确的。
如果M2后于M1到达,则“联盟服务”可以正常处理M1,因为稍后到来的M2再次校正结果。
同样,虽然缓存技术容易滋生Bug, 但是他可以解决上述竞争问题。
9月1日补充。
之所以有这篇文章有出现。其实是因为我想梳理一下思路,从框架层面解决M1和M2的消息竞争问题。
经过几天的思考,我认为框架没有能力也不应该解决这类问题。这类问题可以广义的归纳为异步问题。
比如,我打掉了一块格子, 我需要花钱让他升级。当我们调用rpc:submoney(uid, 100)
返回时,有可能这块地已经被别人打掉了,我需要在rpc:submoney
回来之后,重新检查这块格子是不是我的。
总的来说,服务间通信,异步是常态,这需要业务程序员自己去做约束。
ps. 分布式真的很复杂D: