我们知道,Redis 主从复制是保证 Redis 可用性的一个重要手段。而一旦 Redis 主节点发生故障,哨兵机制就会执行故障切换。这个故障切换过程实现起来其实比较复杂,涉及了哨兵 Leader 选举、新主节点选举和故障切换等关键操作。但同时,这个故障切换过程又是我们在实现高可用系统时经常要面对的开发需求。
所以从这篇文章开始,逐一介绍下,Redis 哨兵机制及其实现故障切换的关键技术设计与实现。通过这部分内容的学习,既可以了解在故障切换过程中,起到重要作用的 Raft 协议是如何实现的,而且还可以掌握在故障切换时,主节点、从节点和客户端相互之间如何完成切换通知的。
不过,在开始了解故障切换的关键技术之前,今天我们会先来了解哨兵实例本身的初始化和基本运行过程,这是因为从源码的角度来看,哨兵实例和常规 Redis 实例的实现都是在一套源码中的,它们共享了一些执行流程。所以了解这部分内容,也可以帮助我们更加清楚地掌握哨兵实例的实现机制。
好,下面我们就先来看下哨兵实例的初始化过程。
哨兵实例的初始化
因为哨兵实例是属于运行在一种特殊模式下的 Redis server,而我在之前的文章中,已经给你介绍过了 Redis server 启动后的入口函数 main 的整体执行过程。其实,这个过程就包含了哨兵实例的初始化操作。
所以,哨兵实例的初始化入口函数也是 main(在 server.c 文件中)。那么,main 函数在运行时,就会通过对运行参数的判断,来执行哨兵实例对应的运行逻辑。具体来说,main 函数在调用 initServerConfig 函数初始化各种配置项之前,会调用 checkForSentinelMode 函数,来判断当前运行的是否为哨兵实例,如下所示:
server.sentinel_mode = checkForSentinelMode(argc,argv);
checkForSentinelMode 函数(在 server.c 文件中)的参数,是 main 函数收到的启动命令字符串 argv 和启动命令中的参数个数 argc。然后,它会根据以下两个条件判断当前是否运行了哨兵实例。
- 条件一:执行的命令本身,也就是 argv[0],是否为“redis-sentinel”。
- 条件二:执行的命令参数中,是否有“–sentinel”。
这部分代码如下所示:
/* Returns 1 if there is --sentinel among the arguments or if * argv[0] contains "redis-sentinel". */ int checkForSentinelMode(int argc, char **argv) { int j; // 第一个判断条件,判断执行命令本身是否为redis-sentinel if (strstr(argv[0],"redis-sentinel") != NULL) return 1; for (j = 1; j < argc; j++) // 第二个判断条件,判断命令参数是否有"--sentienl" if (!strcmp(argv[j],"--sentinel")) return 1; return 0; }
其实,这两个判断条件也就对应了我们在命令行启动哨兵实例的两种方式,一种是直接运行 redis-sentinel 命令,另一种是运行 redis-server 命令,但是带有“–sentinel”参数,如下所示:
redis-sentinel sentinel.conf文件路径 或者 redis-server sentinel.conf文件路径—sentinel
所以,如果这两个条件中有一个成立,那么全局变量 server 的成员变量 sentinel_mode,就会被设置为 1,表明当前运行的是哨兵实例。这样一来,server.sentinel_mode 这一配置项就会在源码的其他地方,被用来判断当前是否运行的是哨兵实例。
初始化配置项
,在完成了对哨兵实例的运行判断之后,接下来,main 函数还是会调用 initServerConfig 函数初始化各种配置项。但是,因为哨兵实例运行时所用的配置项和 Redis 实例是有区别的,所以,main 函数会专门调用 initSentinelConfig 和 initSentinel 两个函数,来完成哨兵实例专门的配置项初始化,如下所示:
if (server.sentinel_mode) { initSentinelConfig(); initSentinel(); }
initSentinelConfig 和 initSentinel 这两个函数都是在sentinel.c文件中实现的。
在sentinel.c文件中查看
/* Perform the Sentinel mode initialization. */ void initSentinel(void) { unsigned int j; /* Remove usual Redis commands from the command table, then just add * the SENTINEL command. */ // 从命令表中删除常用的 Redis 命令,然后添加 SENTINEL 命令。 dictEmpty(server.commands,NULL); dictEmpty(server.orig_commands,NULL); ACLClearCommandID(); for (j = 0; j < sizeof(sentinelcmds)/sizeof(sentinelcmds[0]); j++) { int retval; struct redisCommand *cmd = sentinelcmds+j; cmd->id = ACLGetCommandID(cmd->name); /* Assign the ID used for ACL. */ retval = dictAdd(server.commands, sdsnew(cmd->name), cmd); serverAssert(retval == DICT_OK); retval = dictAdd(server.orig_commands, sdsnew(cmd->name), cmd); serverAssert(retval == DICT_OK); /* Translate the command string flags description into an actual * set of flags. */ if (populateCommandTableParseFlags(cmd,cmd->sflags) == C_ERR) serverPanic("Unsupported command flag"); } /* Initialize various data structures. */ sentinel.current_epoch = 0; sentinel.masters = dictCreate(&instancesDictType,NULL); sentinel.tilt = 0; sentinel.tilt_start_time = 0; sentinel.previous_time = mstime(); sentinel.running_scripts = 0; sentinel.scripts_queue = listCreate(); sentinel.announce_ip = NULL; sentinel.announce_port = 0; sentinel.simfailure_flags = SENTINEL_SIMFAILURE_NONE; sentinel.deny_scripts_reconfig = SENTINEL_DEFAULT_DENY_SCRIPTS_RECONFIG; sentinel.sentinel_auth_pass = NULL; sentinel.sentinel_auth_user = NULL; sentinel.resolve_hostnames = SENTINEL_DEFAULT_RESOLVE_HOSTNAMES; sentinel.announce_hostnames = SENTINEL_DEFAULT_ANNOUNCE_HOSTNAMES; memset(sentinel.myid,0,sizeof(sentinel.myid)); server.sentinel_config = NULL; }
/* This function overwrites a few normal Redis config default with Sentinel * specific defaults. */ void initSentinelConfig(void) { server.port = REDIS_SENTINEL_PORT; server.protected_mode = 0; /* Sentinel must be exposed. */ }
其中,initSentinelConfig 函数主要是将当前 server 的端口号,改为哨兵实例专用的端口号 REDIS_SENTINEL_PORT。这是个宏定义,它对应的默认值是 26379。另外,这个函数还会把 server 的 protected_mode 设置为 0,即允许外部连接哨兵实例,而不是只能通过 127.0.0.1 本地连接 server。
而 initSentinel 函数则是在 initSentinelConfig 函数的基础上,进一步完成哨兵实例的初始化,这其中主要包括两部分工作。
- 首先,initSentinel 函数会替换 server 能执行的命令表。
在 initServerConfig 函数执行的时候,Redis server 会初始化一个执行命令表,并保存在全局变量 server 的 commands 成员变量中。这个命令表本身是一个哈希表,每个哈希项的键对应了一个命令的名称,而值对应了该命令实际的实现函数。
因为哨兵实例是运行在特殊模式的 Redis server,它执行的命令和 Redis 实例也是有区别的,所以 initSentinel 函数会把 server.commands 对应的命令表清空,然后在其中添加哨兵对应的命令,如下所示:
dictEmpty(server.commands,NULL); for (j = 0; j < sizeof(sentinelcmds)/sizeof(sentinelcmds[0]); j++) { … struct redisCommand *cmd = sentinelcmds+j; retval = dictAdd(server.commands, sdsnew(cmd->name), cmd); … }
从这里的代码中你可以看到,哨兵实例可以执行的命令保存在了 sentinelcmds 数组中,这个数组是在 sentinel.c 文件中定义的。
其中你需要注意的是,哨兵实例执行的一些命令,其名称虽然和 Redis 实例命令表中的命令名称一样,但它们的实现函数是针对哨兵实例专门实现的。比如,哨兵实例和 Redis 实例都可以执行 publish、info、role 命令,但是在哨兵实例中,这三个命令分别由 sentinelPublishCommand、sentinelInfoCommand、sentinelRoleCommand 这三个在 sentinel.c 文件中的函数来实现的。所以,当你需要详细了解哨兵实例运行命令的实现时,注意不要找错代码文件。
以下代码也展示了哨兵实例命令表中的部分命令,你可以看看。
struct redisCommand sentinelcmds[] = { {"ping",pingCommand,1,"",0,NULL,0,0,0,0,0}, {"sentinel",sentinelCommand,-2,"",0,NULL,0,0,0,0,0}, … {"publish",sentinelPublishCommand,3,"",0,NULL,0,0,0,0,0}, {"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0}, {"role",sentinelRoleCommand,1,"l",0,NULL,0,0,0,0,0}, … };
- 其次,initSentinel 函数在替换了命令表后,紧接着它会开始初始化哨兵实例用到的各种属性信息。
为了保存这些属性信息,哨兵实例定义了 sentinelState 结构体(在 sentinel.c 文件中),这其中包括了哨兵实例的 ID、用于故障切换的当前纪元、监听的主节点、正在执行的脚本数量,以及与其他哨兵实例发送的 IP 和端口号等信息。下面的代码就展示了 sentinelState 结构体定义中的部分属性,你可以看下。
struct sentinelState { char myid[CONFIG_RUN_ID_SIZE+1]; //哨兵实例ID uint64_t current_epoch; //当前纪元 dict *masters; //监听的主节点的哈希表 int tilt; //是否处于TILT模式 int running_scripts; //运行的脚本个数 mstime_t tilt_start_time; //tilt模式的起始时间 mstime_t previous_time; //上一次执行时间处理函数的时间 list *scripts_queue; //用于保存脚本的队列 char *announce_ip; //向其他哨兵实例发送的IP信息 int announce_port; //向其他哨兵实例发送的端口号 … } sentinel;
这样一来,initSentinel 函数就主要会把这些属性设置为初始化值。比如,它会为监听的主节点创建一个哈希表,哈希项的键记录了主节点的名称,而值记录了对应的数据结构指针。到这里,哨兵实例配置项的初始化工作就完成了。下图展示了这个初始化过程,你可以再回顾下。
接下来,main 函数还会调用 initServer 函数完成 server 本身的初始化操作,这部分哨兵实例也是会执行的。然后,main 函数就会调用 sentinelIsRunning 函数(在 sentinel.c 文件中)启动哨兵实例。
启动哨兵实例
sentinelIsRunning 函数的执行逻辑比较简单,它首先会确认哨兵实例的配置文件存在并且可以正常写入。然后,它会检查哨兵实例是否设置了 ID。如果没有设置 ID 的话,sentinelIsRunning 函数就会为哨兵实例随机生成一个 ID。
最后,sentinelIsRunning 函数会调用 sentinelGenerateInitialMonitorEvents 函数(在 sentinel.c 文件中),给每个被监听的主节点发送事件信息。下图展示了 sentinelIsRunning 函数的基本执行流程,你可以看下。
在sentinel.c文件中查看
/* This function gets called when the server is in Sentinel mode, started, * loaded the configuration, and is ready for normal operations. */ // 当服务器处于 Sentinel 模式、启动、加载配置并准备好进行正常操作时,将调用此函数。 void sentinelIsRunning(void) { int j; /* If this Sentinel has yet no ID set in the configuration file, we * pick a random one and persist the config on disk. From now on this * will be this Sentinel ID across restarts. */ // 如果这个 Sentinel 还没有在配置文件中设置 ID,我们随机选择一个并将配置保存在磁盘上。从现在开始,这将是重启后的 Sentinel ID。 for (j = 0; j < CONFIG_RUN_ID_SIZE; j++) if (sentinel.myid[j] != 0) break; if (j == CONFIG_RUN_ID_SIZE) { /* Pick ID and persist the config. */ // 生成一个16进制的随机ID,选择 ID 并持久化配置 getRandomHexChars(sentinel.myid,CONFIG_RUN_ID_SIZE); sentinelFlushConfig(); } /* Log its ID to make debugging of issues simpler. */ // 记录其 ID 以简化问题的调试。 serverLog(LL_WARNING,"Sentinel ID is %s", sentinel.myid); /* We want to generate a +monitor event for every configured master * at startup. */ // 我们希望在启动时为每个配置的 master 生成一个 +monitor 事件。 sentinelGenerateInitialMonitorEvents(); }
那么,sentinelIsRunning 函数是如何获取到主节点的地址信息呢?
这就和我刚才给你介绍的 initSentinel 函数有关了,它会初始化哨兵实例的数据结构 sentinel.masters。这个结构是使用了一个哈希表记录监听的主节点,每个主节点会使用 sentinelRedisInstance 结构来保存。而在 sentinelRedisInstance 结构中,就包含了被监听主节点的地址信息。这个地址信息是由 sentienlAddr 结构体保存的,其中包括了节点的 IP 和端口号,如下所示:
typedef struct sentinelAddr { char *ip; int port; } sentinelAddr;
此外,sentinelRedisInstance 结构中还包括了一些和主节点、故障切换相关的其他信息,比如主节点名称、ID、监听同一个主节点的其他哨兵实例、主节点的从节点、主节点主观下线和客观下线的时长,等等。以下代码展示了 sentinelRedisInstance 结构的部分内容,你可以看看。
typedef struct sentinelRedisInstance { int flags; //实例类型、状态的标记 char *name; //实例名称 char *runid; //实例ID uint64_t config_epoch; //配置的纪元 sentinelAddr *addr; //实例地址信息 ... mstime_t s_down_since_time; //主观下线的时长 mstime_t o_down_since_time; //客观下线的时长 ... dict *sentinels; //监听同一个主节点的其他哨兵实例 dict *slaves; //主节点的从节点 ... }
这里,你需要注意下,sentinelRedisInstance 是一个通用的结构体,它不仅可以表示主节点,也可以表示从节点或者其他的哨兵实例。
这个结构体的成员变量有一个 flags,它可以设置为不同的值,从而表示不同类型的实例。比如,当 flags 设置为 SRI_MASTER、SRI_SLAVE 或 SRI_SENTINEL 这三种宏定义(在 sentinel.c 文件中)时,就分别表示当前实例是主节点、从节点或其他哨兵。你在阅读哨兵相关的源码时,可以看到代码中会对 flags 进行判断,获得当前实例类型,然后再执行相应的代码逻辑。
好了,到这里,你就知道当哨兵要和被监听的主节点通信时,它只需要从 sentinel.masters 结构中获取主节点对应的 sentinelRedisInstance 实例,然后就可以给主节点发送消息了。
这个 sentinelGenerateInitialMonitorEvents 函数的执行逻辑,你可以参考以下代码:
/* This function is called only at startup and is used to generate a * +monitor event for every configured master. The same events are also * generated when a master to monitor is added at runtime via the * SENTINEL MONITOR command. */ // 此函数仅在启动时调用,用于为每个配置的主站生成 +monitor 事件。 // 当通过 SENTINEL MONITOR 命令在运行时添加要监控的主设备时,也会生成相同的事件 void sentinelGenerateInitialMonitorEvents(void) { dictIterator *di; dictEntry *de; // 获取masters的迭代器 di = dictGetIterator(sentinel.masters); // 获取被监听的主节点 while((de = dictNext(di)) != NULL) { sentinelRedisInstance *ri = dictGetVal(de); sentinelEvent(LL_WARNING,"+monitor",ri,"%@ quorum %d",ri->quorum); } dictReleaseIterator(di); }
从代码中,你可以看到,sentinelGenerateInitialMonitorEvents 函数是调用 sentinelEvent 函数(在 sentinel.c 文件中)来实际发送事件信息的。
sentinelEvent 函数的原型定义如下,它的参数 level 表示当前的日志级别,type 表示发送事件信息所用的订阅频道,ri 表示对应交互的主节点,fmt 则表示发送的消息内容。
void sentinelEvent(int level, char *type, sentinelRedisInstance *ri, const char *fmt, ...)
那么,sentinelEvent 函数会先判断传入的消息内容开头的两个字符,是否为“%”和“@”,如果是的话,它就会判断监听实例的类型是否为主节点。然后如果是主节点,sentinelEvent 函数会把监听实例的名称、IP 和端口号加入到待发送的消息中,如下所示:
/* =========================== Events notification ========================== */ /* Send an event to log, pub/sub, user notification script. * * 'level' is the log level for logging. Only LL_WARNING events will trigger * the execution of the user notification script. * * 'type' is the message type, also used as a pub/sub channel name. * * 'ri', is the redis instance target of this event if applicable, and is * used to obtain the path of the notification script to execute. * * The remaining arguments are printf-alike. * If the format specifier starts with the two characters "%@" then ri is * not NULL, and the message is prefixed with an instance identifier in the * following format: * * <instance type> <instance name> <ip> <port> * * If the instance type is not master, than the additional string is * added to specify the originating master: * * @ <master name> <master ip> <master port> * * Any other specifier after "%@" is processed by printf itself. */ void sentinelEvent(int level, char *type, sentinelRedisInstance *ri, const char *fmt, ...) { va_list ap; char msg[LOG_MAX_LEN]; robj *channel, *payload; /* Handle %@ */ // 如果传递消息以"%"和"@"开头,就判断实例是否为主节点 if (fmt[0] == '%' && fmt[1] == '@') { // 判断实例的flags标签是否为SRI_MASTER,如果是,就表明实例是主节点 sentinelRedisInstance *master = (ri->flags & SRI_MASTER) ? NULL : ri->master; // 如果当前实例是主节点,根据实例的名称、IP地址、端口号等信息调用snprintf生成传递的消息msg if (master) { snprintf(msg, sizeof(msg), "%s %s %s %d @ %s %s %d", sentinelRedisInstanceTypeStr(ri), ri->name, announceSentinelAddr(ri->addr), ri->addr->port, master->name, announceSentinelAddr(master->addr), master->addr->port); } else { snprintf(msg, sizeof(msg), "%s %s %s %d", sentinelRedisInstanceTypeStr(ri), ri->name, announceSentinelAddr(ri->addr), ri->addr->port); } fmt += 2; } else { msg[0] = '\0'; } /* Use vsprintf for the rest of the formatting if any. */ // 如果有其他格式,请使用 vsprintf if (fmt[0] != '\0') { va_start(ap, fmt); vsnprintf(msg+strlen(msg), sizeof(msg)-strlen(msg), fmt, ap); va_end(ap); } /* Log the message if the log level allows it to be logged. */ // 如果日志级别允许记录该消息,则记录该消息。 if (level >= server.verbosity) serverLog(level,"%s %s",type,msg); /* Publish the message via Pub/Sub if it's not a debugging one. */ // 如果不是调试消息,请通过 Pub/Sub 发布消息 if (level != LL_DEBUG) { channel = createStringObject(type,strlen(type)); payload = createStringObject(msg,strlen(msg)); pubsubPublishMessage(channel,payload); decrRefCount(channel); decrRefCount(payload); } /* Call the notification script if applicable. */ // 如果可应用,调用通知脚本 if (level == LL_WARNING && ri != NULL) { sentinelRedisInstance *master = (ri->flags & SRI_MASTER) ? ri : ri->master; if (master && master->notification_script) { sentinelScheduleScriptExecution(master->notification_script, type,msg,NULL); } } }
另外这里你要注意一点,刚才我介绍的 sentinelGenerateInitialMonitorEvents 函数,它给 sentinelEvent 函数发送的参数 type 是“+monitor”,这就表明它会将事件信息发到"+monitor"频道上。
下面的图展示了 sentinelEvent 函数的执行流程,你可以再回顾下。
好了,到这里,哨兵实例的初始化就基本完成了。接下来,哨兵就会和主节点进行通信,监听主节点的状态变化,我会在接下来的文章中给你具体介绍它们之间的通信过程。
小结
今天这篇文章,我给你介绍了哨兵实例的初始化过程。哨兵实例和 Redis 实例使用的是相同的入口 main 函数,但是由于哨兵实例在运行时使用的配置项、运行时信息、支持的可执行命令、事件处理和 Redis 实例又有所区别。
所以,main 函数会先通过 checkForSentinelMode 函数来判断当前运行是否为哨兵实例,并相应地设置全局配置项 server.sentinel_mode,这个配置项就会在源码其他地方被用于标识哨兵实例是否运行。
这样,当启动的是哨兵实例时,main 函数会调用 initSentinelConfig、initSentinel 函数来完成哨兵实例的初始化,然后,main 函数会调用 sentinelIsRunning 函数,来向被监听的主节点发送事件信息,从而开始监听主节点。
最后,我也想再提醒你一下,从今天这篇文章的内容中,我们可以看到哨兵实例在运行后,开始使用 Pub/Sub 订阅频道模式的通信方法,这种通信方法通常适用于多对多的通信场景中。
因为哨兵实例除了和主节点通信外,还需要和其他哨兵实例、客户端进行通信,而采用 Pub/Sub 通信方法,可以高效地完成这些通信过程。
课后题:哨兵实例本身是有配置文件 sentinel.conf 的,那么在哨兵实例的初始化过程中,解析这个配置文件的函数在哪?
Redis 启动时,会在 main 函数中调用 loadServerConfig 加载配置文件,loadServerConfig 函数会读取配置文件中的内容,然后调用 loadServerConfigFromString 函数解析具体的配置项。
loadServerConfigFromString 函数中,其中有一个分支,对哨兵模式进行了判断,如果是哨兵模式,则调用 sentinelHandleConfiguration 函数解析哨兵配置项。
所以,函数调用链为 main -> loadServerConfig(读出配置文件内容) -> loadServerConfigFromString(解析配置项) -> sentinelHandleConfiguration(解析哨兵配置项)。