Redis 集群

Redis 集群

RedisCluster 是 Redis 的分布式解决方案,在 3.0 版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用 Cluster 架构方案达到负载均衡的目的。

集群如何高可用

增加从节点,做主从复制。Redis Cluster 支持给每个分片增加一个或多个从节点,每个从节点在连接到主节点上之后,会先给主节点发送一个 SYNC 命令,请求一次全量复制,也就是把主节点上全部的数据都复制到从节点上。全量复制完成之后,进入同步阶段,主节点会把刚刚全量复制期间收到的命令,以及后续收到的命令持续地转发给从节点。

因为 Redis 不支持事务,所以它的复制相比 MySQL 更简单,连 Binlog 都省了,直接就是转发客户端发来的更新数据命令来实现主从同步。如果某个分片的主节点宕机了,集群中的其他节点会在这个分片的从节点中选出一个新的节点作为主节点继续提供服务。新的主节点选举出来后,集群中的所有节点都会感知到,这样,如果客户端的请求 Key 落在故障分片上,就会被重定向到新的主节点上。

数据分区

RedisCluster 采用哈希分区规则。

虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数,比如RedisCluster槽范围是0~16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽。

RedisCluser 采用虚拟槽分区,所有的键根据哈希函数映射到 0 ~ 16383 整数槽内,计算公式:slot = CRC16(key)& 16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。

槽集合和节点的关系:

使用 CRC16(key) & 16383 将键映射到槽上:

虚拟槽分区,解耦了数据和节点之间的关系,简化了节点扩容和收缩难度。

集群限制

  • key 批量操作支持有限。如msetmget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mSetmget等操作可能存在于多个节点上因此不被支持。
  • 只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
  • key作为数据分区的最小粒度,因此不能将一个大的键值对象如hashlist等映射到不同的节点。
  • 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构

集群搭建

  • Redis 集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。
  • 节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。
  • Redis集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。
  • redis-trib.rb是采用Ruby实现的Redis集群管理工具。内部通过Cluster相关命令帮我们简化集群创建、检查、槽迁移和均衡等常见运维操作,使用之前需要安装Ruby依赖环境。
redis-trib.rb create

节点通信

Gossip 协议的信息交换机制具有天然的分布式特性,但是有成本:加重带宽和计算的负担。因此选择每次需要通信的节点,变得非常重要:

  • 每秒随机选取 5 个最久没有通信的节点发送 ping 消息。
  • 每 100 毫秒会扫描本地节点列表,发现最近一次接受 pong 时间大于 cluster_node_timeout / 2 ,则立刻发送 pong 消息,防止该节点信息太长时间未更新。

Gossip 协议类似病毒扩散的方式,将信息传播到其他的节点,这种协议效率很高,只需要广播到附近节点,然后被广播的节点继续做同样的操作即可。当然这种协议也有一个弊端就是:会存在浪费,哪怕一个节点之前被通知到了,下次被广播后仍然会重复转发

集群伸缩

redis-trib.rb add-node new_host:new_port existing_host:existing_port --slave --master-id <arg>

加入集群后需要为新节点迁移槽和相关数据,槽在迁移过程中集群可以正常提供读写服务,数据迁移过程是逐个槽进行的。

请求路由

在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向。

使用redis-cli命令时,可以加入-c参数支持自动重定向,简化手动发起重定向操作。

Redis首先需要计算键所对应的槽。根据键的有效部分使用CRC16函数计算出散列值,再取对16383的余数,使每个键都可以映射到0~16383槽范围内。

Redis计算得到键对应的槽后,需要查找槽所对应的节点。集群内通过消息交换每个节点都会知道所有节点的槽信息,内部保存在clusterState结构中。

根据MOVED重定向机制,客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。正因为如此通常集群客户端都采用另一种实现:Smart(智能)客户端。

Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。

Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别,保证键命令可正常执行。例如当一个slot数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点

当出现上述情况时,客户端键命令执行流程将发生变化,如下所示:

  • 客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直接执行并返回结果给客户端。
  • 如果键对象不存在,则可能存在于目标节点,这时源节点会回复ASK重定向异常。格式如下:(error)ASK{slot}{targetIP}:{targetPort}
  • 客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。

ASKMOVED虽然都是对客户端的重定向控制,但是有着本质区别。ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存

故障转移

Redis集群自身实现了高可用。高可用首先需要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。

Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)。

主观下线流程:

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节点的ClusterNode结构,保存到下线报告链表中。

通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。

故障恢复

故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程:

  • 从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor用于从节点的有效因子,默认为10
  • 当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。

  • 发起选举。Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作。

预估故障转移时间

failover-time(毫秒) ≤ cluster-node-timeout + cluster-node-timeout / 2 + 1000
  • 主观下线识别时间:cluster-node-timeout
  • 主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
  • 从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功。

故障转移时间跟 cluster-node-timeout 参数息息相关,默认15秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好。

脑裂

在使用主从集群时,我曾遇到过这样一个问题:我们的主从集群有1个主库、5个从库和3个哨兵实例,在使用的过程中,我们发现客戶端发送的一些数据丢失了,这直接影响到了业务层的数据可靠性。

通过一系列的问题排查,我们才知道,这其实是主从集群中的脑裂问题导致的。

所谓的脑裂,就是指在主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响,就是客戶端不知道应该往哪个主节点写入数据,结果就是不同的客戶端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。

数据同步是否有问题

如果是这种情况的数据丢失,我们可以通过比对主从库上的复制进度差值来进行判断,也就是计算master_repl_offsetslave_repl_offset的差值。如果从库上的slave_repl_offset小于原主库的。

排查客戶端的操作日志

在排查客戶端的操作日志时,我们发现,在主从切换后的一段时间内,有一个客戶端仍然在和原主库通信,并没有和升级的新主库进行交互。这就相当于主从集群中同时有了两个主库。根据这个迹象,我们就想到了在分布式主从集群发生故障时会出现的一个问题:脑裂。

但是,不同客戶端给两个主库发送数据写操作,按道理来说,只会导致新数据会分布在不同的主库上,并不会造成数据丢失。那么,为什么我们的数据仍然丢失了呢?

原主库假故障导致的脑裂

我们是采用哨兵机制进行主从切换的,当主从切换发生时,一定是有超过预设数量(quorum 配置项)的哨兵实例和主库的心跳都超时了,才会把主库判断为客观下线,然后,哨兵开始执行切换操作。哨兵切换完成后,客戶端会和新主库进行通信,发送请求操作。

但是,在切换过程中,既然客戶端仍然和原主库通信,这就表明,原主库并没有真的发生故障(例如主库进程挂掉)。我们猜测,主库是由于某些原因无法处理请求,也没有响应哨兵的心跳,才被哨兵错误地判断为客观下线的。结果,在被判断下线之后,原主库又重新开始处理请求了,而此时,哨兵还没有完成主从切换,客戶端仍然可以和原主库通信,客戶端发送的写操作就会在原主库上写入数据了。

正因为原主库并没有真的发生故障,我们在客戶端操作日志中就看到了和原主库的通信记录。等到从库被升级为新主库后,主从集群里就有两个主库了,到这里,我们就把脑裂发生的原因摸清楚了。

为什么脑裂会导致数据丢失?

主从切换后,从库一旦升级为新主库,哨兵就会让原主库执行slave of命令,和新主库重新进行全量同步。而在全量同步执行的最后阶段,原主库需要清空本地的数据,加载新主库发送的RDB文件,这样一来,原主库在主从切换期间保存的新写数据就丢失了。

在主从切换的过程中,如果原主库只是“假故障”,它会触发哨兵启动主从切换,一旦等它从假故障中恢复后,又开始处理请求,这样一来,就会和新主库同时存在,形成脑裂。等到哨兵让原主库和新主库做全量同步后,原主库在切换期间保存的数据就丢失了。

如何应对脑裂问题?

既然问题是出在原主库发生假故障后仍然能接收请求上,我们就开始在主从集群机制的配置项中查找是否有限制主库接收请求的设置。

通过查找,我们发现,Redis已经提供了两个配置项来限制主库的请求处理,分别是min-slaves-to-writemin-slaves-max-lag

  • min-slaves-to-write:这个配置项设置了主库能进行数据同步的最少从库数量;
  • min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送ACK消息的最大延迟(以秒为单位)。

我们可以把min-slaves-to-writemin-slaves-max-lag这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为N和T。这两个配置项组合后的要求是,主库连接的从库中至少有N个从库,和主库进行数据复制时的ACK消息延迟不能超过T秒,否则,主库就不会再接收客戶端的请求了。

即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行ACK确认了。这样一来,min-slaves-to-writemin-slaves-max-lag的组合要求就无法得到满足,原主库就会被限制接收客戶端请求,客戶端也就不能在原主库中写入新数据了。

等到新主库上线时,就只有新主库能接收和处理客戶端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

运维注意

集群内 Gossip 消息通信本身会消耗带宽,官方建议集群最大规模在 1000 以内,也是出于对消息通信成本的考虑,因此单集群不适合部署超大规模的节点。

同一个系统可以针对不同业务场景拆分使用多套集群。这样每个集群既满足伸缩性和故障转移要求,还可以规避大规模集群的弊端。

如何解决集群倾斜(不同节点之间数据量和请求量出现明显差异,这种情况将加大负载均衡和开发运维的难度) ?

  • 使用 redis-trib.rb info {host:ip} 探测每个节点,了解节点上各个槽负责的键的数量。可以使用 redis-trib.rb rebalance 命令重新进行平衡。
  • 使用 redis-cli --bigkeys 识别出大对象,然后根据业务场景进行拆分

如何避免请求倾斜 ?

集群内特定节点请求量/流量过大将导致节点之间负载不均,影响集群均衡和运维成本。常出现在热点键场景,当键命令消耗较低时如小对象的getsetincr等,即使请求量差异较大一般也不会产生负载严重不均。但是当热点键对应高算法复杂度的命令或者是大对象操作如hgetallsmembers等,会导致对应节点负载过高的情况。

  • 热点大集合对象拆分,避免整体读取
  • 不用使用热键作为 hash_tag
  • 一致性要求不高的,客户端使用本地缓存,渐少热键调用

为什么 16384 个槽

作者在这里 给出了回答:

  • 如果槽位为 65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。

如上所述,在消息头中,最占空间的是 myslots[CLUSTER_SLOTS/8]。 当槽位为65536时,这块的大小是: 65536÷8÷1024=8kb因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。

  • redis的集群主节点数量基本不可能超过1000个。

如上所述,集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。 那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。

  • 槽位越小,节点少的情况下,压缩率高

Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。 如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。

超大规模 Redis 集群

但是 Redis Cluster 不太适合构建超大规模集群,主要原因是,它采用了去中心化的设计。Redis 的每个节点上,都保存了所有槽和节点的映射关系表,客户端可以访问任意一个节点,再通过重定向命令,找到数据所在的那个节点。那你有没有想过一个问题,这个映射关系表,它是如何更新的呢?比如说,集群加入了新节点,或者某个主节点宕机了,新的主节点被选举出来,这些情况下,都需要更新集群每一个节点上的映射关系表。

Redis Cluster 采用了一种去中心化的流言 (Gossip) 协议来传播集群配置的变化

这个八卦协议它的好处是去中心化,传八卦不需要组织,吃瓜群众自发就传开了。这样部署和维护就更简单,也能避免中心节点的单点故障。八卦协议的缺点就是传播速度慢,并且是集群规模越大,传播的越慢

参考