一、数据分布
1. Redis数据分区
Redis Cluser采用虚拟槽分区,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据,如图所示。
槽集合与节点关系
使用CRC16(key)&16383将键映射到槽上
Redis虚拟槽分区的特点:
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
- 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
- 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。
数据分区是分布式存储的核心,理解和灵活运用数据分区规则对于掌握Redis Cluster非常有帮助。
2. 集群功能限制
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集群分区规则之后,下面我们开始搭建Redis集群。搭建集群工作需要以下三个步骤:
1)准备节点。
2)节点握手。
3)分配槽。
1. 准备节点
Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes,让Redis运行在集群模式下。建议为集群内所有节点统一目录,一般划分三个目录:conf、data、log,分别存放配置、数据和日志相关文件。把6个节点配置统一放在conf目录下,集群相关配置如下:
# 节点端口
port 6379
# 开启集群模式
cluster-enabled yes
# 节点超时时间,单位毫秒
cluster-node-timeout 15000
# 集群内部配置文件
cluster-config-file "nodes-6379.conf"
其他配置和单机模式一致即可,配置文件命名规则redis-{port}.conf,准备好配置后启动所有节点,命令如下:
redis-server conf/redis-6379.conf
redis-server conf/redis-6380.conf
redis-server conf/redis-6381.conf
redis-server conf/redis-6382.conf
redis-server conf/redis-6383.conf
redis-server conf/redis-6384.conf
检查节点日志是否正确,日志内容如下:
cat log/redis-6379.log
* No cluster configuration found, I'm cfb28ef1deee4e0fa78da86abe5d24566744411e
# Server started, Redis version 3.0.7
* The server is now ready to accept connections on port 6379
6379节点启动成功,第一次启动时如果没有集群配置文件,它会自动创建一份,文件名称采用cluster-config-file参数项控制,建议采用node-{port}.conf格式定义,通过使用端口号区分不同节点,防止同一机器下多个节点彼此覆盖,造成集群信息异常。如果启动时存在集群配置文件,节点会使用配置文件内容初始化集群信息。启动过程如图所示。
Redis集群模式启动过程
集群模式的Redis除了原有的配置文件之外又加了一份集群配置文件。当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中。需要注意的是,Redis自动维护集群配置文件,不要手动修改,防止节点重启时产生集群信息错乱。
如节点6379首次启动后生成集群配置如下:
#cat data/nodes-6379.conf
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
vars currentEpoch 0 lastVoteEpoch 0
文件内容记录了集群初始状态,这里最重要的是节点ID,它是一个40位16进制字符串,用于唯一标识集群内一个节点,之后很多集群操作都要借助于节点ID来完成。需要注意是,节点ID不同于运行ID。节点ID在集群初始化时只创建一次,节点重启时会加载集群配置文件进行重用,而Redis的运行ID每次重启都会变化。在节点6380执行cluster nodes命令获取集群节点状态:
127.0.0.1:6380>cluster nodes
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 myself,master - 0 0 0 c
每个节点目前只能识别出自己的节点信息。我们启动6个节点,但每个节点彼此并不知道对方的存在,下面通过节点握手让6个节点彼此建立联系从而组成一个集群。
2. 节点握手
节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令:cluster meet{ip}{port},如图所示。
节点握手
图中执行的命令是:cluster meet127.0.0.16380让节点6379和6380节点进行握手通信。cluster meet命令是一个异步命令,执行之后立刻返回。内部发起与目标节点进行握手通信,如图所示。
1)节点6379本地创建6380节点信息对象,并发送meet消息。
2)节点6380接受到meet消息后,保存6379节点信息并回复pong消息。
3)之后节点6379和6380彼此定期通过ping/pong消息进行正常的节点通信。
这里的meet、ping、pong消息是Gossip协议通信的载体,之后的节点通信部分做进一步介绍,它的主要作用是节点彼此交换状态数据信息。6379和6380节点通过meet命令彼此建立通信之后,集群结构如图所示。对节点6379和6380分别执行cluster nodes命令,可以看到它们彼此已经感知到对方的存在。
cluster meet命令进行节点握手的过程
通过两个节点握手的集群结构
127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0
0 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468073534265
1 connected
127.0.0.1:6380> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 master - 0 1468073571641
0 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 myself,master - 0 0
1 connected
下面分别执行meet命令让其他节点加入到集群中:
127.0.0.1:6379>cluster meet 127.0.0.1 6381
127.0.0.1:6379>cluster meet 127.0.0.1 6382
127.0.0.1:6379>cluster meet 127.0.0.1 6383
127.0.0.1:6379>cluster meet 127.0.0.1 6384
我们只需要在集群内任意节点上执行cluster meet命令加入新节点,握手状态会通过消息在集群内传播,这样其他节点会自动发现新节点并发起握手流程。最后执行cluster nodes命令确认6个节点都彼此感知并组成集群:
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
节点建立握手之后集群还不能正常工作,这时集群处于下线状态,所有的数据读写都被禁止。通过如下命令可以看到:
127.0.0.1:6379> set hello redis
(error) CLUSTERDOWN The cluster is down
通过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
...
集群握手完成之后的状态
3. 分配槽
Redis集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过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
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集群模式下的主从复制使用了之前介绍的Redis复制流程,依然支持全量和部分复制。复制(replication)完成后,整个集群的结构如图所示。
集群完整结构
通过cluster nodes命令查看集群状态和复制关系,如下所示:
127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 slave 40b8d09d44294d2e2
3c7c768efc8fcd153446746 0 1468076865939 5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-5461
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa
9d29fb174ce733345b3e8f1 0 1468076868966 4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa
78da86abe5d24566744411e 0 1468076869976 3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468076870987 1
connected 5462-10922
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468076867957 2
connected 10923-16383
目前为止,我们依照Redis协议手动建立一个集群。它由6个节点构成,3个主节点负责处理槽和相关数据,3个从节点负责故障转移。手动搭建集群便于理解集群建立的流程和细节,不过读者也从中发现集群搭建需要很多步骤,当集群节点众多时,必然会加大搭建集群的复杂度和运维成本。因此Redis官方提供了redis-trib.rb工具方便我们快速搭建集群。
三、节点通讯
1.通信流程
在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。Redis集群采用P2P的Gossip(流言)协议,Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播,如图所示。
节点彼此传播消息
通信过程说明:
1)集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。
2)每个节点在固定周期内通过特定规则选择几个节点发送ping消息。
3)接收到ping消息的节点用pong消息作为响应。集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点出故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断的ping/pong消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。
2.Gossip消息
Gossip协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的Gossip消息,了解这些消息有助于我们理解集群如何完成信息交换。常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息等,它们的通信模式如图所示。
不同消息通信模式
- meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
- ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。
- pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
- fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。具体细节将在后面10.6节“故障转移”中说明。
所有的消息格式划分为:消息头和消息体。消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据,结构如下:
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;
集群内所有的消息都采用相同的消息头结构clusterMsg,它包含了发送节点关键信息,如节点id、槽映射、节点标识(主从角色,是否下线)等。消息体在Redis内部采用clusterMsgData结构声明,结构如下:
union clusterMsgData {
/* ping,meet,pong 消息体 */
struct {
/* gossip 消息结构数组 */
clusterMsgDataGossip gossip[1];
589
} ping;
/* FAIL 消息体 */
struct {
clusterMsgDataFail about;
} fail;
// ...
};
消息体clusterMsgData定义发送消息的数据,其中ping、meet、pong都采用cluster MsgDataGossip数组作为消息体数据,实际消息类型使用消息头的type属性区分。每个消息体包含该节点的多个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;
当接收到ping、meet消息时,接收节点会解析消息内容并根据自身的识别情况做出相应处理,对应流程如图所示。
消息解析流程
接收节点收到ping/meet消息时,执行解析消息头和消息体流程:
- 解析消息头过程:消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。
- 解析消息体过程:如果消息体的clusterMsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据cluster MsgDataGossip中的flags字段判断该节点是否下线,用于故障移。
消息处理完后回复pong消息,内容同样包含消息头和消息体,发送节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与接收节点最后通信时间,完成一次消息通信。
3.节点选择
虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。因此Redis集群的Gossip协议需要兼顾信息交换实时性和成本开销,通信节点选择的规则如图所示。
选择通信节点的规则和消息携带的数据量
根据通信节点选择的流程可以看出消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量。
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参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。
2.消息数据量
每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是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集群来说并不是大而全的集群更好,对于集群规模控制的建议见之后10.7节“集群运维”。
四、集群伸缩
1. 伸缩原理
Redis集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容,如图所示。
集群节点上下线
从图看出,Redis集群可以实现对节点的灵活上下线控制。其中原理可抽象为槽和对应数据在不同节点之间灵活移动。首先来看我们之前搭建的集群槽和数据与节点的对应关系,如图所示。
槽和数据与节点的对应关系
三个主节点分别维护自己负责的槽和对应的数据,如果希望加入1个节点实现集群扩容时,需要通过相关命令把一部分槽和数据迁移给新节点,如图所示。
槽和相关数据迁移到新节点
图中每个节点把一部分槽和数据迁移到新的节点6385,每个节点负责的槽和数据相比之前变少了从而达到了集群扩容的目的。这里我们故意忽略了槽和数据在节点之间迁移的细节,目的是想让读者重点关注在上层槽和节点分配上来,理解集群的水平伸缩的上层原理:集群伸缩=槽和数据在节点之间的移动,下面将介绍集群扩容和收缩的细节。
2. 扩容集群
扩容是分布式存储最常见的需求,Redis集群扩容操作可分为如下步骤:
1)准备新节点。
2)加入集群。
3)迁移槽和数据。
槽和数据迁移流程
3. 收缩集群
节点安全下线流程五、请求路由
1.请求重定向
在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向,如图所示。
MOVED重定向执行流程
2.Smart客户端
大多数开发语言的Redis客户端都采用Smart客户端支持集群协议,客户端如何选择见:http://redis.io/clients,从中找出符合自己要求的客户端类库。Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。我们以Java的Jedis为例,说明Smart客户端操作集群的流程。
1)首先在JedisCluster初始化时会选择一个运行节点,初始化槽和节点映射关系,使用cluster slots命令完成,如下所示:
127.0.0.1:6379> cluster slots
1) 1) (integer) 0 // 开始槽范围
2) (integer) 1365 // 结束槽范围
3) 1) "127.0.0.1" // 主节点 ip
2) (integer) 6385 // 主节点地址
4) 1) "127.0.0.1" // 从节点 ip
2) (integer) 6386 // 从节点端口
2) 1) (integer) 5462
2) (integer) 6826
3) 1) "127.0.0.1"
2) (integer) 6385
4) 1) "127.0.0.1"
2) (integer) 6386
...
2)JedisCluster解析cluster slots结果缓存在本地,并为每个节点创建唯一的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执行键命令的过程有些复杂,但是理解这个过程对于开发人员分析定位问题非常有帮助,部分代码如下:
public abstract class JedisClusterCommand<T> {
// 集群节点连接处理器
private JedisClusterConnectionHandler connectionHandler;
// 重试次数,默认 5 次
private int redirections;
// 模板回调方法
public abstract T execute(Jedis connection);
public T run(String key) {
if (key == null) {
throw new JedisClusterException("No way to dispatch this command to
Redis Cluster.");
}
return runWithRetries(SafeEncoder.encode(key), this.redirections, false,
false);
}
// 利用重试机制运行键命令
private T runWithRetries(byte[] key, int redirections, boolean tryRandomNode,
boolean asking) {
if (redirections <= 0) {
throw new JedisClusterMaxRedirectionsException("Too many Cluster redi
rections");
}
Jedis connection = null;
try {
if (tryRandomNode) {
// 随机获取活跃节点连接
connection = connectionHandler.getConnection();
} else {
// 使用 slot 缓存获取目标节点连接
connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.
getSlot(key));
}
return execute(connection);
} catch (JedisConnectionException jce) {
// 出现连接错误使用随机连接重试
return runWithRetries(key, redirections - 1, true/* 开启随机连接 */, asking);
} catch (JedisRedirectionException jre) {
if (jre instanceof JedisMovedDataException) {
// 如果出现 MOVED 重定向错误 , 在连接上执行 cluster slots 命令重新初始化 slot 缓存
this.connectionHandler.renewSlotCache(connection);
}
// slot 初始化后重试执行命令
return runWithRetries(key, redirections - 1, false, asking);
} finally {
releaseConnection(connection);
}
}
}
Jedis客户端命令执行流程
从命令执行流程中发现,客户端需要结合异常和重试机制时刻保证跟Redis集群的slots同步,因此Smart客户端相比单机客户端有了很大的变化和实现难度。了解命令执行流程后,下面我们对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方法。部分代码如下:
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public void renewSlotCache(Jedis jedis) {
try {
cache.discoverClusterSlots(jedis);
} catch (JedisConnectionException e) {
renewSlotCache();
}
}
public void discoverClusterSlots(Jedis jedis) {
// 获取写锁
w.lock();
try {
this.slots.clear();
// 执行 cluster slots
List<Object> slots = jedis.clusterSlots();
for (Object slotInfoObj : slots) {
// 初始化 slots 缓存代码 , 忽略细节 ...
}
} finally {
w.unlock();
}
}
public JedisPool getSlotPool(int slot) {
// 获取读锁
r.lock();
try {
// 返回 slot 对应的 jedisPool
return slots.get(slot);
} finally {
r.unlock();
}
}
从代码中看到,获得写锁后再执行cluster slots命令初始化缓存,由于集群所有的键命令都会执行getSlotPool方法计算槽对应节点,它内部要求读锁。Reentrant ReadWriteLock是读锁共享且读写锁互斥,从而导致所有的请求都会造成阻塞。对于并发量高的场景将极大地影响集群吞吐。这个现象称为cluster slots风暴,有如下现象:
- 重试机制导致IO通信放大问题。比如默认重试5次的情况,当抛出
JedisClusterMaxRedirectionsException异常时,内部最少需要9次IO通信:5次发送命令+2次ping命令保证随机节点正常+2次cluster slots命令初始化slots缓存。导致异常判定时间变长。 - 个别节点操作异常导致频繁的更新slots缓存,多次调用cluster slots命令,高并发时将过度消耗Redis节点资源,如果集群slot<->node映射庞大则cluster slots返回信息越多,问题越严重。
- 频繁触发更新本地slots缓存操作,内部使用了写锁,阻塞对集群所有的键命令调用。
针对以上问题在Jedis2.8.2版本做了改进: - 当接收到JedisConnectionException时不再轻易初始化slots缓存,大幅降
低内部IO次数,伪代码如下:
def runWithRetries(byte[] key, int attempts) :
if (attempts <= 0) :
throw new JedisClusterMaxRedirectionsException("Too many Cluster red irections");
Jedis connection = null;
try :
// 获取连接
connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));
return execute(connection);
except JedisConnectionException,jce :
if (attempts <= 1) :
// 当重试到 1 次时,更新本地 slots 缓存
this.connectionHandler.renewSlotCache();
// 抛出异常
throw jce;
// 递归执行重试
return runWithRetries(key, attempts - 1);
except JedisRedirectionException,jre:
// 如果是 MOVED 异常,更新 slots 缓存
if (jre instanceof JedisMovedDataException) :
this.connectionHandler.renewSlotCache(connection);
// 递归,执行重试
return runWithRetries(key, attempts - 1);
finally:
releaseConnection(connection);
根据代码看出,只有当重试次数到最后1次或者出现MovedDataException时才更新slots操作,降低了cluster slots命令调用次数。
- 当更新slots缓存时,不再使用ping命令检测节点活跃度,并且使用redis covering变量保证同一时刻只有一个线程更新slots缓存,其他线程忽略,优化了写锁阻塞和cluster slots调用次数。伪代码如下:
def renewSlotCache(Jedis jedis) :
// 使用 rediscovering 变量保证当有一个线程正在初始化 slots 时,其他线程直接忽略。
if (!rediscovering):
try :
w.lock();
rediscovering = true;
if (jedis != null) :
try :
// 更新本地缓存
discoverClusterSlots(jedis);
return;
except JedisException,e:
// 忽略异常,使用随机查找更新 slots
// 使用随机节点更新 slots
for (JedisPool jp : getShuffledNodesPool()) :
try :
// 不再使用 ping 命令检测节点
jedis = jp.getResource();
discoverClusterSlots(jedis);
return;
except JedisConnectionException,e:
// try next nodes
finally :
if (jedis != null) :
jedis.close();
finally :
// 释放锁和 rediscovering 变量
rediscovering = false;
w.unlock();
综上所述,Jedis2.8.2之后的版本,当出现JedisConnectionException时,命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了cluster slots不必要的并发调用。
综上所述,Jedis2.8.2之后的版本,当出现JedisConnectionException时,
命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了
cluster slots不必要的并发调用。
开发提示
建议升级到Jedis2.8.2以上版本防止cluster slots风暴和写锁阻塞问题,但是笔者认为还可以进一步优化,如下所示:
·执行cluster slots的过程不需要加入任何读写锁,因为cluster slots命令执行不需要做并发控制,只有修改本地slots时才需要控制并发,这样降低了写锁持有时间。
·当获取新的slots映射后使用读锁跟老slots比对,只有新老slots不一致时再加入写锁进行更新。防止集群slots映射没有变化时进行不必要的加写锁行为。
这里我们用大量篇幅介绍了Smart客户端Jedis与集群交互的细节,主要原因是针对于高并发的场景,这里是绝对的热点代码。集群协议通过Smart客户端全面高效的支持需要一个过程,因此用户在选择Smart客户端时要重点审核集群交互代码,防止线上踩坑。必要时可以自行优化修改客户端源码。
3.Smart客户端——JedisCluster
(1)JedisCluster的定义
Jedis为Redis Cluster提供了Smart客户端,对应的类是JedisCluster,它的
初始化方法如下:
public JedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int
soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) {
...
}
其中包含了5个参数:
- Set<HostAndPort>jedisClusterNode:所有Redis Cluster节点信息(也可以是一部分,因为客户端可以通过cluster slots自动发现)。
- int connectionTimeout:连接超时。
- int soTimeout:读写超时。
- int maxAttempts:重试次数。
- GenericObjectPoolConfig poolConfig:连接池参数,JedisCluster会为Redis Cluster的每个节点创建连接池,有关连接池的详细说明参见第4章。
例如下面代码展示了一次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");
对于JedisCluster的使用需要注意以下几点:
- JedisCluster包含了所有节点的连接池(JedisPool),所以建议JedisCluster使用单例。
- JedisCluster每次操作完成后,不需要管理连接池的借还,它在内部已经完成。
- JedisCluster一般不要执行close()操作,它会将所有JedisPool执行destroy操作。
(2)多节点命令和操作
Redis Cluster虽然提供了分布式的特性,但是有些命令或者操作,诸如keys、flushall、删除指定模式的键,需要遍历所有节点才可以完成。下面代码实现了从Redis Cluster删除指定模式键的功能:
// 从 RedisCluster 批量删除指定 pattern 的数据
public void delRedisClusterByPattern(JedisCluster jedisCluster, String pattern,
int scanCounter) {
// 获取所有节点的 JedisPool
Map<String, JedisPool> jedisPoolMap = jedisCluster.getClusterNodes();
for (Entry<String, JedisPool> entry : jedisPoolMap.entrySet()) {
// 获取每个节点的 Jedis 连接
Jedis jedis = entry.getValue().getResource();
// 只删除主节点数据
if (!isMaster(jedis)) {
continue;
}
// 使用 Pipeline 每次删除指定前缀的数据
Pipeline pipeline = jedis.pipelined();
// 使用 scan 扫描指定前缀的数据
String cursor = "0";
// 指定扫描参数:每次扫描个数和 pattern
ScanParams params = new ScanParams().count(scanCounter).match(pattern);
while (true) {
// 执行扫描
ScanResult<String> scanResult = jedis.scan(cursor, params);
// 删除的 key 列表
List<String> keyList = scanResult.getResult();
if (keyList != null && keyList.size() > 0) {
for (String key : keyList) {
pipeline.del(key);
}
// 批量删除
pipeline.syncAndReturnAll();
}
cursor = scanResult.getStringCursor();
// 如果游标变为 0 ,说明扫描完毕
if ("0".equals(cursor)) {
break;
}
}
}
}
// 判断当前 Redis 是否为 master 节点
private boolean isMaster(Jedis jedis) {
String[] data = jedis.info("Replication").split("\r\n");
for (String line : data) {
if ("role:master".equals(line.trim())) {
return true;
}
}
return false;
}
具体分为如下几个步骤:
1)通过jedisCluster.getClusterNodes()获取所有节点的连接池。
2)使用info replication筛选1)中的主节点。
3)遍历主节点,使用scan命令找到指定模式的key,使用Pipeline机制删除。
例如下面操作每次遍历1000个key,将Redis Cluster中以user开头的key全
部删除。
String pattern = "user*";
int scanCounter = 1000;
delRedisClusterByPattern(jedisCluster, pattern, scanCounter);
所以对于keys、flushall等需要遍历所有节点的命令,同样可以参照上面的方法进行相应功能的实现。
(3)批量操作的方法
Redis Cluster中,由于key分布到各个节点上,会造成无法实现mget、mset等功能。但是可以利用CRC16算法计算出key对应的slot,以及Smart客户端保存了slot和节点对应关系的特性,将属于同一个Redis节点的key进行归档,然后分别对每个节点对应的子key列表执行mget或者pipeline操作,具体使用方法可以参考11.5节“无底洞优化”。
(4)使用Lua、事务等特性的方法
Lua和事务需要所操作的key,必须在一个节点上,不过Redis Cluster提供了hashtag,如果开发人员确实要使用Lua或者事务,可以将所要操作的key使用一个hashtag,如下所示:
// hashtag
String hastag = "{user}";
// 用户 A 的关注表
String userAFollowKey = hastag + ":a:follow";
// 用户 B 的粉丝表
String userBFanKey = hastag + ":b:fans";
// 计算 hashtag 对应的 slot
int slot = JedisClusterCRC16.getSlot(hastag);
// 获取指定 slot 的 JedisPool
JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFromSlot(slot);
// 在当个节点上执行事务
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
// 用户 A 的关注表加入用户 B ,用户 B 的粉丝列表加入用户 A
Transaction transaction = jedis.multi();
transaction.sadd(userAFollowKey, "user:b");
transaction.sadd(userBFanKey, "user:a");
transaction.exec();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
if (jedis!= null)
jedis.close();
}
具体步骤如下:
1)将事务中所有的key添加hashtag。
2)使用CRC16计算hashtag对应的slot。
3)获取指定slot对应的节点连接池JedisPool。
4)在JedisPool上执行事务。
六、故障转移
Redis集群自身实现了高可用。高可用首先需要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。本节介绍故障转移的细节,分析故障发现和替换故障节点的过程。
1.故障发现
当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节点是否发生了故障。Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)。
- 主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状
态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。 - 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节
点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节
点进行故障转移。
主观下线
流程说明:
1)节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间。
2)如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。
3)节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-timeout时,更新本地对节点b的状态为主观下线(pfail)。
主观下线简单来讲就是,当cluster-note-timeout时间内某节点无法与另一个节点顺利完成ping消息通信时,则将该节点标记为主观下线状态。
客观下线
通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。这里有两个问题:
1)为什么必须是负责槽的主节点参与故障发现决策?因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。
2)为什么半数以上处理槽的主节点?必须半数以上是为了应对网络分区等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务。
客观下线逻辑流程
流程说明:
1)当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
2)找到pfail对应的节点结构,更新clusterNode内部下线报告链表。
3)根据更新后的下线报告链表告尝试进行客观下线。
尝试客观下线流程
流程说明:
1)首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总数的一半则退出。
2)当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下线状态。
3)向集群广播一条fail消息,通知所有的节点将故障节点标记为客观下线,fail消息的消息体只包含故障节点的ID。
运维提示
下线报告的有效期限是server.cluster_node_timeout2,主要是针对故障误报的情况。例如节点A在上一小时报告节点B主观下线,但是之后又恢复正常。现在又有其他节点上报节点B主观下线,根据实际情况之前的属于误报不能被使用。
如果在cluster-node-time2时间内无法收集到一半以上槽节点的下线报告,那么之前的下线报告将会过期,也就是说主观下线上报的速度追赶不上下线报告过期的速度,那么故障节点将永远无法被标记为客观下线从而导致故障转移失败。因此不建议将cluster-node-time设置得过小。
2. 故障恢复
故障恢复流程资格检查
每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-
validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor用于从节点的有效因子,默认为10。
准备选举时间
当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。故障选举时间相关字段如下:
struct clusterState {
...
mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */
int failover_auth_rank; /* 记录当前从节点排名 */
}
这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。优先级计算伪代码如下:
def clusterGetSlaveRank():
int rank = 0;
// 获取从节点的主节点
ClusteRNode master = myself.slaveof;
// 获取当前从节点复制偏移量
long myoffset = replicationGetSlaveOffset();
// 跟其他从节点复制偏移量对比
for (int j = 0; j < master.slaves.length; j++):
// rank 表示当前从节点在所有从节点的复制偏移量排名,为 0 表示偏移量最大 .
if (master.slaves[j] != myself && master.slaves[j].repl_offset > myoffset):
rank++;
return rank;
}
使用之上的优先级排名,更新选举触发时间,伪代码如下:
def updateFailoverTime():
// 默认触发选举时间:发现客观下线后一秒内执行。
server.cluster.failover_auth_time = now() + 500 + random() % 500;
// 获取当前从节点排名
int rank = clusterGetSlaveRank();
long added_delay = rank * 1000;
// 使用 added_delay 时间累加到 failover_auth_time 中
server.cluster.failover_auth_time += added_delay;
661
// 更新当前从节点排名
server.cluster.failover_auth_rank = rank;
所有的从节点中复制偏移量最大的将提前触发故障选举流程,如图所示。
从节点延迟触发选举时间
主节点b进入客观下线后,它的三个从节点根据自身复制偏移量设置延迟选举时间,如复制偏移量最大的节点slave b-1延迟1秒执行,保证复制延迟低的从节点优先发起选举。
发起选举
当从节点定时任务检测到达故障选举时间(failover_auth_time)到达
后,发起选举流程如下:
(1)更新配置纪元
配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch)标示当前主节点的版本,所有主节点的配置元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(clusterState.current Epoch),用于记录集群内所有主节点配置纪元的最大版本。执行cluster info命令可以查看配置纪元信息:
127.0.0.1:6379> cluster info
...
cluster_current_epoch:15 // 整个集群最大配置纪元
cluster_my_epoch:13 // 当前主节点配置纪元
配置纪元会跟随ping/pong消息在集群内传播,当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突,伪代码如下:
def clusterHandleConfigEpochCollision(clusterNode sender) :
if (sender.configEpoch != myself.configEpoch || !nodeIsMaster(sender) || !nodeIsMaster
(myself)) :
return;
// 发送节点的 nodeId 小于自身节点 nodeId 时忽略
if (sender.nodeId <= myself.nodeId):
return
// 更新全局和自身配置纪元
server.cluster.currentEpoch++;
myself.configEpoch = server.cluster.currentEpoch;
配置纪元的主要作用:
- 标示集群内每个主节点的不同版本和当前集群最大的版本。
- 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。
- 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。
配置纪元的应用场景有: - 新节点加入。
- 槽节点映射冲突检测。
- 从节点投票选举冲突检测。
开发提示
之前在通过cluster setslot命令修改槽节点映射时,需要确保执行请求的主节点本地配置纪元(configEpoch)是最大值,否则修改后的槽信息在消息传播中不会被拥有更高的配置纪元的节点采纳。由于Gossip通信机制无法准确知道当前最大的配置纪元在哪个节点,因此在槽迁移任务最后的cluster
setslot{slot}node{nodeId}命令需要在全部主节点中执行一遍。
从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的本。
(2)广播选举消息
在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。
选举投票
只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。
投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。
Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作,例如集群内有5个持有槽的主节点,主节点b故障后还有4个,当其中一个从节点收集到3张投票时代表获得了足够的选票可以进行替换主节点操作,如图所示。
运维提示
故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点问题。
投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。
替换主节点
当从节点收集到足够的选票之后,触发替换主节点操作:
1)当前从节点取消复制变为主节点。
2)执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
3)向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。
3.故障转移时间
在介绍完故障发现和恢复的流程后,这时我们可以估算出故障转移时
间:
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秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好,下一节的带宽消耗部分会进一步说明。
七、集群运维
Redis集群由于自身的分布式特性,相比单机场景在开发和运维方面存在一些差异。本节我们关注于常见的问题进行分析定位。
- 集群完整性
为了保证集群完整性,默认情况下当集群16384个槽任何一个没有指派到节点时整个集群不可用。执行任何键命令返回(error)CLUSTERDOWN Hash slot not served错误。这是对集群完整性的一种保护措施,保证所有的槽都指派给在线的节点。但是当持有槽的主节点下线时,从故障发现到自动完成转移期间整个集群是不可用状态,对于大多数业务无法容忍这种情况,因此建议将参数cluster-require-full-coverage配置为no,当主节点故障时只影响它负责槽的相关命令执行,不会影响其他主节点的可用性。 - 带宽消耗
集群内Gossip消息通信本身会消耗带宽,官方建议集群最大规模在1000以内,也是出于对消息通信成本的考虑,因此单集群不适合部署超大规模的节点。在之前节点通信小节介绍到,集群内所有节点通过ping/pong消息彼此交换信息,节点间消息通信对带宽的消耗体现在以下几个方面:
- 消息发送频率:跟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个节点,这时机器带宽消耗将非常严重。
网友评论