课程咨询 :13623629309

太原PHP培训 > 达内新闻 > 太原达内php培训班:游戏服务端究竟解决了什么问题?(二)
  • 太原达内php培训班:游戏服务端究竟解决了什么问题?(二)

    发布:太原PHP培训      来源:伯乐在线      时间:2016-07-01

  • 4 游戏世界状态的维护方式

    4.1数据服务的定位

    游戏世界的状态可以简单分为两个部分,一部分是需要存档的,比如玩家数据;一部分是不需要存档的,比如场景状态。

    对于访问较频繁的部分,比如场景状态,会维护成纯内存数据;对于访问较不频繁的部分,比如玩家存档,就可以考虑维护在第三方。这个第三方,就是数据服务。

    数据服务与之前所提到的场景服务、IM服务等都属于应用层的概念。数据服务通常也会依赖于一种基础设施抽象,那就是缓存。

    4.1.1 传统架构中的数据服务

    传统MMO架构中,数据服务的概念非常模糊。

    我们还是先通过回顾发展历史的形式来厘清数据服务的定义。回到场景进程的发展阶段,玩家状态是内存中的数据,但是服务器不会一直开着,因此就有了存盘(文件或db)需求。但是随着业务变复杂,存盘逻辑需要数据层暴露越来越多的存储API细节,非常难扩展。因此发展出了Db代理进程,场景进程直接将存档推给Db代理进程,由Db代理进程定期存盘。

    这样,存储API的细节在Db代理进程内部闭合,游戏逻辑无须再关注。场景进程只需要通过协议封包或者RPC的形式与Db代理进程交互,其他的就不用管了。

    Db代理进程由于是定期存盘,因此它相当于维护了玩家存档的缓存。这个时候,Db代理进程就具有了数据服务的雏形。

    跟之前的讨论一样,我在这里又要开始批判一番了。

    很多团队至今,新立项的项目都仍然采用这种Db代理进程。虽然确实可以用来满足一定程度的需求,但是,存在几个致命问题。

    第一,Db代理进程让整个团队的代码复用级别保持在copy-paste层面。玩家存档一定是项目特定的,而采用Db代理进程的团队,通常并不会将Db代理进程设计成普适、通用的,毕竟对于他们来说,Db代理进程是场景进程和存盘之间的唯一中间层。举个例子,Db代理进程提供一个LoadPlayer的RPC接口,那么,接口实现就一定是具体游戏相关的。

    第二,Db代理进程严重耦合了两个概念:一个是面向游戏逻辑的存储API;一个是数据缓存。数据缓存本质上是一种新的基础设施抽象,kv发展了这么多年,已经涌现出无数高度成熟的工业级缓存基础设施,居然还有新立项游戏对此后知后觉。殊不知,自己对Db代理进程再怎么做扩展,也不过是在feature set上逐渐接近成熟的KV,但是在可用性上就是玩具和工业级生产资料的差距。举个最简单的例子,有多少团队的Db代理进程能提供一个规范化的容忍多少秒掉线的保证?

    第三,Db代理进程在分区分服架构下通常是一区一个的,一个很重要的原因就是Db代理进程通常是自己YY写出来的,很少能够解决扩容问题。如果多服共用一个Db代理进程,全局单点给系统增加不稳定性的问题暂且按下不表,负载早就撑爆了。但是只是负责缓存玩家存档以及将存档存盘,这跟之前讨论过的全局IM服务定位非常类似,又有什么必要分区分服?

    我们可以构建一个数据服务解决这些问题。至于依赖的具体缓存基础设施,我之后会以redis为例。

    redis相比于传统的KV比如memcache、tc,具有不同的设计理念,redis的定位是一种数据结构服务器。游戏服务端开发可以拿redis当缓存用,也可以直接当一个数据库用。

    数据服务解决了什么问题

    数据服务首先要解决的就是玩家存档问题。redis作为一个高性能缓存基础设施,可以满足逻辑层的存档需求。同时还可以实现额外的落地服务,比如将redis中的数据定期存回mysql。之所以这样做,一方面是因为redis的定位是高性能缓存设施,那就不希望它被rdb、aofrewrite机制拖慢表现,或者卡IO;另一方面是对于一些数据分析系统,用SQL来描述数据查询需求更合适,如果只用redis,还得单独开发查询工具,得不偿失。

    数据服务其次要解决的问题是可以做到服务级别的复用。这一点我们可以借助企业应用开发中的ORM来设计一套对象-kv-关系映射。也就是数据服务是统一的,而不同的业务可以用不同的数据结构描述自己的领域模型,然后数据服务的配套工具会自动生成数据访问层API、redis中cache关系以及mysql中的table schema。也就是说,同样的数据服务,我在项目A中引用并定义了Player结构,就会自动生成LoadPlayer的API;在项目B中定义User同理生成LoadUser的API。

    这两个问题是比较容易解决的,最关键的还是一个思路的转换。

    下面看一种non-trivial的实现。Phial中的DataAccess部分,Phial的Model代码生成器。

    实际上,数据服务除去缓存基础设施的部分,都属于外围机制。在有些设计中,我们可以看到还是存在缓存服务与逻辑服务的中间层。这种中间层的单点问题很容易解决——只要不同的逻辑服务访问不同的中间层节点即可。中间层的意义通常是进行RPC到具体缓存协议API的转换,在我的实现中,由于已经有了数据访问API的自动生成,因此没有这种中间层存在的必要。所有需要访问数据服务的逻辑服务都可以直接通过数据访问API访问。

    其中还有几点细节:

    数据访问层API的调用规范与RPC的调用规范保持了统一,都是基于async/await模式。

    通过数据服务对任意存档进行增加或修改都会记录一个job,由落地服务定期检查job进行落地。

    引入新的问题

    目前仍然遗留了几个问题:

    redis单实例的性能确实很强悍,但是如果全区全服只开一个redis实例确实是存在问题的,这个问题需要解决。

    数据服务对于传统MMO架构来说可以无缝替换掉丑陋的Db代理进程,但是,既然数据服务已经能提供抽象程度如此高的存储接口,那是否还可以应用在其他地方?

    4.1.2 无状态服务中数据服务的定位

    定义问题

    之前提到过,游戏世界的状态除了需要存档的玩家数据,还有一部分是不需要存档的逻辑服务的状态。

    数据服务如果只是用来替代MMO中的Db代理进程的,那么它的全部职责就仅仅是为需要存档的数据提供服务。从更高的抽象层次来看的话,数据服务相当于是维护了client在服务端的状态。

    但是,数据服务提供了更强大的抽象能力。现在数据服务的API结构是任意定制的、code first,而且数据服务依赖的基础设施——redis又被证明非常强大,不仅仅是性能极佳,而且提供了多种数据结构抽象。那么,数据服务是否可以维护其他服务的状态?

    在web开发中,用缓存维护服务状态是一种很常规的开发思路。而在游戏服务端开发中,由于场景服务的存在,这种思路通常并不靠谱。

    为什么要用缓存维护服务状态?

    考虑这样一个问题:如果服务的状态维护在服务进程中,那么服务进程挂掉,状态就不存在了。而对于我们来说,服务的状态是比服务进程本身更加重要的——因为进程挂了可以赶紧重启,哪怕耽误个1、2s,但是状态没了却意味着这个服务在整个分布式服务端中所处的全局一致性已经不正确了,即使瞬间就重启好了也没用。

    那么为了让服务进程挂掉时不会导致服务状态丢掉,只要分离服务进程的生命周期和服务状态的生命周期就可以了。

    将进程和状态的生命周期分离带来的另一个好处就是让这类服务的横向扩展成本降到最低。

    比较简单的分离方法是将服务状态维护在共享内存里——事实上很多项目也确实是这样做的。但是这种做法扩展性不强,比如很难跨物理机,而且共享内存就这样一个文件安全性很难保障。

    我们可以将服务状态存放在外部设施中,比如数据服务。

    这种可以将状态存放在外部设施的服务就是无状态服务(stateless service)。而与之对应的,场景服务这种状态需要在进程内维护的就是有状态服务(stateful service)。

    有时候跟只接触过游戏服务端开发的业务狗谈起无状态服务,对方竟然会产生 一种“无状态服务是为了解决游戏断线重连的吧”这种论点,真的很哭笑不得。断线重连在游戏开发中固然是大坑之一,但是解决方案从来都跟有无状态毫无关系, 无状态服务毕竟是服务而不是客户端。如果真的能实现一个无状态游戏客户端,那真的是能直接解决坑人无数的断线重连问题。

    无状态游戏客户端意味着网络通信的成本跟内存数据访问的成本一样低——这当然是不可能实现的。

    无状态服务就是为了scalability而出现的,无状态服务横向扩展的能力相比于有状态服务大大增强,同时实现负载均衡的成本又远低于有状态服务。

    分布式系统中有一个基本的CAP原理,也就是一致性C、响应性能A、分区容错P,无法三者兼顾。无状态服务更倾向于CP,有状态服务更倾向于AP。但是要补充一点,有状态服务的P与无状态服务的P所能达到的程度是不一样的,后者是真的容错,前者只能做到不把鸡蛋放在一个篮子里。

    两种服务的设计意图不同。无状态服务的所有状态访问与修改都增加了内网时延,这对于场景服务这种性能优先的服务是不可忍受的。而有状态服务非常适合场景同步与交互这种数据密集的情景,一方面是数据交互的延迟仅仅是进程内方法调用的开销,另一方面由于数据局部性原理,对同样数据的访问非常快。

    既然设计意图本来就是不同的,我们这一节就只讨论数据服务与无状态服务的关系。

    游戏中可以拆分为无状态服务的业务需求其实有很多,基本上所有服务间交互需求都可以实现为无状态服务。比如切场景服务,因为切场景的请求是有限的,对时延的要求也不会特别高,同理的还有分配房间服务;或者是面向客户端的IM服务、拍卖行服务等等。

    数据服务对于无状态服务来说,解决了什么问题?

    简单来说,就是转移了无状态服务的状态维护成本,同时让无状态服务具有了横向扩展的能力。因为状态维护在数据服务中,所以无状态服务开多少个都无所谓。因此无状态服务非常适合计算密集的业务需求。

    你可能觉得我之前在服务划分一节之后直接提出要引入MQ有些突兀,实际上,服务划分要解决的根本问题就是让程序员能清楚自己定义每种服务的意图是什么,哪一种服务更适合Request-Reply,哪一种服务更适合Ask-Sync。

    假设策划对游戏没有分服的需求,理论上讲,有节操的程序是不应该以“其他游戏就这样做的”或“做不到”之类的借口搪塞。每一种服务都由分布式的多个节点共同提供服务,如果服务的消息流更适合Request-Reply pattern,那么实现为无状态服务就更合适,原因有二:

    一个Request上来,取相关数据,处理,直接返回。整个状态的生命周期保持在一次RPC调用过程中,这描述的就是Request-Reply的工作方式。

    目前只有走MQ的消息pipeline支持Request-Reply pattern,而MQ通常都能很好地支持无状态服务的round-robin work distribution。

    针对第二点,可能需要稍微介绍下rabbitMQ。rabbitMQ中有exchange(交换机)、queue、binding(绑定规则)三个主要概念。其中,exchange是对应生产者的,queue是对应消费者的,binding则是描述消息从exchange到queue的路由关系的。exchange有两种常用类型direct、topic。其中direct exchange接收到的消息是不会dup的,而topic exchange则会将接收到的消息根据匹配的binding确定要dup到哪个target queue上。

    这样,对于无状态服务,比如同一命名空间下的切场景服务,可以共用同一个queue,然后client发来的消息走direct exchange,就可以在MQ层面做到round-robin,将消息轮流分配到不同的切场景服务上。

    而且无状态服务本质上是没有扩容成本的,波峰就多开,波谷就少开。

    程序员负责为不同服务规划不同的横向扩展方式。比如类似公会服务这种走MQ的,横向扩展的触发条件就是现在请求数量级或者是节点压力。比如场景服务这种Ask-Sync的,横向扩展就需要借助第三方的服务作为仲裁者,而这个仲裁者可以实现为基于MQ的服务。

    这里有个问题需要注意一下。

    由于现在同一个client上来的request消息可能由无状态服务的不同节点处理,那么就会出现这样的情况:

    某个client由于一些原因,快速发了两个message1、message2。

    message1先到了服务A,服务A去数据服务拉相关数据集合Sa,并进行后续处理。

    此时message2到了服务B,服务B去数据服务拉相关数据集合Sb,进行后续处理,处理完毕,将结果存回数据服务。

    然后服务A才处理完,并尝试将处理结果存回数据服务。

    假如Sa与Sb有交集,那就会出现竞态条件,如果这时允许服务A存回结果,那数据就有可能存在不一致。

    类似的情况还会出现在像率土之滨或者cok这种策略游戏的大世界刷怪需求中。当然前提是玩家与大地图上的元素交互和后台刷怪逻辑都是基于无状态服务做的。

    这其实是一个跨进程共享状态问题,而且是一个高度简化的版本——因为这个共享状态只在一个实例上维护。可以引入锁来解决问题,思路通常有两个:

    最直观的一种方案是悲观锁。也就是如果要进行修改操作,就需要在读相关数据的时候就都加上锁,最后写成功的时候释放锁。获得锁所有权期间其他impure服务任意读写请求都是非法的。

    但是,这毕竟不是多线程执行环境,没有语言或平台帮你做自动锁释放的保证。获取悲观锁的服务节点不能保证一定会将锁释放掉,拿到锁之后节点挂掉的可能性非常大。这样,就需要给悲观锁增加超时机制。

    第二种方案是乐观锁。也就是impure服务可以随意进行读请求,读到的数据会额外带个版本号,等写的时候对比版本号,如果一致就可以成功写回,否则就通知到应用层失败,由应用层决定后续操作。

    带过期机制的悲观锁和乐观锁本质上都属于可抢占的分布式锁,相当于是将paxos要解决的问题退化为单Acceptor,因此实现起来非常简单。可过期的悲观锁和乐观锁唯一的区别就是前者在申请锁的时候有可能申请失败,而后者申请锁时永远不会失败。两种方案具体的表现优劣跟业务需求有关,不论一开始选择的是哪一种,都非常容易切换到另一种。

    我在示例中实现了一个简单的乐观锁,在提交修改的时候用一个lua脚本做原子检查就能简单实现。如果要实现带过期机制的悲观锁,需要保证应用层有简单的时钟同步机制,而且在申请锁的时候也要写一个lua脚本。

    在应用层也做了对应修改,调用数据访问层API可以按如下这种方式调用。之所以用了RTTI,是考虑到有可能会改成悲观锁实现,在Dispose的时候会自动release lock。现在pure服务与impure服务对数据服务调用的接口是不一样的,我们甚至还可以基于这一点在底层做一些扩展,最典型的比如读写分离。当然,这些都是引入主从之后要考虑的问题了。

    太原达内php培训机构

    有了这样一个简易的锁机制,我们可以保证单redis实例内的一致性。

    引入新的问题

    有了无状态服务的概念,我们的架构中就可以逐步干掉类似切场景管理这种单点进程。无状态服务是高可用的,也就是说,任意挂掉一个,仍然能持续提供服务。

    整个游戏服务端理论上应该具有整体持续提供服务的能力。也就是说,随便挂掉一个节点,不需要停服。场景服务挂掉一个节点,不会影响其他任何服务,只是玩家短期内无法进行场景相关操作了而已。

    而我们见过的大多数架构,处处皆单点,这完全不能叫可用的架构。有的时候一个服务端跑的好好的,有人硬是要额外加一个全局单点,而且理由是更容易管理,让人哭笑不得。分布式系统中动不动就想加单点,这是病,得治。判断一整个游戏服务端是否具有可用性很简单,随便kill掉一个节点,如果服务端仍然能持续提供服务,即使是部分client受到了影响,也能称为是可用的。

    但是,现在逻辑服务具有可用性了,可是数据服务还没有具有可用性,数据服务依赖于一个redis实例,这个redis实例反而成为了整个服务端中的单点。

    幸好,redis像其他大多数工业级缓存基础设施一样,已经提供了足够用的可用性机制。但是,在讨论redis的可用性机制之前,我们先解决一下数据服务的一个遗留问题,那就是如何构建一个可以扩展的全局数据服务。

    4.2 数据服务的扩展

    redis是一种stateful service,继续应用之前的CAP原则,redis是倾向于AP的。之后我们可以看到,redis的各种扩展,实际上都是基于这个原则来做的。

    4.2.1 分片方案

    定义问题

    我们遇到的问题是,如果将数据服务定位为全局服务,那仅用单实例的redis就难以应对多变的负载情况。毕竟redis是单线程的。

    从mysql一路用过来的同学这时都会习惯性地水平拆分,redis中也是类似的原理,将整体的数据进行切分,每一部分是一个分片shard,不同的shard维护的key集合是不同的。

    那么,问题的实质就是如何基于多个redis实例设计全局统一的数据服务。同时,有一个约束条件,那就是我们为了性能需要牺牲全局一致性。也就是说,数据服务进行分片扩展的前提是,不提供跨分片事务的保障。redis cluster也没有提供类似支持,因为分布式事务本来就跟redis的定位是有冲突的。

    因此,我们之后的讨论会有一个预设前提:不同shard中的数据一定是严格隔离的,比如是不同组服的数据,或者是完全不相干的数据。要想实现跨shard的数据交互,必须依赖更上层的协调机制保证,底层不做任何承诺。

    这样,我们的分片数据服务就能通过之前提到的简易锁机制提供单片内的一致性保证,而不再提供全局的一致性保证。

    基于同样的原因,我们的分片方案也不会在分片间做类似分布式存储系统的数据冗余机制。

    分片方案解决了什么问题

    分片需要解决两个问题:

    第一个问题,分片方案需要描述shard与shard之间的联系,也就是cluster membership。

    第二个问题,分片方案需要描述dbClient的一个请求应该交给哪个shard,也就是work distribution。

    针对第一个问题,解决方案通常有三:

    presharding,也就是sharding静态配置。

    gossip protocol,其实就是redis cluster采用的方案。简单地说就是集群中每个节点会由于网络分化、节点抖动等原因而具有不同的集群全局视图。节点之间通过gossip protocol进行节点信息共享。这种方案更强调CAP中的A原则,因为不需要有仲裁者。

    consensus system,这种方案跟上一种正相反,更强调CAP中的C原则,就是借助分布式系统中的仲裁者来决定集群中各节点的身份。

    需求决定解决方案,对于游戏服务端来说,后两者的成本太高,而且增加了很多不确定的复杂性,因此现阶段这两种方案并不是合适的选择。比如gossip protocol,redis cluster现在都不算是release,确实不太适合游戏服务端。而且,游戏服务端毕竟不是web服务,通常是可以在设计阶段确定每个分片的容量上限的,也不需要太复杂的机制支持。

    但是第一种方案的缺点也很明显,做不到动态增容减容,而且无法高可用。但是如果稍加改造,就足以满足需求了。

    在谈具体的改造措施之前,先看之前提出的第二个问题。

    第二个问题实际上是从另一种维度看分片,解决方案很多,但是如果从对架构的影响上来看,大概分为两种:

    一种是proxy-based,基于额外的转发代理。例子有twemproxy/Codis。

    一种是client sharding,也就是dbClient(每个对数据服务有需求的服务)维护sharding规则,自助式选择要去哪个redis实例。redis cluster本质上就属于这种,client侧缓存了部分sharding信息。

    第一种方案的缺点显而易见,在整个架构中增加了额外的间接层,pipeline中增加了一趟round-trip。如果是像twemproxy或者Codis这种支持高可用的还好,但是github上随便一翻还能找到特别多的没法做到高可用的proxy-based方案,无缘无故多个单点,这样就完全搞不明白sharding的意义何在了。

    第二种方案的缺点就是集群状态发生变化的时候没法即时通知到dbClient。

    第一种方案,我们其实可以直接pass掉了。因为这种方案本质上还是更适合web开发的。web开发部门众多,开发数据服务的部门有可能和业务部门相去甚远,因此需要统一的转发代理服务。但是游戏开发不一样,数据服务逻辑服务都是一帮人开发的,没什么增加额外中间层的必要。

    那么,看起来只能选择第二种方案了。

    将presharding与client sharding结合起来后,现在我们的改造成果是:数据服务是全局的,redis可以开多个实例,不相干的数据需要到不同的shard上存取,dbClient掌握这个映射关系。

    引入新的问题

    目前的方案只能满足游戏对数据服务的基本需求。

    大部分采用redis的游戏团队,一般最终会选定这个方案作为自己的数据服务。后续的扩展其实对他们来说不是不可以做,但是可能有维护上的复杂性与不确定性。今天这篇文章,我就继续对数据服务做扩展,后面的内容权当抛砖引玉。

    现在的这个方案存在两个问题:

    首先,虽然我们没有支持在线数据迁移的必要,但是离线数据迁移是必须得有的,毕竟presharding做不到万无一失。而在这个方案中,如果用单纯的哈希算法,增加一个shard会导致原先的key到shard的对应关系变得非常乱,抬高数据迁移成本。

    其次,分片方案固然可以将整个数据服务的崩溃风险分散在不同shard中,比如相比于不分片的数据服务,一台机器挂掉了,只影响到一部分玩家。但是,我们理应可以对数据服务做更深入的扩展,让其可用程度更强。

    针对第一个问题,处理方式跟proxy-based采用的处理方式没太大区别,由于目前的数据服务方案比较简单,采用一致性哈希即可。或者采用一种比较简单的两段映射,第一段是静态的固定哈希,第二段是动态的可配置map。前者通过算法,后者通过map配置维护的方式,都能最小化影响到的key集合。

    而对于第二个问题,实际上就是上一节末提到的数据服务可用性问题。

    4.2.2 可用性方案

    定义问题

    讨论数据服务的可用性之前,我们首先看redis的可用性。

    对于redis来说,可用性的本质是什么?其实就是redis实例挂掉之后可以有后备节点顶上。

    redis通过两种机制支持这一点。

    一种机制是replication。通常的replication方案主要分为两种。一种是active-passive,也就是active节点先修改自身状态,然后写统一持久化log,然后passive节点读log跟进状态。另一种是active-active,写请求统一写到持久化log,然后每个active节点自动同步log进度。

    还是由于CAP原则,redis的replication方案采用的是一种一致性较弱的active-passive方案。也就是master自身维护log,将log向其他slave同步,master挂掉有可能导致部分log丢失,client写完master即可收到成功返回,是一种异步replication。

    这个机制只能解决节点数据冗余的问题,redis要具有可用性就还得解决redis实例挂掉让备胎自动顶上的问题,毕竟由人肉去监控master状态再人肉切换是不现实的。 因此还需要第二种机制。

    第二种机制是redis自带的能够自动化fail-over的redis sentinel。reds sentinel实际上是一种特殊的reds实例,其本身就是一种高可用服务,可以多开,可以自动服务发现(基于redis内置的pub-sub支持,sentinel并没有禁用掉pub-sub的command map),可以自主leader election(基于sentinel实现的raft算法),然后在发现master挂掉时由leader发起fail-over,并将掉线后再上线的master降为新master的slave。

    redis基于自带的这两种机制,已经能够实现一定程度的可用性。那么接下来,我们来看数据服务如何高可用。

    数据服务具有可用性的本质是什么?除了能实现redis可用性的需求——redis实例数据冗余、故障自动切换之外,还需要将切换的消息通知到每个dbClient。

    由于是redis sentinel负责主从切换,因此最自然的想法就是问sentinel请求当前节点主从连接信息。但是redis sentinel本身也是redis实例,数量也是动态的,redis sentinel的连接信息不仅在配置上成了一个难题,动态更新时也会有各种问题。而且,redis sentinel本质上是整个服务端的static parts(要像dbClient提供服务),但是却依赖于redis的启动,并不是特别优雅。另一方面,dbClient要想问redis sentinel要到当前连接信息,只能依赖其内置的pub-sub机制。redis的pub-sub只是一个简单的消息分发,没有消息持久化,因此需要轮询式的请求连接信息模型。

    上一节末提到过,要想最小化数据迁移成本可以采用两段映射或一致性哈希。这时还有另一种可以扩展的思路,如果采用两段映射,那么我们可以动态下发第二段的配置数据;如果采用一致性哈希,那么我们可以动态下发分片的连接信息。这其中的动态,就可以基于新的符合Phial规范的服务来做。而这个通知机制,就非常适合采用Phial中的Notify pattern实现。而且redis sentinel的实现难度比较低,我们完全可以以较低的成本实现一个扩展性更强,定制性更强,还能额外支持分片服务的部分在线数据迁移机制的服务。

    同时,有一部分我在这篇文章里也没提过,那就是落地服务所依赖的mysql的可用性保障机制。相比于再开一个额外的mysql高可用组件,倒不如整合到同样的一个数据服务监控服务中。

    这个监控服务就是watcher。由于原理类似,接下来的讨论就不再涉及对mysql的监控部分,只针对redis的。

    watcher解决了什么问题?

    要能够监控redis的生存状态。这一点实现起来很简单,定期的PING redis实例即可。需要的信息以及做出客观下线和主观下线的判断依据都可以直接照搬sentinel实现。

    要做到自主服务发现,包括其他watcher的发现与所监控的master-slave组中的新节点的发现。前者基于MQ定期Notify通知,后者定期INFO 监控的master实例即可。

    要在发现master客观下线的时候选出leader进行后续的故障转移流程。这部分实现起来算是最复杂的部分,接下来会集中讨论。

    选出leader之后将一个最合适的slave提升为master,然后等老的master再上线了就把它降级为新master的slave。

    解决这些问题,watcher的职责就已经达成,我们的数据服务也就更加健壮,可用程度更高。

    引入新的问题

    但是,如果我们引入了新的服务,那就引入了新的不确定性。如果引入这个服务的同时还要保证数据服务具有可用性,那我们就还得保证这个服务本身是可用的。

    先简单介绍一下redis sentinel的可用性是如何做到的。同时监控同一组主从的sentinel可以有多个,master挂掉的时候,这些sentinel会根据一种raft算法的工业级实现选举出leader,算法流程也不是特别复杂,至少比paxos简单多了。所有sentinel都是follower,判断出master客观下线的sentinel会升级成candidate同时向其他follower拉票,所有follower同一epoch内只能投给第一个向自己拉票的candidate。在具体表现中,通常一两个epoch就能保证形成多数派,选出leader。有了leader,后面再对redis做SLAVEOF的时候就容易多了。

    如果想用watcher取代sentinel,最复杂的实现细节可能就是这部分逻辑了。

    这部分逻辑说白了就是要在分布式系统中维护一个一致状态,举个例子,可以将“谁是leader”这个概念当作一个状态量,由分布式系统中的身份相等的几个节点共同维护,既然谁都有可能修改这个变量,那究竟谁的修改才奏效呢?

    幸好,针对这种常见的问题情景,我们有现成的基础设施抽象可以解决。

    这种基础设施就是分布式系统的协调器组件(coordinator),老牌的有zookeeper(zab),新一点的有etcd(raft)。这种组件通常没有重复开发的必要,像paxos这种算法理解起来都得老半天,实现起来的细节数量级更是难以想象。因此很多现成的开源项目都是依赖这两者实现高可用的,比如codis就是用的zk。

    zk解决了什么问题?

    就我们的游戏服务端需求来说,zk可以用来选leader,还可以用来维护dbClient的配置数据——dbClient直接去找zk要数据就行了。

    zk的具体原理我就不再介绍了,具体的可以参考lamport的paxos paper,没时间没精力的话搜一下看看zk实现原理的博客就行了。

    简单介绍下如何基于zk实现leader election。zk提供了一个类似于os文件系统的目录结构,目录结构上的每个节点都有类型的概念同时可以存储一些数据。zk还提供了一次性触发的watch机制。leader election就是基于这几点概念实现的。

    假设有某个目录节点/election,watcher1启动的时候在这个节点下面创建一个子节点,节点类型是临时顺序节点,也就是说这个节点会随创建者挂掉而挂掉,顺序的意思就是会在节点的名字后面加个数字后缀,唯一标识这个节点在/election的子节点中的id。

    一个简单的方案是我们可以每个watcher都watch /election的所有子节点,然后看自己的id是否是最小的,如果是就说明自己是leader,然后告诉应用层自己是leader,让应用层进行后续操作就行了。但是这样会产生惊群效应,因为一个子节点删除,每个watcher都会收到通知,但是至多一个watcher会从follower变为leader。

    优化一些的方案是每个节点都关注比自己小一个排位的节点。这样如果id最小的节点挂掉之后,id次小的节点会收到通知然后了解到自己成为了leader,避免了惊群效应。

    还有一点需要注意的是,临时顺序节点的临时性体现在一次session而不是一次连接的终止。例如watcher1每次申请节点都叫watcher1,第一次它申请成功的节点全名假设是watcher10002(后面的是zk自动加的序列号),然后下线,watcher10002节点还会存在一段时间,如果这段时间内watcher1再上线,再尝试创建watcher1就会失败,然后之前的节点过一会儿就因为session超时而销毁,这样就相当于这个watcher1消失了。解决方案有两个,可以创建节点前先显式delete一次,也可以通过其他机制保证每次创建节点的名字不同,比如guid。

    至于配置下发,就更简单了。配置变更时直接更新节点数据,就能借助zk通知到关注的dbClient,这种事件通知机制相比于轮询请求sentinel要配置数据的机制更加优雅。

    我在实现中将zk作为路由协议的一种整合进了Phial规范,这样基于zk的消息通知可以直接走Phial的RPC协议。

    有兴趣的同学可以看下我实现的zkAdaptor,leader election的功能作为zkAdaptor的特殊API,watcherService会直接调用。而配置下发直接走了RPC协议,集成在统一的Phial.RPC规范中。zkAdaptor仅支持Phial.RPC中的Notify pattern。

    watcher的实现在这里。

    5.总结目前形成的架构以及能做什么

    整理下这篇文章到目前为止做了什么事情:

    在文章的一开始确定了游戏服务端要解决的核心两个问题:消息的pipeline与游戏世界状态维护。

    通过回顾历史的形式提出游戏服务端中最常见的需求情景:多玩家场景同步,并梳理了场景同步最适合的消息pipeline。

    结合切场景的扩展需求,提出Gate这种基础设施抽象(infrastructure abstraction,简称IA)。

    尝试进行高内聚、低耦合的服务划分,并总结Gate无法兼顾的消息pipeline。

    针对Gate无法处理的消息pipeline(service -> service),提出新的MQ-IA,可以大大简化服务间拓扑关系。

    基于不同的IA与相关协议,提出更高层次的RPC协议,定义了适合.Net2.0和.Net4.5的两种异步RPC调用规范。实现了不同IA到统一规范的Adaptor。总结了游戏中RPC应用的pattern,不同pattern如何与不同IA结合使用。

    同样通过回顾历史的形式引入数据服务来取代传统MMO中的Db代理进程。

    结合MQ与数据服务,提出无状态服务在游戏服务端中的应用情景,展开介绍数据服务对于无状态服务的意义所在。

    基于构建全局数据服务的理念,尝试实现一种多实例的、每实例内向不同服务提供原子修改操作级别一致性的数据服务。

    为数据服务增加了符合需求的高可用支持。引入了zookeeper,可以让普通的服务也可以复用同样的协调者组件。

    总结下出现的几种概念:

    IA。包括Gate、MQ、内存db、持久化强一致性db、分布式协调器等等。不同的IA各司其职,各自只负责解决分布式系统中的一小部分问题。

    RPC与Pattern。面向应用层的统一服务调用方式与规范。

    Adaptor。不同的IA与相关协议到统一RPC与Pattern的适配器。

    到目前为止的拓扑图:

    太原达内php培训班

    系统设计中的static parts与dynamic parts

    static

    gate/mq/zk/redis/mysql

    dynamic

    almost all custom services

    这篇文章的灵感起源是the log,看完之后深有感触。虽然JAVA不是一门好语言,但是JAVA技术栈却发展得如此优雅。JAVA技术栈上的每一种IA都专注于解决特定的一小块问题,比如这里提到的。未来的应用框架开发者,就像是用胶水将这些基础设施粘合起来。游戏服务端程序员通常习惯于c++的小圈子,甚至有一种传教的趋势宣扬c++才是代表的游戏服务端的核心技术。有的时候,游戏程序员需要从c++的小圈子跳出来向外走一走,有可能你就不想再湮没在繁文缛节中,而是发现更大的世界。

    不过话又说回来,不喜欢跳出c++小圈子的游戏服务端程序员,大部分又都对c++本身其实知之甚少,奉OOP为圭臬,各种虚继承、多继承出来的代码看到想吐。尝试用模板的各种奇技淫巧把c++写成haskell的虽然更有跳出c++小圈子的倾向,但是既然都如此用了,又何必拘泥于c++?

    其他

    我在这篇文章里尽量少的插入代码,尽量描述游戏服务端定义问题、解决问题的思路。服务端用C#写的毕竟是少数,但是有了思路随便改写成其他语言都没问题。

    我顺便也借着写这篇博客的机会,整理了下一些小东西放在github上。

    比如之前的面向组合子博客提到的代码生成器组合子,CodeC

    比如之前的定时器博客提到的linux内核风格定时器,以及基于定时器写的example,C#协程,都放在这里,CoroutineSharp

    比如之前的游戏AI博客提到的行为树编译器原型和c# runtime示例,Behaviour

    还有学习parsec的一个小结,可以用来parse单个c#文件拿到一些描述信息的,当然纯属学习性质,有这种需求的时候最好优先用反射。cs_file_parser

    然后就是跟这篇博客相关的

    一个简单的网络库,Network;

    一个简单的基于Network的Gate,GateSharp;

    规范的整个底层库,Phial;

    为底层库开发的两个配套代码生成器,Phial.CodeGenerator;

    示例实现,Phial.Fantasy。

    github中的以演示为目的,因此相比于博客,还有不少部分是to be determined(比如详细的配置流程、MQ的集群化、mysql的故障转移集成、落地服务的实现细节等等),之后我也会继续维护。

上一篇:太原php培训班:游戏服务端究竟解决了什么问题?

下一篇:太原达内php培训机构:PHP弱类型安全问题总结

最新开班日期  |  更多

php高级开发名企定制班(剩2个名额)

php高级开发名企定制班(剩2个名额)

开班日期:12-29

php高级开发周末班(剩5个名额)

php高级开发周末班(剩5个名额)

开班日期:12-29

php高级开发免费试听(剩5个名额)

php高级开发免费试听(剩5个名额)

开班日期:12-29

更多高级开发工程师精品班

更多高级开发工程师精品班

开班日期:12-29

  • 地址:山西省太原市小店区学府街长治路高新国际A座24层
  • 课程培训电话:13623629309     全国服务监督电话:400-827-0010
  • 服务邮箱 ts@tedu.cn
  • 2001-2016 达内国际公司(TARENA INTERNATIONAL,INC.) 版权所有 京ICP证08000853号-56

    在线客服系统