简介
Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
节点
一个Redis集群通常由多个节点组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组件一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接各个节点的工作可以通过CLUSTER MEET命令来完成。
CLUSTER MEET <ip> <port>
向一个节点发送CUSTER MEET 命令,可以让node节点与ip和port所指定的节点进行握手,当握手成功时,node节点就会将ip和port所指定的节点添加到node节点当前所在的集群中。
启动节点
一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。
节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件。
集群数据结构
clusterNode结构保存了一个节点的当前状态,如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等等。
每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态:
struct clusterNode {
// 创建节点的时间
mstime_t ctime;
// 节点的名字 ,由40个十六进制字符组成
char name[REDIS_CLUSTER_NAMELEN];
// 节点表示
// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
// 以及节点目前所处的状态(如在线或者下线)。
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的IP地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int prot;
// 保存连接节点所需的有关信息
clusterLink *link;
// ...
};
clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区:
typedef struct clusterLink {
// 连接的创建时间
mstime_t ctime;
// TCP套接字描述符
int fd;
// 输出缓冲区,保存着等待发送给其他节点的消息
sds sndbuf;
// 输入缓冲区,保存着从其他节点接收到的消息
sds rcvbuf;
// 与这个连接相关联的节点,如果没有的话,就为NULL
struct clusterNode *node;
} clusterLink;
每个节点都保存这一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含 多少个节点,集群当前的配置纪元,诸如此类:
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创建一个clusterNode结构,并将该结构添加到自己的clusterState. nodes字典里面。、
- 节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message)。
- 如果顺利的话,节点B接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
- 节点B将向节点A返回一条PONG消息。
- 如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了自己发送地MEET消息。
- 节点A将向节点B放回一条PING消息。
- 如果顺利的话,节点B将接收到节点A返回的PING消息,通过这条消息PING消息节点B可以知道节点A已经成功地接收到自己返回地PONG消息,握手完成。
节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。
槽指派
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot), 数据库中的每个键属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态;相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态。
通过向节点发送CLUSTER ADDSLOTS命令,可以将一个或多个槽指派给节点负责:
CLUSTER ADDSLOTS <slot> [slot ...]
记录节点的槽指派信息
clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽:
struct clusterNode {
// ...
unsigned char slots[16384/8];
int numslots;
// ..
};
slots属性是一个二进制位数组,这个数组的长度为16384/8=2048个字节,共含16384个二进制位。
Redis以0为起始索引,16383为终止索引,对slots数组中的16383个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽i:
- 如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i。
- 如果slots数组在索引i上的二进制位的值位0,那么表示节点不负责处理槽i。
传播节点的槽指派信息
一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
因为集群中的每个节点都会将自己的slots数组通过消息给集群中的其他节点,并且每个接收到slots数组的节点都会将数组保存到相应节点的clusterNode结构里面,因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。
记录集群所有槽的指派信息
clusterState结构中的slots数组记录了集群中所有的16384个槽的指派信息:
typedef struct cluserState {
// ..
clusterNode *slots[16384];
// ...
} clusterState;
slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:
- 如果slots[i]指针指向NULL, 那么表示槽i尚未指派给任何节点。
- 如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。
为什么有了clusterNode.slots还要clusterState.slots 如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经指派了,或者槽i被指派了那个节点,程序需遍历clusterState.nodes字典中的所有clusterNode结构,时间复杂度为O(N)。N为clusterState.nodes字典保存的clusterNode结构的数量。 而通过将所有槽的指派信息保存到clusterState.slots数组里面,程序要检查槽i是否已经被指派,有或者取得负责处理槽i的节点,只需访问cluseterState.slots[i]的值即可,这个操作的复杂度为O(1)。
在集群中执行命令
在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令。
当客户端向节点发送与数据库键有关的命令时,接收命令的节点就会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派 给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前要执行的命令。如图所示:
计算键属于哪个槽
节点使用以下算法来计算给定键key属于哪个槽:
def slot_number(key):
return CRC16(key) & 16383
其中,CRC16(key)用于计算键的CRC-16校验和,而& 16383则用于计算出一个介于0至16383之间的整数作为键key的槽号。
判断槽是否由当前节点负责处理
当节点计算出键所属的槽i之后,节点就会检查自己自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责:
1)如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令。
2)如果clusterState.slots[i]不等于clusterState.myself,那么说明槽并非由当前节点负责,节点会根据clusterState.slots[i]指向的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点。
MOVED错误
当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个MOVED错误,指引客户端转向至正在负责槽的节点。
节点数据库的实现
节点和单机服务器在数据库方面的一个区别是,节点只能用0号数据库,而单机Redis服务器则没有这一限制。
除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的slots_to_keys跳跃表来保存槽和键之间的关系:
typedef struct clusterState {
// ...
zskiplist *slots_to_keys;
// ..
} clusterState;
slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员都是一个数据库键:
- 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys条约表。
- 当节点删除数据库中的某个键值对时,节点就会在slots_to_keys跳跃表解除被删除键与槽号的关联。
重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线进行,在重新分片的过程中,集群不需要下线,并且源节点和目标 节点可以继续处理命令请求。
重新分片的实现原理
Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。
redis-trib对集群的单个槽slot进行重新分片的步骤如下:
1)redis-trib对目标节点发送CLUSTER SETSLOT IMPORT <source_id>命令,让目标节点准备好从源节点导入属于槽slot的键值对。
2)redis-trib对源节点发送CLUSTER SETSLOTS MIGATING <target_id>命令,让源节点准备好将属于槽slot的键值对迁移至目标节点。
3)redis-trib向源节点发送CLUSTER GETKEYSINSLOT 命令,获得最多count个属于槽slot的键值对的键名。
4)对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> 0 命令,将被选中的键原子地址从源节点迁移至目标节点。
5)重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都迁移到目标节点为止。
6)redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT NODE <target_id>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽slot已经指派给了目标节点。
如果重新分片涉及多个槽,那么redis-trib将对每个给定的槽分别执行上面给出的步骤。
ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:
- 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
- 如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
CLUSTER SETSLOT IMPORTING命令的实现
clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽:
typedef struct clusterState {
// ...
clusterNode *importing_slots_from[16384];
// ...
} clusterState;
如果importing_slots_from[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在从clusterNode所指向的节点导入槽i。
CLUSTER SETSLOT <i> IMPORTING <source_id>
可以将目标节点clusterState.importing_slots_from[i]的值设置为source_id所代表节点的clusterNode结构。
CLUSTER SETSLOT MIGRATING命令的实现
clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽:
typedef struct clusterState {
// ...
clusterNode *migrating_slots_to[16384];
// ...
} clusterState;
如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在将槽i迁移至clusterNode所代表的节点。
ASKING命令
ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个MOVED错误;但是,如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽i,并且发送命令的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次。如图所示:
并且客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING标识的客户端发送的命令之后,客户端的REDIS_ASKING标识就会被移除。
ASK错误和MOVED错误的区别
ASK错误和MOVED错误都会导致客户端转向,区别在于:
- MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点。
- ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。
复制与故障转移
Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
设置从节点
向一个节点发送命令:
CLUSTER REPLICATE <node_id>
可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:
- 接受到该命令的节点首先会在自己的clusterState.nodes字典中找到node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制主节点。
- 然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已经由原来的主节点变成了从节点。
- 最后,节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构所保存的IP地址和端口号,并对主节点进行复制。因为节点的复制功能和单机Redis服务器的复制功能使用了相同代码,所以让从节点复制主节点相当于向从节点发送命令SLAVEOF <master_ip> <master_port>。
一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。
集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单:
struct clusterNode {
// ...
// 正在复制这个主节点的从节点数量
int numslaves;
// 一个数组
// 每个数组项指向一个正在复制这个主节点的从节点的clusterNode结构
struct clusterNode **slaves;
// ...
}
故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(probable fail, PFAIL)。