# 答疑5 | 第25~32讲课后思考题答案及常见问题答疑 你好,我是蒋德钧。今天这节课,我们来继续解答第25讲到32讲的课后思考题。 今天讲解的这些思考题,主要是围绕哨兵命令实现、Redis Cluster实现,以及常用开发技巧提出来的。你可以根据这些思考题的解答思路,进一步了解下哨兵实例命令和普通实例命令的区别、Redis Cluster对事务执行的支持情况,以及函数式编程方法在Redis测试中的应用等内容。 ## [第25讲](https://time.geekbang.org/column/article/422627) **问题:**如果我们在哨兵实例上执行publish命令,那么,这条命令是不是就是由pubsub.c文件中的publishCommand函数来处理的呢? 这道题目主要是希望你能了解,哨兵实例会使用到哨兵自身实现的命令,而不是普通Redis实例使用的命令。这一点我们从哨兵初始化的过程中就可以看到。 哨兵初始化时,会调用 **initSentinel函数**。而initSentinel函数会先把server.commands对应的命令表清空,然后执行一个循环,把哨兵自身的命令添加到命令表中。哨兵自身的命令是使用 **sentinelcmds数组**保存的。 那么从sentinelcmds数组中,我们可以看到publish命令对应的实现函数,其实是 **sentinelPublishCommand**。所以,我们在哨兵实例上执行publish命令,执行的并不是pubsub.c文件中的publishCommand函数。 下面的代码展示了initSentinel 函数先清空、再填充命令表的基本过程,以及sentinelcmds数组的部分内容,你可以看下。 ```plain void initSentinel(void) {     ...     dictEmpty(server.commands,NULL);  //清空现有的命令表     // 将sentinelcmds数组中的命令添加到命令表中     for (j = 0; j < sizeof(sentinelcmds)/sizeof(sentinelcmds[0]); j++) {         int retval;         struct redisCommand *cmd = sentinelcmds+j;         retval = dictAdd(server.commands, sdsnew(cmd->name), cmd);         …     }     ...}   //sentinelcmds数组的部分命令定义 struct redisCommand sentinelcmds[] = {     ...     {"subscribe",subscribeCommand,-2,"",0,NULL,0,0,0,0,0},     {"publish",sentinelPublishCommand,3,"",0,NULL,0,0,0,0,0}, //publish命令对应哨兵自身实现的sentinelPublishCommand函数     {"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0},     ... }; ``` ## [第26讲](https://time.geekbang.org/column/article/424827) **问题:**在今天课程介绍的源码中,你知道为什么clusterSendPing函数计算wanted值时,是用的集群节点个数的十分之一吗? Redis Cluster在使用clusterSendPing函数,检测其他节点的运行状态时,**既需要及时获得节点状态,又不能给集群的正常运行带来过大的额外通信负担。** 因此,clusterSendPing函数发送的Ping消息,其中包含的节点个数不能过多,否则会导致Ping消息体较大,给集群通信带来额外的负担,影响正常的请求通信。而如果Ping消息包含的节点个数过少,又会导致节点无法及时获知较多其他节点的状态。 所以,wanted默认设置为集群节点个数的十分之一,主要是为了避免上述两种情况的发生。 ## [第27讲](https://time.geekbang.org/column/article/425404) **问题:**processCommand函数在调用完getNodeByQuery函数后,实际调用clusterRedirectClient函数进行请求重定向前,会根据当前命令是否是EXEC,分别调用discardTransaction和flagTransaction两个函数。 那么,你能通过阅读源码,知道这里调用discardTransaction和flagTransaction的目的是什么吗? ```plain int processCommand(client *c) { … clusterNode *n = getNodeByQuery(c,c->cmd,c->argv,c->argc,                                         &hashslot,&error_code); if (n == NULL || n != server.cluster->myself) {    if (c->cmd->proc == execCommand) {       discardTransaction(c);    } else {       flagTransaction (c);    }    clusterRedirectClient(c,n,hashslot,error_code);    return C_OK; } … } ``` 这道题目,像@Kaito、@曾轼麟等同学都给了较为详细的解释,我完善了下他们的答案,分享给你。 首先你要知道,当Redis Cluster运行时,它并不支持跨节点的事务执行。那么,我们从题目中的代码中可以看到,当getNodeByQuery函数返回null结果,或者查询的key不在当前实例时,discardTransaction或flagTransaction函数会被调用。 这里你要**注意**,getNodeByQuery函数返回null结果,通常是表示集群不可用、key找不到对应的slot、操作的key不在同一个 slot中、key正在迁移等这些情况。 那么,当这些情况发生,或者是查询的key不在当前实例时,如果client执行的是EXEC命令,**discardTransaction函数**就会被调用,它会放弃事务的执行,清空当前client之前缓存的命令,并对事务中的key执行unWatch操作,最后重置client的事务标记。 而如果当前client执行的是事务中的普通命令,那么 **flagTransaction函数**会被调用。它会给当前client设置标记CLIENT\_DIRTY\_EXEC。这样一来,当client后续执行EXEC命令时,就会根据这个标记,放弃事务执行。 总结来说,就是当集群不可用、key找不到对应的slot、key不在当前实例中、操作的key不在同一个slot中,或者key正在迁移等这几种情况发生时,事务的执行都会被放弃。 ## [第28讲](https://time.geekbang.org/column/article/426420) **问题:**在维护Redis Cluster集群状态的数据结构clusterState中,有一个字典树slots\_to\_keys。当在数据库中插入key时它会被更新,你能在Redis源码文件db.c中,找到更新slots\_to\_keys字典树的相关函数调用吗? 这道题目也有不少同学给出了正确答案,我来给你总结下。 首先,**dbAdd函数是用来将键值对插入数据库中的**。如果Redis Cluster被启用了,那么dbAdd函数会调用slotToKeyAdd函数,而slotToKeyAdd函数会调用slotToKeyUpdateKey函数。 那么在slotToKeyUpdateKey函数中,它会调用raxInsert函数更新slots\_to\_keys,调用链如下所示: > dbAdd -> slotToKeyAdd -> slotToKeyUpdateKey -> raxInsert 然后,**dbAsyncDelete和dbSyncDelete是用来删除键值对的**。如果Redis Cluster被启用了,这两个函数都会调用slotToKeyUpdateKey函数。而在slotToKeyUpdateKey函数里,它会调用raxRemove函数更新slots\_to\_keys,调用链如下所示: > dbAsyncDelete/dbSyncDelete -> slotToKeyDel -> slotToKeyUpdateKey -> raxRemove 另外,**empytDb函数是用来清空数据库的**。它会调用slotToKeyFlush函数,并由slotToKeyFlush函数,调用raxFree函数更新slots\_to\_keys,调用链如下所示: > empytDb -> slotToKeyFlush -> raxFree 还有在 **getKeysInSlot函数**中,它会调用raxStart获得slots\_to\_keys的迭代器,进而查询指定slot中的keys。而在 **delKeysInSlot函数**中,它也会调用raxStart获得slots\_to\_keys的迭代器,并删除指定slot中的keys。 此外,@曾轼麟同学还通过查阅Redis源码的git历史提交记录,发现slots\_to\_keys原先是使用跳表实现的,后来才替换成字典树。而这一替换的目的,也主要是为了方便通过slot快速查找到slot中的keys。 ## [第29讲](https://time.geekbang.org/column/article/427126) **问题:**在addReplyReplicationBacklog函数中,它会计算从节点在全局范围内要跳过的数据长度,如下所示: ```plain skip = offset - server.repl_backlog_off; ``` 然后,它会根据这个跳过长度计算实际要读取的数据长度,如下所示: ```plain len = server.repl_backlog_histlen - skip; ``` 请你阅读addReplyReplicationBacklog函数和调用它的masterTryPartialResynchronization函数,你觉得这里的skip会大于repl\_backlog\_histlen吗? 其实,在masterTryPartialResynchronization函数中,从节点要读取的全局位置对应了变量psync\_offset,这个函数会比较psync\_offset是否小于repl\_backlog\_off,以及psync\_offset是否大于repl\_backlog\_off加上repl\_backlog\_histlen的和。 当这两种情况发生时,masterTryPartialResynchronization函数会进行**全量复制**,如下所示: ```plain int masterTryPartialResynchronization(client *c) { … // psync_offset小于repl_backlog_off时,或者psync_offset 大于repl_backlog_off加repl_backlog_histlen的和时 if (!server.repl_backlog ||         psync_offset < server.repl_backlog_off ||         psync_offset > (server.repl_backlog_off + server.repl_backlog_histlen)) {     …    goto need_full_resync;  //进行全量复制 } ``` 当psync\_offset大于repl\_backlog\_off,并且小于repl\_backlog\_off加上repl\_backlog\_histlen的和,此时,masterTryPartialResynchronization函数会调用addReplyReplicationBacklog函数,进行**增量复制**。 而psync\_offset会作为参数offset,传给addReplyReplicationBacklog函数。因此,在addReplyReplicationBacklog函数中计算skip时,就不会发生skip会大于repl\_backlog\_histlen的情况了,这种情况已经在masterTryPartialResynchronization函数中处理了。 ## [第30讲](https://time.geekbang.org/column/article/427537) **问题:**Redis在命令执行的call函数中,为什么不会针对EXEC命令,调用slowlogPushEntryIfNeeded函数来记录慢命令呢? 我设计这道题的主要目的,是希望你能理解EXEC命令的使用场景和事务执行的过程。 **EXEC命令是用来执行属于同一个事务的所有命令的**。当程序要执行事务时,会先执行MULTI命令,紧接着,执行的命令并不会立即执行,而是被放到一个队列中缓存起来。等到EXEC命令执行时,在它之前被缓存起来等待执行的事务命令,才会实际执行。 因此,EXEC命令执行时,实际上会执行多条事务命令。此时,如果调用slowlogPushEntryIfNeeded函数记录了慢命令的话,并不能表示EXEC本身就是一个慢命令。而实际可能会耗时长的命令是事务中的命令,并不是EXEC命令自身,所以,这里不会针对EXEC命令,来调用slowlogPushEntryIfNeeded函数。 ## [第31讲](https://time.geekbang.org/column/article/428471) **问题:**你使用过哪些Redis的扩展模块,或者自行开发过扩展模块吗?欢迎分享一些你的经验。 我自己有使用过Redis的 **TimeSeries扩展模块**,用来在一个物联网应用的场景中保存一些时间序列数据。TimeSeries模块的功能特点是可以使用标签来对不同的数据集合进行过滤,通过集合标签筛选应用需要的集合数据。而且这个模块还支持对集合数据做聚合计算,比如直接求最大值、最小值等。 此外,我还使用过 **RedisGraph扩展模块**。这个模块支持把图结构的数据保存到Redis中,并充分利用了Redis使用内存读写数据的性能优势,提供对图数据进行快速创建、查询和条件匹配。你要是感兴趣,可以看下RedisGraph的[官网](https://redisgraph.io/)。 ## [第32讲](https://time.geekbang.org/column/article/428474) **问题:**Redis源码中还有一个针对SDS的小型测试框架,你知道这个测试框架是在哪个代码文件中吗? 这个小型测试框架是在testhelp.h文件中实现的。它定义了一个**宏test\_cond**,而这个宏实际是一段测试代码,它的参数包括了测试项描述descr,以及具体的测试函数\_c。 这里,你需要注意的是,在这个小框架中,测试函数是作为test\_cond参数传递的,这体现了函数式编程的思想,而且这种开发方式使用起来也很简洁。 下面的代码展示了这个小测试框架的主要部分,你可以看下。 ```plain int __failed_tests = 0;  //失败的测试项的数目 int __test_num = 0;    //已测试项的数目 #define test_cond(descr,_c) do { \     __test_num++; printf("%d - %s: ", __test_num, descr); \     if(_c) printf("PASSED\n"); else {printf("FAILED\n"); __failed_tests++;} \  //运行测试函数_c,如果能通过,则打印PASSED,否则打印FAILED } while(0); ``` 那么,基于这个测试框架,在sds.c文件的sdsTest函数中,我就调用了test\_cond宏,对SDS相关的多种操作进行了测试,你可以看看下面的示例代码。 ```plain int sdsTest(void) {     {         sds x = sdsnew("foo");  //调用sdsnew创建一个sds变量x         test_cond("Create a string and obtain the length", sdslen(x) == 3 && memcmp(x,"foo\0",4) == 0)  //调用test_cond测试sdsnew是否成功执行           …         x = sdscat(x,"bar");  //调用sdscat向sds变量x追求字符串         test_cond("Strings concatenation", sdslen(x) == 5 && memcmp(x,"fobar\0",6) == 0); //调用test_cond测试sdscat是否成功执行          …} ``` ## 小结 今天这节课,也是我们最后一节答疑课,希望通过这5节答疑课程,解答了你对咱们课后思考题的疑问。同时也希望,你能通过这些课后思考题,去进一步扩展自己对Redis源码的了解,以及掌握Redis实现中的设计思想。 当然,如果你在看了答疑后,仍然有疑惑不解的话,也欢迎你在留言区写下你的疑问,我会和你继续探讨。