节点与集群构建
节点组成:Redis集群由多个独立节点组成,通过
CLUSTER MEET
命令实现节点握手,形成集群。握手过程:
节点A为节点B创建
clusterNode
结构,发送MEET
消息。节点B接收后创建节点A的
clusterNode
结构,返回PONG
消息。节点A返回
PING
消息,完成握手。
之后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。
数据结构:
clusterNode:
结构的link属性是一个 clusterLink 结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输人缓冲区和输出缓冲区:
struct clusterNode { // 创建节点的时间 mstime_t ctime; // 节点的名字,由40个十六进制字符组成 // 例如68eef66df23420a5862208ef5b1a7005b806f2ff char name[REDIS_CLUSTER_NAMELEN]; // 节点标识 // 使用各种不同的标识值记录节点的角色(比如主节点或者从节点) // 以及节点目前所处的状态(比如在线或者下线)。 int flags; // 节点当前的配置纪元,用于实现故障转移 uint64_t configEpoch; // 节点的IP地址 char iP[REDIS_IP_STR_LEN]; // 节点的端口号 int port; // 保存连接节点所需的有关信息 clusterLink *link; // 负责的槽位(二进制位数组) unsigned char slots[16384 / 8]; int numslots; //... };
clusterLink
:clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输人缓冲区和输出缓冲区.
typedef struct clusterLink { // 连接的创建时间 mstime_t ctime; // TCP 套接字描述符 int fd; // 输出缓冲区(保存待发送给其他节点的消息) sds sndbuf; // 输入缓冲区(保存从其他节点接收的消息) sds rcvbuf; // 关联的节点(NULL 表示未关联) struct clusterNode *node; } clusterLink;
clusterState
:每个节点都保存着一个 clusterState 结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类。
typedef struct clusterState {
// 当前节点自身的指针
clusterNode *myself;
// 集群当前配置纪元(用于故障转移)
uint64_t currentEpoch;
// 集群状态(在线/下线)
int state;
// 处理至少一个槽的节点数量
int size;
// 集群节点字典(键为节点ID,值为 clusterNode 结构)
dict *nodes;
// 所有槽的指派信息(数组索引为槽号,值为指向 clusterNode 的指针)
clusterNode *slots[16384];
// 当前节点正在导入的槽(用于重新分片)
clusterNode *importing_slots_from[16384];
// 当前节点正在迁移的槽(用于重新分片)
clusterNode *migrating_slots_to[16384];
// 槽与键的映射关系(跳跃表,分值=槽号,成员=键名)
zskiplist *slots_to_keys;
} clusterState;
槽指派与数据分片
槽总数:16384个槽,所有槽被指派后集群上线(
cluster_state:ok
)。槽指派命令:
CLUSTER ADDSLOTS <slot>
将槽指派给节点。
127.0.0.1:7000>CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
OK
查看集群状态
127.0.0.1:7000>CLUSTER NODES
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388317426165
槽信息存储(节点视角):
clusterNode.slots
(二进制位数组,记录自身负责的槽)。
slots属性 unsigned char
占用 1 个字节(8 位)的内存,所有位都用于表示数值,没有符号位。因此,其取值范围为 0 到 255,是一个二进制位数组(bitarray),这个数组的长度为16384 / 8=2048个字节,共包含16384个二进制位。
Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽1:
如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i。
如果slots数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i。
展示了一个slots数组示例:这个数组索引0至索引7上的二进制位的值都为1,其余所有二进制位的值都为0,这表示节点负责处理槽0至槽7。
通过位运算检查槽指派,时间复杂度为O(1)
。
槽信息存储(集群视角):clusterstate结构中的slots数组记录了集群中所有16384个槽的指派信息(指针数组,直接定位槽所属节点)。查找时间复杂度为
O(1)
。
clusterstate.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode 结构所代表的节点的槽指派信息,多用于某个节点本身的槽信息发送给其他节点,这是两个slots数组的关键区别所在。
传播节点的槽指派信息:
一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的clusterstate.nodes
字典中查找节点B对应的clusterNode
结构,并对结构中的slots
数组进行保存或者更新。
因为集群中的每个节点都会将自己的slots数组通过消息发送给集群中的其他节点并且每个接收到slots数组的节点都会将数组保存到相应节点的 clusterNode
结构里面,因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。
命令执行与重定向
槽计算:
CRC16(key) & 16383
确定键所属槽。
使用CLUSTER KEYSLOT<key>
命令可以查看一个给定键属于哪个槽:
127.0.0.1:7000>CLUSTER KEYSLOT "date"
(integer)2022
节点处理逻辑:当节点计算出键所属的槽i之后,节点就会检査自己在clusterstate.slots数组中的项i,判断键所在的槽是否由自己负责。
若槽由当前节点负责,直接执行命令。
若槽由其他节点负责,返回
MOVED <slot> <ip>:<port>
错误,触发客户端转向。
MOVED:槽指派权已永久转移,客户端需缓存新节点信息。
重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
slots_to_keys跳跃表
typedef struct clusterState {
// ...
zskiplist *slots_to_keys; // 跳跃表,记录槽与键的映射关系
// ...
} clusterState;
数据结构:一个有序的跳跃表(Skip List),每个节点包含两个属性:
分值(score):槽号(
0~16383
)。成员(member):数据库键名(如
"user:1001"
)。
核心用途
快速获取属于某个槽的所有键
场景:当需要对特定槽进行操作时(如槽迁移、批量删除),需快速找到该槽的所有键。
传统方式的问题:
如果直接遍历整个数据库,时间复杂度为O(N)
(N
为键总数),效率极低。跳跃表的优势:
通过slots_to_keys
,可直接按槽号查询键,时间复杂度为O(log M)
(M
为槽内键数量),效率显著提升。
示例:
执行 CLUSTER GETKEYSINSLOT <slot> <count>
命令时,直接遍历跳跃表中分值为 slot
的节点,返回键名。
支持槽迁移(Resharding)
重新分片过程:
当需要将槽X
从节点 A 迁移到节点 B 时,需精确找到槽X
的所有键并迁移。跳跃表的作用:
通过slots_to_keys
快速获取槽X
的所有键名,无需扫描整个数据库,大幅提升迁移效率。
重新分片具体过程
工具:由
redis-trib
管理,通过迁移键实现槽的重新分配。步骤:
目标节点准备导入槽(
CLUSTER SETSLOT IMPORTING
)。源节点准备迁移槽(
CLUSTER SETSLOT MIGRATING
)。分批迁移键(
MIGRATE
命令)。更新槽指派信息并广播至集群。
ASK错误
如果节点收到一个关于键key的命令请求,并且键key所属的槽i正好就指派给了这个节点,那么节点会尝试在自己的数据库里查找键key,如果找到了的话,节点就直接执行客户端发送的命令。
与此相反,如果节点没有在自己的数据库里找到键key,那么节点会检查自己的clusterstate.migrating_slots_to[i],看键key所属的槽i是否正在进行迁移如果槽i的确在进行迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端到正在导入槽i的节点去查找键key。
ASK错误和 MOVED 错误的区别
ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:
MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点: 在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点。
ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施: 在客户端收到关于槽i的 ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。
故障转移与复制
主从复制
接收命令的节点通过CLUSTER REPLICATE <node_id>
成为node_id
的从节点,并开始对主节点进行复制。
全量同步(Full Resynchronization)
触发条件:
从节点首次连接主节点。
从节点的复制偏移量(
replication offset
)不在主节点的 复制积压缓冲区(Replication Backlog) 范围内(例如从节点断开时间过长)。主从节点的
run_id
不匹配(主节点重启或数据不一致)。
实现流程:
生成 RDB 快照:主节点执行
BGSAVE
,生成当前数据的 RDB 快照文件。传输 RDB 文件:主节点将 RDB 文件发送给从节点。
加载 RDB:从节点清空旧数据,加载 RDB 文件完成全量数据同步。
增量同步后续命令:主节点将 RDB 生成期间的新写命令存入缓冲区,待 RDB 传输完成后发送给从节点。
优点:
数据完整性高,适用于初次同步或数据差异较大的场景。缺点:
资源消耗大(CPU、内存、网络带宽)。
数据量大时同步耗时长。
增量同步(Partial Resynchronization)
触发条件:
从节点短暂断开后重新连接,且满足以下条件:主从节点的
run_id
一致(主节点未更换)。从节点的复制偏移量仍在主节点的 复制积压缓冲区 内。
实现流程:
偏移量比对:从节点发送自身的复制偏移量给主节点。
发送缺失命令:主节点从复制积压缓冲区中提取从节点缺失的写命令,发送给从节点。
执行命令:从节点执行这些命令,追上主节点状态。
优点:
高效快速,仅传输差异数据。
资源消耗低,适合网络波动后的快速恢复。
缺点:
依赖缓冲区大小(repl-backlog-size
),若缓冲区溢出或从节点断开太久,增量同步会失败,退化为全量同步。
故障检测
节点定期发送
PING
,超时未响应(未收到PONG
消息)标记为疑似下线(PFAIL
)。半数以上主节点确认后,标记为已下线(
FAIL
),广播FAIL
消息。
选举新的主节点
从节点触发选举,基于Raft算法获得多数主节点投票。
集群的配置纪元是一个自增计数器,它的初始值为0。
当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG TYPE FAILOVERAUTH表示这个主节点支持从节点成为新的主节点。
每个参与选举的从节点都会接收CLUSTERMSGTYPEFAILOVERAUTHACK消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。
因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
新主节点接管槽,广播
PONG
更新集群状态。原主节点恢复后成为新主节点的从节点。
消息通信机制
消息类型:
MEET(Gossip协议):邀请节点加入集群。
PING/PONG(Gossip协议):
PING:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送的 PONG 消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一半,那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后。
PONG:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。另外,一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新关于这个节点的认识,例如当一次故障转移操作成功执行之后,新的主节点会向集群广播一条PONG消息,以此来让集群中的其他节点立即知道这个节点已经变成了主节点,并且接管了已下线节点负责的槽。
FAIL:快速广播节点下线信息。
PUBLISH:同步频道消息至所有节点。
消息结构:包含发送者信息、槽指派、配置纪元等,通过消息头(
clusterMsg
)和消息正文传递。
核心设计思想
关键数据结构
clusterNode:节点自身及他节点信息(槽、角色、状态等)。
clusterState:集群全局视图(槽指派、节点列表、配置纪元等)。
slots_to_keys跳跃表:记录槽与键的映射关系,支持批量操作(如
CLUSTER GETKEYSINSLOT
)。
去中心化:节点通过Gossip协议交换状态,无需依赖中心节点。
高可用:故障转移机制(Raft选主)保障主节点下线时服务连续性。
高效分片:槽位节点分配 与 键路由算法 确保数据分布均匀,查询高效。
感谢
《Redis设计与实现》 黄健宏 著