Redis lazy异步删除

在学习数据异步或同步删除之前,你首先需要知道,删除操作实际上是包括了两步子操作。

  • 子操作一:将被淘汰的键值对从哈希表中去除,这里的哈希表既可能是设置了过期 key 的哈希表,也可能是全局哈希表。
  • 子操作二:释放被淘汰键值对所占用的内存空间。

也就是说,如果这两个子操作一起做,那么就是同步删除;如果只做了子操作一,而子操作二由后台线程来执行,那么就是异步删除。

那么对于 Redis 源码来说,它是使用了 dictGenericDelete 函数,来实现前面介绍的这两个子操作。dictGenericDelete 函数是在 dict.c 文件中实现的,下面我们就来了解下它的具体执行过程。

首先,dictGenericDelete 函数会先在哈希表中查找要删除的 key。它会计算被删除 key 的哈希值,然后根据哈希值找到 key 所在的哈希桶。

因为不同 key 的哈希值可能相同,而 Redis 的哈希表是采用了链式哈希,所以即使我们根据一个 key 的哈希值,定位到了它所在的哈希桶,我们也仍然需要在这个哈希桶中去比对查找,这个 key 是否真的存在。

也正是由于这个原因,dictGenericDelete 函数紧接着就会在哈希桶中,进一步比对查找要删除的 key。如果找到了,它就先把这个 key 从哈希表中去除,也就是把这个 key 从哈希桶的链表中去除。

然后,dictGenericDelete 函数会根据传入参数 nofree 的值,决定是否实际释放 key 和 value 的内存空间。dictGenericDelete 函数中的这部分执行逻辑如下所示:

h = dictHashKey(d, key); //计算key的哈希值
for (table = 0; table <= 1; table++) {
   idx = h & d->ht[table].sizemask;  //根据key的哈希值获取它所在的哈希桶编号
   he = d->ht[table].table[idx];   //获取key所在哈希桶的第一个哈希项
   prevHe = NULL;
   while(he) {   //在哈希桶中逐一查找被删除的key是否存在
      if (key==he->key || dictCompareKeys(d, key, he->key)) {
         //如果找见被删除key了,那么将它从哈希桶的链表中去除
         if (prevHe)
            prevHe->next = he->next;
         else
            d->ht[table].table[idx] = he->next;
          if (!nofree) {  //如果要同步删除,那么就释放key和value的内存空间
             dictFreeKey(d, he); //调用dictFreeKey释放
             dictFreeVal(d, he);
             zfree(he);
           }
           d->ht[table].used--;
           return he;
      }
      prevHe = he;
       he = he->next;   //当前key不是要查找的key,再找下一个
   }
   ...
}

那么,从 dictGenericDelete 函数的实现中,你可以发现,dictGenericDelete 函数实际上会根据 nofree 参数,来决定执行的是同步删除还是异步删除。而 Redis 源码在 dictGenericDelete 函数的基础上,还封装了两个函数 dictDelete 和 dictUnlink。

这两个函数的区别就在于,它们给 dictGenericDelete 函数传递的 nofree 参数值是 0,还是 1。如果其中 nofree 的值为 0,表示的就是同步删除,而 nofree 值为 1,表示的则是异步删除。

面的代码展示了 dictGenericDelete 函数原型,以及 dictDelete 和 dictUnlink 两个函数的实现,你可以看下。

//dictGenericDelete函数原型,参数是待查找的哈希表,待查找的key,以及同步/异步删除标记
static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) 
//同步删除函数,传给dictGenericDelete函数的nofree值为0
int dictDelete(dict *ht, const void *key) {
    return dictGenericDelete(ht,key,0) ? DICT_OK : DICT_ERR;
}
//异步删除函数,传给dictGenericDelete函数的nofree值为1
dictEntry *dictUnlink(dict *ht, const void *key) {
    return dictGenericDelete(ht,key,1);
}

好了,到这里,我们就了解了同步删除和异步删除的基本代码实现。

下面我们就来看下,缓存淘汰流程的freeMemoryIfNeeded 函数中,它在删除键值对时,所调用的 dbAsyncDelete 和 dbSyncDelete 这两个函数,是如何使用 dictDelete 和 dictUnlink 来实际删除被淘汰数据的。

基于异步删除的数据淘汰

我们先来看下基于异步删除的数据淘汰过程。这个过程是由 dbAsyncDelete 函数执行的,它是在lazyfree.c文件中实现的。而这个函数的执行逻辑其实并不复杂,主要可以分成三步。

第一步,dbAsyncDelete 函数会调用 dictDelete 函数,在过期 key 的哈希表中同步删除被淘汰的键值对,如下所示:
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);

第二步,dbAsyncDelete 函数会调用 dictUnlink 函数,在全局哈希表中异步删除被淘汰的键值对,如下所示:
dictEntry *de = dictUnlink(db->dict,key->ptr);

而到这里,被淘汰的键值对只是在全局哈希表中被移除了,它占用的内存空间还没有实际释放。所以此时,dbAsyncDelete 函数会调用 lazyfreeGetFreeEffort 函数,来计算释放被淘汰键值对内存空间的开销。如果开销较小,dbAsyncDelete 函数就直接在主 IO 线程中进行同步删除了。否则的话,dbAsyncDelete 函数会创建惰性删除任务,并交给后台线程来完成。

这里,你需要注意的是,虽然 dbAsyncDelete 函数说是执行惰性删除,但其实,它在实际执行的过程中,会使用前面提到的这个 lazyfreeGetFreeEffort 函数来评估删除开销。

lazyfreeGetFreeEffort 函数是在 lazyfree.c 文件中实现的,它对删除开销的评估逻辑很简单,就是根据要删除的键值对的类型,来计算删除开销。当键值对类型属于 List、Hash、Set 和 Sorted Set 这四种集合类型中的一种,并且没有使用紧凑型内存结构来保存的话,那么,这个键值对的删除开销就等于集合中的元素个数。否则的话,删除开销就等于 1。

我举个简单的例子,以下代码就展示了 lazyfreeGetFreeEffort 函数,计算 List 和 Set 类型键值对的删除开销。可以看到,当键值对是 Set 类型,同时它是使用哈希表结构而不是整数集合来保存数据的话,那么它的删除开销就是 Set 中的元素个数。

size_t lazyfreeGetFreeEffort(robj *obj) {
    if (obj->type == OBJ_LIST) {  //如果是List类型键值对,就返回List的长度,也就其中元素个数
        quicklist *ql = obj->ptr;
        return ql->len;
    } else if (obj->type == OBJ_SET && obj->encoding == OBJ_ENCODING_HT) {
        dict *ht = obj->ptr;
        return dictSize(ht);   //如果是Set类型键值对,就返回Set中的元素个数
    }
    ...
}

这样,当 dbAsyncDelete 函数通过 lazyfreeGetFreeEffort 函数,计算得到被淘汰键值对的删除开销之后,接下来的第三步,它就会把删除开销和宏定义 LAZYFREE_THRESHOLD(在 lazyfree.c 文件中)进行比较,这个宏定义的默认值是 64。

所以,当被淘汰键值对是包含超过 64 个元素的集合类型时,dbAsyncDelete 函数才会调用 bioCreateBackgroundJob 函数,来实际创建后台任务执行惰性删除。

不过,如果被淘汰键值对不是集合类型,或者是集合类型但包含的元素个数小于等于 64 个,那么 dbAsyncDelete 函数就直接调用 dictFreeUnlinkedEntry 函数(在 dict.c 文件中),来释放键值对所占的内存空间了。

以下代码就展示了 dbAsyncDelete 函数,使用后台任务或主 IO 线程释放内存空间的逻辑,你可以看下。

//如果要淘汰的键值对包含超过64个元素
if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
   atomicIncr(lazyfree_objects,1);
   bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL); //创建惰性删除的后台任务,交给后台线程执行
   dictSetVal(db->dict,de,NULL);  //将被淘汰键值对的value设置为NULL
}
if (de) {
   dictFreeUnlinkedEntry(db->dict,de);
   ...
   return 1;
}

好,那么现在,我们也就了解了基于异步删除的数据淘汰过程,实际上会根据要删除的键值对包含的元素个数,来决定是实际使用后台线程还是主线程来进行删除操作。

不过,如果是使用了后台线程来释放内存,那么随之带来的一个问题就是:主线程如何知道后台线程释放的内存空间,已经满足待释放空间的大小呢?

其实,freeMemoryIfNeeded 函数本身在调用 dbAsyncDelete 或 dbSyncDelete 函数的前后,都会统计已经使用的内存量,并计算调用删除函数前后的差值,这样其实就可以获得已经释放的内存空间大小。

而除此之外,freeMemoryIfNeeded 函数还会在调用 dbAsyncDelete 函数后,再次主动检测当前的内存使用量,是否已经满足最大内存容量要求。一旦满足了,freeMemoryIfNeeded 函数就会停止淘汰数据的执行流程了。这步的执行逻辑,你可以参考以下给出的代码:


int freeMemoryIfNeeded(void) {
...
//执行循环流程,删除淘汰数据
while (mem_freed < mem_tofree) {
...
//如果使用了惰性删除,并且每删除16个key后,统计下当前内存使用量
if (server.lazyfree_lazy_eviction && !(keys_freed % 16)) {
   //计算当前内存使用量是否不超过最大内存容量
   if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
      mem_freed = mem_tofree;  //如果满足最大容量要求,让已释放内存量等于待释放量,以便结束循环
   }
}
...
}}

基于同步删除的数据淘汰

其实,和基于异步删除的数据淘汰过程相比,基于同步删除的数据淘汰过程就比较简单了。这个过程是由 dbSyncDelete 函数(在 db.c 文件中)实现的。

dbSyncDelete 函数主要是实现了两步操作。首先,它会调用 dictDelete 函数,在过期 key 的哈希表中删除被淘汰的键值对。紧接着,它会再次调用 dictDelete 函数,在全局哈希表中删除被淘汰的键值对。这样一来,同步删除的基本操作就完成了。

不过,这里你需要注意的是,dictDelete 函数通过调用 dictGenericDelete 函数,来同步释放键值对的内存空间时,最终是通过分别调用 dictFreeKey、dictFreeVal 和 zfree 三个函数来释放 key、value 和键值对对应哈希项这三者占用的内存空间的。

其中,zfree 函数是在 zmalloc.c 文件中实现的。而 dictFreeKey、dictFreeVal 这两个函数是在 dict.h 文件中定义的两个宏定义。它们的具体实现是根据操作的哈希表类型,调用相应的 valDestructor 函数和 keyDestructor 函数来释放内存。你可以看看下面的代码,其中就展示了 dictFreeKey 和 dictFreeVal 的宏定义。

#define dictFreeVal(d, entry) \
    if ((d)->type->valDestructor) \
        (d)->type->valDestructor((d)->privdata, (entry)->v.val)
#define dictFreeKey(d, entry) \
    if ((d)->type->keyDestructor) \
        (d)->type->keyDestructor((d)->privdata, (entry)->key)

那么,为了方便你能找到最终进行内存释放操作的函数,下面我就以全局哈希表为例,来带你看下当操作全局哈希表时,键值对的 dictFreeVal 和 dictFreeKey 两个宏定义对应的函数。
首先,全局哈希表是在 initServer 函数中创建的。在创建时,全局哈希表的类型是 dbDictType,如下所示:

void initServer(void) {
...
 for (j = 0; j < server.dbnum; j++) {
        server.db[j].dict = dictCreate(&dbDictType,NULL);
        server.db[j].expires = dictCreate(&keyptrDictType,NULL);
        ...
}
...
}

其中,dbDictType 是一个 dictType 类型的结构体,dictType 类型是在 dict.h 文件中定义的。它的最后两个成员变量,就是 keyDestructor 函数指针和 valDestructor 函数指针,如下所示:

typedef struct dictType {
    ...
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

然后,对于 dbDictType 来说,它是在 server.c 文件中定义的。因为它作为全局哈希表,保存的是 SDS 类型的 key,以及多种数据类型的 value。所以,dbDictType 类型哈希表的 key 和 value 释放函数,实际上分别是 dictSdsDestructor 函数和 dictObjectDestructor 函数.

这两个函数都是在 server.c 文件中实现的。

其中,dictSdsDestructor 函数主要是直接调用 sdsfree 函数(在 sds.c 文件中),释放 SDS 字符串占用的内存空间。而 dictObjectDestructor 函数会调用 decrRefCount 函数(在 object.c 文件中),来执行释放操作.

那么在这里,你要知道的是,decrRefCount 函数在执行时,会判断待释放对象的引用计数。只有当引用计数为 1 了,它才会根据待释放对象的类型,调用具体类型的释放函数来释放内存空间。否则的话,decrRefCount 函数就只是把待释放对象的引用计数减 1。

void decrRefCount(robj *o) {
    if (o->refcount == 1) {
        switch(o->type) {
        case OBJ_STRING: freeStringObject(o); break;
        case OBJ_LIST: freeListObject(o); break;
        ...
        }
        zfree(o);
    } else {
        ...
        if (o->refcount != OBJ_SHARED_REFCOUNT) o->refcount--;
    }
}

所以说,基于同步删除的数据淘汰过程,其实就是通过 dictDelete 函数,将被淘汰键值对从全局哈希表移除,并通过 dictFreeKey、dictFreeVal 和 zfree 三个函数来释放内存空间。而通过以上内容的学习,你就已经知道释放 value 空间的函数是 decrRefCount 函数,它会根据 value 的引用计数和类型,最终调用不同数据类型的释放函数来完成内存空间的释放。

而在这里,你也要注意的是,基于异步删除的数据淘汰,它通过后台线程执行的函数是 lazyfreeFreeObjectFromBioThread 函数(在 lazyfree.c 文件),而这个函数实际上也是调用了 decrRefCount 函数,来释放内存空间的。

总结

无论是同步删除还是异步的惰性删除,它们都会先把被淘汰的键值对从哈希表中移除。然后,同步删除就会紧接着调用 dictFreeKey、dictFreeVal 和 zfree 三个函数来分别释放 key、value 和键值对哈希项的内存空间。而异步的惰性删除,则是把空间释放任务交给了后台线程来完成。

注意,虽然惰性删除是由后台线程异步完成的,但是后台线程启动后会监听惰性删除的任务队列,一旦有了惰性删除任务,后台线程就会执行并释放内存空间。所以,从淘汰数据释放内存空间的角度来说,惰性删除并不会影响缓存淘汰时的空间释放要求

freeMemoryIfNeeded 函数在使用后台线程,删除被淘汰数据的过程中,主线程是否仍然可以处理外部请求?

肯定是可以继续处理请求的。

主线程决定淘汰这个 key 之后,会先把这个 key 从「全局哈希表」中剔除,然后评估释放内存的代价,如果符合条件,则丢到「后台线程」中执行「释放内存」操作。

之后就可以继续处理客户端请求,尽管后台线程还未完成释放内存,但因为 key 已被全局哈希表剔除,所以主线程已查询不到这个 key 了,对客户端来说无影响。

posted @ 2023-01-30 11:37  misaka-mikoto  阅读(394)  评论(0编辑  收藏  举报