gitbook/Redis源码剖析与实战/docs/434382.md
2022-09-03 22:05:03 +08:00

16 KiB
Raw Permalink Blame History

答疑4 | 第19~24讲课后思考题答案及常见问题答疑

你好我是蒋德钧。这节课我们继续来解答第19到24讲的课后思考题。

注意今天讲解的这些思考题一方面会涉及Redis哨兵实例的代码细节以及管道机制在Redis中的应用另一方面这些思考题也是考查常用的开发知识比如状态机、子进程使用等进程考查。希望你通过这节课的内容可以再回顾下Redis哨兵实例的代码并进一步了解题目解答中涉及的开发知识和技术。

第19讲

**问题:**RDB文件的创建是由一个子进程来完成的而AOF重写也是由一个子进程完成的这两个子进程可以各自单独运行。那么请你思考一下为什么Redis源码中在有RDB子进程运行时不会启动AOF重写子进程呢

我设计这道题的目的是希望你能了解和掌握RDB文件创建和AOF重写这两个操作本身涉及到的资源消耗。我们在开发系统软件时对于使用子进程或是线程来进行并发处理有时会存在一个误区:只要使用了多子进程或是多线程就可以加速并行执行的任务。

但是执行多子进程能够获得的收益还是要看这些子进程对资源竞争的情况。就像这道题目提出的虽然RDB创建和AOF重写可以会用两个子进程单独运行但是从它们使用的资源角度来看它们之间会存在竞争。

那么,一个最明显的资源竞争就是对磁盘的写竞争。创建RDB文件和重写AOF都需要把数据写入磁盘如果同时让这两个子进程写盘就会给磁盘带来较大的压力。而除了磁盘资源竞争以外RDB文件创建和AOF重写还需要读取Redis数据库中的所有键值对如果这两个子进程同时执行也会消耗CPU资源。

第20讲

**问题:**这节课我给你介绍了重写子进程和主进程间进行操作命令传输、ACK信息传递用的三个管道。那么你在Redis源码中还能找到其他使用管道的地方吗

这道题目是希望你能更多地了解下管道在Redis中的应用。有不少同学都找到了多个使用管道的地方我在这里总结下。

  • 首先创建RDB、AOF重写和主从复制时会用到管道。

在RDB文件的创建函数rdbSaveBackground、AOF重写的函数rewriteAppendOnlyFileBackground以及把RDB通过socket传给从节点的函数rdbSaveToSlavesSockets中它们都会调用openChildInfoPipe函数创建一个管道child_info_pipe这个管道的描述符数组保存在了全局变量server中。

当RDB创建结束或是AOF文件重写结束后这两个函数会调用sendChildInfo函数通过刚才创建的管道child_info_pipe把子进程写时复制的实际数据量发送给父进程。

下面的代码展示了rdbSaveBackground、rewriteAppendOnlyFileBackground、rdbSaveToSlavesSockets这三个函数使用管道的主要代码你可以看下。

int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
…
openChildInfoPipe();
if ((childpid = fork()) == 0) {
…
server.child_info_data.cow_size = private_dirty; //记录实际的写时复制数据量
	sendChildInfo(CHILD_INFO_TYPE_RDB); //将写时复制数据量发送给父进程
	…} …}
	 
int rdbSaveToSlavesSockets(rdbSaveInfo *rsi) {
…
openChildInfoPipe();
if ((childpid = fork()) == 0) {
…
server.child_info_data.cow_size = private_dirty; //记录实际的写时复制数据量
sendChildInfo(CHILD_INFO_TYPE_RDB); //将写时复制数据量发送给父进程
…} …}

 
int rewriteAppendOnlyFileBackground(void) {
…
openChildInfoPipe();  //创建管道
…
if ((childpid = fork()) == 0) {
…
if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
…
server.child_info_data.cow_size = private_dirty; //记录实际写时复制的数据量
sendChildInfo(CHILD_INFO_TYPE_AOF); //将写时复制的数据量发送给父进程
…} …}
…}

此外在刚才介绍的rdbSaveToSlavesSockets函数中它还会创建一个管道。当子进程把数据传给从节点后子进程会使用这个管道向父进程发送成功接收到所有数据传输的从节点ID你可以看看下面的代码。

int rdbSaveToSlavesSockets(rdbSaveInfo *rsi) {
…
if (pipe(pipefds) == -1) return C_ERR;
server.rdb_pipe_read_result_from_child = pipefds[0];  //创建管道读端
server.rdb_pipe_write_result_to_parent = pipefds[1]; //创建管道写端
…
if ((childpid = fork()) == 0) {
…
//数据传输完成后通过管道向父进程传输从节点ID
if (*len == 0 || write(server.rdb_pipe_write_result_to_parent,msg,msglen) != msglen) {…}
…} …}

  • 其次Redis module运行时会用到管道。

在module的初始化函数moduleInitModulesSystem中它会创建一个管道module_blocked_pipe这个管道会用来唤醒由于处理module命令而阻塞的客户端。

下面的代码展示了管道在Redis module中的使用你可以看下。

void moduleInitModulesSystem(void) {
...
if (pipe(server.module_blocked_pipe) == -1) {...} //创建管道
...}

int RM_UnblockClient(RedisModuleBlockedClient *bc, void *privdata) {
...
if (write(server.module_blocked_pipe[1],"A",1) != 1) {...} //向管道中写入“A”字符表示唤醒被module阻塞的客户端
...}

void moduleHandleBlockedClients(void) {
...
while (read(server.module_blocked_pipe[0],buf,1) == 1); //从管道中读取字符
...}

  • 最后linuxMadvFreeForkBugCheck函数会用到管道。

基于arm64架构的Linux内核有一个Bug这个Bug可能会导致数据损坏。而Redis源码就针对这个Bug打了一个补丁这个补丁在main函数的执行过程中会调用linuxMadvFreeForkBugCheck函数这个函数会fork一个子进程来判断是否发现Bug而子进程会使用管道来和父进程交互检查结果。你也可以具体看下修复这个Bug的补丁

第21讲

**问题:**这节课我们介绍的状态机是当实例为从库时会使用的。那么,当一个实例是主库时,为什么不需要使用一个状态机,来实现主库在主从复制时的流程流转呢?

在Redis实现主从复制时从库涉及到的状态变迁有很多包括了发起连接、主从握手、复制类型判断、请求数据等。因此使用状态机开发从库的复制流程可以很好地帮助我们实现状态流转。

但是,如果你再去看下主从复制的启动,你会发现,主从复制都是由从库执行slaveof或replicaof命令而开始。这也就是说,主从复制的发起方是从库,而对于主库来说,它只是被动式地响应从库的各种请求并根据从库的请求执行相应的操作比如生成RDB文件或是传输数据等。

而且,从另外一个角度来说,主库可能和多个从库进行主从复制,而不同从库的复制进度和状态很可能并不一样,如果主库要维护状态机的话,那么,它还需要为每个从库维护一个状态机,这个既会增加开发复杂度,也会增加运行时的开销。正是因为这些原因,所以主库并不需要使用状态机进行状态流转。

除此之外, @曾轼麟同学也提到了一个原因,主库本身是可能发生故障,并要进行故障切换的。如果主库在执行主从复制时,也维护状态机,那么一旦主库发生了故障,也还需要考虑状态机的冗余备份和故障切换,这会给故障切换的开发和执行带来复杂度和开销。而从库维护状态机本身就已经能完成主从复制,所以没有必要让主库再维护状态机了。

第22讲

**问题:**哨兵实例本身是有配置文件sentinel.conf的那么你能在哨兵实例的初始化过程中找到解析这个配置文件的函数吗

在前面的第8讲我重点给你介绍了Redis server的启动和初始化过程。因为哨兵实例本身也是一个Redis server所以它启动后的初始化代码执行路径和Redis server是类似的。

哨兵实例启动后它的入口函数是serve.c文件中的main函数。然后main函数会调用loadServerConfig函数加载配置文件。而loadServerConfig会进一步调用loadServerConfigFromString函数解析配置文件中的具体配置项。

那么当loadServerConfigFromString函数在解析配置项时它会使用条件分支判断来匹配不同的配置项。当它匹配到配置项为“sentinel”时,它就会执行解析哨兵实例配置项的代码分支了,具体来说,它会调用sentinelHandleConfiguration函数来进行解析,如下所示:

void loadServerConfigFromString(char *config) {
else if (!strcasecmp(argv[0],"sentinel")) {
…
err = sentinelHandleConfiguration(argv+1,argc-1);
…}…}

sentinelHandleConfiguration函数是在sentinel.c文件中实现的它和loadServerConfigFromString函数类似也是匹配sentinel.conf中的不同配置项进而执行不同的代码分支。你可以进一步阅读它的代码来进行了解。

我在这里也画了一张图,展示了哨兵实例解析配置项的函数调用关系,你可以看下。

图片

第23讲

**问题:**哨兵实例执行的周期性函数sentinelTimer它在函数执行逻辑的最后会修改server.hz配置项如下所示

void sentinelTimer(void) {
...
server.hz = CONFIG_DEFAULT_HZ + rand() % CONFIG_DEFAULT_HZ;
}

那么你知道调整server.hz的目的是什么吗

这道题目,像是@Kaito、@曾轼麟、@可怜大灰狼等不少同学,都给出了正确答案,这里我就来总结一下。

那么,要回答这道题目,首先你要知道server.hz表示的是定时任务函数serverCron的执行频率而哨兵实例执行的周期性函数sentinelTimer也是在serverCron中被调用执行的。所以sentinelTimer函数的运行频率会按照server.hz来执行。

我在第23讲中给你介绍过当哨兵实例判断了主节点客观下线后它们就要开始选举Leader节点以便进行故障切换。但是Leader选举时哨兵需要获得半数以上的赞成票如果在一轮选举中没能选出Leader此时哨兵实例会再次进行选举。

但是为了避免多个哨兵同时开始进行选举又同时都没法获得超过半数的赞成票而导致Leader选举失败sentinelTimer函数在执行的最后一步对server.hz做了微调在默认值CONFIG_DEFAULT_HZ的基础上增加一个随机值。

这样一来每个哨兵的执行频率就不会完全同步了。一轮选举失败后哨兵再次选举时不同哨兵的再次执行频率不一样这就把它们发起投票的时机错开了从而降低了它们都无法获得超过半数赞成票的概率也就保证了Leader选举能快速完成可以执行实际的故障切换。

所以sentinelTimer函数修改server.hz可以避免故障切换过程中因为Leader节点选举不出来而导致无法完成的情况提升了Redis的可用性。

第24讲

**问题:**哨兵在sentinelTimer函数中会调用sentinelHandleDictOfRedisInstances函数对每个主节点都执行sentinelHandleRedisInstance函数并且还会对主节点的所有从节点也执行sentinelHandleRedisInstance函数。那么哨兵会判断从节点的主观下线和客观下线吗

这道题目是希望你能进一步阅读sentinelHandleRedisInstance函数的源码对它的执行流程有个更加详细的了解。

@曾轼麟同学在留言区就给出了比较详细的分析,我在此基础上做了些完善,分享给你。

首先在sentinelHandleDictOfRedisInstances函数中它会执行一个循环流程针对当前哨兵实例监听的每个主节点执行sentinelHandleRedisInstance函数

在这个处理过程中,存在一个递归调用也就是说如果当前处理的节点就是主节点那么sentinelHandleDictOfRedisInstances函数会进一步针对这个主节点的从节点再次调用sentinelHandleDictOfRedisInstances函数从而对每个从节点执行sentinelHandleRedisInstance函数。

这部分的代码逻辑如下所示:

void sentinelHandleDictOfRedisInstances(dict *instances) {
…
di = dictGetIterator(instances);
while((de = dictNext(di)) != NULL) {
   sentinelRedisInstance *ri = dictGetVal(de);  //获取哨兵实例监听的每个主节点
   sentinelHandleRedisInstance(ri);  //调用sentinelHandleRedisInstance
   if (ri->flags & SRI_MASTER) {  //如果当前节点是主节点那么调用sentinelHandleDictOfRedisInstances对它的所有从节点进行处理。
	sentinelHandleDictOfRedisInstances(ri->slaves);
	…}
…}…}

然后在sentinelHandleRedisInstance函数执行时它会调用sentinelCheckSubjectivelyDown函数,来判断当前处理的实例是否主观下线。这步操作没有任何额外的条件约束,也就是说,无论当前是主节点还是从节点,都会被判断是否主观下线的。这部分代码如下所示:

void sentinelHandleRedisInstance(sentinelRedisInstance *ri) {
…
sentinelCheckSubjectivelyDown(ri);  //无论是主节点还是从节点,都会检查是否主观下线
…}

但是要注意sentinelHandleRedisInstance函数在调用sentinelCheckObjectivelyDown函数判断实例客观下线状态时它会检查当前实例是否有主节点标记如下所示

void sentinelHandleRedisInstance(sentinelRedisInstance *ri) {
…
  if (ri->flags & SRI_MASTER) {  //只有当前是主节点,才检查是否客观下线
        sentinelCheckObjectivelyDown(ri);
   …}
…}

那么总结来说对于主节点和从节点它们的sentinelHandleRedisInstance函数调用路径就如下所示

主节点sentinelHandleRedisInstance -> sentinelCheckSubjectivelyDown -> sentinelCheckObjectivelyDown
从节点sentinelHandleRedisInstance -> sentinelCheckSubjectivelyDown

所以,回到这道题目的答案上来说,哨兵会判断从节点的主观下线,但不会判断其是否客观下线。

此外,@曾轼麟同学还通过分析代码,看到了从节点被判断为主观下线后,是不能被选举为新主节点的。这个过程是在sentinelSelectSlave函数中执行的这个函数会遍历当前的从节点依次检查它们的标记如果一个从节点有主观下线标记那么这个从节点就会被直接跳过不会被选为新主节点。

下面的代码展示了sentinelSelectSlave函数这部分的逻辑你可以看下。

sentinelRedisInstance *sentinelSelectSlave(sentinelRedisInstance *master) {
…
di = dictGetIterator(master->slaves);
    while((de = dictNext(di)) != NULL) { //遍历主节点的每一个从节点
        sentinelRedisInstance *slave = dictGetVal(de);
        …
        if (slave->flags & (SRI_S_DOWN|SRI_O_DOWN)) continue; //如果从节点主观下线,那么直接跳过该节点,不能被选为新主节点
    …} …}

小结

好了,今天这节课就到这里了,我们来总结下。

今天这节课我主要给你解答了第19讲到24讲的课后思考题。这些题目有些是涉及Redis源码的细节比如哨兵实例的初始化操作、周期性任务对主从节点执行的操作等。对于这部分内容我希望你能结合代码做进一步的阅读并掌握好它。

而有些题目则是和通用的开发知识和技巧相关。比如管道在子进程和父进程间提供的通信机制、状态机在复杂流程开发中的使用、分布式共识开发中的投票频率调整等。关于这部分内容我希望你能结合它们在Redis代码实现中的应用掌握它们的使用方法并应用到你自己的系统开发中。