You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

15 KiB

答疑1 | 第1~6讲课后思考题答案及常见问题答疑

你好,我是蒋德钧。

咱们的课程已经快接近尾声了,之前我主要把精力和时间集中在了课程内容的准备上,没有来得及及时给大家做答疑,以及回复同学们提出的问题,在这也和同学们说一声抱歉,接下来我会尽快来回复大家的疑问。但其实,在这期间我看到了很多同学的留言,既有针对咱们课程课后思考题的精彩解答,也有围绕课程内容本身提出的关键问题,而且这些问题的质量很高,非常值得好好讨论一下。

那么今天这节课我就先来对课程的前6节的思考题做一次答疑。你也可以借此机会再来回顾下咱们课程一开始时学习的内容温故而知新。

第1讲

问题Redis从4.0版本开始能够支持后台异步执行任务比如异步删除数据那么你能在Redis功能源码中找到实现后台任务的代码文件吗

关于这个问题,@悟空聊架构、@小五、@Kaito等不少同学都给出了正确答案。我在这些同学回答的基础上稍微做了些完善你可以参考下。

Redis支持三类后台任务它们本身是在bio.h文件中定义的,如下所示:

#define BIO_CLOSE_FILE    0    //后台线程关闭文件
#define BIO_AOF_FSYNC     1   //后台线程刷盘
#define BIO_LAZY_FREE     2   //后台线程释放内存

那么在Redis server启动时入口main函数会调用InitServerLast函数而InitServerLast函数会调用bioInit函数来创建这三类后台线程。这里的bioInit函数则是在bio.c文件中实现的。

而对于这三类后台任务的执行来说它们是在bioProcessBackgroundJobs函数在bio.c文件中中实现的。其中BIO_CLOSE_FILE和BIO_AOF_FSYNC这两类后台任务的实现代码分别对应了close函数和redis_fysnc函数。而BIO_LAZY_FREE任务根据参数不同对应了lazyfreeFreeObjectFromBioThread、lazyfreeFreeDatabaseFromBioThread和lazyfreeFreeSlotsMapFromBioThread三处实现代码。而这些代码都是在lazyfree.c文件中实现。

此外,还有一些同学给出了异步删除数据的执行流程和涉及函数,比如,@曾轼麟同学以unlink为例列出了删除操作涉及的两个执行流程我在这里也分享下。

unlink实际代码的执行流程如下所示

  • 使用异步删除时的流程unlinkCommand -> delGenericCommand -> dbAsyncDelete -> dictUnlink -> bioCreateBackgroundJob创建异步删除任务 -> 后台异步删除。
  • 不使用异步删除时的流程unlinkCommand -> delGenericCommand -> dbAsyncDelete -> dictUnlink -> dictFreeUnlinkedEntry直接释放内存。

此外,@悟空聊架构同学还提到了在Redis 6.0中增加的IO多线程。不过Redis 6.0中的多IO线程主要是为了利用多核并行读取数据、解析命令以及写回数据这些线程其实是和主线程一起工作的所以我通常还是把它们看作前台的工作线程。

第2讲

问题SDS字符串在Redis内部模块实现中也被广泛使用你能在Redis server和客户端的实现中找到使用SDS字符串的地方吗

我们可以直接在全局变量server对应的结构体redisServer中查找使用sds进行定义的成员变量其代码如下所示

struct redisServer {
…
sds aof_buf;
sds aof_child_diff;
…}

同样我们可以在客户端对应的结构体client中查找sds定义的成员变量如下代码所示

typedef struct client {
…
sds querybuf;
sds pending_querybuf;
sds replpreamble;
sds peerid;
…} client;

此外你也要注意的是在Redis中键值对的key也都是SDS字符串。在执行将键值对插入到全局哈希表的函数dbAdd在db.c文件中的时候键值对的key会先被创建为SDS字符串然后再保存到全局哈希表中你可以看看下面的代码。

void dbAdd(redisDb *db, robj *key, robj *val) {
    sds copy = sdsdup(key->ptr);  //根据redisObject结构中的指针获得实际的key调用sdsdup将其创建为一个SDS字符串
	int retval = dictAdd(db->dict, copy, val); //将键值对插入哈希表
	…
}

第3讲

问题Hash函数会影响Hash表的查询效率及哈希冲突情况那么你能从Redis的源码中找到Hash表使用的是哪一种Hash函数吗

关于这个问题,@Kaito、@陌、@可怜大灰狼、@曾轼麟等不少同学都找到了Hash函数的实现在这里我也总结下。

其实我们在查看哈希表的查询函数dictFind时可以看到它会调用dictHashKey函数来计算键值对key的哈希值如下所示

dictEntry *dictFind(dict *d, const void *key) {
    ...
    // 计算 key 的哈希值
    h = dictHashKey(d, key);
    ...
}

那么我们进一步看dictHashKey函数可以发现它是在dict.h文件中定义的如下所示

#define dictHashKey(d, key) (d)->type->hashFunction(key)

从代码可以看到dictHashKey函数会实际执行哈希表类型相关的hashFunction来计算key的哈希值。所以这实际上就和哈希表结构体中的type有关了。
这里我们来看看哈希表对应的数据结构dict的定义如下所示

typedef struct dict {
    dictType *type;
    ...
} dict;

dict结构体中的成员变量type类型是dictType结构体而dictType里面包含了哈希函数的函数指针hashFunction。

typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    ...
} dictType;

那么既然dictType里面有哈希函数的指针所以比较直接的方法就是去看哈希表在初始化时是否设置了dictType中的哈希函数。

在Redis server初始化函数initServer中会对数据库中的主要结构进行初始化这其中就包括了对全局哈希表的初始化如下所示

void initServer(void) {
...
for (j = 0; j < server.dbnum; j++) {
        server.db[j].dict = dictCreate(&dbDictType,NULL);  //初始化全局哈希表
        ...}
}

从这里,你就可以看到全局哈希表对应的哈希表类型是dbDictType而dbDictType是在server.c文件中定义的它设置的哈希函数是dictSdsHash在server.c文件中如下所示

dictType dbDictType = {
    dictSdsHash,                //哈希函数
    ...
};

我们再进一步查看dictSdsHash函数的实现可以发现它会调用dictGenHashFunction函数在dict.c文件中而dictGenHashFunction函数又会进一步调用siphash函数在siphash.c文件中来实际执行哈希计算。所以到这里我们就可以知道全局哈希表使用的哈希函数是siphash

下面的代码展示了dictSdsHash函数及其调用的关系你可以看下。

uint64_t dictSdsHash(const void *key) {
    return dictGenHashFunction((unsigned char*)key, sdslen((char*)key));
}

uint64_t dictGenHashFunction(const void *key, int len) {
    return siphash(key,len,dict_hash_function_seed);
}

其实Redis源码中很多地方都使用到了哈希表它们的类型有所不同相应的它们使用的哈希函数也有区别。在server.c文件中你可以看到有很多哈希表类型的定义这里面就包含了不同类型哈希表使用的哈希函数你可以进一步阅读源码看看。以下代码也展示了一些哈希表类型的定义你可以看下。

dictType objectKeyPointerValueDictType = {
    dictEncObjHash,
    ...
}

dictType setDictType = {
    dictSdsHash,
    ...
}

dictType commandTableDictType = {
    dictSdsCaseHash, 
    ...
}

第4讲

问题SDS判断是否使用嵌入式字符串的条件是44字节你知道为什么是44字节吗

这个问题不少同学都是直接分析了redisObject和SDS的数据结构作出了正确的解答。从留言中也能看到同学们对Redis代码的熟悉程度是越来越高了。这里我来总结下。

嵌入式字符串本身会把redisObject和SDS作为一个连续区域来分配内存而就像@曾轼麟同学在解答时提到的,我们在考虑内存分配问题时,需要了解内存分配器的工作机制。那么对于Redis使用的jemalloc内存分配器来说它为了减少内存碎片并不会按照实际申请多少空间就分配多少空间。

其实jemalloc会根据申请的字节数N找一个比N大但是最接近N的2的幂次数来作为实际的分配空间大小这样一来既可以减少内存碎片也能避免频繁的分配。在使用jemalloc时它的常见分配大小包括8、16、32、64等字节。

对于redisObject来说它的结构体是定义在server.h文件中,如下所示:

typedef struct redisObject {
    unsigned type:4;   // 4 bits
    unsigned encoding:   //4 bits
    unsigned lru:LRU_BITS;  //24 bits
    int refcount;  //4字节
    void *ptr;   //8字节
} robj;

从代码中可以看到redisObject本身占据了16个字节的空间大小。而嵌入式字符串对应的SDS结构体sdshdr8它的成员变量len、alloc和flags一共占据了3个字节。另外它包含的字符数组buf中还会包括一个结束符“\0”占用1个字符。所以这些加起来一共是4个字节。

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;       // 1字节
    uint8_t alloc;    // 1字节
    unsigned char flags; // 1字节
    char buf[];   //字符数组末尾有一个结束符占1个字节
};

对于嵌入式字符串来说jemalloc给它分配的最大大小是64个字节而这其中redisObject、sdshdr结构体元数据和字符数组结束符已经占了20个字节所以这样算下来嵌入式字符串剩余的空间大小最大就是44字节了64-20=44。这也是SDS判断是否使用嵌入式字符串的条件是44字节的原因。

第5讲

问题:在使用跳表和哈希表相结合的双索引机制时,在获得高效范围查询和单点查询的同时,你能想到这种双索引机制有哪些不足之处吗?

其实,对于双索引机制来说,它的好处很明显,就是可以充分利用不同索引机制的访问特性,来提供高效的数据查找。但是,双索引机制的不足也比较明显,它要占用的空间比单索引要求的更多,这也是因为它需要维护两个索引结构,难以避免会占用较多的内存空间。

我看到有不少同学都提到了“以空间换时间”这一设计选择,我能感觉到大家已经开始注意透过设计方案,去思考和抓住设计的本质思路了,这一点非常棒!**因为很多优秀的系统设计,其实背后就是计算机系统中很朴素的设计思想。**如果你能有意识地积累这些设计思想,并基于这些思想去把握自己的系统设计核心出发点,那么,这可以让你对系统的设计和实现有一个更好的全局观,在你要做设计取舍时,也可以帮助你做决断。

就像这里的“以空间换时间”的设计思想,本身很朴素。而一旦你能抓住这个本质思想后,就可以根据自己系统对内存空间和访问时间哪一个要求更高,来决定是否采用双索引机制。

不过,这里我也想再提醒你注意一个关键点对于双索引结构的更新来说我们需要保证两个索引结构的一致性不能出现一个索引结构更新了而另一个索引没有相应的更新。比如我们只更新了Hash而没有更新跳表。这样一来就会导致程序能在哈希上找到数据但是进行范围查询时就没法在跳表上找到相应的数据了。

对于Redis来说因为它的主线程是单线程而且它的索引结构本身是不做持久化的所以双索引结构的一致性保证问题在Redis中不明显。但是一旦在多线程的系统中有多个线程会并发操作访问双索引时这个一致性保证就显得很重要了。

如果我们采用同步的方式更新两个索引结构,这通常会对两个索引结构做加锁操作,保证更新的原子性,但是这会阻塞并发访问的线程,造成整体访问性能下降。不过,如果我们采用异步的方式更新两个索引结构,这会减少对并发线程的阻塞,但是可能导致两个索引结构上的数据不一致,而出现访问出错的情况。所以,在多线程情况下对双索引的更新是要重点考虑的设计问题。

另外,在同学们的解答中,我还看到@陌同学提到了一个观点他把skiplist + hash实现的有序集合和double linked list + hash实现的LRU管理进行了类比。其实对LRU来说它是使用链表结构来管理LRU链上的数据从而实现LRU所要求的数据根据访问时效性进行移动。而与此同时使用的Hash可以帮助程序能在O(1)的时间复杂度内获取数据,从而加速数据的访问。

我觉得@陌同学的这个关联类比非常好,这本身也的确是组合式多数据结构协作,完成系统功能的一个典型体现。

第6讲

问题ziplist会使用zipTryEncoding函数计算插入元素所需的新增内存空间。那么假设插入的一个元素是整数你知道ziplist能支持的最大整数是多大吗

ziplist的zipTryEncoding函数会判断整数的长度如果整数长度大于等于32位时zipTryEncoding函数就不将其作为整数计算长度了而是作为字符串来计算长度了所以最大整数是2的32次方。这部分代码如下所示

int zipTryEncoding(unsigned char *entry, unsigned int entrylen, long long *v, unsigned char *encoding) {
	…
	//如果插入元素的长度entrylen大于等于32直接返回0表示将插入元素作为字符串处理
	if (entrylen >= 32 || entrylen == 0) return 0;
	…
}

小结

好了今天这节课就到这里。在前6讲中我主要是给你介绍了Redis的数据结构要想掌握好这几讲的内容一个关键点是你要理解这些数据结构本身的组成和操作这样你在看代码时才能结合着数据结构本身的设计来理解代码的设计和实现从而获得更高的代码阅读效率。

非常感谢你对课后思考题的仔细思考和认真解答。在看留言的过程中,我从大家的答复中看到了更加全面或是更加深入的代码解读,我自己受益匪浅。接下来,我还会针对剩余的课后思考题,以及同学们的提问来做解答。也希望你将仍然存在的疑问提到留言区,我们来一起交流讨论。

让我们将学习进行到底!