本人从事游戏行业,因此这个缓存系统主要针对网络游戏的数据访问模式设计。

首先分析下网络游戏的数据访问模式:

1)关系性弱:对于绝对大多数的游戏类型来说,能通过一个key来访问数据就够了。

2)读多写多:用户登录时通常需要加载大量数据,查询其它玩家信息也是一个频繁的操作请求。在不考虑定时回写的情况下,玩家的每个更新类请求都会产生一次数据库回写请求,对于一个在线10W,养成类游戏,每秒写请求达到10W+是可能的。

3)冷数据多:游戏的注册用户数和活跃用户数差距非常大。大量数据平时几乎不被访问,只有偶尔做活动用户回流时才被访问。

4)响应及时性:即使是一个查看其它用户信息的查询请求,响应时间超过200ms也是非常不友好的。

基于以上考量,我设计了一套名为flyfish的冷热缓存系统,系统设计目标如下:

1)作为数据访问的统一接口:所有数据访问都通过flyfish,由flyfish负责数据的加载,回写以及冷数据淘汰。

2)面向行缓存:最终数据要同步到关系数据库,因此,flyfish的value缓存的是关系数据库中的一行。value类似redis中的hash,可以单独访问每个field。

3)高可用与强一致性:通过部署多个副本,在部分节点出现故障时系统依旧可以对外提供强一致的数据服务。

4)动态伸缩:在不中断系统服务的情况下,通过增加/移除节点调整系统的负载能力。

5)高吞吐低延迟:对于已缓存数据,单一节点每秒支持10W+的读写能力,平均延迟50ms以内。

6)在后端数据库故障的情况下提供受限服务:flyfish作为数据访问接口,除了要容忍系统本身故障外,还需要容忍后端数据库的故障。在后端数据库出现故障的情况下,需要保证对已缓存的数据继续提供无丢失的读写服务。

7)支持单key的cas以及原子递增操作。

8)支持版本号以实现悲观事务。

9)分布式事务。

目前flyfish的第一个版本尚未实现pd及proxy,只支持静态分片。所以仅达成了除4,9以外的目标。

数据库表格定义

flyfish作为一个行级缓存,其缓存的value对应到数据库表中的一行,因此,flyfish是有模式的kv缓存。flyfish支持string,blob,int64,uint64,float64五种字段类型。

定义表格时,必须定义两个flyfish使用的内部字段__key__(string),以及__version__(int64),其中__key__需要被设定为主键。

定义表格后,向table_conf插入一行以描述表格元数据,例如,有一个user表其字段定义如下:

__key__(string),__version__(int64),age(int64),phone(string)
则需要向table_conf插入下面行:

table conf

users age:int:0,phone:string:10086
表格元数据规则:

字段1:类型:默认值,字段2:类型:默认值,字段3:类型:默认值

kvnode

每一个kv对归属于一个region,每个region可以在1-N个kvnode节点上保存副本,编号相同的region组成一个raftgroup。在这个raftgroup中,通过raft协议选举出一个leader,由leader负责这个region上kv的访问请求。

kv访问流程

只读请求

1)客户端使用unikey(key:table)向leader发起访问请求。

2)如果缓存中没有数据,向sql数据库发起加载请求。用数据库加载的结果向所有副本发起添加kv的复制请求(如果记录不存在,请求添加一条标记为缺失的kv),当复制被提交,向本地缓存添加kv并返回响应。

3)如果缓存有数据,向所有副本发起readIndex请求,请求被提交后返回响应。

更新请求

1)客户端使用unikey(key:table)向leader发起访问请求。

2)如果缓存中没有数据,向sql数据库发起加载请求,加载成功后在原数据的基础上产生修改请求,向所有副本复制请求,复制被提交后修改本地缓存返回响应。将kv插入更新队列,执行异步sql回写。

3)如果缓存有数据,在原数据的基础上产生修改请求,向所有副本复制请求,复制被提交后修改本地缓存返回响应。将kv插入更新队列,执行异步sql回写。

从以上处理可以看出,flyfish保证各副本之间的强一致性,而与sql数据库保持最终一致性。因为flyfish中缓存的数据可以通过raft快照和日志重建,并保证最终一定会回写到sql数据库。因此,即使在sql数据库出现故障的情况下,对于已缓存的数据依旧可以提供读写服务。

sql回写的数据安全性

flyfish采用异步回写策略,并且回写请求是针对一个kv而不是每次变更。即,一个kv被推入回写队列之后,实际执行回写之前,如果又发生了多次变更,这些变更将会被合并为一次sql回写请求。因此,回写的操作频率与kv更新的频率无关,只与发生变更的kv数量有关。因此不用担心sql回写执行太慢而导致回写队列被撑爆。

因为flyfish采用异步回写策略,因此存在数据回写覆盖的风险。flyfish保证只有leader可以提供数据访问服务,因此也只有leader有权执行sql回写。考虑一下场景:

A是leader,执行sql回写前,因为网络分区,集群选举B为leader,此时A尚未感知到自己已经不是leader继续发出sql回写请求。

B此时也收到更新请求,执行回写。如果A回写在B之后执行,将导致老数据覆盖新数据。

为了避免此类问题的出现,回写必须在获得lease的情况下才能执行:

一个节点在成为leader之后才能申请lease,lease通过raft的proposal提交,一旦提交成功获得lease,按约定的时间间隔定期续约。

对于上面描述的场景,B在成为leader后,发现之前的lease尚未失效,所以它没有回写权限。必须等到A所持有的lease到期后才会申请lease。而对于A,因为网络分区,它的续约请求将无法通过,因此当它感知到失去leader或lease过期之后就会停止执行回写。

回写前节点崩溃导致回写丢失

考虑下面的场景:

leader在执行回写前崩溃。新的leader并不知道相应的kv是dirty的。这个kv长时间没有被访问,最后被lru淘汰。此时这个kv的数据更新就丢失了。为了避免以上情况,新leader在第一次获得lease时,需要将所有的kv标记为dirty并请求执行回写。这样就保证了任何情况下都不会丢失数据更新。

flyfish


sniperHW
117 声望10 粉丝