如何实现高可用的redis集群

Redis因具有丰富的数据结构和超高的性能以及简单的协议,使其能够很好的作为数据库的上游缓存层。但在大规模的Redis使用过程中,会受限于多个方面:单机内存有限、带宽压力、单点问题、不能动态扩容等。

基于以上,Redis集群方案显得尤为重要。通常有3个途径:官方Redis Cluster;通过Proxy分片;客户端分片(Smart Client)。以上三种方案各有利弊。

Redis Cluster(官方):虽然正式版发布已经有一年多的时间,但还缺乏最佳实践;对协议进行了较大修改,导致主流客户端也并非都已支持,部分支持的客户端也没有经过大规模生产环境的验证;无中心化设计使整个系统高度耦合,导致很难对业务进行无痛的升级。

Proxy:现在很多主流的Redis集群都会使用Proxy方式,例如早已开源的Codis。这种方案有很多优点,因为支持原声redis协议,所以客户端不需要升级,对业务比较友好。并且升级相对平滑,可以起多个Proxy后,逐个进行升级。但是缺点是,因为会多一次跳转,平均会有30%左右的性能开销。而且因为原生客户端是无法一次绑定多个Proxy,连接的Proxy如果挂了还是需要人工参与。除非类似Smart Client一样封装原有客户端,支持重连到其他Proxy,但这也就带来了客户端分片方式的一些缺点。并且虽然Proxy可以使用多个,并且可以动态增加proxy增加性能,但是所有客户端都是共用所有proxy,那么一些异常的服务有可能影响到其他服务。为每个服务独立搭建proxy,也会给部署带来额外的工作。

而我们选择了第三种方案,客户端分片(Smart Client)。客户端分片相比Proxy拥有更好的性能,及更低的延迟。当然也有缺点,就是升级需要重启客户端,而且我们需要维护多个语言的版本,但我们更爱高性能。

下面我们来介绍一下我们的Redis集群:

概貌:
如图0所示,

我们的Redis集群一共由四个角色组成:
Zookeeper:保存所有redis集群的实例地址,redis实例按照约定在特定路径写入自身地址,客户端根据这个约定查找redis实例地址,进行读写。
Redis实例:我们修改了redis源码,当redis启动或主从切换时,按照约定自动把地址写到zookeeper特定路径上。
Sentinel:redis自带的主从切换工具,我们通过sentinel实现集群高可用。
客户端(Smart Client):客户端通过约定查找redis实例在ZooKeeper中写入的地址。并且根据集群的group数,进行一致性哈希计算,确定key唯一落入的group,随后对这个group的主库进行操作。客户端会在ZooKeeper设置监视,当某个group的主库发生变化时,ZooKeeper会主动通知客户端,客户端会更新对应group的最新主库。
我们的Redis集群是以业务为单位进行划分的,不同业务使用不同集群(即业务和集群是一对一关系)。一个Redis集群会由多个group组成(一个group由一个主从对redis实例组成)。即group越多,可以部署在更多的机器上,可利用的内存、带宽也会更多。在图0中,这个业务使用的redis集群由2个group组成,每个group由一对主从实例组成。
Failover
如图1所示,
当redis启动时,会把自己的 IP:Port 写入到 ZooKeeper 中。其中的主实例模式启动时会在 /redis/业务名/组名永久节点写入自己的 IP:Port(如果节点不存在则创建)。由主模式变成从模式时,会创建 /redis/业务名/组名/slaves/ip:port 临时节点,并写入自己的 IP:Port(如果相同节点已经存在,则先删除,再创建)。而从实例模式启动时会创建 /redis/业务名/组名/slaves/ip:port 临时节点,并写入自己的 ip:port (如果相同节点已经存在,则先删除,再创建)。由从模式变成主模式时,先删除 /redis/业务名/组名/slaves/ip:port 临时节点,并在 /redis/业务名/组名永久节点写入自己的 IP:Port。

ZooKeeper 会一直保存当前有效的主从实例 IP:Port 信息。至于主从自动切换过程,使用 redis 自带的 sentinel 实现,现设置为超过 30s 主server 无响应,则由 sentinel 进行主从实例的切换,切换后就会触发以主、从实例通过以上提到的一系列动作,从而完成最终的切换。

而客户端侧通过给定业务名下的所有groupName进行一致性哈希计算,确定key落入哪个组。客户端启动时,会从 ZooKeeper 获取指定业务名下所有 group 的主从IP:Port,并在 ZooKeeper 中设置监视(监视的作用是当ZooKeeper的节点发生变化时,会主动通知客户端)。若客户端从 Zookeeper 收到节点变化通知,会重新获取最新的主从I:Port,并重新设置监视(ZooKeeper监视是一次性的)。通过此方法,客户端可以实时获知当前可访问最新的主从IP:Port信息。

因为我们的所有redis实例信息都按照约定保存在ZooKeeper上,所以不需要针对每个实例部署监控,我们编写了一个可以自动通过ZooKeeper获取所有redis实例信息,并且监控cpu、qps、内存、主从延迟、主从切换、连接数等的工具。

发展:

现在redis集群在某些业务内存需求超过预期很多后,无法通过动态扩容进行扩展。所以我们正在做动态扩容的支持。原先的客户端我们是通过一致性哈希进行key的路由策略,但这种方式在动态扩容时会略显复杂,所以我们决定采用实现起来相对简单的预分片方式。一致性哈希的好处是可以无限扩容,而预分片则不是。预分片时我们会在初始化阶段指定一个集群的所有分片数量,这个数量一旦指定就不能再做改变,这个预分片数量就是后续可以扩容到最大的redis实例数。假设预分片128个slot,每个实例10G也可以达到TB级别的集群,对于未来数据增长很大的集群我们可以预分片1024,基本可以满足所有大容量内存需求了。

原先我们的redis集群有四种角色,Smart Client, redis,sentinel,ZooKeeper。为了支持动态扩容,我们增加了一个角色,redis_cluster_manager(以下简称manager),用于管理redis集群。主要工作是初始化集群(即预分片),增加实例后负责修改ZooKeeper状态,待客户端做好准备后迁移数据到新增实例上。为了尽量减少数据迁移期间对现性能带来的影响,我们每次只会迁移一个分片的数据,待迁移完成,再进行下一个分片的迁移。

如图2所示

相比原先的方案,多了 slots、Manager Lock、clients、Migrating Clients 节点。

Slots:所有分片会把自身信息写入到slots节点下面。Manager在初始化集群时,根据设置的分片数,以及集群下的group数,进行预分片操作,把所有分片均匀分配给已有group。分片的信息由一个json串组成,记录有分片的状态(stats),当前拥有此分片的group(src),需要迁移到的group(dst)。分片的状态一共有三种:online、pre_migrate、migrating。
Online指这个分片处于正常状态,这时dst是空值,客户端根据src的group进行读写。
Pre_migrate是指这个分片被manager标记为需要迁移,此时dst仍然为空,manager在等所有client都已经准备就绪,因为ZooKeeper回掉所有客户端有时间差,所以如果某些client没有准备就绪的时候manager进行了数据迁移,那么就会有数据丢失。
Migrating是manager确认了所有客户端都已经做好迁移准备后,在dst写入此分片需要迁移的目标group。待迁移完成,会在src写入目标group_name,dst设为空,stats设为online。

Manager Lock:因为我们是每次只允许迁移一个slot,所以不允许超过一个manager操作一个集群。所以manager在操作集群前,会在Manager Lock下注册临时节点,代表这个集群已经有manager在操作了,这样其他manager想要操作这个集群时就会自动退出。

Clients和Migrating Clients是为了让manager知道客户端是否已经准备就绪的节点。客户端通过uid代表自己,格式是客户端语言_主机名_pid。当集群没有进行迁移,即所有分片都是online的时候,客户端会在 clients下创建uid的临时节点。

当某个slot从online变成pre_migrate后,客户端会删除clients下的uid临时节点,然后在Migrating Clients创建uid临时节点。注意,因为需要保证数据不丢失,从pre_migrate到migrating期间,这个slot是被锁定的,即所有对这个slot的读写都会被阻塞。所以mananger会最多等待10s,确认所有客户端都已经切换到准备就绪状态,如果发现某个客户端一直未准备就绪,那么mananger会放弃此次迁移,把slot状态由pre_migrate改为online。如果客户端发现slot状态由pre_migrate变成online了,那么会删除migrating_clients下的uid节点,在clients下重新创建uid节点。还需要注意的一点是,有可能一个客户刚启动,并且正在往clients下创建uid节点,但是因为网络延迟还没创建完成,导致manager未确认到这个client是否准备就绪,所以mananger把slot改为pre_migrate后会等待1s再确认所有客户端是否准备就绪。

如果Manager看到clients下已经没有客户端的话(都已经准备就绪),会把slot状态改为migrating。Slot变成migrating后,锁定也随之解除,manager会遍历src group的数据,把对应slot的数据迁移到dst group里。客户端在migrating期间如果有读写migrating slot的key,那么客户端会先把这个key从src group 迁移到 dst group,然后再做读写操作。即这期间客户端性能会有所下降。这也是为什么每次只迁移一个slot的原因。这样即使只有128个分片的集群,在迁移期间受到性能影响的key也只有 1/128,是可以接受的。

Manager发现已经把slot已经迁移完毕了,会在src写入目标group_name,dst设为空,stats设为online。客户端也删除migrating_clients下的uid,在clients下创建uid节点。

最后再次看图2,这个图代表集群正在把slot2的数据从group1迁移到group2。并且目前只有一个 java客户端在进行读写。

以上,欢迎交流、提问、沟通,长按图片识别订阅喔。