一.概述
redis集群是Redis提供的分布式数据库方案,它通过将数据进行分片存储来实现,并提供复制和故障转移功能。redis集群由多个节点组成,每个节点都是一个运行在集群模式下的redis服务器。
二.集群的节点
1.节点
redis集群中的每个节点都是一个运行在集群模式下的redis服务器,redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式(但会继续使用所有在单机模式中使用的服务器组件,如文件时间事件处理程序,RDB,AOF模块,主从复制模块等等)。起初每个节点都是相互独立的,都只处于一个只包含自己的集群当中。要将这些几点连接起来需要使用命令CLUSTER MEET <ip> <port>命令,该命令可以让节点与ip:port指定的节点进行握手,若握手成功,则将ip:port指定节点加入到自己的集群中。
2.节点数据结构
redis中每个几点的状态都由一个clusterNode结构进行描述,其中保存了节点的创建时间,节点名,当前配置纪元,ip,port以及用于与其它节点连接所需的数据。其结构如下所示:
// 保存了连接节点所需的有关信息
typedef struct clusterLink {
mstime_t ctime; // 连接的创建时间
int fd; // TCP套接字描述符
sds sndbuf; // 发送缓存
sds rcvbuf; // 输入缓存
struct clusterNode *node; // 与这个连接相关联的节点
} clusterLink;
// 描述了一个节点的状态信息与通信方式
typedef struct clusterNode {
mstime_t ctime; // 创建时间
char name[REDIS_CLUSTER_NAMELEN]; // 节点名称
int flags; // 节点标志,比如:REDIS_NODE_MASTER表示该节点是一个主节点
uint64_t configEpoch; // 配置纪元
unsigned char slots[REDIS_CLUSTER_SLOTS/8]; // 该节点处理那些槽(即REDIS_CLUSTER_SLOTS个二进制位的位图)
int numslots; // 该节点处理的槽的总数
int numslaves; // 该节点的从服务器数量
struct clusterNode **slaves; // 如果该节点是一个主节点,那么该数组存储了所有指向从节点结构的指针
struct clusterNode *slaveof; // 如果这是一个从节点,那么指向主节点
mstime_t ping_sent; // 最后一次发送ping命令的时间
mstime_t pong_received; // 最后收到PONG回复的时间
mstime_t fail_time; /* Unix time when FAIL flag was set */
mstime_t voted_time; /* Last time we voted for a slave of this master */
mstime_t repl_offset_time; /* Unix time we received offset for this node */
mstime_t orphaned_time; /* Starting time of orphaned master condition */
long long repl_offset; /* Last known repl offset for this node. */
char ip[REDIS_IP_STR_LEN]; // 节点IP
int port; // 端口号
clusterLink *link; // 保存了用于连接该clusterNode结构描述的节点所需的有关信息
list *fail_reports; // 记录了所有其它节点对该节点的下线报告,每个报告都是一个clusterNodeFailReport结构
} clusterNode;
【redisClient与clusterLink的异同点】:redisClinet与clusterLink结构都有自己的套接字描述符和输入输出缓冲区,这两个结构的区别在于redisClient中的套接字是用于连接客户端的,而clusterLink中的套接字用于连接节点。
3.节点中保存的集群状态
redis中每个节点都使用clusterState结构保存了在自己视角下的集群状态。包括:集群的在线状态(在线还是下线),集群节点字典nodes,集群的当前配置纪元等等。其结构如下所示:
// 集群状态
typedef struct clusterState {
clusterNode *myself; // 指向当前节点的指针 /* This node */
uint64_t currentEpoch; // 配置纪元
int state; // 集群的当前状态是上线还是下线,当所有槽都分配完毕后进入上线状态
int size; // 集群中至少处理着一个槽的集群的数量 /* Num of master nodes with at least one slot */
dict *nodes; // 集群节点的名单 /* Hash table of name -> clusterNode structures */
dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */
clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS]; // 记录了当前节点正在迁移至其它节点的槽
// 记录了当前节点正在从其它节点导入的槽,若importing_slots_from[i]不为空,则说明正从importing_slots_from[i]指向的节点导入槽i
clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS];
// 若slots[i] == NULL则说明槽i还未指派给任何节点,否则说明已指派给了clusterNode代表的节点(dict *nodes)
clusterNode *slots[REDIS_CLUSTER_SLOTS];
// 该跳跃表存储了槽和键之间的关系(跳跃表中的分值即槽号,值为键(字符串对象)),以便快速查找某个槽所对应的键
zskiplist *slots_to_keys;
/* The following fields are used to take the slave state on elections. */
mstime_t failover_auth_time; /* Time of previous or next election. */
int failover_auth_count; /* Number of votes received so far. */
int failover_auth_sent; /* True if we already asked for votes. */
int failover_auth_rank; /* This slave rank for current auth request. */
uint64_t failover_auth_epoch; /* Epoch of the current election. */
int cant_failover_reason; /* Why a slave is currently not able to
failover. See the CANT_FAILOVER_* macros. */
/* Manual failover state in common. */
mstime_t mf_end; /* Manual failover time limit (ms unixtime).
It is zero if there is no MF in progress. */
/* Manual failover state of master. */
clusterNode *mf_slave; /* Slave performing the manual failover. */
/* Manual failover state of slave. */
long long mf_master_offset; /* Master offset the slave needs to start MF
or zero if stil not received. */
int mf_can_start; /* If non-zero signal that the manual failover
can start requesting masters vote. */
/* The followign fields are used by masters to take state on elections. */
uint64_t lastVoteEpoch; /* Epoch of the last vote granted. */
int todo_before_sleep; /* Things to do in clusterBeforeSleep(). */
long long stats_bus_messages_sent; /* Num of msg sent via cluster bus. */
long long stats_bus_messages_received; /* Num of msg rcvd via cluster bus.*/
} clusterState;
其中的nodes字典记录了同一集群下的所有节点,其键为节点的名字,值为表示该节点的clusterNode结构。myself指向描述本节点的clusterNode结构,此结构亦保存在nodes字典中(即本节点的clusterNode结构被myself与nodes字典共享)。
三.节点的连接
客户端通过向节点A发送CLUSTER MEET <B_IP> <B_PORT>命令,可以将节点B添加到节点A所在的集群中。当收到命令后,A节点首先会和B节点进行握手操作,以确定彼此处于正常状态且网络状态良好。
1.握手过程
step1):节点A收到CLUSTER MEET <B_IP> <B_PORT>命令后将为B创建一个clusterNode节点,并将其添加到clusterState结构的nodes字典中。
step2):节点A创建连向B的连接,并发送一条MEET消息。
step3):若节点B成功接收到A发送的MEET消息,节点B也将为A创建一个clusterNode节点,并添加到自己的clusterState结构的nodes字典中。之后会回复一条PONG消息。
step4):当A收到B回复的PONG消息后会再回复一条PING消息至B,当B收到该条消息后握手完成
step5):A节点在握手完成后还会将B节点的信息通过Gossip协议发送给集群中的其它节点,让其他节点与B节点进行握手,一段时间之后,集群中的节点都将认识B节点
【注】:Gossip协议是分布式系统中的一种常用协议。
四.redis槽(数据库分片分布存储)
1.槽的概念
redis中槽的概念很像是单机上哈希表结构的一个桶,即每个槽都会对应一部分键值对数据,每个节点则负责处理一部分的槽,从而实现了每个节点处理一部分的数据。总的来书redis的槽就是一个分布式hash的桶。
2.槽分配
redis集群将整个数据库分为16384个槽,数据库中的每个键都属于其中一个,集群中每个节点可以处理0~16384个槽。客户端可以通过向节点发送CLUSTER ADDSLOTS命令将一个或多个槽指派给该节点。当对数据库(节点不同于一般的redis服务器,节点只可使用0号数据库)中的16384个槽都进行了分配,该集群就会处于上线状态。
3.记录本节点的槽分配信息
每个节点都将自己的槽分配信息保存在clusterNode结构的一张位图中,每一个比特位分别对应16384个槽中的一个,若本节点拥有该槽,则将相应比特位置为1,否则为0。其结构如下:
struct clusterNode {
...
unsigned char slots[REDIS_CLUSTER_SLOTS/8]; // 该节点处理那些槽(即REDIS_CLUSTER_SLOTS个二进制位的位图)
int numslots; // 该节点处理的槽的总数
...
}
每个节点还会将位图slots通过消息发送至集群中的其它节点,以此来告知自己目前负责处理哪些槽。当其它节点收到源节点的slots位图后还会在clusterState.nodes字典中查找源节点对应的clusterNode,并更新其中的位图。
4.记录集群中槽的指派信息
前面说到每个节点的clusterState结构都记录了自己视角下集群的状态,当然其中也记录了集群中的槽指派信息,其中定义了一个槽总数大小的clusterNode* slots[16384]数组,其中每一个下标对应着一个槽,而其元素指向描述拥有该槽的节点的clusterNode结构。即,若slots[ i ]指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode所指代的节点; 若slots[ i ] 指向NULL,那么槽i尚未指派给任何节点。
【思考】:为什么已经在每一个clusterNodes中都记录了分配给该节点的槽,还要在clusterStates结构中维护这样一个指针数组,直接查找clusterState.nodes不就好了吗?
【答】:这是因为若想知道某个槽是否已指派,那么需要变量字典nodes中的每一个节点,这样的时间复杂度为O(n),而查找clusterState中的指针数组slots的时间复杂度为O(1)。
【思考】:那么既然有了clusterState中的指针数组slots,为什么还需要在每个接节点结构clusterNode中维护一个位图呢?
【答】:因为需要将自己槽分配信息告知其它节点,若只使用clusterState.slots,那么需要先遍历该指针数组,记录分配信息再发送至其它节点。而使用位图的话只需直接发送即可,显然着更加高效。
五.在集群中执行命令
1.命令处理过程
当集群处于上线之后,便可以接收并处理来自客户端的命令。若该命令与数据库键有关,那么接收该命令的节点会计算该键属于哪个槽(就好似hash表中某个键属于哪个桶一样),并根据该槽是否被分配给了自己进行以下两种处理:
- 该槽属于本节点:该节点直接执行这个命令
- 该键不属于本节点:向客户端返回一个MOVED错误,指引客户端转向正确的节点,之后客户端将重新发送要执行的命令。
2.计算键属于哪个槽
前面说过redis的槽就好似哈希表的桶,当将某个值放入hash表时先要将键通过hash算法转化为桶索引,同样redis中为了将键对应到槽也使用了一个算法进行这种映射转化。redis中使用CRC-16计算key的校验和,之后 &16383,其结果便是槽号(slot = CRC16(key & 16383)),这样就将key对应到了一个槽。
3.如何判断该槽是否由本节点处理
当节点计算出键所属的槽号i后,节点会检查clusterState.nodes[i]是否等于clusterState.myself,若相等说明该槽被分配给了本节点,否则不属于本节点,那么将客户端返回MOVED错误,使客户端转向正确的节点。
4.MOVED错误
MOVED错误是为了让客户端转向正确的处理节点,其格式为:MOVED <slot> <ip>:<port>;slot为键所对应的槽,ip和port则是负责处理该槽的节点地址与端口。
集群模式下的客户端在收到MOVED错误后并不会被打印出来,而是会自动进行节点转发,而单机模式下的客户端会打印该错误(其并不认识MOVED)。
5.节点数据库及键槽映射跳跃表
节点(分布式服务器)不同于单机redis服务器,节点只能使用0号数据库。此外除了像单机数据库那样每个键值对都存在于数据库的字典中,节点还会维护一个跳跃表将键与槽号进行关联,这样便可以快速访问某一槽内的所有键。
struct clusterState {
...
// 该跳跃表存储了槽和键之间的关系(跳跃表中的分值即槽号,值为键(字符串对象)),以便快速查找某个槽所对应的键
zskiplist *slots_to_keys;
...
};
此跳跃表内每一个节点的分值都是一个槽号,每个节点的成员都是一个数据库的键(跳跃表内的节点会按照各自所保存的分值从小到达进行排序)。每当往数据库中添加一个键值对时,节点就会将这个键以及对应的槽号关联到跳跃表slots_to_keys中(即向跳跃表中添加一个分值为槽,值为键的节点)。
当我们使用CLUSTER GETKEYSINSLOT <slot> <count>变可以快速从跳跃表中返回最多count个属于槽slot的键。
【总结】:综上所述,redis节点中三个与槽键有关的结构为clusetrNode.slots位图,clusterState.slots指针数组,以及键槽映射跳跃表clusterState.slots_to_keys,它们的优点分别为:
- clusetrNode.slots位图:便于通知其它节点自己负责的槽
- clusterState.slots指针数组:便于快速查找某个槽被分配给了哪个节点
- 键槽映射跳跃表clusterState.slots_to_keys:便于快速超找属于某个槽的键
六.重新分片
1.重新分片的概念
redis重新分片即将任意数量的已经指派给某个节点(源节点)的槽重新指派给另一个节点(目标节点),属于这些槽的数据也将被转移至目标节点。重新分片过程是在线进行的,无需下线,且源节点和目标节点在重新分片过程中都可以继续处理命令请求。
2.重新分片的实现原理
Redis中的重新分片操作由redis集群管理软件redis-trib负责执行,redis-trib对每个槽进行重新分片的步骤如下:
step1)redis-trib向目标节点(迁移的目标节点)发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,以告知目标节点准备接收来自source_id节点的槽slot的迁移。当目标节点收到该命令后会将clusterState.importing_slots_from[slot]指向代表源节点的clusterNode结构。(结构: clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS]; ,若importing_slots_from[i]不为空则表示正在从importing_slots_from[i]指向的节点迁移槽 i )
step2)redis-trib向源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,以告知源节点准备将槽slot迁移至目标节点target_id。当源节点收到该命令后会将migrating_slots_to[slot]指向代表目标节点的clusterNode。(结构:clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS];若migrating_slots_to[i]不为空,则表示正在将槽i迁移至migrating_slots_to[i]所指向的节点)
step3)redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令,获取最多count个属于槽slot的键名。
step4)对步骤3中获得的每个键名,redis-trib都将向目标节点发送一个MIGRATE<target_ip> <target_port> <key_name> 0 <time_out>命令,告知目标节点去源节点获取该键值对。
step5)重复第三,第四步,直至 槽slot的所有键值对都被迁移至目标节点。
step6)redis-trib通过向任意一个节点发送slot已被重新指派的通知,之后通过消息传至整个集群。
3.ASK转向
在迁移过程中会出现该槽的一部分键还在源节点中,而另一部分在目标节点中的情况。此时若源节点收到客户端关于属于该槽的键的命令,那么会检查该节点是否存在于源节点,若存在则直接处理,否则会检查migrating_slots_to[slot]是否为空,若不为空则表示该槽正在进行迁移,那么向客户端返回一个ASK转向,指引客户端转向目标节点。
当客户端收到一个ASK转向命令后,会转向命令中的地址和端口号所指的节点,随后首先发送一个ASKING命令,这样目标节点就会为该客户端添加REDIS_ASKING标志(即标识该客户端是被ASK转向引导过来的),若不发送该命令(即客户端未被标识为REDIS_ASKING),那么目标节点将直接返回一个MOVED错误。在发送ASKING命令后,接着发送要执行的命令。目标节点的处理过程如下:
【ASK与MOVED的区别】:ASK转向用于在重新分配槽的过程中引导客户端转向正确的节点,而MOVED是在槽已完成指派时引导客户端转向正确的节点。即ASK用于中间转移过程,而MOVED用于已完成分配。
七.复制与故障转移
1.主从节点概念
就像可以为单机Redis服务器设置从服务器一样,也可以为某个节点设置从节点。主节点用于处理槽,而从节点用于复制某个节点,并当主节点下线时代替主节点继续处理命令。当某个主节点下线时,会由其它主节点投票决定选取下线主服务器的那个从服务器来进行代替(回想并对比一下,主服务器客观下线时,是先由各个哨兵选举领头哨兵,再由领头哨兵筛选决定将哪个从服务器提升为主服务器的)。
2.为主节点设置从节点
通过向某个节点发送CLUSTER REPLICATE <node_id>命令使该节点成为node_id节点的从节点。一个系欸但变为从节点的过程如下:
step1)当从节点收到该命令后会先更改自己的clusterNode.flags(通过clusterState.myself找到)标志为REDIS_NODE_SLAVE,即标识自己是一个从节点,并将clusterNode.slaveof指向代表要复制主节点的clusterNode结构。
step2)复制主节点,复制的代码与主从服务器复制的代码相同。
一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送至集群中的其它节点,最终整个集群都将知道某个从节点正在复制某个主节点。且每个节点都会将从节点信息添加到其主节点对应的clusterNode结构的slaves数组中。数组定义如下所示:
struct clusterNode {
...
struct clusterNode **slaves; // 如果该节点是一个主节点,那么该数组存储了所有指向从节点结构的指针
...
};
3.主节点下线检测
集群中的每个节点都会定期向其它节点发送PING命令,若接收PING的节点未在规定时间内返回PONG,则认为其处于疑似下线状态,设置疑似下线标志clusterNode.flag &= REDIS_NODE_PFAIL。
集群中的各个节点将定期交换各个节点的状态信息,其中便包括是否疑似下线,若有半数以上的主节点认为某主节点处于疑似下线状态(注意与哨兵系统如何判断主服务器下线对比),那么该主节点将被标记为已下线,并将主节点已下线的消息告知集群中的其它节点(包括已下线主节点的从节点,这样之后才可开始故障转移),所有收到该消息的节点都会将该主节点标记为已下线。
4.主节点下线后的故障转移
从节点发现自己复制的主节点已处于下线状态,那么会要求成为新的主节点,在选举完成后,将会有一个从节点被推举为新的主节点,该节点将执行SLAVEOF no one命令以成为主节点,已下线主节点的槽将全部归由新的主节点处理,最后主节点将向集群广播PONG消息,用于告知其它节点自己已成为新的主节点。
5.如何选举新的主节点
就像在哨兵系统中选举领头哨兵时每个纪元内每个哨兵只能投出一票一样,集群也存在配置纪元,在每个配置纪元内,所有的主节点只能投出一票。每个从节点在发现主节点进入已下线状态时,将广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息至集群,若某个主节点据由投票权(该主节点被分配了槽)且尚未投票,那么将向发送请求投票消息的从节点返回CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK,表示支持从节点成为主节点。若某个从节点获得的投票超过一半,那么将成为主节点。若本纪元未产生新的主节点,那么将进入下一个纪元重新选举。