你好,我是蒋德钧。

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

那么,今天这节课,我就先来对课程的前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实际代码的执行流程如下所示:

此外,@悟空聊架构同学还提到了在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的数据结构,要想掌握好这几讲的内容,一个关键点是,你要理解这些数据结构本身的组成和操作,这样你在看代码时,才能结合着数据结构本身的设计来理解代码的设计和实现,从而获得更高的代码阅读效率。

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

让我们将学习进行到底!

评论