1. Redis集群的解决方案
目前业界主流的Redis集群化方案主要包括以下几个:客户端分片、Codis、Twemproxy、Redis Cluster。客户端分片、Redis Cluster属于无中心化的集群方案,Codis、Tweproxy属于中心化的集群方案。是否中心化是指客户端访问多个Redis节点时,是直接访问还是通过一个中间层Proxy来进行操作,直接访问的就属于无中心化的方案,通过中间层Proxy访问的就属于中心化的方案,它们有各自的优劣。
1.1 客户端分片
客户端分片主要是说,我们只需要部署多个Redis节点,具体如何使用这些节点,主要工作在客户端。客户端通过固定的Hash算法,针对不同的key计算对应的Hash值,然后对不同的Redis节点进行读写。
客户端分片需要业务开发人员事先评估业务的请求量和数据量,然后让DBA部署足够的节点交给开发人员使用即可。
优点:部署非常方便,业务需要多少个节点DBA直接部署交付即可,剩下的事情就需要业务开发人员根据节点数量来编写key的请求路由逻辑,制定一个规则,一般采用固定的Hash算法,把不同的key写入到不同的节点上,然后再根据这个规则进行数据读取。
缺点:业务开发人员使用Redis的成本较高,需要编写路由规则的代码来使用多个节点,而且如果事先对业务的数据量评估不准确,后期的扩容和迁移成本非常高,因为节点数量发生变更后,Hash算法对应的节点也就不再是之前的节点了。所以后来又衍生出了一致性哈希算法,就是为了解决当节点数量变更时,尽量减少数据的迁移和性能问题。
应用场景:这种客户端分片的方案一般用于业务数据量比较稳定,后期不会有大幅度增长的业务场景下使用,只需要前期评估好业务数据量即可。
1.2 Codis
当需要使用Redis时,我们不想关心集群后面有多少个节点,我们希望我们使用的Redis是一个大集群,当我们的业务量增加时,这个大集群可以增加新的节点来解决容量不够用和性能问题。这种方式就是服务端分片方案,客户端不需要关心集群后面有多少个Redis节点,只需要像使用一个Redis的方式去操作这个集群,这种方案将大大降低开发人员的使用成本,开发人员可以只需要关注业务逻辑即可,不需要关心Redis的资源问题。
多个节点组成的集群,如何让开发人员像操作一个Redis时那样来使用呢?这就涉及到多个节点是如何组织起来提供服务的,一般我们会在客户端和服务端中间增加一个代理层,客户端只需要操作这个代理层,代理层实现了具体的请求转发规则,然后转发请求到后面的多个节点上,因此这种方式也叫做中心化方式的集群方案,如下图所示:
Codis就是以这种方式实现的集群化方案。Codis是由我国的豌豆荚团队开源的一个代理中间件,用的是 GO 语言开发的,Codis的架构图如下:
Codis 系统主要由 Codis-server、Codis-proxy、Codis-dashboard、Zookeeper 等组成:
- Codis-server 是 Codis 的存储组件,它是基于 Redis 的扩展,增加了 slot 支持和数据迁移功能,所有数据存储在预分配的 1024 个 slot 中,可以按 slot 进行同步或异步数据迁移。
- Codis-proxy 处理 Client 请求,解析业务请求,并路由给后端的 Codis-server group。Codis 的每个 server group 相当于一个 Redis 分片,由 1 个 master 和 N 个从库组成。
- Zookeeper 用于存储元数据,如 Proxy 的节点,以及数据访问的路由表。除了 Zookeeper,Codis 也支持 etcd 等其他组件,用于元数据的存储和通知。
- Codis-dashboard 是 Codis 的管理后台,可用于管理数据节点、Proxy 节点的加入或删除,还可用于执行数据迁移等操作。Dashboard 的各项变更指令通过 Zookeeper 进行分发。
Codis-Proxy就是负责请求转发的组件,它内部维护了请求转发的具体规则,Codis把整个集群划分为1024个槽位,在处理读写请求时,采用crc32
Hash算法计算key的Hash值,然后再根据Hash值对1024个槽位取模,最终找到具体的Redis节点。Codis最大的特点就是可以在线扩容,在扩容期间不影响客户端的访问,也就是不需要停机。这对业务使用方是极大的便利,当集群性能不够时,就可以动态增加节点来提升集群的性能。
1.3 Twemproxy
Twemproxy是由Twitter开源的集群化方案,它既可以做Redis Proxy,还可以做Memcached Proxy。它的功能比较单一,只实现了请求路由转发,没有像Codis那么全面有在线扩容的功能,它解决的重点就是把客户端分片的逻辑统一放到了Proxy层而已,其他功能没有做任何处理。
Tweproxy推出的时间最久,在早期没有好的服务端分片集群方案时,应用范围很广,而且性能也极其稳定。但它的痛点就是无法在线扩容、缩容,这就导致运维非常不方便,而且也没有友好的运维UI可以使用。Codis就是因为在这种背景下才衍生出来的。
1.4 Redis Cluster
采用中间加一层Proxy的中心化模式时,这就对Proxy的要求很高,因为它一旦出现故障,那么操作这个Proxy的所有客户端都无法处理,要想实现Proxy的高可用,还需要另外的机制来实现,例如Keepalive。而且增加一层Proxy进行转发,必然会有一定的性能损耗,那么除了客户端分片和上面提到的中心化的方案之外,还有比较好的解决方案么?
Redis官方推出的Redis Cluster另辟蹊径,它没有采用中心化模式的Proxy方案,而是把请求转发逻辑一部分放在客户端,一部分放在了服务端,它们之间互相配合完成请求的处理。
没有了Proxy层进行转发,客户端可以直接操作对应的Redis节点,这样就少了Proxy层转发的性能损耗。Redis Cluster也提供了在线数据迁移、节点扩容缩容等功能,内部还内置了哨兵完成故障自动恢复功能,可见它是一个集成所有功能于一体的Cluster。因此它在部署时非常简单,不需要部署过多的组件,对于运维极其友好。Redis Cluster在节点数据迁移、扩容缩容时,对于客户端的请求处理也做了相应的处理。当客户端访问的数据正好在迁移过程中时,服务端与客户端制定了一些协议,来告知客户端去正确的节点上访问,帮助客户端订正自己的路由规则。下面我们介绍redis cluster。
2. Redis Cluster
Redis集群是Redis 3.0版本中新加入的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
2.1. 集群节点
(1)启动节点
一个节点就是一个运行在集群下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。运行在集群模式下的节点会继续使用所有在单机模式中使用的服务器组件。只有在集群模式下才会用到的数据,节点将它们保存到了clusterNode、clusterLink和clusterState结构里面。
(2)多个节点组建集群
在刚开始的时候,每个节点都是相互独立的,它们都处在一个只包含自己的集群当中,要组件一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。连接各个节点的工作可以使用CLUSER MEET命令来完成,该命令的格式如下:
> cluster meet <ip> <port>
向一个节点发送这个命令时,可以让node节点与ip和port指定的节点进行握手,当握手成功时,node节点就会将ip和port所指定的节点添加到node节点当前所在的集群中。 redis集群的数量最大为槽slot的数量,即16384个节点。
组建好集群中查看集群状态的指令为:
> cluster nodes # 查看集群中各个节点的slot信息
> cluster info # 查看整个集群的信息
(3)集群的数据结构
- clusterNode:保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等。每个节点都会使用这个数据结构来保存自己的状态,并为集群中的其他节点都创建一个相应的数据结构来记录其他节点的状态。
/* 节点状态 */
typedef struct clusterNode {
mstime_t ctime; // 节点的创建时间
char name[CLUSTER_NAMELEN]; // 节点名字
int flags; // 节点标识,标识主节点或者从节点,以及节点所属状态(在线或者下线)
uint64_t configEpoch; // 节点当前的配置纪元,用于实现故障转移
unsigned char slots[CLUSTER_SLOTS/8]; // 该节点所处理的slot
sds slots_info; // slot的信息
int numslots; // 该节点处理的slot的个数
int numslaves; // 从节点的数量,如果是主节点的话
struct clusterNode **slaves; // 如果这是一个从节点,指向从节点
struct clusterNode *slaveof; // 指向主节点,这个可能为空
mstime_t ping_sent; // 最后一次发送 PING 命令的时间
mstime_t pong_received; // 最后一次接收 PONG 回复的时间戳
mstime_t data_received; // 收到任何数据的最新Unix time
mstime_t fail_time; // 最后一次被设置为 FAIL 状态的时间
mstime_t voted_time; // 最后一次给某个从节点投票的时间
mstime_t repl_offset_time; // 最后一次从这个节点接收到复制偏移量的时间
mstime_t orphaned_time; /* Starting time of orphaned master condition */
long long repl_offset; // 这个节点的复制偏移量
char ip[NET_IP_STR_LEN]; // 节点的IP
int port; /* Latest known clients port (TLS or plain). */
int pport; /* Latest known clients plaintext port. Only used
if the main clients port is for TLS. */
int cport; /* Latest known cluster port of this node. */
clusterLink *link; // 保存连接节点所需的有关信息
list *fail_reports; // 一个链表,记录了所有其他节点对该节点的下线报告
} clusterNode;
- clusterLink:保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区。
/* clusterLink 包含了与其他节点进行通讯所需的全部信息 */
typedef struct clusterLink {
mstime_t ctime; // 连接创建时间
connection *conn; // 与远程节点的连接
sds sndbuf; // 输出缓冲区,保存着待发送给其它节点的信息
char *rcvbuf; // 输入缓冲区,保存着从其它节点收到的信息
size_t rcvbuf_len; // 已经使用的输入缓冲区的大小
size_t rcvbuf_alloc; // 已经分配的输入缓冲区的大小
struct clusterNode *node; // 与这个连接相关联的节点,如果没有则为NULL
} clusterLink;
- clusterState:该结构记录了当前节点的视角下,集群目前所处的状态,如集群是在线还是下线,集群包含多少节点,集群当前的配置纪元等。
/* 集群状态,每个节点都保存着一个这样的状态,记录了它们眼中的集群的样子 */
typedef struct clusterState {
clusterNode *myself; // 指向当前节点的指针
uint64_t currentEpoch; // 当前纪元
int state; // 集群的状态,OK 或者 FAIL
int size; // 集群中至少处理着一个slot的节点的数量
dict *nodes; // 集群节点名单(包括myself),键为节点名,值为对应的clusterNode
dict *nodes_black_list; // 节点黑名单,用于 CLUSTER FORGET 命令
/*
* 记录要从当前节点迁移到目标节点的槽,以及迁移的目标节点
* migrating_slots_to[i] = NULL 表示槽 i 未被迁移
* migrating_slots_to[i] = clusterNode_A 表示槽 i 要从本节点迁移至节点 A
*/
clusterNode *migrating_slots_to[CLUSTER_SLOTS];
/*
* 记录要从源节点迁移到本节点的槽,以及进行迁移的源节点
* mporting_slots_from[i] = NULL 表示槽 i 未进行导入
* importing_slots_from[i] = clusterNode_A 表示正从节点 A 中导入槽 i
*/
clusterNode *importing_slots_from[CLUSTER_SLOTS];
/* 负责处理各个槽的节点,例如 slots[i] = clusterNode_A 表示槽 i 由节点 A 处理 */
clusterNode *slots[CLUSTER_SLOTS];
uint64_t slots_keys_count[CLUSTER_SLOTS]; // 每个slot包含多少个key
rax *slots_to_keys; // 基数树用于保存槽和键的关系
/* 以下这些域被用于进行故障转移选举 */
mstime_t failover_auth_time; // 次执行选举或者下次执行选举的时间
int failover_auth_count; // 节点获得的投票数量
int failover_auth_sent; // 如果值为 1 ,表示本节点已经向其他节点发送了投票请求
int failover_auth_rank; // This slave rank for current auth request.
uint64_t failover_auth_epoch; // 当前选举的纪元
int cant_failover_reason; // slave不能进行故障转移的原因
/* 共用的手动故障转移状态 */
mstime_t mf_end; // 手动故障转移执行的时间限制
clusterNode *mf_slave; // 主服务器的手动故障转移状态
long long mf_master_offset; // 从服务器的手动故障转移状态
int mf_can_start; // 指示手动故障转移是否可以开始的标志值,值为非 0 时表示各个主服务器可以开始投票
/* 以下这些域由主服务器使用,用于记录选举时的状态 */
uint64_t lastVoteEpoch; // 集群最后一次进行投票的纪元
int todo_before_sleep; // 在进入下个事件循环之前要做的事情,以各个 flag 来记录
long long stats_bus_messages_sent[CLUSTERMSG_TYPE_COUNT]; // 通过 cluster 连接发送的每个类型消息数量
long long stats_bus_messages_received[CLUSTERMSG_TYPE_COUNT]; // 通过 cluster 接收到的每个类型消息数量
long long stats_pfail_nodes; /* Number of nodes in PFAIL status,
excluding nodes without address. */
} clusterState;
2.2 槽指派
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分成16384个槽(slot),数据库中每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或者最多16384个槽。当数据库的16384个槽都有节点在处理时,集群处于上线状态,相反,如果数据库中任何一个槽没有的到处理,那么集群处于下线状态。
使用CLUSER MEET命令将多个节点连接到同一个集群里面后,这个集群仍然处于下线状态,因为集群中的三个节点都没有在处理任何槽。通过向节点发送CLUSER ADDSLOTS命令,我们可以将一个或者多个槽指派给节点负责:
> cluster addslots <slot> [slot ...]
# 或者用如下脚本实现
./redis-cli cluster addslots {5001..16384}
例如,执行以下命令可以将0-5000指派给节点7000负责:
127.0.0.1:7000> cluster addslots 0 1 2 ... 5000
(1)记录节点的槽指派信息
struct clusterNode
{
/* ... ... */
unsigned char slots[16384/8];
int numslots;
/* ... ... */
}
- slots属性是一个二进制位数组,用于保存该节点处理哪些槽
- numslots记录了节点负责处理的槽的数量,也即是slots数组中值为1的二进制位的数量。
(2)传播节点的槽指派信息
一个节点除了将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。其他节点收到之后,会将slots数组保存到相应节点的clusterNode结构里面,因此,集群中的每个节点都会知道数据库中16384个槽分别指派给了集群中的哪些节点。
(3)记录集群所有槽的指派信息
typedef struct clusterState
{
/* ... ... */
clusterNode *slots[16384];
/* ... ... */
} clusterState;
clusterState中的slots数组记录了集群中所有16384个槽的指派信息。如果slots[i]指针为NULL,那么表示槽i尚未指派给任何节点;如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。如果只将槽指派信息保存在各个节点的clusterNode.slots数组里面,会出现一些无法高效处理的问题,例如为了知道槽i是否已经被指派,或者槽i指派给了哪个节点,程序需要遍历clusterState.nodes字典中所有的clusterNode结构,检查这些结构的slots数组,这个过程负责度为O(N),而通过将所有槽指派信息保存在clusterState.slots数组里面,程序要检查槽i是否被指派或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度为O(1)。
clusterState.slots记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这时两个slots数组的关键所在。
2.3. 集群中执行命令
数据库中的16384个槽都进行了指派之后,集群就进入了上线状态。当客户端向节点发送与数据库有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己。
- 如果键所在的槽正好指派给了当前节点,那么节点直接执行这个命令
- 如果键所有的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前想要执行的命令。
(1)计算键属于哪个槽
CRC16(key) & 16383
# 集群中查看某个key属于哪个slot
> cluster keyslot key
CRC(16)用于计算键key的CRC-16的校验和,而&16383则用于计算出一个介于0至16383之间的整数作为键的槽号。
(2)判断槽是否由当前节点负责处理
得到槽号i之后,如果clusterState.slots[i]指向的是自己的节点,则节点执行命令。如果不是,则根据clusterState.slots[i]指向的clusterNode记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点。
(3)MOVED错误
MOVED错误的格式为:
MOVED <slot> <ip> <port>
- slot为键所在的槽
- ip和port是负责处理槽slot的ip和端口号
集群模式下,收到MOVED错误时不会打印出来,客户端会根据MOVED错误自己转向。
2.4. 重新分片
Redis集群重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关槽所属的键值对也会从源节点移动到目标节点。重新分片是由集群管理软件redis-trib负责执行的,redis提供了重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。具体步骤如下:
(1)redis-trib对目标节点发送命令,让目标节点准备好从源节点导入属于槽slot的键值对
(2)redis-trib对源节点发送命令,让源节点准备好将属于槽slot的键值对迁移到目标节点
(3)redis-trib对源节点发送命令,获得最多count个属于slot槽的键值对键名
(4)对于3)得到的键名,redis-trib都向源节点发送命令,将选中的键从源节点迁移到目标节点
(5)重复执行3和4步骤,直到源节点保存的所有属于槽slot的键值对都被迁移到目标节点为止。
(6)redis-trib向集群的任一节点发送命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群。
重新分片也可以用集群命令实现,语法如下:
./redis-cli --cluster reshard 127.0.0.1:6379 --cluster-from [Node_ID] --cluster-to [Node_ID]
--cluster-slots n # n 表示迁移n个slot
2.5. ASK错误
在进行重新分片期间,源节点向目标节点迁移过程中,可能会出现这样一种情景,属于被迁移槽的一部分键在源节点,另一部分键在目标节点。当客户端向数据库发送一个与数据库键有关的命令,并且命令要处理的键属于正在被迁移的槽时:
- 源节点会现在自己的数据库查找指定的键,如果找到的话,就执行客户端发送的命令
- 如果没找到,那么这个键可能已经被迁移到目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要处理的命令。
ASK错误和MOVED命令的区别:
- MOVED错误会影响客户端今后的命令发送所指向的节点
- ASK错误不会影响客户端今后发送的命令所指向的节点
节点的检查的命令为:
./redis-cli --cluster check IP port
节点修复的命令为:
./redis-cli --cluster fix IP port
2.6. 复制和故障转移
(1)设置从节点
向一个节点发送命令:
CLUSTER REPLICATE <node-id>
可以让接收命令的节点成为node-id所指定节点的从节点,并开始对主节点进行复制。
(2)故障检测
集群中的每个节点都会定期向集群中的其它节点发送PING信息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线节点。flags的取值可以为:
#define REDIS_NODE_MASTER 1 // 该节点为主节点
#define REDIS_NODE_SLAVE 2 // 该节点为从节点
#define REDIS_NODE_PFAIL 4 // 该节点疑似下线,需要对它的状态进行确认
#define REDIS_NODE_FAIL 8 // 该节点已下线
#define REDIS_NODE_MYSELF 16 // 该节点是当前节点自身
#define REDIS_NODE_HANDSHAKE 32 // 该节点还未与当前节点完成第一次 PING - PONG 通讯
#define REDIS_NODE_NOADDR 64 // 该节点没有地址
#define REDIS_NODE_MEET 128 // 当前节点还未与该节点进行过接触,带有这个标识会让当前节点发送 MEET 命令而不是 PING 命令
#define REDIS_NODE_PROMOTED 256 // 该节点被选中为新的主节点
在一个集群里面,集群的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点是处于在线状态、疑似下线状态还是已下线状态,如果半数以上处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线,将主节点x标记为已下线的节点会向集群广播一条关于主节点x已下线的消息,所有收到已下线消息的节点都会将主节点x标记为已下线。
(3)故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,具体步骤如下:
- 复制下线主节点的从节点里面,会有一个从节点被选中
- 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点
- 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
- 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
- 新的主节点开始处理和接收自己负责处理的槽有关的命令请求,故障转移完成
(4)选举新的主节点
集群选举新的主节点的流程如下:
(1)集群的配置纪元是一个自增计数器,它的初始值为0;
(2)当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增1;
(3)对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票机会,而第一个向主节点要求投票的从节点将获得主节点的投票;
(4)当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条消息,要求所有收到这条消息并且具有投票的主节点向这个节点投票;
(5)如果一个主节点具有投票权,并且这个主节点尚未投票给其它从节点,那么主节点将向要求投票的从节点返回一条投票消息,表示这个主节点支持从节点成为新的主节点;
(6)每个参与选举的从节点收到投票消息后,根据自己收到多少条这种消息来统计自己获得多少主节点的支持;
(7)如果集群中有N个具有投票的主节点,那么当一个从节点收集到大于等于N/2 + 1张支持票时,这个主节点就会当选为新的主节点;
(8)因为在每个配置纪元里面,每个具有投票的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2 + 1张支持票的从节点只会有一个,这确保了新的主节点只会有一个;
(9)如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入下一个新的纪元配置,并在此进行选举,知道选出新的主节点为止。
2.7. 集群消息
集群发送的消息主要有以下5种:
- MEET消息(单播):
当发送者收到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送MEET消息,请求接收者加入到当前所处的集群里面;
- PING消息(单播)
集群里的每个节点默认没隔1s就会从已知节点列表中随机选出5个节点,然后对这5个节点中最长时间没有发送PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。此外,如果节点A最后一次收到节点B发送的PONG的时间,距离当前时间已经超过了节点A的cluster-node-timeout的时长一半,那么A节点也会向B节点发送PING消息,这可以防止A节点长时间没有随机选中B节点发送PING消息。
- PONG消息(单播或广播)
当接收者接收到发送者发送来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会发送一条PONG消息。此外,一个节点也可以向集群其它节点广播自己的PONG消息来刷新其它节点对这个节点的认知。例如当一次故障转移操作完成之后,主节点会向集群广播一条PONG消息,以此来让集群中的其它节点立即知道这个节点已经变成了主节点。
- FAIL消息(广播)
当一个主节点A判断另一个主节点B进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到的这条消息的节点都会立即将节点B标记为已下线。
- PUBLISH消息(广播)
当节点收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。
一条消息由消息头和消息正文组成:
(1)消息头
节点发送的消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息,消息的结构体定义为clusterMsg:
/* 用来表示集群消息的结构(消息头,header)*/
typedef struct {
char sig[4]; /* Siganture "RCmb" (Redis Cluster message bus). */
// 消息的长度(包括这个消息头的长度和消息正文的长度)
uint32_t totlen; /* Total length of this message */
uint16_t ver; /* Protocol version, currently set to 0. */
uint16_t notused0; /* 2 bytes not used. */
uint16_t type; // 消息的类型
/* 消息正文包含的节点信息数量,只在发送 MEET 、 PING 和 PONG 这三种 Gossip 协议消息时使用 */
uint16_t count;
uint64_t currentEpoch; // 消息发送者的配置纪元
// 如果消息发送者是一个主节点,那么这里记录的是消息发送者的配置纪元
// 如果消息发送者是一个从节点,那么这里记录的是消息发送者正在复制的主节点的配置纪元
uint64_t configEpoch;
uint64_t offset; // 节点的复制偏移量
char sender[REDIS_CLUSTER_NAMELEN]; // 消息发送者的名字(ID)
unsigned char myslots[REDIS_CLUSTER_SLOTS/8]; // 消息发送者目前的槽指派信息
// 如果消息发送者是一个从节点,那么这里记录的是消息发送者正在复制的主节点的名字
// 如果消息发送者是一个主节点,那么这里记录的是 REDIS_NODE_NULL_NAME
char slaveof[REDIS_CLUSTER_NAMELEN]; // (一个 40 字节长,值全为 0 的字节数组)
char notused1[32]; /* 32 bytes reserved for future usage. */
uint16_t port; // 消息发送者的端口号
uint16_t flags; // 消息发送者的标识值
unsigned char state; // 消息发送者所处集群的状态
unsigned char mflags[3]; // 消息标志
union clusterMsgData data; // 消息的正文(或者说,内容)
} clusterMsg;
/* 消息的内容 */
union clusterMsgData {
/* PING, MEET and PONG */
struct {
// 每条MEET、PING、PONG消息都包含两个 clusterMsgDataGossip 结构
clusterMsgDataGossip gossip[1];
} ping;
/* FAIL */
struct {
clusterMsgDataFail about;
} fail;
/* PUBLISH */
struct {
clusterMsgDataPublish msg;
} publish;
/* UPDATE */
struct {
clusterMsgDataUpdate nodecfg;
} update;
};
clusterMsg的消息头中记录了发送者自身的节点信息,接收者会根据这些信息,在自己的clusterState.nodes字典中找到发送者对应的clusterNode结构,并对结构进行更新。
(2)MEET、PING、PONG消息
Redis集群中的各个节点通过Gossip协议来交换各自不同节点的状态信息,Gossip协议原理参考这篇博客。因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头的type属性来判断这一条消息是MEET消息、PING消息还是PONG消息。
每次发送消息时,发送者都从自己的已知节点列表中随机选出两个节点,并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip结构里面,该结构记录了被选中节点的名字,发送者与被选中者最后一次发送PING和接收PONG消息的时间戳,被选中节点的IP和端口号,以及被选中节点的标识值:
typedef struct {
// 节点的名字
// 在刚开始的时候,节点的名字会是随机的
// 当 MEET 信息发送并得到回复之后,集群就会为节点设置正式的名字
char nodename[REDIS_CLUSTER_NAMELEN];
uint32_t ping_sent; // 最后一次向该节点发送 PING 消息的时间戳
uint32_t pong_received; // 最后一次从该节点接收到 PONG 消息的时间戳
char ip[REDIS_IP_STR_LEN]; // 节点的 IP 地址
uint16_t port; // 节点的端口号
uint16_t flags; // 节点的标识值
uint32_t notused; // 对齐字节,不使用
} clusterMsgDataGossip;
当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个clusterMsgDataGossip结构,并根据自己是否认识结构中记录的选中节点来选择进行哪种操作:
- 如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息和被选中节点进行握手。
- 如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被选中节点进行过接触,接收者将根据结构中记录的信息,对被选中节点所对应的clusterNode结构进行更新。
(3)FAIL消息
在集群数量比较大的情况下,单纯使用Gossip协议来传播节点的下线信息会给节点的信息更新带来一定延迟,因此Gossip协议消息通常需要一段时间才能传播至整个集群,而发送FAIL消息可以让集群所有节点立即知道某个节点是否下线,从而尽快判断是否将集群标记为下线,又或者对下线主节点进行故障转移。FAIL消息只包含一个nodename属性,记录了已下线节点的名字:
typedef struct {
// 下线节点的名字
char nodename[REDIS_CLUSTER_NAMELEN];
} clusterMsgDataFail;
(4)PUBLISH消息
当客户端向集群中的某个节点发送PUBLISH命令时,接收到PUBLISH命令的节点不仅会向channel频道发送消息message,它还会向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会向channel频道发送message消息。换句话说,向集群中的某个节点发送PUBLISH命令时,集群中的所有节点都会向channel频道发送message消息。
typedef struct {
// 频道名长度
uint32_t channel_len;
// 消息长度
uint32_t message_len;
// 消息内容,格式为 频道名+消息
// bulk_data[0:channel_len-1] 为频道名
// bulk_data[channel_len:channel_len+message_len-1] 为消息
unsigned char bulk_data[8]; /* defined as 8 just for alignment concerns. */
} clusterMsgDataPublish;
- buik_data的0字节至channel_len - 1字节保存的时channel参数
- bulk_data的channel_len字节至channel_len + message_len - 1字节保存的则是message参数
2.8. 槽数量16384
(1)如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。
如在消息头中,最占空间的是myslots[CLUSTER_SLOTS/8]
。当槽位为65536时,这块的大小是:65536÷8÷1024=8kb。
因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。
(2)redis的集群主节点数量基本不可能超过1000个
集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。
(3)槽位越小,节点少的情况下,压缩比高
Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。ps
:文件压缩率指的是,文件压缩前后的大小比。
参考:《Redis设计与实现》