本以为上次11天没更已经够慢,没想到这次有13天(捂脸。最近实在是业务忙,没了 刚开 订阅号时两三天码一篇的闲情逸致。
本篇的话题继续回到数据服务。
小说君之前写过一篇文章关于数据服务:「如何快速搭建数据服务」——这篇文章中,小说君主要着眼于应用层,介绍了如何借助ORM,集成一个单机redis到项目中,顺便讲了下用mysql等持久db设施取代redis自带的比较挫的持久话机制。
今天的话,我们的主题就如之前预告所说,来聊聊如何扩展数据服务,如何实现分片(sharding)以及高可用(high availability)。
分布式系统不存在完美的设计,处处都体现了trade off。
因此我们在开始正文前,需要确定后续的讨论原则,仍然以分布式系统设计中的CAP原则为例。由于主角是redis,那性能表现肯定是最高设计目标,之后讨论过程中的所有抉择,都会优先考虑CAP中的AP性质。
两个点按顺序来,先看分片。
何谓分片?简单来说,就是对单机redis做水平扩展。
当然,做游戏的同学可能要问了,一服一个redis,为什么需要水平扩展?这个话题我们在之前几篇文章中都有讨论,可以看这里,或这里,小说君不再赘述。
如果要实现服务级别的复用,那么数据服务的定位往往是全局服务。如此仅用单实例的redis就难以应对多变的负载情况——毕竟redis是单线程的。
从mysql一路用过来的同学这时都会习惯性地水平拆分,redis中也是类似的原理,将整体的数据进行切分,每一部分是一个分片(shard),不同的分片维护不同的key集合。
那么,分片问题的实质就是如何基于多个redis实例设计全局统一的数据服务。同时,有一个约束条件,那就是我们无法保证强一致性。
也就是说,数据服务进行分片扩展的前提是,不提供跨分片事务的保障。redis cluster也没有提供类似支持,因为分布式事务本来就跟redis的定位是有冲突的。
因此,我们的分片方案有两个限制:
不同分片中的数据一定是严格隔离的,比如是不同组服的数据,或者是完全不相干的数据。要想实现跨分片的数据交互,必须依赖更上层的协调机制保证,数据服务层面不做任何承诺。 而且这样一来,如果想给应用层提供协调机制,只要在每个分片上部署上篇文章介绍的 单实例简易锁机制 即可,简单明了。
我们的分片方案无法在分片间做类似分布式存储系统的数据冗余机制,换言之,一份数据交叉存在多个分片中。
如何实现分片?
首先,我们要确定分片方案需要解决什么问题。
分片的redis集群,实际上共同组成了一个有状态服务(stateful service)。设计有状态服务,我们通常会从两点考虑:
cluster membership,系统间各个节点,或者说各个分片的关系是怎样的。
work distribution,外部请求应该如何、交由哪个节点处理,或者说用户(以下都简称dbClient)的一次读或写应该去找哪个分片。
针对第一个问题,解决方案通常有三:
presharding,也就是sharding静态配置。
gossip protocol,其实就是redis cluster采用的方案。简单地说就是集群中每个节点会由于网络分化、节点抖动等原因而具有不同的集群全局视图。节点之间通过gossip protocol进行节点信息共享。这是业界比较流行的去中心化的方案。
consensus system,这种方案跟上一种正相反,是依赖外部分布式一致性设施,由其仲裁来决定集群中各节点的身份。
需求决定解决方案,小说君认为,对于游戏服务端以及大多数应用型后端情景,后两者的成本太高,会增加很多不确定的复杂性,因此两种方案都不是合适的选择。 而且,大部分服务通常是可以在设计阶段确定每个分片的容量上限的,也不需要太复杂的机制支持。
但是presharding的缺点也很明显,做不到动态增容减容,而且无法高可用。不过其实只要稍加改造,就足以满足需求了。
不过,在谈具体的改造措施之前,我们先看之前提出的分片方案要解决的第二个问题—— work distribution 。
这个问题实际上是从另一种维度看分片,解决方案很多,但是如果从对架构的影响上来看,大概分为两种:
一种是proxy-based,基于额外的转发代理。例子有twemproxy/Codis。
一种是client sharding,也就是dbClient(每个对数据服务有需求的服务)维护sharding规则,自助式选择要去哪个redis实例。redis cluster本质上就属于这种,dblient侧缓存了部分sharding信息。
第一种方案的缺点显而易见——在整个架构中增加了额外的间接层,流程中增加了一趟round-trip。如果是像twemproxy或者Codis这种支持高可用的还好,但是github上随便一翻还能找到特别多的没法做到高可用的proxy-based方案,无缘无故多个单点,这样就完全搞不明白sharding的意义何在了。
第二种方案的缺点,小说君能想到的就是集群状态发生变化的时候没法即时通知到dbClient。
第一种方案,我们其实可以直接pass掉了。因为这种方案更适合私有云的情景,开发数据服务的部门有可能和业务部门相去甚远,因此需要统一的转发代理服务。但是对于一些简单的应用开发情景,数据服务逻辑服务都是一帮人写的,没什么增加额外中间层的必要。
那么,看起来只能选择第二种方案了。
将presharding与client sharding结合起来后,现在我们的成果是:数据服务是全局的,redis可以开多个实例,不相干的数据需要到不同的分片上存取,dbClient掌握这个映射关系。
不过目前的方案只能算是满足了应用对数据服务的基本需求。
游戏行业中,大部分采用redis的团队,一般最终会选定这个方案作为自己的数据服务。后续的扩展其实对他们来说不是不可以做,但是可能有维护上的复杂性与不确定性。
但是作为一名有操守的程序员,小说君选择继续扩展。
现在的这个方案存在两个问题:
首先,虽然我们没有支持在线数据迁移的必要,但是离线数据迁移是必须得有的,毕竟presharding做不到万无一失。而在这个方案中,如果用单纯的哈希算法,增加一个shard会导致原先的key到shard的对应关系变得非常乱,抬高数据迁移成本。
其次,分片方案固然可以将整个数据服务的崩溃风险分散在不同shard中,比如相比于不分片的数据服务,一台机器挂掉了,只影响到一部分client。但是,我们理应可以对数据服务做更深入的扩展,让其可用程度更强。
针对第一个问题,处理方式跟proxy-based采用的处理方式没太大区别,由于目前的数据服务方案比较简单,采用一致性哈希即可。或者采用一种比较简单的两段映射,第一段是静态的固定哈希,第二段是动态的可配置map。前者通过算法,后者通过map配置维护的方式,都能最小化影响到的key集合。
而对于第二个问题,解决方案就是实现高可用。
如何让数据服务高可用?在 讨论这个问题之前,我们首先看redis如何实现「 可用性 」。
对于redis来说,可用性的本质是什么?其实就是redis实例挂掉之后可以有后备节点顶上。
redis通过两种机制支持这一点。
第一种机制是replication。 通常的replication方案主要分为两种。
一种是active-passive,也就是active节点先修改自身状态,然后写统一持久化log,然后passive节点读log跟进状态。
另一种是active-active,写请求统一写到持久化log,然后每个active节点自动同步log进度。
redis的replication方案采用的是一种一致性较弱的active-passive方案。也就是master自身维护log,将log向其他slave同步,master挂掉有可能导致部分log丢失,client写完master即可收到成功返回,是一种异步replication。
这个机制只能解决节点数据冗余的问题,redis要具有可用性就还得解决redis实例挂掉让备胎自动顶上的问题,毕竟由人肉去监控master状态再人肉切换是不现实的。 因此还需要第二种机制。
第二种机制是redis自带的能够自动化fail-over的redis sentinel。reds sentinel实际上是一种特殊的redis实例,其本身就是一种高可用服务——可以多开,可以自动服务发现(基于redis内置的pub-sub支持,sentinel并没有禁用掉pub-sub的command map),可以自主leader election(基于 raft算法 实现,作为 sentinel的一个模块 ),然后在发现master挂掉时由leader发起fail-over,并将掉线后再上线的master降为新master的slave。
redis基于这两种机制,已经能够实现一定程度的可用性。
接下来,我们来看数据服务如何高可用。
数据服务具有可用性的本质是什么?除了能实现redis可用性的需求——redis实例数据冗余、故障自动切换之外,还需要将切换的消息通知到每个dbClient。
也就是说把最开始的图,改成下面这个样子:
每个分片都要改成主从模式。
如果redis sentinel负责主从切换,拿最自然的想法就是让dbClient向sentinel请求当前节点主从连接信息。但是redis sentinel本身也是redis实例,数量也是动态的,redis sentinel的连接信息不仅在配置上成了一个难题,动态更新时也会有各种问题。
而且,redis sentinel本质上是整个服务端的static parts(要向dbClient提供服务),但是却依赖于redis的启动,并不是特别优雅。另一方面,dbClient要想问redis sentinel要到当前连接信息,只能依赖其内置的pub-sub机制。redis的pub-sub只是一个简单的消息分发,没有消息持久化,因此需要轮询式的请求连接信息模型。
那么,我们是否可以以较低的成本定制一种服务,既能取代redis sentinel,又能解决上述问题?
回忆下前文我们解决resharding问题的思路:
一致性哈希。
采用一种比较简单的两段映射,第一段是静态的固定哈希,第二段是动态的可配置map。前者通过算法,后者通过map配置维护的方式,都能最小化影响到的key集合。
两种方案都可以实现动态resharding,dbClient可以动态更新:
如果采用两段映射,那么我们可以动态下发第二段的配置数据。
如果采用一致性哈希,那么我们可以动态下发分片的连接信息。
再梳理一下,我们要实现的服务(下文简称为watcher),至少要实现这些需求 :
要能够监控redis的生存状态。这一点实现起来很简单,定期的PING redis实例即可。需要的信息以及做出客观下线和主观下线的判断依据都可以直接照搬sentinel实现。
要做到自主服务发现,包括其他watcher的发现与所监控的master-slave组中的新节点的发现。在实现上,前者可以基于消息队列的pub-sub功能,后者只要向redis实例定期INFO获取信息即可。
要在发现master客观下线的时候选出leader进行后续的故障转移流程。这部分实现起来算是最复杂的部分,接下来会集中讨论。
选出leader之后将一个最合适的slave提升为master,然后等老的master再上线了就把它降级为新master的slave。
解决这些问题,watcher就兼具了扩展性、定制性,同时还提供分片数据服务的部分在线迁移机制。这样,我们的数据服务也就更加健壮,可用程度更高。
这样一来,虽然保证了redis每个分片的master-slave组具有可用性,但是因为我们引入了新的服务,那就引入了新的不确定性——如果引入这个服务的同时还要保证数据服务具有可用性,那我们就还得保证这个服务本身是可用的。
说起来可能有点绕,换个说法,也就是服务A借助服务B实现了高可用,那么服务B本身也需要高可用。
先简单介绍一下redis sentinel是如何做到高可用的。同时监控同一组主从的sentinel可以有多个,master挂掉的时候,这些sentinel会根据redis自己实现的一种raft算法选举出leader,算法流程也不是特别复杂,至少比paxos简单多了。所有sentinel都是follower,判断出master客观下线的sentinel会升级成candidate同时向其他follower拉票,所有follower同一epoch内只能投给第一个向自己拉票的candidate。在具体表现中,通常一两个epoch就能保证形成多数派,选出leader。有了leader,后面再对redis做SLAVEOF的时候就容易多了。
如果想用watcher取代sentinel,最复杂的实现细节可能就是这部分逻辑了。
这部分逻辑说白了就是要在分布式系统中维护一个一致状态,举个例子,可以将「 谁是leader 」这个概念当作一个状态量,由分布式系统中的身份相等的几个节点共同维护,既然谁都有可能修改这个变量,那究竟谁的修改才奏效呢?
幸好,针对这种常见的问题情景,我们有现成的基础设施抽象可以解决。
这种基础设施就是分布式系统的协调器组件(coordinator),老牌的有zookeeper(基于对paxos改进过的zab协议,下面都简称zk了),新一点的有etcd(这个大家都清楚,基于raft协议)。这种组件通常没有重复开发的必要,像paxos这种算法理解起来都得老半天,实现起来的细节数量级更是难以想象。因此很多开源项目都是依赖这两者实现高可用的,比如codis一开始就是用的zk。
zk解决了什么问题?
以通用的应用服务需求来说,zk可以用来选leader,还可以用来维护dbClient的配置数据——dbClient直接去找zk要数据就行了。
zk的具体原理小说君就不再介绍了,有时间有精力可以研究下paxos,看看lamport的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要配置数据的机制更加优雅。
看下最后的架构图:
这篇文章是服务端系列的倒数第二篇,篇幅稍长,内容也大部分都整理自小说君年初写的某篇关于游戏服务端的博客,改动比较少。
下一篇作为结篇,主要聊聊RPC相关的话题。
小说君暂时还没想好后续的系列主题,如果有同学有想了解或想讨论的也可以直接后台留言发发消息什么的。
服务端系列文章的链接,以及后续的主题(按顺序阅读更佳):
从零手写服务端框架
面向中间件的开发模式
如何快速搭建数据服务
面向微服务的服务端架构
以消息队列为中心的服务端架构
聊聊无状态服务
聊聊分布式锁
基于redis构建数据服务(本篇)
聊聊RPC与消息流(暂定)
个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。