redis cluster 学习

其实网上有很多介绍 redis cluster 集群的博客文章,这里主要是总结集群设计的一些关键点。

节点通讯

Gossip 算法:是一种用于在分布式集群中,去中心化(无leader节点),保持所有节点数据最终一致性的算法。

当某个节点的状态发生变更(例如新节点加入、现有节点离开或状态改变)时,它会周期性地随机挑选几个其他节点进行通信,向这些节点发送包含自身状态的信息和已知的其他节点状态信息。接收到这些信息的其他节点会更新自己的状态,并在下一个周期内随机选择其他节点进行类似的信息交换。通过这种方式,信息逐渐在整个集群中传播开来,最终使得所有节点的状态达到一致。

节点加入

新节点想要加入到集群中,需要在这个节点上,执行 cluster meet 命令(CLUSTER MEET <ip> <port> [cport]),与目标节点建立握手(目标节点已经是集群中的某个节点)。

定时探测

在新节点加入到集群后,会定时向其他节点发送 PING 消息,用于心跳检测,检查集群中的其他节点的状态。目标节点在收到 PING 消息后,也会回复一条 PONG 消息应答。

MEET,PING,PONG 消息都是用同一个结构内容 ClusterMsg:

  • 消息头:消息签名(sig 字段)、消息版本(ver 字段)、消息长度(totlen 字段)、消息类型(type 字段)、携带的节点信息条数(count 字段)。
  • 发送节点信息:发送节点的名称(sender 字段)、当前节点的 configEpoch 信息(configEpoch 字段)、主从复制的 Replication Offset(offset 字段)、节点的 ip、port 以及 cport当前节点的 flags 状态信息当前节点负责的 slot 集合(myslots 字段)、当前节点的主节点名称(slaveof 字段)。
  • 已知的其他节点信息:一个 clusterMsgDataGossip 数组构成,每个 clusterMsgDataGossip 对应一个节点信息。包括了节点的名称、当前节点最后一次向其发送 PING 消息以及收到 PONG 响应的时间戳、节点的 IP、port、cport 信息以及节点的 flags 标识。

当 clusterSendPing() 函数构建 PING 消息体时,它会将多个节点的信息写入 PING 消息。

1. 那么,clusterSendPing() 函数具体会携带多少个节点的信息呢?

携带个数:最小:3,默认:集群节点数的1/10,最大:集群节点个数-2(减掉自己节点,以及目标节点)

wanted = floor(dictSize(server.cluster->nodes)/10);
if (wanted < 3) wanted = 3;
if (wanted > freshnodes) wanted = freshnodes;

2. 知道携带节点个数后,redis 又是挑选哪些节点发送呢?

redis 会从所有已知节点中,挑选 wanted 个节点。这些节点不能是发送方自身节点、可能有故障的节点、正在握手的节点、失联的节点以及没有地址信息的节点。这些除外的节点,都可以被放到 clusterMsgDataGossip 数组中,然后发送给目标节点。

3. 什么时候发送 PING 消息,以及向哪个节点发送呢?

redis 集群中的每个节点都会在每1秒向一个随机节点发送 PING 消息。具体来说,先随机选五个节点,然后在这五个节点中,选出最早向当前节点发送 PONG 消息的那个节点,并向它发送 PING 消息。

void clusterCron(void) {
if (!(iteration % 10)) { //每执行10次clusterCron函数,执行1次该分支代码
int j;
for (j = 0; j < 5; j++) { //随机选5个节点
de = dictGetRandomKey(server.cluster->nodes);
clusterNode *this = dictGetVal(de);
//不向断连的节点、当前节点和正在握手的节点发送Ping消息
if (this->link == NULL || this->ping_sent != 0) continue;
if (this->flags & (CLUSTER_NODE_MYSELF|CLUSTER_NODE_HANDSHAKE))
continue;
//遴选向当前节点发送Pong消息最早的节点
if (min_pong_node == NULL || min_pong > this->pong_received) {
min_pong_node = this;
min_pong = this->pong_received;
}
}
//如果遴选出了最早向当前节点发送Pong消息的节点,那么调用clusterSendPing函数向该节点发送Ping消息
if (min_pong_node) {
serverLog(LL_DEBUG,"Pinging node %.40s", min_pong_node->name);
clusterSendPing(min_pong_node->link, CLUSTERMSG_TYPE_PING);
}
}
}

4. 节点不可用时,集群是如何确认,以及如何传播的呢?

先介绍下节点的两个不可用状态:

  • CLUSTER_NODE_PFAIL:拟似下线状态,即发送节点认为接收节点可能存在故障,不可用,只是主观认为,实际还得等集群中一半以上的节点认为不可用,最终才是不可用。
  • CLUSTER_NODE_FAIL:标记一个节点真正的下线状态,集群内超过一半节点都认为该节点不可用了,从而达成共识的结果。

CLUSTER_NODE_PFAIL 状态判断:

当前节点会遍历所有已知节点,如果和某个节点A通信时间超过 cluster_node_timeout 时间(默认15s),则对节点A状态设置为CLUSTER_NODE_PFAIL(拟似下线状态)。此时,会检查整个集群可用状态,如果可用节点个数少于集群节点数一半左右,那么,整个集群就会对外不可用。

void clusterCron(void) {
...
// 获取最后一次和某个节点通讯的最小时间,无论是发送者 ping 我方或者接收者 pong 响应我方的时间都可以
// data_delay 比 ping_delay 大的时候,说明对方很久没发 ping 消息过来了,可能已经不可用了
mstime_t node_delay = (ping_delay < data_delay) ? ping_delay :
data_delay;
// 距离最后一次通讯的时间超过cluster_node_timeout,默认15s,标记节点为可能下线状态
if (node_delay > server.cluster_node_timeout) {
/* Timeout reached. Set the node as possibly failing if it is
* not already in this state. */
if (!(node->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL))) {
serverLog(LL_DEBUG,"*** NODE %.40s possibly failing",
node->name);
node->flags |= CLUSTER_NODE_PFAIL;
update_state = 1;
}
}
...
}

 CLUSTER_NODE_PFAIL 状态传播(主观下线):

在节点随机向某个目标节点发送 PING 消息,或者其他节点发送 PONG 消息过来时,都会随带所有 pfail 拟似下线状态的节点信息。

void clusterSendPing(clusterLink *link, int type) {
...
/* Include all the nodes in PFAIL state, so that failure reports are
* faster to propagate to go from PFAIL to FAIL state. */
int pfail_wanted = server.cluster->stats_pfail_nodes; // 获取 pfail 状态的所有节点个数
...
/* If there are PFAIL nodes, add them at the end. 把所有 pfail 节点打包到buffer中,最终发送给对方 */
if (pfail_wanted) {
dictIterator *di;
dictEntry *de;
di = dictGetSafeIterator(server.cluster->nodes);
while((de = dictNext(di)) != NULL && pfail_wanted > 0) {
clusterNode *node = dictGetVal(de);
if (node->flags & CLUSTER_NODE_HANDSHAKE) continue;
if (node->flags & CLUSTER_NODE_NOADDR) continue;
if (!(node->flags & CLUSTER_NODE_PFAIL)) continue;
clusterSetGossipEntry(hdr,gossipcount,node);
gossipcount++;
/* We take the count of the slots we allocated, since the
* PFAIL stats may not match perfectly with the current number
* of PFAIL nodes. */
pfail_wanted--;
}
dictReleaseIterator(di);
}
...
}

CLUSTER_NODE_FAIL 状态判断(客观下线):
在节点收到 PING 或者 PONG 消息时,会检查携带的其他节点信息,采集故障节点的失败报告(即pfail状态的节点)。然后根据失败报告,判断某个节点 pfail 状态是否超过集群节点数的一半以上,如果是的话,就将该节点标记为真正下线状态 fail。即节点由 pfail 转成 fail 状态。并且最后,广播节点 fail 消息,通知所有已知的其他节点,该节点已经客观下线。集群其他节点收到 fail 消息后,也会把该节点实例标记为客观下线,并快速传播。

void markNodeAsFailingIfNeeded(clusterNode *node) {
int failures;
int needed_quorum = (server.cluster->size / 2) + 1; // 集群半数以上
...
// 根据失败报告,判断 pfail 节点个数,如果超过集群个数一半以上,则将该节点转为 fail 真正不可用状态
failures = clusterNodeFailureReportsCount(node);
/* Also count myself as a voter if I'm a master. */
if (nodeIsMaster(myself)) failures++;
if (failures < needed_quorum) return; /* No weak agreement from masters. */
serverLog(LL_NOTICE,
"Marking node %.40s as failing (quorum reached).", node->name);
/* Mark the node as failing. */
node->flags &= ~CLUSTER_NODE_PFAIL;
node->flags |= CLUSTER_NODE_FAIL;
node->fail_time = mstime();
...
// 广播节点 fail 消息,通知其他节点,该节点已经客观下线
clusterSendFail(node->name);
}

pfail 状态取消:

当某个节点收到 PING 或者 PONG 消息时,会去检查发送者节点是不是处于 pfail 状态,如果是的话,则会清除 CLUSTER_NODE_PFAIL 标记,也就是该节点可能处于误判状态。该节点有可能某个时刻网络比较拥挤,或者发生一些其他情况,导致暂时不可用,但后面又很快恢复正常了。所以需要变更状态。

int clusterProcessPacket(clusterLink *link) {
...
if (nodeTimedOut(link->node)) {
link->node->flags &= ~CLUSTER_NODE_PFAIL;
...
}

节点状态变更图: 

fail_reports 链表介绍:

每个节点都有 fail_reports 链表字段,这个链表主要是记录 pfail 状态的节点用的,在 clusterNodeAddFailureReport() 函数中实现,比如说,某个节点 m 被其他节点 a,b,c 标记为 pfail 状态(注意,这里的 a,b,c 一定是要主节点,只有主节点才会参与pfail状态判断)。那么就会存在如下关系:

我们要对节点 m 判断是否需要变更为 fail 状态,只需要通过判断 m->fail_reports 链表长度是否有超过集群节点个数的一半以上即可。

节点宕机

在集群中,如果某个节点宕机了,redis cluster 不会立马删除这个节点信息,只是把节点的 link 字段部分清空。等 100ms 后,会尝试进行连接,相对于1s 尝试连接10次。如果在默认时间 15s 内,还没能连上该节点,那么就会彻底删除该节点信息。

static int clusterNodeCronHandleReconnect(clusterNode *node, mstime_t handshake_timeout, mstime_t now) {
...
/* A Node in HANDSHAKE state has a limited lifespan equal to the
* configured node timeout. 判断握手时间是否超过15s */
if (nodeInHandshake(node) && now - node->ctime > handshake_timeout) {
clusterDelNode(node);
return 1;
}
...
}

看代码,好像并没有在删除节点后做广播,通知其他集群其他节点,感觉这个应该是每个节点靠自己感知触发 delete node 的。如果是误删,那么只要集群中有节点能和这个误删的节点正常通讯,最后也会通过 PING,PONG 加回来。

小结:

PING 和 PONG 消息使用的是同一个函数 clusterSendPing() 来生成和发送的,所以它们包含的内容也是相同的。这也就是说,PONG 消息中也包含了 PONG 发送节点的信息和它已知的其他节点信息。因此,PING 消息的发送节点从 PONG 消息中,也能获取其他节点的最新信息,这就能实现 Gossip 协议通过多轮消息传播,达到每个节点拥有一致信息的目的。

Gossip 协议实现的另一个关键就是要随机选择节点发送,这一点,Redis Cluster 在源码中就比较容易实现了。其实,就是 clusterCron 函数先通过随机选择五个节点,然后,再在其中挑选和当前节点最长时间没有发送 Pong 消息的节点,作为目标节点,这样一来,也满足了 Gossip 协议的要求。

故障转移

当确定某个主节点状态为 fail 时,不可用时,该主节点的其中一个从节点会当选举为新的主节点来负责接管其槽位,对外保持可服务状态,完成故障转移。从节点主要用到了 raft 类似的算法来做选主的。

故障转移主要的5个步骤:

其中 1,2,3,5 步骤主要是在 clusterHandleSlaveFailover() 函数里实现的。而步骤4 选举投票则是在接收到 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息的主节点上 clusterSendFailoverAuthIfNeeded() 函数执行的。

  1. 选举资格检查:如果当前节点是从节点才有资格去选主,而且主节点是已经被确认为 fail 状态的,并且主节点还是要有负责过槽位管理的。
  2. 计算选举时间:每个从节点都会随机一个时间点来发起选举,当从节点的复制偏移字段 repl_offset 越大,那么等待选举的时间越短,说明从节点的数据越完整,越先可能成为新的主节点。
  3. 更新期号,并向集群里的其他主节点发起 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,请求选我做新的主节点。
  4. 集群里的其他主节点收到 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息后,经过一系列检查,如果没投过票,那么会返回一个 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,表示同意投票。
  5. 从节点在收到 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息后,会收集选我的投票人数,如果投票人数超过集群节点数的一半以上,自动升级为新的主节点。最后广播给集群里的所有节点。

cluster 槽位

redis cluster 集群采用分片机制,定义了16384个槽位,每个节点负责管理一部分槽位以及槽位对应的所有数据。客户端可以连接集群中任意一个 redis 节点实例,发送读写命令,然后通过 CRC16 计算出 key 所对应的槽位,如果这个槽位不是自己所负责的,那么,会返回正确的槽位节点地址给客户端,让客户端重新请求到正确的节点上。

redis key的路由计算公式:slot  = CRC16(key) % 16384

数据迁移

数据迁移是分布式存储集群经常会遇到的一个问题,当集群节点承担的负载压力不均衡时,或者有新节点加入或是已有节点下线时,那么,数据就需要在不同的节点间进行迁移。所以,如何设计和实现数据迁移也是在集群开发过程中,我们需要考虑的地方。

注意,槽位转移,只能是针对主节点之间发生,不能是从节点。

代码实现,主要在 clusterCommand、migrateCommand 和 restoreCommand 函数中。

在数据迁移的时候,主要有几个关键点

  • 一个是数据迁移是通过调用 syncWrite() 和 syncReadLine() 函数的,同步读写数据,会阻塞主线程,不能及时处理客户端请求。所以,可以分多次迁移数据,一次不要迁移太多数据,并且,最好选择一个使用量不是很高的时间点,做数据迁移。
  • 另一个是在数据迁移的时候,如果某一个节点宕机了,write/read 失败了,可能会返回给客户端一个 "IOERR error" 的错误,这个时候,我们可以在重启节点后,接着执行迁移操作,数据不会丢失,因为,迁移源节点是只有在所有数据迁移成功后,才会delete key/value 的。
  • 在迁移过程中,客户端发起对这个槽位的数据读写请求时,如果是请求到源节点,即迁出节点,那么会给客户端返回一个 -ASK 转向(redirection)的错误,需要重定向到新的迁入节点。
  • 当一个槽被设置为 IMPORTING 状态时, 节点仅在接收到 ASKING 命令之后, 才会接受关于这个槽的命令请求。

 

posted @   墨色山水  阅读(38)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示