有状态服务
有状态服务在游戏行为应用较多,早期的游戏服务基本都是有状态的服务,这样的服务写起来最为简单、性能也高,这也是早期手游爆发期,业务快速上线需要与服务稳定性的折衷;但随着手游市场的竞争加剧,品质要求也越来越高,有状态服务对于故障、扩缩容的处理也越发复杂。
按数据缓存时机划分
根据服务数据缓存的时机,可以分为启动时加载与LRU按需加载
启动时加载
服务启动时,即加载完所有需要缓存的数据,这种适用于数据需要冷启动的服务,如队伍搜索这样的服务,冷启动的数据来源一般来自数据库或配置生成,数据量集一般较大,需要一定的加载时间,在运行过程中通过增量的方式继续保证数据的有效性。
按需加载
服务启动时,不进行任何数据加载,而是根据收到的请求(校验通过后),进行数据的加载并缓存,缓存的数据会长期存在,因为服务的内存是有限的,一般会设置缓存数据的上限,超出上限的会进行拒绝服务(如gamesvr的player缓存)或淘汰旧的缓存(如mailsvr的mailhead缓存)。
按数据路由方式划分
根据数据缓存的方式,决定了访问数据的不同路由方式,一般分为三种:ConsistentHash、Shard、Stand-alone。
ConsistentHash
一般用于按需加载的方式,根据访问的key决定了路由到的服务实例,通常情况下,对于相同的key,路由到的服务实例是相同的;在服务故障、扩缩容等情况下,相同的key的前后两次访问可能路由到不同的服务实例,所以需要处理数据迁移、双写等问题。
e.g. RankboardSvr按RankboardKey进行ConsistentHash路由
Shard
一般用于启动加载的方式,相同的key,无论何种情况,都路由到相同的服务实例,这样保证了路由的稳定性,但服务实例不可能一直保持健康,所以需要处理服务可用性等问题。
e.g. CacheSvr用于队伍数据的搜索
Stand-alone
单点缓存服务,一般用于可用性要求不高的业务场景,在业务中用处不多,一般还是要考虑至少主备的方式进行容灾。
e.g. GlobalSvr用于全局序号器生成(已废弃)
读写操作
有状态服务可以从读操作、写操作来进行分析。
读操作
内存操作,不访问DB,性能高,消耗小;按需加载方式时,如果内存中没有缓存,还需要先建立缓存。
写操作
按写入的时机不同,可以分为定时写与即时写两种。
- 定时写
写操作只操作内存,不对DB进行操作,性能高,消耗小。缺点是数据回写前会有丢失的风险。
e.g. GameSvr中player数据的回写 - 即时写
写操作同时操作内存和DB,性能较低,好处是没有数据丢失风险。
无状态服务
因为有状态服务运营过程中存在着诸多不便,无状态服务重新进入游戏开发的视野(要理解这个过程不是一蹴而就的,依赖的各种技术及实现方案是随着一代代产品逐步完善的),并随着云服务的兴起,无状态服务以其独特的优势成为服务的首选。
无状态服务
它们的共同特点是本身不缓存任何数据,故障或重启对业务的影响微乎其微, 只需要简单的容灾方案,服务的可用性就可以大幅提升。业务框架也因此变得极为通用,可以很简单的抽象出一套开发框架,大大简化开发门槛、提升开发质量。
根据服务是否有需要访问其它数据,又可以分为转发服务与Web型服务。
转发服务
典型的转发服务:proxysvr、gatesvr。
路由可以使用Random路由,扩缩容和故障对业务几乎没有影响。Web型服务
典型的数据处理服务:idipsvr、teamsvr。
路由需要采用ConsistenHash路由,扩缩容和故障需要考虑短暂的双写问题,但即使不处理,对业务的影响也极小。
但这不是本文的重点,因为数据库压力、性能等考虑,无状态服务是比较有局限性的,而无状态和有状态的中间体:弱状态服务就显现出其优势。
弱状态服务
“弱状态”是一个新造的词汇,表示服务是有状态的,但又具有无状态服务的特点(简单扩缩容、容灾等)。
典型的弱状态服务:friendsvr(管理好友)、guildsvr(管理公会)、rankboardsvr(管理排行榜)。
数据缓存模型
理解”弱状态”服务首先要理解无状态服务在实践过程中遇到的问题:
每次读操作都从DB加载消耗了大量CPU,增加时延,导致服务的性能急剧下降
以mailsvr为例,假设每个玩家至多有200封邮件,那每次读操作都需要从数据库中拉取这200封邮件,虽然可以做一些设计上的优化,如只拉取邮件头,但仍然是一块不小的开销。
而在一般的业务中,读量是远大于写量的(10倍+),所以每次读都转换成一次DB操作并不是一个好的设计,反而加一层缓存,能完美解决读的问题。
所以,弱状态服务的读操作都是直接从内存返回的。每次写操作造成了DB的压力(部分服务)
以rankboardsvr为例,排行榜变动是比较频繁的,特别是对于一些大容量的排行榜(10000+),如果每次变动,都进行DB回写,一个是回写的数据量大,需要消耗较多的CPU与IO等待,一个是回写的频率高,会对DB造成极大的压力。
所以DB的回写可以看作一个很重的操作,需要进行收敛,最简单的做法就是定时回写(e.g.每秒回写一次),这样能完美解决写的问题。
当然,写量在一般场景下是偏小的,对这种场景,采用即时写是最佳方案。
下面来完整描述下缓存模型:
服务对DataX(key=X)进行缓存,缓存仅存活一小段时间,读操作直接从缓存中读取,写操作直接写DB。
缓存时长:假设N毫秒(实践上看设定1秒的缓存时长和回写时长能应对绝大部分场景)。
要充分考虑数据的不一致,缓存时间越长,越可能出现内存与DB数据不一致,导致读取的数据是错误的;对于定时写而言,回写时间越长,越可能出现数据的丢失。
读操作:如果缓存不存在,从DB加载,建立缓存并返回数据;如果缓存存在,直接返回数据。
写操作:在写操作时,从DB加载,修改数据,并回写DB
有些服务(比如rankboardsvr),写操作数量和大小都高,不是每次都进行回写,这里需要用定时回写的方式,如果缓存不存在,从DB加载,建立缓存并修改数据;如果缓存存在,直接修改数据。
路由选择
无状态服务采用最简单的随机路由即可,而弱状态服务则不可以,因为带有状态,需要保证路由的一致性,这里最佳选择就是ConsistentHash。
ConsistenHash只能保证相同的数据的访问,路由到同一台服务实例,因为有异步存在,同一台服务实例的请求处理也会出现并发,这里就需要用本地队列的方式来解决:
本地队列保证了相同数据的访问是串行的,上一个请求完全处理完,才会处理下一个请求,避免了同一个服务实例上并发处理的问题。(当然,这个特性xRPC也是内置的)
扩缩容及容灾
对于无状态服务,扩缩容和容灾就是极其简单的,甚至不用做任何工作;而对于弱状态服务,则需要考虑状态数据的迁移问题。
无论是在扩缩容还是故障时,服务的路由都可能发生变化,这时就会在短暂的时间(100ms左右)出现同一个数据的请求路由到不同的服务实例上,同时处理的情况,对于读操作来说,这没有影响,但考虑到写操作就要考虑读写冲突、多写冲突的问题了。
应对这种场景,有如下的解决方式:
“鸵鸟”策略
业务本身不进行任何处理,任由双写发生就好了,基于多写的情况并不多,及业务身自要求并不高的情况下,这是一种性价比极高的处理方式,实际上过去很多游戏就是这样处理的,并且运行得很好(世界远没有你想的那么不堪)。
带版本号回写
虽然双写无法解决,但能感知并保证双写结果可预测性也是十分有意义的,最简单的办法就是引入数据版本号,每次回写版本号+1,数据库只接收比当前数据大的版本号回写,这样,当发生双写时,后写的因为版本号会失败,这样业务可以即时告警,后续通过人工干预进行修复,并且,双写的行为也可预期的,后写入的失败。
分布式锁
当然有很多种方式来解决双写的问题,其中一种就是分布式锁方案。在访问数据前,先需要获取锁,这个锁是全局的,所以在数据处理完之前,不会有其它进程能修改这块数据。
当然,分布式锁需要考虑的问题也很多,比如续租、锁超时、锁性能等,都和实际采用的锁实现有关(如基于zk,基于redis等),这些问题不在这里详述,这个方案需要考虑到锁服务对性能的影响。数据迁移(Kickout)
在版本号的基础上,稍加改进,可以得到一个数据迁移版本。
利用数据库的作为中心节点(全局服务也可以),记录DataX当前被哪个服务实例持有,当有其它服务实例试图访问这个数据时,需要先将DataX从持有的服务实例踢下线,再锁定此数据DataX。
DataX需要记录持有的服务实例与持有开始的时间戳(可选)。
持有的服务实例:用来判定DataX目前被谁所持有,如果是自身,那直接使用即可;如果是nil,那需要写入持有信息,再进行使用;如果是其它实例,那需要进行Kickout操作,再写入持有信息
持有开始的时间戳(可选):在服务实例首次持有DataX时,需要设置时间戳,用于避免短时间内DataX被多个实例反复抢占的情况(默认可以设置为200ms),即如果上一个持有者持有的时间不超过200ms,那么其它人不能进行抢战操作。Kickout实现细节:
A向B发生Kickout请求,需要等待B回复后,才可以进行后续的数据加载和锁定;如果等待B回复超时,A会进行N次重试(默认为3次),N次重试都超时后,A会强行加载数据并锁定;
B收到Kickout请求后,会检查当前DataX的消息是否已处理完,设置缓存失效,检查队列中是否还有DataX的消息(Kickout本身任何其它操作,需要设置为pipeline),都没有则回复成功,否则回复失败。
注:Kickout消息需要设置为pipeline的原因数据迁移(2PC)
考虑到业务的高可用服务,可以通过两阶段提交的方式进行数据迁移来保证数据无损。
上面是一个简单的两阶段描述,数据迁移方案很多(比如双写方案、动态拦截方案),这个方案需要考虑实现的复杂度。
抽象弱状态服务模型
根据上文的描述,我们可以得到弱状态服务中,一个RPC处理的简化模型:
其中,只有红框的部分是需要业务实现的,其余部分都可以抽像到框架,这样,你不需要关心数据从哪来的,是否合法,以及前置检查,也不需要关心数据有没有回写,你只需要使用数据执行逻辑就可以了(这部分也涉及RPC的设计,有空再详述)。