Redis Cluster
Redis Cluster是Redis官方在Redis 3.0版本正式推出的高可用以及分布式的解决方案。
Redis Cluster由多个Redis实例组成的整体,数据按照槽(slot)存储分布在多个Redis实例上,通过Gossip协议来进行节点之间通信。
Redis Cluster实现的功能:
• 将数据分片到多个实例(按照slot存储);
• 集群节点宕掉会自动failover;
• 提供相对平滑扩容(缩容)节点。
Redis Cluster暂未有的:
• 实时同步
• 强一致性
Redis Cluster分片实现
一般分片(Sharding)实现的方式有list、range和hash(或者基于上述的组合方式)等方式。而Redis的实现方式是基于hash的分片方式,具体是虚拟槽分区。
虚拟槽分区
槽(slot):使用分散度良好的hash函数把所有数据映射到一个固定范围的整数集合中,这个整数集合就是槽。
Redis Cluster槽: Redis Cluster槽的范围是0 ~ 16383。槽是集群内数据管理和迁移的基本单位。
分片的具体算法
Redis Cluster使用slot = CRC16(key) %16384来计算键key属于哪个slot。(Redis先对key使用CRC16算法计算出一个结果,然后再把结果对16384求余,得到结果即跟Redis Cluster的slot对应,也就是对应数据存储的槽数。)
(注: CRC16算法——循环冗余校验(Cyclic Redundancy Check/Code),Redis使用的是CRC-16-CCITT标准,即G(x)为:x16+ x12+ x5+ 1。)
Redis Cluster中的每个分片只需要维护自己的槽以及槽所映射的键值数据。
Hash标签
哈希标签(hash tags),在Redis集群分片中,可以通过哈希标签来实现指定两个及以上的Key在同一个slot中。只要Key包含“{…}”这种模式,Redis就会根据第一次出现的’{’和第一次出现的’}’之间的字符串进行哈希计算以获取相对应的slot数。如上Redis源码实现。
所以如果要指定某些Key存储到同一个slot中,只需要在命令Key的之后指定相同的“{…}”命名模式即可。
集群节点和槽
我们现在已经知道,Redis Cluster中的keys被分割为16384个槽(slot),如果一个槽一个节点的话,那Redis Cluster最大的节点数量也就是16384个。官方推荐最大节点数量为1000个左右。
(关于Redis为什么使用16384个槽,原作者有回答:why redis-cluster use 16384 slots?)
1当Redis Cluster中的16384个槽都有节点在处理时,集群处于上线状态(ok);
如果Redis Cluster中有任何一个槽没有得到处理(或者某一分片的最后一个节点挂了),那么集群处于下线状态(fail)。(info cluster中的:cluster_state状态)。那整个集群就不能对外提供服务。
Redis-3.0.0.rc1加入cluster-require-full-coverage参数,默认关闭,打开集群容忍部分失败。
但是如果集群超过半数以上master挂掉,无论是否有slave集群进入fail状态。
节点ID
Redis Cluster每个节点在集群中都有唯一的ID,该ID是由40位的16进制字符组成,具体是节点第一次启动由linux的/dev/urandom生成。具体信息会保存在node.cnf配置文件中(该文件有Redis Cluster自动维护,可以通过参数cluster-config-file来指定路径和名称),如果该文件被删除,节点ID将会重新生成。(删除以后所有的cluster和replication信息都没有了)或者通过Cluster Reset强制请求硬重置。
节点ID用于标识集群中的每个节点,包括指定Replication Master。只要节点ID不改变,哪怕节点的IP和端口发生了改变,Redis Cluster可以自动识别出IP和端口的变化,并将变更的信息通过Gossip协议广播给其他节点。
ClusterNode
Master 节点维护这一个16384/8字节的位序列,Master节点用bit来标识对于某个槽自己是否拥有。(判断索引是不是为1即可)
slots属性是一个二进制位数组(bit arry),这个数组的长度为16384/8 = 2048个字节,共包含16384个二进制。
Redis Cluster对slots数组中的16384个二进制位进行编号:从0为起始索引,16383为终止索引。
根据索引i上的二进制位的值来判断节点是否负责处理槽i:
•slots数组在索引i上的二进制位的值为1,即表示该节点负责处理槽i;
•slots数组在索引i上的二进制位的值为0,即表示该节点不负责处理槽i;
示例1:(如下节点负责处理slot0-slot7)
即在Redis Cluster中Master节点使用bit(0)和bit(1)来标识对某个槽是否拥有,而Master只要判断序列第二位的值是不是1即可,时间复杂度为O(1)。
numslots属性记录节点负责处理的槽的数量,也就是slots数组中值为1的二进制位的数量。上图中节点处理的槽数量为8个。
ClusterState
集群中所有槽的分配信息都保存在ClusterState数据结构的slots数组中,程序要检查槽i是否已经被分配或者找出处理槽i的节点,只需要访问clusterState.slots[i]的值即可,时间复杂度为O(1)。
slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:
•如果slots[i]指针指向null,那么表示槽i尚未指派给任何节点;
•如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。
示例2:
1. slots[0]至slots[4999]的指针都指向端口为6381的节点,即槽0到4999都由节点6381负责处理;
2. slots[5000]至slots[9999]的指针都指向端口为6382的节点,即槽5000到9999都由节点6382负责处理;
3. slots[10000]至slots[16383]的指针都指向端口为6383的节点,即槽10000到16384都由节点6383负责处理。
数组 clusterNode.slots和clusterState.slots:
• clusterNode.slots数组记录了clusterNode结构所代表的节点的槽指派信息(每个节点负责哪些槽)。
• clusterState.slots数组记录了集群中所有槽的指派信息。
• 如果需要查看某个节点的槽指派信息,只需要将相应节点的clusterNode.slots数组整个发送出去即可。
• 但是如果需要查看槽i是否被分配或者分配给了哪个节点,就需要遍历clusterState.nodes字典中所有clusterNode结构,检查这些结构的slots数组,直到遍历到负责处理槽i的节点为止,这个过程的时间复杂度为O(N),N是clusterState.nodes字典保存的clusterNode结构的数量。
• 引入clusterState.slots ,将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,或者查看负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的时间复杂度为O(1)。
• 如果只使用clusterState.slots数组(不引入clusterNode.slots),如果要将节点A的槽指派信息传播给其他节点时,必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后再发送给其他节点。比直接发送clusterNode.slots数组要低效的多。
Redis Cluster节点通信
Redis Cluster采用P2P的Gossip协议,Gossip协议的原理就是每个节点与其他节点间不断通信交换信息,一段时间后节点信息一致,每个节点都知道集群的完整信息。
Redis Cluster通信过程:
(1)集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000;
(2)每个节点在固定周期内通过特定规则选择几个节点发送ping消息;
(3)接收到ping消息的节点用pong消息作为响应。
集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,
只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点出故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断的ping/pong消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。
Gossip消息
Gossip协议的主要职责就是信息交换,信息交换的载体就是节点彼此发送的Gossip消息,常用的Gossip消息可分为:
• meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换;
• ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。
• pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
• fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。
消息格式:
所有的消息格式划分为:消息头和消息体。
消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据。
消息格式数据结构
消息头:包含自身的状态数据,发送节点关键信息,如节点id、槽映等节点标识(主从角色,是否下线)等。
消息格式数据结构
消息体:
定义发送消息的数据。
消息体在Redis内部采用clusterMsgData结构声明:
通信消息处理流程
当接收到ping、meet消息时,接收节点会解析消息内容并根据自身的识别情况做出相应处理:
接收节点收到ping/meet消息时,执行解析消息头和
消息体流程:
• 解析消息头过程:消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。
• 解析消息体过程:如果消息体的clusterMsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据clusterMsgDataGossip中的flags字段判断该节点是否下线,用于故障转移。
消息处理完后回复pong消息,内容同样包含消息头和消息体,发送节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与接收节点最后信息时间,完成一次消息通信。
通信规则
Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。
• 通信节点选择过多可以让信息及时交换,但是成本过高;
• 通信节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。
节点选择
消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量。
(1)选择发送消息的节点数量
集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选择5个节点找出最久没有通信的节点发送ping消息,用于Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最后一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received> cluster_node_timeout/2),因此cluster_node_timeout参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大此参数。但是如果cluster_node_timeout过大会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。
(2)消息数据量
每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换。而消息体携带数据量跟集群的节点数量相关,集群越大每次消息通信的成本也就更高。
通信开销
Redis Cluster内节点通信自身开销:
(1)节点自身信息,主要是自己负责的slots信息:slots[CLUSTER_SLOTS/8],占用2KB;
(2)携带总节点1/10的其他节点的状态信息(1个节点的状态数据约为104byte)
注:并不是所有的都是携带十分之一的节点信息的。
如果total_nodes/10小于3,那就至少携带3个节点信息;
如果total_nodes/10大于total_nodes-2,最多携带total_nodes-2个节点信息;
Else就total_nodes/10个节点信息。
通信开销
节点状态信息:clusterMsgDataGossip,ping、meet、pong采用clusterMsgDataGossip数组作为消息体。
所以每个Gossip消息大小为2KB+total_nodes/10*104b
Redis Cluster带宽消耗主要为:业务操作(读写)消耗+Gossip消息消耗。
我们现在假设节点数为64*2=128,floor(122)=12:
每个Gossip消息的大小约为:2KB+12*104b ≈ 3KB。
根据之前的每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received> cluster_node_timeout/2)
假设:cluster_node_timeout为15秒时,num=20,即开销=3KB*(1+10*20)*2*20=25MB/s;
cluster_node_timeout为30秒时,num=5,即开销=3KB*(1+10*5)*2*20=6MB/s。
可以看出影响Gossip开销的主要两点:Cluster Redis的节点数和cluster_node_timeout设置的阈值:
那如果节点越多,Gossip消息就越大,最近接收pong消息时间间隔大于cluster_node_timeout/2秒的节点也会越多,那么带宽的开销越大。
所以得出如下结论:
(1)尽量避免大集群,针对大集群就拆分出去;
(2)如果某些场景必须使用大集群,那就可以通过增大cluster_node_timeout来降低带宽的消耗,但是会影响failover的时效,这个可以根据业务场景和集群具体状态评估;
(3)docker的分配问题,将大集群打散到小集群的物理机上,可以平衡和更高效的利用资源。