Redis集群是分布式数据库方案,通过分片来进行数据共享,并提供复制和故障转移功能
一个Redis 集群通常由多个节点组成,在刚开始的时候,每个节点都是独立的,只处于只包含自己的集群中,当要组成一个真正可工作的集群时,就需要将这些独立的节点连接起来,构建成一个包含多个节点的集群。
如何连接各个节点:
CLUSTER MEET <ip> <port>
向一个节点发送上面的命令,可以让节点与ip和port所指定的节点进行握手,握手成功,节点就会将ip和port指定的节点添加到当前的集群中。
节点的本质还是服务器,服务器会根据 cluster-enabled
配置选项来决定是否开启集群模式。
//一个节点的当前状态
struct clusterNode{
// 创建节点的时间
mstime_t ctime;
// 节点的名字,由40个16进制字符组成
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的ip地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int port;
// 保存连接节点所需的有关信息
clusterLink *link;
//……
};
clusterNode
结构的 link
属性:
typedef struct clusterLink {
// 连接的创建时间
mstime_t ctime;
// TCP 套接字描述符
int fd;
// 输出缓冲区,保存着等待发送给其他节点的消息(message)。
sds sndbuf;
// 输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;
// 与这个连接相关联的节点,如果没有的话就为 NULL
struct clusterNode *node;
} clusterLink;
每个节点都保存着 clushState
结构,记录当前节点所处的集群目前所处状态,包含多少节点,集群当前配置纪元:
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself;
// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
// 集群当前的状态:是在线还是下线
int state;
// 集群中至少处理着一个槽的节点的数量
int size;
// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
dict *nodes;
// ...
} clusterState;
CLUSTER MEET
命令的实现:
向节点A发送 CLUSTER MEET
命令,能让接收命令的节点A将另一个节点B添加到节点A当前所处的集群里。
收到命令的节点A 和节点B进行握手,以此来确认彼此的存在,并为将来的进一步通信打好基础:
节点A为节点B创建一个clusterNode
结构,并将该结构添加到自己的clusterState.nodes
字典中。
节点A根据ip
和port
发送meet
消息给节点B。
如果一切顺利,节点B收到meet
消息,为节点A创建一个clusterNode
结构,并将该结构添加到自己的clusterState.nodes
字典中。
如果一切顺利,节点B向节点A发送PONG
消息
如果一切顺利,节点A向节点B返回PING
消息
如果一切顺利,至此,握手完成
当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态,否则,处于下线状态。
// 一个节点的当前状态
struct clusterNode{
//……
// 记录处理那些槽
// 二进制位数组,长度为 2048 个字节,包含 16384 个二进制位
// 如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i;否则表示节点不负责处理槽i
unsigned char slots[16384/8];
//记录自己负责处理的槽的数量
int numslots;
//……
};
一个节点除了会将自己负责处理的槽记录在clusterNode
结构的slots
属性和numslots
属性之外,它还会将自己的slots
数组通过消息发送给集群中其他的节点,以此来告知其他节点自己目前负责处理哪些槽。
当节点A通过消息从节点B那里接收到节点B的slots
数组时,节点A会在自己的clusterState.nodes
字典中查找接电脑B对应的clusterNode
结构,并对结构中的slots
数组进行保存或者更新。
// 当前节点视角下集群目前所处的状态,集群中所有16384个槽的指派信息
typedef struct clusterState{
// ……
// slots[i]指针如果指向NULL,说明槽i尚未被指派给任何节点;
// slots[i]指针如果指向一个clusterNode 结构,
// 说明槽i已经被指派给了这个clusterNode结构所代表的节点;
clusterNode *slots[16384];
// ……
};
CLUSTER ADDSLOTS
命令的实现这个命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:
CLUSTER ADDSLOTS <slot> [slot ...]
命令实现的伪代码:
def CLUSTER_ADDSLOTS(*all_input_slots):
# 遍历所有输出槽,检查他们是否都是未指派槽
for i in all_input_slots;
# 如果有任意一个槽已经被指派给了某个节点,那么向客户端返回错误,并终止命令执行
if clusterState.slots[i] != NULL
reply_error()
return;
# 如果所有输入槽都是未指派槽
# 如果通过检查,再一次遍历所有槽,将这些槽指派给当前节点
for i in all_input_slots;
# 设置clusterState.slots数组,
# 将slots[i]的指针指向代表当前节点的clusterNode结构
clusterState.slots[i] = clusterState.myself
# 访问当前节点的clusterNode结构的slots数组,将数组在索引i上的二进制位设置位1
setSlotBit(clusterState.myself.slots,i)
# 发送消息告知集群中的其他节点,自己目前正在负责处理那些槽
节点用一下算法计算给定键 key
属于哪个槽:
def slot_number(key):
return CRC16(key) & 16383
其中 CRC16(key)
语句计算键 key
的CRC-16校验和,& 16383
语句用于计算出一个介于0至16383之间的整数作为键 key
的槽号。
介绍一个命令:
# 用于查看一个给定键属于哪个槽
CLUSTER KETSLOT <key>
这个命令的伪代码:
def CLUSTER_KEYSLOT(key):
# 计算槽号
slot = slot_number(key);
# 将槽号返回给客户端
reply_client(slot);
当节点计算出键所属槽 i 之后,节点会检查自己在 clusterState.slots
数组中的项 i ,判断键所处的槽是否由自己负责:
clusterState.slots[i]
等于 clusterState.myself
,那么说明槽 i 由当前节点负责,节点可以执行客户端发送的命令;clusterState.slots[i]
所指向的 clusterNode
结构所记录的节点IP和端口号,向客户端返回 MOVED
错误并指引客户端转向正在处理槽i的节点。MOVED
错误当节点发现键所在的槽并非由自己负责处理的时候,就会向客户端返回一个 MOVED
错误,指引客户端转向正在负责槽的节点。
格式如下:
MOVED <slot> <ip>:<port>
客户端接收到 MOVED
命令之后,根据其提供的IP和端口,转向负责处理槽 slot
的节点,并向节点重新发送之前想要执行的命令。
节点只能使用 0 号数据库,单机服务器没有限制,但两者都能保存键值对及键值对过期时间,且实现都是一样的。
除了将键值对保存在数据库里面之外,节点还会用 clusterState
结构中的 slots_to_keys
跳跃表来保存槽和键之间的关系:
typedef struct clusterState{
// ...
zskiolist *slots_to_keys;
// ...
} clusterState;
这个跳跃表每个节点的分值( score
)都是一个槽号,节点的成员( member
)都是一个数据库键:
slots_to_keys
跳跃表中通过这个跳跃表中记录各个数据库键对应的槽,节点可以很方便对某个或某些槽的所有数据库键进行批量操作。
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点移动到目标节点。 重新分片可以在线进行,在这过程中,集群不用下线,且源节点和目标节点都可以继续处理命令。
由 redis-trib
负责执行,redis-trib
通过向源节点和目标节点发送命令来进行重新分片。
redis-trib
对集群的单个槽 slot
进行重新分片的步骤如下:
redis-trib
对目标节点发送 CLUSTER SETSLOT
命令,让目标节点准备好从源节点导入槽 slot
的键值对
redis-trib
对源节点发送 CLUSTER SETSLOT
命令,让源节点准备好将属于槽 slot
的键值对迁移至目标节点
redis-trib
对源节点发送 CLUSTER GETKEYSINSLOT
命令,获得最多 count
个属于槽 slot
的键值对的键名。
对于步骤三获得的每个键名,redis-trib
都向源节点发送一个MIGRATE
命令,将被选中的键原子的从源节点迁移至目标节点.
重复步骤3和4,直到源节点保存的所有属于槽slot
的键值对都被迁移到目标节点为止。
redis-trib
向集群中的任意一个节点发送 CLUSTER SETSLOT
命令,将槽slot
指派给目标节点的信息发送给整个集群。
如果重新分片涉及多个槽,那么 redis-trib
将对每个给定的槽分别执行上面给出的步骤。
在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对保存在目标节点中。
大致原理就是,当客户端向源节点发送关于键key的命令,源节点先在自己的数据库里查找这个键,如果找到就直接返回执行客户端命令,如果没找到,这个键可能已经被迁移到了目标节点,源节点向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前要执行的命令。
CLUSTER SETSLOT IMPORTING
命令的实现格式:
CLUSTER SETSLOT <slot> IMPORTING <source_id>
clusterState
结构的 importing_slots_from
数组记录了当前节点正在从其他节点导入的槽:
typedef struct clusterState{
// ……
// 如果importing_slots_from[i]的值不为NULL,而是指向一个clusterNode结构,表示当前节点正在从
// clusterNode所代表的节点导入槽i
clusterNode *importing_slots_from[16384];
// ……
}clusterState;
CLUSTER SETSLOT MIGRTING
命令的实现格式:
CLUSTER SETSLOT <i> MTGRATING <target_id>
clusterState
结构的 migrating_slots_to
数组记录了当前节点正在迁移至其他节点的槽:
typedef struct clusterState{
// ……
// 如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,表示当前节点正在将
// 槽i迁移至clusterNode所代表的节点
clusterNode *migrating_slots_to[16384];
// ……
}clusterState;
接到ASK 错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节点,然后向目标节点发送一个 ASKING 命令, 之后再重新发送原本想要执行的命令。
ASKING命令要做的就是打开发送该命令的客户端的 REDIS_ASKING
标识。如果该客户端的 REDIS_ASKING
标识未打开,直接发送请求,由于槽的迁移过程还未完成,请求的键还属于源节点,此时直接请求目标节点,目标节点会返回一个MOVED错误。 但是,如果节点的 clusterState.importing_slots_from[i]
显示节点正在导入槽 i ,并且发送命令的客户端带有 REDIS_ASKING
标识,那么节点将破例执行这个关于槽 i 的命令一次。
注意:客户端的 REDIS_ASKING
标识是一个一次性标识,当节点执行了一个带有 REDIS_ASKING
标识的客户单发送的命令之后,客户端的这个表示就会被移除。
命令的伪代码:
def ASKING():
# 打开标识
client.flags |= REDIS_ASKING
# 向客户端返回OK回复
reply("OK")
MOVED错误代表槽的负责全已经从一个结点转移到了另一个节点
ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施
向一个节点发送命令:CLUSTER REPLICATE
这个命令可以让接收命令的节点成为 node_id
所指定的从节点,并开始对主节点进行复制:
clusterState.nodes
字典中找到 node_id
所对应节点的 clusterNode
结构,并将自己的 clusterState.myself.slaveof
指针指向这个结构,以此来记录这个节点正在复制的主节点clusterState.myself.flags
中的属性,关闭原本的 REDIS_NODE_MASTER
标识,打开 REDIS_NODE_SLAVE
标识,表示这个节点由原来的主节点变成了从节点clusterState.myself.slaveof
指向的 clusterNode
结构所保存的IP地址和端口号,对主节点进行复制。就是相当于向从节点发送命令 SLAVEOF
集群中的每个节点都会定期地想集群中的其他节点发送 PING
消息,以此来检测对方是否在线,如果接受 PING
消息的节点没有在规定时间内返回 PONG
,那么发送 PING
的节点就会将接受 PING
消息的节点标记为意思下线(PFAIL)。
当一个主节点A通过消息得知主节点B认为主节点C进入疑似下线状态,主节点A会在自己的 clusterState.nodes
字典中找到主节点C所对应的 clusterNode
结构,并将主节点B的下线报告添加到这个结构的 fail_reports
链表里面
struct clusterNode{
// ...
// 一个链表,记录了所有其他节点对该节点的下线 报告
list *fail_reports;
// ...
}
每个节点的下线报告由一个 clusterNodeFailReport
结构表示:
struct clusterNodeFailReport{
// 报告目标节点已经下线的节点
struct clusterNode *node;
// 最后一次从node节点收到下线报告的时间
// 程序使用这个时间戳来检查下线报告是否过期
// (与电气概念时间相差太久的下线报告会被删除)
mstime_t time;
}
如果一个集群里,半数以上负责处理槽的主节点都将某个主节点X报告为疑似下线,那么这个主节点X将被标记为已下线(FAIL),将主节点X标记为已下线的节点会向集群广播一条关于主节点X的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点X标记为已下线。
当一个从节点发现自己正在复制的主节点进入了已下线状态,从节点将开始对下线主节点进行故障转移:
slaveof no one
命令,成为新的主节点PONG
消息,告诉集群中的其他节点自己成为了新的主节点。CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
消息,要求所有收到这条消息、并具有投票权的主节点向这个从节点投票CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,表示这个主节点支持从节点成为新的主节点CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,并根据自己受到了多少条这种消息来统计自己获得了多少主节点 的支持MEET
消息:当发送者接到客户端发送的 CLUSTER MEET
命令时,发送者会向接收者发送 MEET
消息,请求接收者加入发送者当前所处的集群中。PING
消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过 PING
消息的节点发送 PING
消息,以此来检测选中的节点是否在线PONG
消息:当接收者收到发送者发来的 MEET
消息或者 PING
消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条 PONG
消息FAIL
消息:当一个主节点A判断另一个主节点B已经进入FAIL
状态时,节点A会向集群广播一条关于B的FAIL
消息,所有收到这条消息的节点都会立即将节点B标记为已下线。PUBLISH
消息:当节点接收到一个 PUBLISH
命令时,节点会执行这个命令,并向集群广播一条PUBLISH
消息,所有接收到这条 PUBLISH
消息的节点都会执行相同的 PUBLISH
消息。节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些消息,因为这些消息也会被消息接收者用到,所有消息头本身也是消息的一部分
每个消息头都由一个 cluster.h/clushterMsg
结构表示:
typedef struct{
// 消息的长度(包括这个消息头的长度和消息正文的长度)
unit32_t totlen;
// 消息的类型
uint16_t type;
// 消息正文包含的节点信息数量,只在发送MEET、PING、PONG这小中Gossip协议消息时使用
uint16_t count;
// 发送者所处的配置纪元
uint64_t currentEpoch;
// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
uint64_t configEpoch;
// 发送者的名字(ID)
char sender[REDIS_CLUSTER_NAMELEN];
// 发送者目前的槽指派信息
unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
// 如果发送者是一个主节点,那么这里记录的是REDIS_NONE_NULL_NAME
// (一个40字节长,值全为0的字节数组)
char slaveof[REDIS_CLUSTER_NAMELEN];
// 发送者的端口号
uint16_t port;
// 发送者的标识值
uint16_t flags;
// 发送者所处集群的状态
unsigned char state;
// 消息的正文
union clusterMsgData data;
}clusterMsg;
clusterMsg.data
属性指向联合 clusterh/clusterMsgData
,这个联合就是消息正文:
union clusterMsgData{
// MEET、PING、PONG消息的正文
struct{
// 每条MEET、PING、PONG消息都包括两个clusterMsgDataGossip结构
clusterMsgDataGossip gossip[1];
}ping;
// FAIL消息的正文
struct{
clusterMsgDataFail about;
}fail;
// PUBLISH消息的正文
struct{
clusterMsgDataPublish msg;
}publish;
// 其他消息的正文……
};
clusterMsg
结构的currentEpoch
、sender
、myslots
等属性记录了发送者自身的节点信息。接受者会根据这些信息,在自己的 clusterState.nodes
字典里找到发送者对应的 clusterNode
结构,并对结构进行更新。
节点通过消息头的 type
判断是三种消息中的哪一种。每次发送 MEET
、PING
、PONG
消息时,发送者都会从自己的已知节点中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip
中。接收者收到消息,会取出这两个 clusterMsgDataGossip
结构 ,并根据其中的信息对自己的 clusterState.nodes
进行更新。
clusterMsgDataFossip
结构记录的信息,对被选中节点所对应的 clusterNode
结构进行更新。clusterMsgDataGossip
(记录了被选中节点的名字,发送者和被选中节点最后一次发送和接收 PING 消息和 PONG 消息的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值)
custerMsgDataGossip
结构:
typedef struct {
// 节点的名字
char nodename[REDIS_CLUSTER_NAMELEN];
// 最后一次向该节点发送PING消息的时间戳
uint32_t ping_sent;
// 最后一次从该节点接收到PONG消息的时间戳
uint32_t pong_received;
// 节点的IP地址
char ip[16];
// 节点的端口号
uint16_t port;
// 节点的标识值
uint16_t flags;
}clusterMsgDataGossip;
在集群的节点数量比较大的情况下,单纯用 Gossip
协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,,而FAIL消息对实时性要求比较高。
FAIL 消息的正文由 cluster.h/clusterMsgDataFail
结构表示,这个节点只包含一个 nodename
属性,改属性记录了已下线节点的名字:
typedef struct {
// 记录已下线节点的名字(名字是唯一的哦)
char nodename[REDIS_CLUSTER_NAMELEN];
}clusterMsgDataFail;
当客户端先集群中的某个节点发送命令
PUBLISH <channel> <message>
的时候,接收到 PUBLISH 命令的节点不仅会向 channel
频道发送消息 message
,它还会想集群广播一条 PUBLISH 消息,所接收这条消息的节点都会向 channel
频道发送 message
消息。
PUBLISH 消息的正文由 cluster.h/clusterMsgDataPublish
结构表示:
typedef struct{
// 保存了channel参数的长度
uint32_t channel_len;
// 保存了message参数的长度
uint32_t message_len;
// 定义为 8 字节只是为了对齐其他消息结构
// 保存了客户单通过PUBLISH命令发送给节点的channel和message参数
// 0-channel_len-1 字节保存的是channel参数
// channel_len - channel_len+message_len-1 保存的是message参数
unsigned char bulk_data[8];
}clusterMsgDataPublish;