redis(六)Redis Cluster集群原理

一、数据分布

一、数据分布理论

分布式存储数据需要将整个数据集按照分区规则映射到多个节点上,每个节点负责一部分数据。

这里我们需要重点注意数据分区规则,常见的分区规则有hash分区、顺序分区。两种分区算法比较:

 redis cluster使用的是哈希分区规则,常见的hash分区规则有以下几种:
1、节点取余分区

获取key或者用户id的hash值,然后对redis节点数N做取余计数。即hash(key)%N  得到最终的值决定数据放到哪个节点上。

这种方案存在一个问题:如果对集群节点进行收缩或者扩容,此时所有的数据都需要重新进行计算,这样会导致数据重新迁移。

(这种方案比较简单,常用在数据库的分库分表规则,不过需要预先规划好数据量提前规划好分区数,当数据量达到一定程度再考虑迁移到其他数据库中)

2、一致性哈希分区

该实现思路是为系统每个节点分配一个token(范围在0~2^ 32 ),将这些token构成一个哈希环。数据读写的时候,先计算出key的hash值,先粗略得到该hash值在环中的哪个位置,然后顺时针遍历哈希环找到第一个大于hash(key)的环节点。

当需要扩容时,假如加入了node4节点。

这个时候就有问题了,如果key4在扩容前(加入node4前)就已经放进node3了,此时在扩容后,我们要来get(key4),此时根据一致性hash规则,我们应该去node4获取这个数据,而此时key4却是存在node3的。所有key4就获取不到了.

这就是一致性哈希分区规则的弊端,在删除和加入节点是会影响到附近的节点(相间的节点不会影响)。总结一下该规则的一些问题:
1)、加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。
2)、当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。
3)、普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。

虽然一致性哈希分区规则一定程度优化了节点取余分区规则,但还是远远不够。

3、虚拟槽分区

虚拟槽分区使用分散度良好的哈希函数将数据映射到对应的数据槽中,而不是映射到对应的节点。redis会根据节点个数给每个节点分配相应的数据槽,定位时,使用哈希函数(例如CRC16)计算出key对应哪个数据槽(例如redis中 slot=CRC16(key)&16383),然后获取该槽对应的节点就可以去直接访问了。(虚拟槽只是一个逻辑的概念,虚拟存在的)

当节点扩容时,重新给各个节点分配自己需要负责的槽即可。(虽然也是存在数据迁移,但是以槽为单位进行迁移,降低了扩容收缩难度)

二、redis的数据分区

redis采用的就是上面的虚拟槽分区规则。

我们假设现在有5个节点,此时的16384数据槽分配如下:

 我们如果要get一个key,此时先对key做哈希函数运算,然后再对16383进行取余,就可以得到相应的槽位:

 该分区规则特点:

1)、解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
2)、节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
3)、支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

(对于扩容收缩是怎么运行的后面会讲)(这里拿到槽位后是根据槽位和节点的映射信息找到相应节点去获取数据的???)

三、集群功能限制

redis集群相对于单机做了一些限制,具体如下:

1)key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mget、mget等操作可能存在于多个节点上因此不被支持。
2)key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
3)key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点。
4)不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0。
5)复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

二、搭建集群(去中心化)

一、准备节点

redis集群的节点至少6个节点才能保证组成完整的高可用集群。每个节点都需要开启配置cluster-enabled  yes(开启集群模式)。

1、下载redis

​wget http://download.redis.io/releases/redis-5.0.0.tar.gz
tar xzf redis-5.0.0.tar.gz
cd redis-5.0.0
make

创建集群节点要存放配置文件的目录

mkdir  /root/software/redis/redis-cluster-conf/6379

mkdir   /root/software/redis/redis-cluster-conf/6380

。。。

mkdir   /root/software/redis/redis-cluster-conf/6384

依次创建6个节点

去redis中拿到redis.conf配置文件,然后分别给这六个目录中拷贝一份进去。

然后修改各个节点的配置文件:(其中6379在其他节点用自己的端口代替)

port 6379  #端口
cluster-enabled yes #启用集群模式
cluster-config-file nodes-6379.conf  #集群配置文件
cluster-node-timeout 5000 #超时时间
appendonly yes
daemonize yes #后台运行
pidfile  /var/run/redis_6379.pid

进入到redis目录下的src中开启各个redis(指定相应的配置文件)

./redis-server   /root/software/redis/redis-cluster-conf/6379/redis.conf

依次开启6个节点。

如果配置了开启集群,则第一次启动redis时如果没有集群配置文件,则会创建cluster-config-file参数配置的集群配置文件。(这里可以用端口区分开来,防止多个节点文件覆盖)。节点会使用配置文件内容初始化集群信息。启动过程如下:

当集群内节点信息发生变化时,如添加节点、节点下线、故障转移时,都会将相应的信息保存到配置文件中(这个过程是redis自动维护的,尽量不要手动修改,防止加载时信息错乱)

虽然我们现在开启了6个节点,但是这6个节点并不知道其他节点的存在,所有我们需要通过节点握手让6个节点建立联系,从而组成一个集群。

二、节点握手

节点握手:即一批运行在集群模式下的节点通过Gossio协议进行通信,从而感知对方的过程。

我们可以在客户端输入命令:cluster meet{ip}{port}  例如:(6379去连接6380)

 cluster meet是一个异步命令,发送完立刻返回。然后再由两节点进行握手通信,具体通信步骤:
1)、6379在本地创建6380节点的信息对象,并发送meet消息给6380

2)、6380接到meet消息后保存6379的节点信息并回复pong消息

3)、之后两节点会定期通过ping/pong 通信确保两节点的正常通信。

这里的meet、ping、pong的作用都是为了节点之间交换状态信息。

这样6379和6380就在同一个集群中了

 这个时候我们想向这个集群中添加一个新的节点。也可以直接使用cluster  meet命令。例如加入6381节点:(依次加入)

127.0.0.1:6379>cluster meet 127.0.0.1 6381

 然后我们输入查看集群的节点信息:

127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 master - 0 1468073975551
5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 master - 0 1468073978579
4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 master - 0 1468073980598
3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468073974541
1 connected
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468073979589
2 connected

这个时候6个节点通过通信都能知道各自的存在。但这个时候该集群还是处于下线状态,所有的读写都是被禁止的。通过cluster info命令可以获取集群当前状态:

127.0.0.1:6379> cluster info
cluster_state:fail
cluster_slots_assigned:0
cluster_slots_ok:0
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:0
...

从输出内容可以看到,被分配的槽(cluster_slots_assigned)是0,由于目前所有的槽没有分配到节点,因此集群无法完成槽到节点的映射。只有当16384个槽全部分配给节点后,集群才进入在线状态。

三、分配槽

上面6个节点进行了握手通信,虽然6个节点处于同一个集群下了,但该集群还没有进行槽的分配。

可以通过cluster addslots命令为节点分配槽。这里利用bash特性批量设置槽(slots),命令如下:

redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0...5461}
redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5462...10922}
redis-cli -h 127.0.0.1 -p 6381 cluster addslots {10923...16383}

此时16384个slot平均分配给6379、6380、6381三个节点。执行cluster info查看集群状态,如下所示:

127.0.0.1:6379> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:5
cluster_my_epoch:0
cluster_stats_messages_sent:4874
cluster_stats_messages_received:4726

当前集群状态是OK,集群进入在线状态。所有的槽都已经分配给节点,执行cluster nodes命令可以看到节点和槽的分配关系:

127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 master - 0 1468076240123
5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-5461
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 master - 0 1468076239622
4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 master - 0 1468076240628
3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468076237606
579
 1 connected
5462-10922
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468076238612
2 connected
10923-16383

目前还有三个节点没有使用,作为一个完整的集群,每个负责处理槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。集群模式下,Reids节点角色分为主节点和从节点。首次启动的节点和被分配槽的节点都是主节点,从节点负责复制主节点槽信息和相关的数据。使用cluster  replicate{nodeId}命令让一个节点成为从节点。其中命令执行必须在对应的从节点上执行,nodeId是要复制主节点的节点ID,命令如下:

127.0.0.1:6382>cluster replicate cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
127.0.0.1:6383>cluster replicate 8e41673d59c9568aa9d29fb174ce733345b3e8f1
OK
127.0.0.1:6384>cluster replicate 40b8d09d44294d2e23c7c768efc8fcd153446746
OK

此时主从也配置好了。

最后整个集群的结构如下:

四、使用redis-cli进行集群配置(自动通信、自动分配槽、自动主从配置)

当我们为每个节点配置好配置信息,并开启相应的redis节点后。如果是上面的方式此时要进行通信、分配槽、主从配置等。而在redis5版本,则提供了redis-cli --cluster create  {各节点ip+端口}进行集群的配置。

此时我们只需要在各个节点配置好配置文件并开启后,执行以下命令:

/root/software/redis/redis-5.0.0/src/redis-cli --cluster create 192.168.2.40:7001 192.168.2.40:7002 192.168.2.40:7003 192.168.2.40:7004 192.168.2.40:7005 192.168.2.40:7006 --cluster-replicas 1

 其中写入各个节点的ip+port,--cluster-replicas 1 表示一个副本,即一个主节点对应一个从节点。

三、节点通信

一、通信流程

在分布式存储中需要有维护节点元数据信息的机制。

元数据:即各个节点负责哪些数据,是否出现故障等状态信息。

常见的元数据维护方式有集中式和p2p方式。redis集群采用的是p2p的Gossip(流言)协议,该协议的原理就是各节点间进行不断通信交换各自的信息。这样一段时间后所有节点都会知道集群完整的信息,这种方式类似于流言传播。

通信传播过程:

1)、集群里的每个节点会单独开辟一个TCP通道,用于节点间的通信(通信端口是基础端口+10000)

2)、每个节点会周期性的通过特定规则选择几个节点发送ping消息。

3)、节点接收到ping消息后就会回复pong消息。

集群每个节点都是通过一定规则挑选要通信的节点,所以每个节点可能知道所有节点的信息,也可能知道部分节点的信息。但是只要时间充足且能正常通信,最后还是能达到所有节点的信息一致的状态。当有新节点加入、节点出故障、主从角色改变、槽信息改变时通过不断地ping/pong,一段时间后所有节点都会知道集群地最新状态。

二、Gossip消息

Gossip协议主要负责的是信息交易。各节点发送的就是Gossip消息。

常见的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息。他们各自的通信模式如下:

 meet消息:用于通知新节点加入,meet消息通信正常后,新节点会加入到集群中进行周期性的ping、pong消息交换。

ping消息:最常用的消息。用于检测各节点是否在线和彼此状态信息。ping消息中封装了自身节点+部分其他节点的信息。

pong消息:当接收到ping、meet消息后,会响应pong消息表示正常通信,pong消息中封装了自身状态信息等数据。(节点可以向集群广播自身的pong消息,从而达到整个集群对自身状态信息的更新)

fail消息:当节点判定集群内某个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息后会将对应的节点信息更新为下线状态。

所有的消息格式都有消息头和消息体。

1、消息头

包含节点状态数据。(接收节点就是根据消息头获取发送节点的相关数据的),结构如下:

typedef struct {
char sig[4]; /* 信号标示 */
uint32_t totlen; /* 消息总长度 */
uint16_t ver; /* 协议版本 */
uint16_t type; /* 消息类型 , 用于区分 meet,ping,pong 等消息 */
uint16_t count; /* 消息体包含的节点数量,仅用于 meet,ping,ping 消息类型 */
uint64_t currentEpoch; /* 当前发送节点的配置纪元 */
uint64_t configEpoch; /* 主节点 / 从节点的主节点配置纪元 */
uint64_t offset; /* 复制偏移量 */
char sender[CLUSTER_NAMELEN]; /* 发送节点的 nodeId */
unsigned char myslots[CLUSTER_SLOTS/8]; /* 发送节点负责的槽信息 */
char slaveof[CLUSTER_NAMELEN]; /* 如果发送节点是从节点,记录对应主节点的 nodeId */
uint16_t port; /* 端口号 */
uint16_t flags; /* 发送节点标识 , 区分主从角色,是否下线等 */
unsigned char state; /* 发送节点所处的集群状态 */
unsigned char mflags[3]; /* 消息标识 */
union clusterMsgData data /* 消息正文 */;
} clusterMsg;

2)、消息体

其中的消息正文(消息体)采用的都是clusterMsgData结构:

union clusterMsgData {
    /* ping,meet,pong 消息体 */
    struct {
        /* gossip 消息结构数组 ,可以存放多个clusterMsgDataGossip */
        clusterMsgDataGossip gossip[1];
     } ping;

    /* FAIL 消息体 */
    struct {
        clusterMsgDataFail about;
    } fail;
 // ...
};

每个消息体可以报告该节点的多个clusterMsgDataGossip结构数据(可以是新的节点也可以是旧的节点),该数据结构如下:

typedef struct {
char nodename[CLUSTER_NAMELEN]; /* 节点的 nodeId */
uint32_t ping_sent; /* 最后一次向该节点发送 ping 消息时间 */
uint32_t pong_received; /* 最后一次接收该节点 pong 消息时间 */
char ip[NET_IP_STR_LEN]; /* IP */
uint16_t port; /* port*/
uint16_t flags; /* 该节点标识 , */
} clusterMsgDataGossip;

最后接收节点的具体解析流程:

 先解析消息头,如果消息头的类型是meet类型,则将新节点信息加入到本地列表,是已知节点则更新状态。

解析消息体,看是否存在自己未知的新节点,有则自动发起meet消息和新节点进行通信,没有则看旧节点的flags是否下线(用于故障转移)。

处理完上面则回复pong消息,内容同样包含消息头和消息体。其他节点接收到pong消息后采用同样的流程进行处理,并更新于节点的最后通信时间,这样就完成了一次消息通信。

三、节点选择

        Gossip虽然具有分布式特性,但是它是有成本的。内部的节点需要进行频繁的节点信息交换。而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。reidis集群内节点通信采用定时任务(每秒执行10次)。

        因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。因此Redis集群的Gossip协议需要兼顾信息交换实时性和成本开销,通信节点选择的规则如图:

 从图可以看出该通信成本主要体现在单位时间内要发送的节点数和每个消息携带的数据量。

1、选择发送消息的节点数量

每秒执行10次,每秒会随机选出5个节点然后选择最久没通信的节点发送ping消息。每100毫秒会扫描本地节点列表,如果发现有节点最后一次接收pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息(防止该节点消息长时间未更新)。

所有一秒内需要发送ping消息的节点数为:1+10*num(node.pong_received>cluster_node_timeout/2)。因此cluster_node_timeout参数对消息发送的节点数量影响是非常大的。当我们的带宽资源紧张时,可以适当调大该值。例如默认15秒我们可以改为30秒。如果调的太大会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。

2、消息数据量

消息数据量体现在消息头+消息体。而消息头主要占用空间的字段是                                myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。

而消息体携带的一定数量的其他节点的信息,具体数量看下面的伪代码:

def get_wanted():
    int total_size = size(cluster.nodes)
    # 默认包含节点总量的 1/10
    int wanted = floor(total_size/10);
    if wanted < 3:
        # 至少携带 3 个其他节点信息
        wanted = 3;
    if wanted > total_size -2 :
        # 最多包含 total_size - 2 个
        wanted = total_size - 2;
    return wanted;

可以看出消息体携带数据量跟集群的节点数息息相关。

四、集群伸缩

一、伸缩原理

redis集群中提供了灵活的节点扩容和收缩方案。在不影响集群使用的情况下,可以为集群添加节点进行扩容缩容。

 其中就是将数据槽在不同节点之间灵活移动,例如我现在是三个节点分摊数据槽:

现在我再往其中加入一个节点,此时就要给新节点分配一些槽:

 

 集群伸缩=槽和数据在节点之间的移动

二、扩容集群

扩容是分布式存储最常见的需求。

redis集群的扩容操作主要分为:准备新节点、加入集群、迁移槽和数据。

1、准备新节点

需要提前将新节点以集群模式开启(新节点的配置建议和集群里的节点的配置保存一致,这样便于管理)。开启后的节点是还没有和其他节点进行通信的。

2、加入集群

新节点使用cluster  meet加入集群中(在已经在集群里的节点的客户端发送meet给服务端进行新节点通信)。通过一段时间的ping/pong消息通信后,所有节点都会将新节点的信息保存起来。

刚开始的新节点都是主节点状态,但由于此时只是进行通信还没有进行槽的分配,所以不能进行读写操作。对于新节点我们的后续操作可以有两种选择:
1)、给他分配槽和数据,从而实现扩容

2)、作为其他主节点的从节点,负责故障转移。

也可以使用redis-cli --cluster命令完集群中添加主节点、从节点操作。(redis-cli会将后面的槽和数据都一起迁移了。)

3、迁移槽和数据

如果上面通信使用的是cluster meet而不是使用redis-cli  cluster,那么此时就还需要进行槽和数据的迁移。这个过程也是扩容中最核心的步骤。

3-1、槽迁移计划

槽是redis集群管理数据的基本单位,槽迁移之前先要为新节点制定槽的迁移计划,确定原有节点的哪些槽需要迁移到新节点。迁移计划需要确定每个节点负责类似数量的槽,从而保证个节点的数据均匀。

3-2、迁移数据(添加主节点时需要进行数据迁移)

前面确定好哪些槽要迁移到新节点后,此时就要进行真正的数据迁移,数据迁移的过程是逐个槽进行的。每个槽的迁移流程如下:

1)、对目标节点(新节点)发送cluster setslot {slot} importing{sourceNodeId}命令(即告诉新节点要准备下,要进行某个槽数据导入了)

2)、对源节点(原先集群中的节点)发送cluster setslot {slot} migrating {targetNodeId}命令(告诉旧节点准备下,要进行某个槽数据的迁移了)

3)、源节点(旧节点)循环执行cluster getkeysinslot {slot} {count}命令,获取该槽里键的数量。

4)、在源节点(旧节点)执行migrate {targetIp} {targetPort} ""0 {timeout} keys {keys...}命令,把获取到的键通过流水线(pipline)机制批量迁移到新节点上。(批量迁移版本的migrate命令在Redis3.0.6以上版本提供)。

5)、重复执行执行3)和4),直到把槽里的数据都迁移过去了。

6)、向集群里的所有主节点发送luster setslot{slot}node{targetNodeId}命令,通知这个槽已经分配完成了。这样其他主节点进行数据更新。

3-3、 添加从节点

注意集群下不支持slaveof添加从节点操作。所以可以使用cluster replicate或者redis-cli --cluster进行配置从节点。具体可以百度一下。

三、收缩集群

收缩集群意味着有节点下线,此时下线的流程如下:

 流程如下:

1)、首先确认下线节点是否有负责的槽,如果有则进行槽数据迁移(迁移流程和上面的类似),然后再通知集群内的其他节点进信息更新(忘记节点)

2)、如果没有负责的槽或者该节点是从节点时,此时直接通知集群里的其他节点进行信息更新(忘记节点)。当所有节点更新完毕后则该节点可以正常关闭。

1、下线迁移槽

就是把自己负责的槽迁移到其他节点,原理和之前的一致。

2、忘记节点

数据迁移完后,此时要实现其他节点忘记该节点,此时redis中提供了cluster forget{downNodeId}命令实现该功能。如图:

当节点 接收到命令后,则把nodeId直到的节点加入到禁用列表中。在禁用列表内的节点不会再进行Gossip消息发送。禁用列表的有效期时60秒,超过60秒会再次进行消息传递。也就是60秒内让所有节点都忘记节点(但一般不这么做,而是使用redis-cli cluster进行下线操作,这样会自动进行后续的忘记节点、下线等操作)。等所有节点都忘记节点了,此时就可以将该节点安全下线。

注意:如果下线的主节点,除了要进行数据迁移外,此时还需要对从节点先进行下线操作,不然从节点会以为是故障转移在主节点下线后晋升为主节点,而且这样也可以避免不必要的全量复制。

五、请求路由

上面我们已经讲解了搭建redis集群并了解了集群节点通信和伸缩的细节。但是还没有使用客户端去操作集群。redis集群对客户端协议做了比较大的修改,为了追求性能最大化并没有采用代理的方式,而是采用客户端直连节点的方式。(所以要从单机切换到集群要修改客户端代码)。现在我们来看看请求集群节点时的路由细节,看看客户端如何高效的操作集群的。

一、请求重定向

集群模式下,redis任何节点接收到相关命令都先计算键对应的槽,再根据槽找出对应的节点。如果节点是自身则直接处理命令,否则则回复MOVED重定向错误(该错误信息会包含正确的节点槽、ip、端口等信息),通知客户端请求正确的节点。(该过程称为MOVED重定向)。

例如我们现在往一个不对应的节点发送命令:

127.0.0.1:6379> set key:test:2 value-2
(error) MOVED 9252 127.0.0.1:6380

此时在低版本时就需要进行重定向,再往正确的节点发送命令。而redis中提供了redis-cli  -c

,其中-c参数表示支持自动重定向,这样就简化了手动重定向的操作。如下:

#redis-cli -p 6379 -c
127.0.0.1:6379> set key:test:2 value-2
-> Redirected to slot [9252] located at 127.0.0.1:6380
OK

节点对于不属于它的键的命令只回复重定向响应,并不负责转发。正因为集群模式下重定向的过程放在客户端完成,所以集群客户端协议相对于单机有了很大的变化。

(cluster keyslot命令可以查看键处于哪个槽中)

键命令执行步骤主要分两步执行:
1、计算槽

使用CRC16函数计算出键的散列值,再对16383进行取余。

再集群模式下,使用mget等批量调用命令时,如果键类比不属于同一个槽是会报错的。而我们可以利用hash_tag让不同键具有相同的slot达到优化的目的。例如:
第一个我们没有使用hash_tag,第二个加了两个{}就达到了两个键处于同一slot的效果。

127.0.0.1:6385> mget user:10086:frends user:10086:videos
(error) CROSSSLOT Keys in request don't hash to the same slot
127.0.0.1:6385> mget user:{10086}:friends user:{10086}:videos
1) "friends"
2) "videos"

具体原因我们看看CRC16函数的伪代码:
可以看出如果键中包含‘{’或者‘}’,则使用{}中间的值进行CRC16函数的计算,即两个键都使用10086进行计算,所以最后得到的槽位是一样的。(注意存取都要用{}才能达到存取槽位一致)

def key_hash_slot(key):
    int keylen = key.length();
    for (s = 0; s < keylen; s++):
        if (key[s] == '{'):
        break;
    if (s == keylen) return crc16(key,keylen) & 16383;
    for (e = s+1; e < keylen; e++):
        if (key[e] == '}') break;
        if (e == keylen || e == s+1) return crc16(key,keylen) & 16383;
    /* 使用 { 和 } 之间的有效部分计算槽 */
    return crc16(key+s+1,e-s-1) & 16383;

这样虽然可以达到批量操作时,获取的键在相同的槽上,但也存在一些问题,即会出现各个槽的数据分布不均匀。想想本身redis要求得就是要将数据均匀的分配到各个槽中,现在为了批量操作,将一些数据集中存放在某个槽上,这样的操作是相逆的。

(集群中Pipeline同样是收益于hash_tag,由于Pipeline只能向一个节点批量发送执行命令,而相同slot必然会对应到唯一的节点,降低了集群使用Pipeline的门槛。)

2、槽节点查找

redis计算出键相应的槽后,就需要查找槽对应的节点。此时因为消息交换,每个节点都有所有节点的槽信息,内部保存在节点本地的clusterState结构中。此时节点就会去根据这些信息查看是否是本身节点,是则执行,不是则回复moved错误。具体伪代码:

def execute_or_redirect(key):
    int slot = key_hash_slot(key);
    ClusterNode node = slots[slot];
    if(node == clusterState.myself):
        return executeCommand(key);
    else:
        return '(error) MOVED {slot} {node.ip}:{node.port}';

二、Smart客户端

1)、Dummy(傀儡)客户端

客户端可以向集群的任一个节点获取键所在节点,这种客户端叫Dummy(傀儡)客户端,优点是代码实现简单,对客户端协议影响小,只需要根据重定向消息再次发送请求即可。

但这种客户端的弊端也很明显,每次执行命令前要先发送命令获取到重定向信息才能找到所在节点进行重定向。额外的增加了IO开销。这会使得性能下降。所以通常集群客户端都采用另一种实现:Smart(智能)客户端。

2)、Smart(智能)客户端

2-1)、smart客户端原理

Smart客户端中维护者slot——node的映射关系,在本地就可以实现查看键所在节点,从而减少了查询节点的IO消耗,而MOVED错误用于更新客户端本地缓存(如果本地缓存没更新,此时请求过去就会收到MOVED错误,此时就进行本地更新以便后面的请求正确到达指定节点)。

我们以Jedis客户端为例,看看大概是怎么实现Smart客户端的:
1、在jedis中的smart实现是JedisCluster。首先JedisCluster在初始化时会选择一个运行节点,然后初始化槽和节点的映射关系。其中是使用cluster slots命令完成的。

2、JedisCluster解析获取到的结构缓存到本地,然后为每个节点创建唯一的JedisPool连接池。槽和节点的信息被存到JedisClusterInfoCache类中,具体结构如下:

public class JedisClusterInfoCache {
private Map<String, JedisPool> nodes = new HashMap<String, JedisPool>();
private Map<Integer, JedisPool> slots = new HashMap<Integer, JedisPool>();
...
}

3、当客户端要执行命令时,JedisCluster的具体执行流程如下:

 具体流程:
1、计算出键对应slot并根据slots缓存获取目标节点进行连接,然后发送命令。

2、如果出现连接错误,则随机找个节点重新执行键命令(每次重试对redis-rections减1)

3、在上面的步骤中如果捕获到MOVED重定向错误,则会更新客户端本地的缓存(renewSlotCache方法)。

4、重复1-3步,直到命令执行成功,或者当redis-rections小于等于0时抛出Jedis ClusterMaxRedirectionsException异常

实现的部分代码

 smart客户端也存在一些问题:
1、客户端内部维护slots缓存表,并且针对每个节点维护连接池,当集群规模非常大时,客户端会维护非常多的连接并消耗更多的内存。

2、使用Jedis操作集群时最常见的错误是:

throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections");

而实际的错误是节点宕机或请求超时都会抛出JedisConnectionException,导致触发了随机重试,当重试次数耗尽抛出这个错误。

3、当出现JedisConnectionException时,Jedis认为可能是集群节点故障需要随机重试来更新slots缓存,因此了解哪些异常将抛出JedisConnectionException变得非常重要。有以下几种情况会抛出JedisConnectionException异常:

  • Jedis连接节点发生socket错误时抛出。
  • ·所有命令/Lua脚本读写超时抛出。
  • ·JedisPool连接池获取可用Jedis对象超时抛出

前两点都可能是节点故障需要通过JedisConnectionException来更新slots缓存,但是第三点没有必要,因此Jedis2.8.1版本之后对于连接池的超时抛出Jedis Exception,从而避免触发随机重试机制。

4、redis集群支持自动故障转移,但是从发现故障到完成转移需要一定时间,在这段时间该节点的所有命令都会触发随机重试机制,每次收到MOVED都会调用JedisClusterInfoCache类的renewSlotCache方法,该方法的部分代码如下:

从代码可以看到, 进行更新的时候是加了写锁的,那此时其他线程有其他命令进来就需要来调用getSlotPool(这个方法是要加读锁的)(不同线程读写锁是互斥的),但此时被前面的线程加了写锁。所以此时新进来的线程就会被阻塞起来等待前面的写锁释放。这种更新本地缓存时阻塞其他线程执行命令的现象在高并发常见会极大的影响集群吞吐。这种现象被称为cluster slots风暴。出现cluster slots风暴会出现以下现象:

1、重试机制导致IO通信放大问题。比如默认重试5次的情况,当抛出
JedisClusterMaxRedirectionsException异常时,内部最少需要9次IO通信:5次发送命令+2次ping命令保证随机节点正常+2次cluster slots命令初始化slots缓存。导致异常判定时间变长。

2、个别节点操作异常导致频繁的更新slots缓存,多次调用cluster slots命令,高并发时将过度消耗Redis节点资源,如果集群slot<->node映射庞大则cluster slots返回信息越多,问题越严重。
3、频繁触发更新本地slots缓存操作,内部使用了写锁,阻塞对集群所有的键命令调用。

针对上面的问题Jedis2.8.2版本进行了改进优化:
1、不再轻易初始化slots缓存(即在一定条件下再去调用renewSlotCache方法)。

具体伪代码:

 根据代码看出,只有当重试次数到最后1次或者出现MovedDataException时才更新slots操作,降低了cluster slots命令调用次数。

2、当更新slots缓存时,不再使用ping命令检测节点活跃度,并且使用rediscovering变量保证同一时刻只有一个线程更新slots缓存,其他线程忽略,优化了写锁阻塞和cluster slots调用次数。伪代码如下:

 综上所述,Jedis2.8.2之后的版本,当出现JedisConnectionException时,命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了cluster slots不必要的并发调用。

建议升级到Jedis2.8.2以上版本防止cluster slots风暴和写锁阻塞问题,但是笔者认为还可以进一步优化,如下所示:
1、执行cluster slots的过程不需要加入任何读写锁,因为cluster slots命令执行不需要做并发控制,只有修改本地slots时才需要控制并发,这样降低了写锁持有时间。(减少锁粒度)
2、当获取新的slots映射后使用读锁跟老slots比对,只有新老slots不一致时再加入写锁进行更新。防止集群slots映射没有变化时进行不必要的加写锁行为。(更新前比较是否存在差异)。


2-2)、Smart客户端——JedisCluster使用

1、JedisCluster的定义

Jedis提供的Smart客户端对应的类就是JedisCluster,其初始化方法如下:

public JedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int
soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) {
...
}

其中有五个参数:

Set<HostAndPort>jedisClusterNode:所有Redis Cluster节点信息(也可以是一部分,因为客户端可以通过cluster slots自动发现)。

int connectionTimeout:连接超时。
int soTimeout:读写超时。
int maxAttempts:重试次数。
GenericObjectPoolConfig poolConfig:连接池参数,JedisCluster会为Redis Cluster的每个节点创建连接池。

例如下面就是一次JedisCluster初始化的过程:

// 初始化所有节点 ( 例如 6 个节点 )
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("10.10.xx.1", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.2", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.3", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.4", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.5", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.6", 6379));
// 初始化 commnon-pool 连接池,并设置相关参数
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 初始化 JedisCluster
JedisCluster jedisCluster = new JedisCluster(jedisClusterNode, 1000, 1000, 5, poolConfig);

初始完后,我们就可以使用jediscluster对象来操作集群了。例如:

jedisCluster.set("hello", "world");
jedisCluster.get("key");

sprin-data-redis的客户端兼容了单机和集群的操作(即单机和集群的操作方法都一样),使用起来比jedis对集群的操作的简单。

对于JedisCluster的使用需要注意以下几点:

  • ·JedisCluster包含了所有节点的连接池(JedisPool),所以建议JedisCluster使用单例。
  • ·JedisCluster每次操作完成后,不需要管理连接池的借还,它在内部已经完成。
  • ·JedisCluster一般不要执行close()操作,它会将所有JedisPool执行destroy(销毁)操作。

2、jediscluster进行多节点命令和操作

Redis Cluster虽然提供了分布式的特性,但是有些命令或者操作,诸如keys、flushall、删除指定模式的键,需要遍历所有节点才可以完成。下面代码实现了从Redis Cluster删除指定模式键的功能:

 具体流程:

1)通过jedisCluster.getClusterNodes()获取所有节点的连接池。
2)使用info replication筛选1)中的主节点。
3)遍历主节点,使用scan命令找到指定模式的key,使用Pipeline机制删除。

3、批量操作的方法
Redis Cluster中,由于key分布到各个节点上,会造成无法实现mget、mset等功能。但是可以利用CRC16算法计算出key对应的slot,以及Smart客户端保存了slot和节点对应关系的特性,将属于同一个Redis节点的key进行归档,然后分别对每个节点对应的子key列表执行mget或者pipeline操作。

4、使用Lua、事务等特性的方法

Lua和事务需要所操作的key,必须在一个节点上,不过Redis Cluster提供了hashtag,如果开发人员确实要使用Lua或者事务,可以将所要操作的key使用一个hashtag,如下所示:

(就我个人而言,比较喜欢使用spring-data-redis的客户端,该客户的连接是安全的(jedis是要用连接池才能是连接安全的),并且spring-data-redis中对单机和集群的API调用都是兼容一样的,不像jedis单机和集群的操作不一致)

 三、ASK重定向

1、客户端ASK重定向流程

ASK重定向,即当集群进行水平伸缩时,会发生槽的数据迁移。在迁移过程中,此时可能部分数据在新节点,部分数据在旧节点。而这个时候又刚刚好有该槽的键命令操作。发生上述情况时,客户端键命令执行流程会发生下面改变:
1)、客户端首先根据本地缓存发生命令到指定节点,如果存在则执行并返回结果

2)、如果不存在,则此时可能数据就被转移到新节点了,此时旧节点会返回ASK重定向异常。格式如下:(error)ASK{slot}{targetIP}:{targetPort}。

3)、客户端从ASK重定向异常获取新节点信息,发生asking命令到新节点打开客户端连接标识,然后发送命令给新节点,存在则执行命令,不存在则返回不存在信息。

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

2、节点内部处理

为了支持ASK重定向,新节点和旧节点在内部的clusterState结构中维护了当前正在迁移的槽信息,用于识别槽迁移情况。结构如下:
 

typedef struct clusterState {
clusterNode *myself; /* 自身节点 /
clusterNode *slots[CLUSTER_SLOTS]; /* 槽和节点映射数组 */
clusterNode *migrating_slots_to[CLUSTER_SLOTS];/* 正在迁出的槽节点数组 */
clusterNode *importing_slots_from[CLUSTER_SLOTS];/* 正在迁入的槽节点数组 */
...
} clusterState;

节点每接到键命令,都会进行以下处理:

1)、如果所在槽在当前节点上,但键不存在,则去migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向。

2)、如果客户端发送asking命令则打开CLIENT_ASKING标识,则该客户端下次发送键命令时查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令。

(需要注意的是,asking命令是一次性命令,每次执行完后客户端标识都会修改回原状态,因此每次客户端接收到ASK重定向后都需要发送asking命令。)

(批量操作。ASK重定向对单键命令支持得很完善,但是,在开发中我们经常使用批量操作,如mget或pipeline。当槽处于迁移状态时,批量操作会受到影响,因为可能键列表存在不同节点上)

3)、如果现在执行的是Pipline且键处于不同节点。此时不会直接抛出异常,而是返回一段信息(其中包括ASK的信息)例如:

redis.clients.jedis.exceptions.JedisAskDataException: ASK 4096 127.0.0.1:6379
redis.clients.jedis.exceptions.JedisAskDataException: ASK 4096 127.0.0.1:6379
value:5028

结果分析:返回结果并没有直接抛出异常,而是把ASK异常JedisAskDataException包含在结果集中。但是使用Pipeline的批量操作也无法支持由于slot迁移导致的键列表跨节点问题。

综上所处,使用smart客户端批量操作集群时,需要评估mget/mset、Pipeline等方式在slot迁移场景下的容错性,防止集群迁移造成大量错误和数据丢失的情况。

        集群环境下对于使用批量操作的场景,建议优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操作的IO优化,又可以兼容slot迁移场景。

六、故障转移

一、故障发现

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

1、主观下线:某个节点认为另一个节点不可用了(下线)(只是单个节点的主观判断,可能存在误判的情况)

集群里的节点间会周期性的进行通信,如果在cluster-node-timeout时间内一直通信失败,则发送节点会认为接收节点存在故障,然后把该节点标记为主观下线状态(pfail)(更改本地的节点信息数据)。(通过往节点的故障链表中加数据)

2、客观下线:多个节点认为某个节点不可用(多个节点的客观判断达成的共识结果,此时次才是真正的下线)

当某个节点被标记为主观下线后,后面随着周期性的通信,该节点的主观下线信息会被其他节点接收到,此时其他节点会将该节点也标记为主观下线状态(通过往节点的故障链表/下线报告链表中加数据)。

就这样直到集群里有半数以上主节点都标记某个节点是主观下线时,会触发客观下线流程。这里有两个问题需要注意:

(1)为什么时主节点参与故障发生决策?

因为只要主节点才负责读写请求和集群槽等关键信息的维护,而从节点只进行主节点数据和状态信息的复制。

(2)为什么要半数以上?

是为了应付网络分区等原因造成的集群分割情况。被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务。

触发客观下线的流程:

1)当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
2)找到pfail对应的节点结构,更新clusterNode内部下线报告链表(故障链表)。
3)根据更新后的下线报告链表告尝试进行客观下线。 


下面对于维护下线报告链表和尝试下线逻辑进一步分析:
1、维护下线报告列表

每个节点中都存在一个下线列表结构,用于保存其他节点的下线报告,结构如下:

typedef struct clusterNodeFailReport {
    struct clusterNode *node; /* 报告该节点为主观下线的节点 */
    mstime_t time; /* 最近收到下线报告的时间 */
} clusterNodeFailReport;

当接收到fail状态时,会维护对应节点的下线报告链表,每个下线报告节点都有有效期,每次尝试触发客观下线时,都会检测下各个节点是否过期,过期则会被删除(有限期是server.cluster_node_timeout*2)。

这个有限期是为了防止故障误报情况,例如节点A在上一小时报告节点B主观下线,但是之后又恢复正常。现在又有其他节点上报节点B主观下线,根据实际情况之前的属于误报不能被使用。

如果在cluster-node-time*2时间内无法收集到一半以上槽节点的下线报告,那么之前的下线报告将会过期,也就是说主观下线上报的速度追赶不上下线报告过期的速度,那么故障节点将永远无法被标记为客观下线从而导致故障转移失败。因此不建议将cluster-node-time设置得过小。

2、尝试客观下线

集群中的节点每次接收到其他节点的pfail状态,都会尝试触发客观下线。流程:

如果确定为客观下线,则向集群 广播一条fail消息,通知所有的节点将故障节点标记为客观下
线,fail消息的消息体只包含故障节点的ID。

广播fail消息是客观下线的最后一步,它承担着非常重要的职责:

  • ·通知集群内所有的节点标记故障节点为客观下线状态并立刻生效。
  • ·通知故障节点的从节点触发故障转移流程。

需要理解的是,尽管存在广播fail消息机制,但是集群所有节点知道故障节点进入客观下线状态是不确定的。比如当出现网络分区时有可能集群被分割为一大一小两个独立集群中。大的集群持有半数槽节点可以完成客观下线并广播fail消息,但是小集群无法接收到fail消息。如图:

 但是当网络恢复后,只要故障节点变为客观下线,最终总会通过Gossip消息传播至集群的所有节点。

        络分区会导致分割后的小集群无法收到大集群的fail消息,因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移,因此部署主从结构时需要根据自身机房/机架拓扑结构,降低主从被分区的可能性。

二、故障恢复

(从节点也会进行通信??不同组的从节点会和其他组的主从节点通信??从节点故障下线???还是说每组主从结构的从节点是隔离的,从节点只和自己组的主从通信,不和其他组的主从通信????)(个人认为集群里的节点都是会进行Goosip协议进行互相通信,如果是从节点故障则走上面的发现故障客观下线即可,如果是主节点且该节点有负责的槽,此时则主要进行故障恢复选举新的主节点)

故障的节点被客观下线后,如果此时下线的是持有槽的主节点(集群里的主从节点都会进行通信,从节点故障则执行下线即可,主节点下线才需要进行故障恢复/故障转移),那么此时

需要进行故障转移,则需要从它的从节点中选出一个来替换它。当从节点通过内部定时任务发现自身复制的主节点进入客观下线,此时就会触发故障恢复流程。如下:

1、资格检查

每个节点会检查最后和主节点断线时间,如果断线时间超过cluster-node-time*cluster-slave-
validity-factor,则该节点没资格参与选举。(参数cluster-slave-validity-factor用于从节点的有效因子,默认为10。)

2、准备选举时间

当有从节点恢复故障转移资格后,会更新该节点相应的触发故障选举的时间(就是设置延时时间),比如现在a节点获取到资格了,此时就会给a节点设置一个选举的延时时间,例如设置1s,则表示节点a在1s后才会参加选举。

(这里采用的是延迟触发机制,是为了让不同节点的触发选举时间存在误差,这样多个从节点使用不同的延迟选举时间来支持优先级问题)

那么这个延时时间怎么设置?
此时就会获取各个从节点的复制偏移量,如果复制偏移量更大,证明其数据完整性更高,此时复制偏移量越高,设置的延时时间则越短,这样可以保证偏移量越高的节点更早的去参加选举。如图:偏移量更高的延迟时间越低。

 3、发起选举

上面设置好了选举时间后,每个从节点有个定时任务去检测该节点是否到达故障选举时间,如果到达了则发起选举,流程如下:
1)、更新配置纪元

配置纪元其实是一个只增不减的整数,每个主节点自身都会维护一个配置纪元(clusterNode.configEpoch)标记当前主节点的版本,所有主节点的配置纪元是不相等的(唯一的),从节点会复制主节点的配置纪元。而且整个集群又维护了一个全局的配置纪元(clusterState.current Epoch),用于记录整个集群里所有主节点的配置纪元的最大值。执行cluster info可查看配置纪元的信息。

        配置纪元会跟随ping/pong消息在集群内传播,当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突。(这样就保证了集群内所有主节点的配置纪元是唯一的)

配置纪元的主要作用:

  • 标示集群内每个主节点的不同版本和当前集群最大的版本。
  • 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。
  • 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

配置纪元的应用场景有:

  • ·新节点加入。
  • ·槽节点映射冲突检测。
  • ·从节点投票选举冲突检测。

从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在
clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。

2)、广播选举消息

在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。(每个从节点触发选举都会发起一起选举,修改全局配置纪元????)

4、选举投票

只有持有槽的主节点才会处理故障选举消息,因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。

即每个用槽的主节点都能投一票。当一个从节点获得N/2+1的选票时,就可以进行主从替换了。

 那为什么不让从节点参加选举??为什么是N/2+1??

        投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。
        Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。

        故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节(也会在故障发现环节出现该问题)。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点问题(这样宕机一个就还有两个主节点可以进行选举)。


投票作废:每个配置纪元代表了一次选举周期(集群的全局配置纪元),如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

 5、替换主节点

当从节点收集到足够的选票之后,触发替换主节点操作:
1)当前从节点取消复制变为主节点。
2)执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
3)向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。


对于全局配置纪元和自身的配置纪元:
全局配置纪元:每个主节点都会存储全局配置纪元到本地中,而且每个主节点都有自己的配置纪元。

全局配置纪元(currentEpoch):为了故障转移是标记版本号的,每个从节点发起选举都会自增全局配置纪元,然后所有主节点在该纪元下会拥有一张投票,(如果主节点接收到投票的请求,此时判断请求中的全局配置纪元是否大于本地存的全局配置纪元,如果大于则进行更新,更新完入股哦有票则将票投给这个节点)

自身的配置纪元(configEpoch ):configEpoch 主要用于解决不同的节点的配置发生冲突的情况。

两者具体作用:https://blog.csdn.net/chen_kkw/article/details/82724330

三、故障转移时间

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

根据以上分析可以预估出故障转移时间,如下:

failover-time( 毫秒 ) ≤ cluster-node-timeout + cluster-node-timeout/2 + 1000

因此,故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好,下一节的带宽消耗部分会进一步说明。

七、集群运维

一、集群完整性

        为了保证集群完整性,默认情况下当集群16384个槽任何一个没有指派到节点时,整个集群不可用。执行任务都会返回(error)CLUSTERDOWN Hash slot not served错误。

        所以当持有槽的主节点下线时,从故障发现到自动转移期间整个集群都是不可以状态。但是这样大部分业务时无法容忍的,因此建议将参数cluster-require-full-coverage配置为no,当主节点故障时只影响它负责槽的相关命令执行,不会影响其他主节点的可用性。

二、带宽消耗

因为集群内Gossip消息通信本身会消耗带宽,所以官方建议集群最大规模建议在1000以内。节点间消息通信对带宽的消耗体现在下面几个方面:

  • ·消息发送频率:跟cluster-node-timeout密切相关,当节点发现与其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息。
  • ·消息数据量:每个消息主要的数据占用包含:slots槽数组(2KB空间)和整个集群1/10的状态数据(10个节点状态数据约1KB)。
  • ·节点部署的机器规模:机器带宽的上线是固定的,因此相同规模的集群分布的机器越多每台机器划分的节点越均匀,则集群内整体的可用带宽越高。

例如,一个总节点数为200的Redis集群,部署在20台物理机上每台划分10个节点,cluster-node-timeout采用默认15秒,这时ping/pong消息占用带宽达到25Mb。如果把cluster-node-timeout设为20,对带宽的消耗降低到15Mb以下。

集群带宽消耗主要分为:读写命令消耗+Gossip消息消耗。因此搭建Redis集群时需要根据业务数据规模和消息通信成本做出合理规划:
1)在满足业务需要的情况下尽量避免大集群。同一个系统可以针对不同业务场景拆分使用多套集群。这样每个集群既满足伸缩性和故障转移要求,还可以规避大规模集群的弊端。如笔者维护的一个推荐系统,根据数据特征使用了5个Redis集群,每个集群节点规模控制在100以内。
2)适度提高cluster-node-timeout降低消息发送频率,同时cluster-node-timeout还影响故障转移的速度,因此需要根据自身业务场景兼顾二者的平衡。
3)如果条件允许集群尽量均匀部署在更多机器上。避免集中部署,如集群有60个节点,集中部署在3台机器上每台部署20个节点,这时单个机器带宽消耗将非常严重。

三、Pub/Sub广播问题

Redis在2.0版本提供了Pub/Sub(发布/订阅)功能,用于针对频道实现消息的发布和订阅。但是在集群模式下内部实现对所有的publish命令都会向所有的节点进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重带宽负担,如图:

 针对集群模式下publish广播问题,需要引起开发人员注意,当频繁应用Pub/Sub功能时应该避免在大量节点的集群内使用,否则会严重消耗集群内网络带宽。针对这种情况建议使用sentinel结构专门用于Pub/Sub功能,从而规避这一问题。

四、集群倾斜

集群倾斜指不同节点之间数据量和请求量出现明显差异,这种情况将加大负载均衡和开发运维的难度。因此需要理解哪些原因会造成集群倾斜,从而避免这一问题。

1、数据倾斜

数据倾斜主要分为以下几种:
1)、节点和槽分配严重不均

2)、不同槽对应键数量差异过大。

键通过CRC16哈希函数映射到槽上,正常情况下槽内键数量会相对均匀。但当大量使用hash_tag时,会产生不同的键映射到同一个槽的情况。特别是选择作为hash_tag的数据离散度较差时,将加速槽内键数量倾斜情况。

通过命令:cluster countkeysinslot{slot}可以获取槽对应的键数量,识别出哪些槽映射了过多的键。再通过命令cluster getkeysinslot{slot}{count}循环迭代出槽下所有的键。从而发现过度使用hash_tag的键。

3)、集合对象包含大量元素。

对于大集合对象的识别可以使用redis-cli  --bigkeys命令识别,找出大集合之后可以根据业务场景进行拆分。同时集群槽数据迁移是对键执行migrate操作完成,过大的键集合如几百兆,容易造成migrate命令超时导致数据迁移失败。

4)、内存相关配置不一致。

内存相关配置指hash-max-ziplist-value、set-max-intset-entries等压缩数据结构配置。当集群大量使用hash、set等数据结构时,如果内存压缩数据结构配置不一致,极端情况下会相差数倍的内存,从而造成节点内存量倾斜。

2、请求倾斜

集群内特定节点请求量/流量过大将导致节点之间负载不均,影响集群均衡和运维成本。常出现在热点键场景,当键命令消耗较低时如小对象的get、set、incr等,即使请求量差异较大一般也不会产生负载严重不均。但是当热点键对应高算法复杂度的命令或者是大对象操作如hgetall、smembers等,会导致对应节点负载过高的情况。避免方式如下:
1)合理设计键,热点大集合对象做拆分或使用hmget替代hgetall避免整体读取。
2)不要使用热键作为hash_tag,避免映射到同一槽。
3)对于一致性要求不高的场景,客户端可使用本地缓存减少热键调用。

五、集群读写分离

1、只读连接

集群模式下,从节点默认不接受任何读写请求(非集群下从节点是可以进行读请求的),接收到的请求全部重定向到负责槽的主节点上(包括其他主节点),当需要使用从节点分担主节点读压力时,可以使用readonly命令打开客户端连接只读状态。之前的复制配置slave-read-only在集群模式下无效。当开启只读状态时,从节点接收读命令处理流程变为:如果对应的槽属于自己正在复制的主节点则直接执行读命令,否则返回重定向信息。

 

readonly命令是连接级别生效,因此每次新建连接时都需要执行readonly开启只读状态。执行readwrite命令可以关闭连接只读状态。(注意:断开连接后readonly就失效了,再次连接需要重新使用该命令

2、读写分离

上面开启了从节点读请求处理后,集群下的读写分离,同会遇到之前的复制延迟、读取过期数据、从节点故障等问题。

针对从节点故障问题,客户端需要维护可用节点列表(集群提供了cluster slaves{nodeId}命令,返回nodeId对应主节点下所有从节点信息):

// 返回 6379 节点下所有从节点
127.0.0.1:6382> cluster slaves cfb28ef1deee4e0fa78da86abe5d24566744411e
1) "40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 myself,slave cfb28e
f1deee4e0fa78da86abe5d24566744411e 0 0 3 connected"
2) "2e7cf7539d076a1217a408bb897727e5349bcfcf 127.0.0.1:6384 slave,fail cfb28ef1
deee4e0fa78da86abe5d24566744411e 1473047627396 1473047622557 13 disconnected"

解析以上从节点列表信息,排除fail状态节点,这样客户端对从节点的故障判定可以委托给集群处理,简化维护可用从节点列表难度。

集群模式下读写分离涉及对客户端修改如下:
1)维护每个主节点可用从节点列表。
2)针对读命令维护请求节点路由。
3)从节点新建连接开启readonly状态。
集群模式下读写分离成本比较高,可以直接扩展主节点数量提高集群性能,一般不建议集群模式下做读写分离。

集群读写分离有时用于特殊业务场景如:
1)利用复制的最终一致性使用多个从节点做跨机房部署降低读命令网络延迟。
2)主节点故障转移时间过长,业务端把读请求路由给从节点保证读操作可用。

        以上场景也可以在不同机房独立部署Redis集群解决,通过客户端多写来维护,读命令直接请求到最近机房的Redis集群,或者当一个集群节点故障时客户端转向另一个集群。

六、手动故障转移

七、数据迁移

可以使用redis-shake实现数据迁移。

preview

https://cloud.tencent.com/developer/article/1774536

https://zhuanlan.zhihu.com/p/90445769

  • 4
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
Redis集群是一个由多个主从节点组成的分布式服务集群。它的主要特点是具有复制、高可用和分片的能力。Redis集群不需要sentinel哨兵也能完成节点移除和故障转移的功能。具体来说,Redis集群将数据划分为16384个slots,每个节点负责一部分槽位的数据。每个节点之间通过Gossip协议相互交互集群信息,并保存着其他节点的槽位分配情况。这种去中心化的方式使得集群具有高可用性和扩展性。集群中的每个节点都可以水平扩展,官方建议不超过1000个节点。Redis集群的配置相对简单,性能和高可用性也优于之前版本的哨兵模式。而在Redis 4.0之后的版本中,引入了面对槽位的管理,使得集群的路由信息管理和数据迁移更加灵活和高效。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [redis系列redis-cluster集群原理](https://blog.csdn.net/sswltt/article/details/106438796)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 50%"] - *2* *3* [Redis——cluster集群原理](https://blog.csdn.net/weixin_41605937/article/details/114779041)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值