learn-tech/专栏/Redis源码剖析与实战/25PubSub在主从故障切换时是如何发挥作用的?.md
2024-10-16 06:37:41 +08:00

280 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
25 PubSub在主从故障切换时是如何发挥作用的
在前面两节课,我们学习了哨兵工作的基本过程:哨兵会使用 sentinelRedisInstance 结构体来记录主节点的信息,在这个结构体中又记录了监听同一主节点的其他哨兵的信息。那么,一个哨兵是如何获得其他哨兵的信息的呢?
这其实就和哨兵在运行过程中使用的发布订阅Pub/Sub通信方法有关了。Pub/Sub 通信方法可以让哨兵订阅一个或多个频道,当频道中有消息时,哨兵可以收到相应消息;同时,哨兵也可以向频道中发布自己生成的消息,以便订阅该频道的其他客户端能收到消息。
今天这节课我就带你来了解发布订阅通信方法的实现以及它在哨兵工作过程中的应用。同时你还可以了解哨兵之间是如何发现彼此的以及客户端是如何知道故障切换完成的。Pub/Sub 通信方法在分布式系统中可以用作多对多的信息交互,在学完这节课之后,当你要实现分布式节点间通信时,就可以把它应用起来。
好了,接下来,我们先来看下发布订阅通信方法的实现。
发布订阅通信方法的实现
发布订阅通信方法的基本模型是包含发布者、频道和订阅者,发布者把消息发布到频道上,而订阅者会订阅频道,一旦频道上有消息,频道就会把消息发送给订阅者。一个频道可以有多个订阅者,而对于一个订阅者来说,它也可以订阅多个频道,从而获得多个发布者发布的消息。
下图展示的就是发布者 - 频道 - 订阅者的基本模型,你可以看下。
频道的实现
了解了发布订阅方法的基本模型后,我们就来看下频道是如何实现的,因为在发布订阅通信方法中,频道很重要,它是发布者和订阅者之间通信的基础。
其实Redis 的全局变量 server 使用了一个成员变量 pubsub_channels 来保存频道pubsub_channels 的初始化是在 initServer 函数在server.c文件中中完成的。initServer 函数会调用 dictCreate 创建一个 keylistDictType 类型的哈希表,然后用这个哈希表来保存频道的信息,如下所示:
void initServer(void) {
server.pubsub_channels = dictCreate(&keylistDictType,NULL);
}
注意,当哈希表是 keylistDictType 类型时,它保存的哈希项的 value 就是一个列表。而之所以采用这种类型来保存频道信息,是因为 Redis 把频道的名称作为哈希项的 key而把订阅频道的订阅者作为哈希项的 value。就像刚才我们介绍的一个频道可以有多个订阅者所以 Redis 在实现时,就会用列表把订阅同一个频道的订阅者保存起来。
pubsub_channels 哈希表保存频道和订阅者的示意图如下所示:
了解了频道是如何实现的之后,下面我们再分别看下发布命令和订阅命令的实现。
发布命令的实现
发布命令在 Redis 的实现中对应的是 publish。我在【第 14 讲】中给你介绍过Redis server 在初始化时,会初始化一个命令表 redisCommandTable表中就记录了 Redis 支持的各种命令,以及对应的实现函数。
这张命令表是在 server.c 文件中定义的,当你需要了解 Redis 某个命令的具体实现函数时,一个快捷的方式就是在这张表中查找对应命令,然后就能定位到该命令的实现函数了。我们同样可以用这个方法来定位 publish 命令,这样就可以看到它对应的实现函数是 publishCommand在pubsub.c文件中如下所示
struct redisCommand redisCommandTable[] = {
{"publish",publishCommand,3,"pltF",0,NULL,0,0,0,0,0},
}
我们来看下 publishCommand 函数,它是调用 pubsubPublishMessage 函数(在 pubsub.c 文件中)来完成消息的实际发送,然后,再返回接收消息的订阅者数量的,如下所示:
void publishCommand(client *c) {
//调用pubsubPublishMessage发布消息
int receivers = pubsubPublishMessage(c->argv[1],c->argv[2]);
… //如果Redis启用了cluster那么在集群中发送publish命令
addReplyLongLong(c,receivers); //返回接收消息的订阅者数量
}
而对于 pubsubPublishMessage 函数来说,它的原型如下。你可以看到,它的两个参数分别是要发布消息的频道,以及要发布的具体消息。
int pubsubPublishMessage(robj *channel, robj *message)
pubsubPublishMessage 函数会在 server.pubsub_channels 哈希表中,查找要发布的频道。如果找见了,它就会遍历这个 channel 对应的订阅者列表,然后依次向每个订阅者发送要发布的消息。这样一来,只要订阅者订阅了这个频道,那么发布者发布消息时,它就能收到了。
//查找频道是否存在
de = dictFind(server.pubsub_channels,channel);
if (de) { //频道存在
//遍历频道对应的订阅者,向订阅者发送要发布的消息
while ((ln = listNext(&li)) != NULL) {
client *c = ln->value;
addReplyBulk(c,channel);
addReplyBulk(c,message);
receivers++;
}
}
好了,了解了发布命令后,我们再来看下订阅命令的实现。
订阅命令的实现
和查找发布命令的方法一样,我们可以在 redisCommandTable 表中,找到订阅命令 subscribe 对应的实现函数是 subscribeCommand在 pubsub.c 文件中)。
subscribeCommand 函数的逻辑比较简单,它会直接调用 pubsubSubscribeChannel 函数(在 pubsub.c 文件中)来完成订阅操作,如下所示:
void subscribeCommand(client *c) {
int j;
for (j = 1; j < c->argc; j++)
pubsubSubscribeChannel(c,c->argv[j]);
c->flags |= CLIENT_PUBSUB;
}
从代码中你可以看到subscribeCommand 函数的参数是 client 类型的变量,而它会根据 client 的 argc 成员变量执行一个循环,并把 client 的每个 argv 成员变量传给 pubsubSubscribeChannel 函数执行。
对于 client 的 argc 和 argv 来说,它们分别代表了要执行命令的参数个数和具体参数值,那么,这里的参数值是指什么呢?
其实,我们来看下 pubsubSubscribeChannel 函数的原型就能知道了,如下所示:
int pubsubSubscribeChannel(client *c, robj *channel)
pubsubSubscribeChannel 函数的参数除了 client 变量外还会接收频道的信息这也就是说subscribeCommand 会按照 subscribe 执行时附带的频道名称,来逐个订阅频道。我也在下面展示了 subscribe 命令执行的一个示例,你可以看下。当这个 subscribe 命令执行时,它会订阅三个频道,分别是 channel1、channel2 和 channel3
subscribe channel1 channel2 channel3
下面我们来具体看下 pubsubSubscribeChannel 函数的实现。这个函数的逻辑也比较清晰,主要可以分成三步。
首先,它把要订阅的频道加入到 server 记录的 pubsub_channels 中。如果这个频道是新创建的,那么它会在 pubsub_channels 哈希表中新建一个哈希项,代表新创建的这个频道,并且会创建一个列表,用来保存这个频道对应的订阅者。
如果频道已经在 pubsub_channels 哈希表中存在了,那么 pubsubSubscribeChannel 函数就直接获取该频道对应的订阅者列表。
然后pubsubSubscribeChannel 函数把执行 subscribe 命令的订阅者,加入到订阅者列表中。
最后pubsubSubscribeChannel 函数会把成功订阅的频道个数返回给订阅者。
下面的代码展示了这部分的逻辑,你可以看下。
if (dictAdd(c->pubsub_channels,channel,NULL) == DICT_OK) {
de = dictFind(server.pubsub_channels,channel); //在pubsub_channels哈希表中查找频道
if (de == NULL) { //如果频道不存在
clients = listCreate(); //创建订阅者对应的列表
dictAdd(server.pubsub_channels,channel,clients); //新插入频道对应的哈希项
} else {
clients = dictGetVal(de); //频道已存在,获取订阅者列表
}
listAddNodeTail(clients,c); //将订阅者加入到订阅者列表
}
addReplyLongLong(c,clientSubscriptionsCount(c)); //给订阅者返回成功订阅的频道数量
现在,你就了解了 Redis 中发布订阅方法的实现。接下来,我们来看下哨兵在工作过程中,又是如何使用发布订阅功能的。
发布订阅方法在哨兵中的应用
首先,我们来看下哨兵用来发布消息的函数 sentinelEvent。
sentinelEvent 函数与消息生成
哨兵在使用发布订阅方法时,封装了 sentinelEvent 函数在sentinel.c文件中用来发布消息。所以你在阅读 sentinel.c 文件中关于哨兵的源码时,如果看到 sentinelEvent这就表明哨兵正在用它来发布消息。
我在【第 22 讲】中给你介绍过 sentinelEvent 函数,你可以再回顾下。这个函数的原型如下所示:
void sentinelEvent(int level, char *type, sentinelRedisInstance *ri, const char *fmt, ...)
实际上,这个函数最终是通过调用刚才我提到的 pubsubPublishMessage 函数,来实现向某一个频道发布消息的。那么,当我们要发布一条消息时,需要确定两个方面的内容:一个是要发布的频道,另一个是要发布的消息。
sentinelEvent 函数的第二个参数 type表示的就是要发布的频道而要发布的消息就是由这个函数第四个参数 fmt 后面的省略号来表示的。
看到这里,你可以会有一个疑问,为什么 sentinelEvent 函数参数中会有省略号?
其实,这里的省略号表示的是可变参数,当我们无法列出传递给函数的所有实参类型和数目时,我们可以用省略号来表示可变参数,这就是说,我们可以给 sentinelEvent 函数传递 4 个、5 个、6 个甚至更多的参数。
我在这里就以 sentinelEvent 函数的实现为例,给你介绍下可变参数的使用,这样一来,当你在开发分布式通信程序时,需要生成内容不定的消息时,就可以把哨兵源码中实现的方法用起来。
在 sentinelEvent 函数中,为了使用了可变参数,它主要包含了四个步骤:
首先,我们需要定义一个 va_list 类型的变量,假设是 ap。这个变量是指向可变参数的指针。
然后,当我们要在函数中使用可变参数了,就需要通过 va_start 宏来获取可变参数中的第一个参数。va_start 宏有两个参数,一个是刚才定义的 va_list 类型变量 ap另一个是可变参数的前一个参数也就是 sentinelEvent 函数参数中,省略号前的参数 fmt。
紧接着,我们可以使用 vsnprintf 函数,来按照 fmt 定义的格式打印可变参数中的内容。vsnprintf 函数会逐个获取可变参数中的每一个参数,并进行打印。
最后,我们在获取完所有参数后,需要调用 va_end 宏将刚才创建的 ap 指针关闭。
下面的代码展示了刚才介绍的这个过程,你可以再看下。
void sentinelEvent(int level, char *type, sentinelRedisInstance *ri, const char *fmt, ...) {
va_list ap;
...
if (fmt[0] != '\0') {
va_start(ap, fmt);
vsnprintf(msg+strlen(msg), sizeof(msg)-strlen(msg), fmt, ap);
va_end(ap);
}
...
}
为了让你有个更加直观的了解,我在下面列了三个 sentinelEvent 函数的调用示例,你可以再学习掌握下。
第一个对应了哨兵调用 sentinelCheckSubjectivelyDown 函数检测出主节点主观下线后sentinelCheckSubjectivelyDown 函数调用 sentinelEvent 函数,向“+sdown”频道发布消息。此时传递给 sentinelEvent 的参数就是 4 个,并没有可变参数,如下所示:
sentinelEvent(LL_WARNING,"+sdown",ri,"%@");
第二个对应了哨兵在初始化时,在 sentinelGenerateInitialMonitorEvents 函数中,调用 sentinelEvent 函数向“+monitor”频道发布消息此时传递给 sentinelEvent 的参数有 5 个,包含了 1 个可变参数,表示的是哨兵的 quorum 阈值,如下所示:
sentinelEvent(LL_WARNING,"+monitor",ri,"%@ quorum %d",ri->quorum);
最后一个对应了哨兵在完成主节点切换后,在 sentinelFailoverSwitchToPromotedSlave 函数中,调用 sentinelEvent 函数向“+switch-master”频道发布消息。此时传递给 sentinelEvent 的可变参数一共有 5 个对应了故障切换前的主节点名称、IP 和端口号,以及切换后升级为主节点的从节点 IP 和端口号,如下所示:
sentinelEvent(LL_WARNING,"+switch-master",master,"%s %s %d %s %d",
master->name, master->addr->ip, master->addr->port,
ref->addr->ip, ref->addr->port);
这样一来,你也就了解了,哨兵在工作过程中是通过 sentinelEvent 函数和 pubsubPublishMessage 函数,来实现消息的发布的。在哨兵的整个工作过程中,它会在一些关键节点上,使用 sentinelEvent 函数往不同的频道上发布消息。除了刚才给你举例的三个频道 +monitor、+sdown、+switch-master 以外,我还把哨兵在工作过程中会用到的消息发布频道列在了下表中,你可以了解下。
其实在哨兵的工作过程中如果有客户端想要了解故障切换的整体情况或进度比如主节点是否被判断为主观下线、主节点是否被判断为客观下线、Leader 是否完成选举、新主节点是否切换完成,等等,就可以通过 subscribe 命令,订阅上面这张表中的相应频道。这样一来,客户端就可以了解故障切换的过程了。
好,下面我们再来看下,哨兵在工作过程中对消息的订阅是如何实现的。
哨兵订阅与 hello 频道
首先你要知道每个哨兵会订阅它所监听的主节点的”sentinel:hello”频道。在【第 23 讲】中,我给你介绍过,哨兵会周期性调用 sentinelTimer 函数来完成周期性的任务,这其中,就有哨兵订阅主节点 hello 频道的操作。
具体来说,哨兵在周期性执行 sentinelTimer 函数时,会调用 sentinelHandleRedisInstance 函数,进而调用 sentinelReconnectInstance 函数。而在 sentinelReconnectInstance 函数中,哨兵会调用 redisAsyncCommand 函数,向主节点发送 subscribe 命令,订阅的频道由宏定义 SENTINEL_HELLO_CHANNEL在 sentinel.c 文件中指定也就是”sentinel:hello”频道。这部分的代码如下所示
retval = redisAsyncCommand(link->pc,
sentinelReceiveHelloMessages, ri, "%s %s",
sentinelInstanceMapCommand(ri,"SUBSCRIBE"),
SENTINEL_HELLO_CHANNEL);
从代码中我们也可以看到当在”sentinel:hello”频道上收到 hello 消息后,哨兵会回调 sentinelReceiveHelloMessages 函数来进行处理。而 sentinelReceiveHelloMessages 函数,实际是通过调用 sentinelProcessHelloMessage 函数,来完成 hello 消息的处理的。
对于 sentinelProcessHelloMessage 函数来说,它主要是从 hello 消息中获得发布 hello 消息的哨兵实例的基本信息,比如 IP、端口号、quorum 阈值等。如果当前哨兵并没有记录发布 hello 消息的哨兵实例的信息那么sentinelProcessHelloMessage 函数就会调用 createSentinelRedisInstance 函数,来创建发布 hello 消息的哨兵实例的信息记录,这样一来,当前哨兵就拥有了其他哨兵实例的信息了。
好了了解了哨兵对”sentinel:hello”频道的订阅和处理后我们还需要搞清楚一个问题即哨兵是在什么时候发布 hello 消息的呢?
这其实是哨兵在 sentinelTimer 函数中,调用 sentinelSendPeriodicCommands 函数时,由 sentinelSendPeriodicCommands 函数调用 sentinelSendHello 函数来完成的。
sentinelSendHello 函数会调用 redisAsyncCommand 函数向主节点的”sentinel:hello”频道发布 hello 消息。在它发送的 hello 消息中,包含了发布 hello 消息的哨兵实例的 IP、端口号、ID 和当前的纪元以及该哨兵监听的主节点的名称、IP、端口号和纪元信息。
下面的代码就展示了 hello 消息的生成和发布,你可以看下。
//hello消息包含的内容
snprintf(payload,sizeof(payload),
"%s,%d,%s,%llu," //当前哨兵实例的信息包括ip、端口号、ID和当前纪元
"%s,%s,%d,%llu", //当前主节点的信息包括名称、IP、端口号和纪元
announce_ip, announce_port, sentinel.myid,
(unsigned long long) sentinel.current_epoch,
master->name,master_addr->ip,master_addr->port,
(unsigned long long) master->config_epoch);
//向主节点的hello频道发布hello消息
retval = redisAsyncCommand(ri->link->cc,
sentinelPublishReplyCallback, ri, "%s %s %s",
sentinelInstanceMapCommand(ri,"PUBLISH"),
SENTINEL_HELLO_CHANNEL,payload);
这样,当哨兵通过 sentinelSendHello向自己监听的主节点的”sentinel:hello”频道发布 hello 消息时和该哨兵监听同一个主节点的其他哨兵也会订阅主节点的”sentinel:hello”频道从而就可以获得该频道上的 hello 消息了。
通过这样的通信方式,监听同一主节点的哨兵就能相互知道彼此的访问信息了。如此一来,哨兵就可以基于这些访问信息,执行主节点状态共同判断,以及进行 Leader 选举等操作了。
小结
今天这节课,我们了解了 Redis 实现的发布订阅通信方法。这个方法是提供了频道的方式,让要通信的双方按照频道来完成消息交互。而不同频道的不同名称,就代表了哨兵工作过程中的不同状态。当客户端需要了解哨兵的工作进度或是主节点的状态判断时,就可以通过订阅哨兵发布消息的频道来完成。
当然对于一个哨兵来说它一定会订阅的频道是它所监听的主节点的”sentinel:hello”频道。通过这个频道监听同一主节点的不同哨兵就能通过频道上的 hello 消息,来交互彼此的访问信息了,比如哨兵的 IP、端口号等。
此外,在这节课,我还给你介绍了一个 C 语言函数可变参数的使用小技巧,当你开发发布订阅功能时,都需要生成发布的消息,而可变参数就可以用来生成长度不定的消息。希望你能把这个小技巧应用起来。
每课一问
如果我们在哨兵实例上执行 publish 命令,那么,这条命令是不是就是由 pubsub.c 文件中的 publishCommand 函数来处理的呢?