Lazy Free会影响缓存替换吗

157 阅读7分钟

惰性删除是 Redis 4.0 版本后提供的功能,它会使用后台线程来执行删除数据的任务,从而避免了删除操作对主线程的阻塞。

惰性删除的设置

其中包括了四个配置项,分别对应了如下的四种场景:

  • lazyfree-lazy-eviction:对应缓存淘汰时的数据删除场景。
  • lazyfree-lazy-expire:对应过期 key 的删除场景。
  • lazyfree-lazy-server-del:对应会隐式进行删除操作的 server 命令执行场景。
  • replica-lazy-flush:对应从节点完成全量同步后,删除原有旧数据的场景。

这四个配置项的默认值都是 no。

被淘汰数据的删除过程

freeMemoryIfNeeded 函数在筛选出被淘汰的键值对后,就要开始删除被淘汰的数据,这个删除过程主要分成两步。

第一步,freeMemoryIfNeeded 函数会为被淘汰的 key 创建一个 SDS 对象,然后调用 propagateExpire 函数,如下所示:

int freeMemoryIfNeeded(void) {
   …
   if (bestkey) {
      db = server.db+bestdbid;
      robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
		  propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);
   …
}

propagateExpire 函数是在db.c文件中实现的。它会先创建一个 redisObject 结构体数组,该数组的第一个元素是删除操作对应的命令对象,而第二个元素是被删除的 key 对象。因为 Redis server 可能针对缓存淘汰场景启用了惰性删除,所以,propagateExpire 函数会根据全局变量 server 的 lazyfree_lazy_eviction 成员变量的值,来决定删除操作具体对应的是哪个命令。

如果 lazyfree_lazy_eviction 被设置为 1,也就是启用了缓存淘汰时的惰性删除,那么,删除操作对应的命令就是 UNLINK;否则的话,命令就是 DEL。

紧接着,propagateExpire 函数会判断 Redis server 是否启用了 AOF 日志。如果启用了,那么 propagateExpire 函数会先把被淘汰 key 的删除操作记录到 AOF 文件中,以保证后续使用 AOF 文件进行 Redis 数据库恢复时,可以和恢复前保持一致。这一步是通过调用 feedAppendOnlyFile 函数(在aof.c文件中)来实现的。

然后,propagateExpire 函数会调用 replicationFeedSlaves 函数(在replication.c文件中),把删除操作同步给从节点,以保证主从节点的数据一致。

//如果启用了AOF日志,则将删除操作写入AOF文件
    if (server.aof_state != AOF_OFF)
        feedAppendOnlyFile(server.delCommand,db->id,argv,2);
    //将删除操作同步给从节点
    replicationFeedSlaves(server.slaves,db->id,argv,2);
    …

第二步,freeMemoryIfNeeded 函数会根据 server 是否启用了惰性删除,分别执行两个分支:

  • 分支一:如果 server 启用了惰性删除,freeMemoryIfNeeded 函数会调用 dbAsyncDelete 函数进行异步删除。
  • 分支二:如果 server 未启用惰性删除,freeMemoryIfNeeded 函数会调用 dbSyncDelete 函数进行同步删除。

而无论是执行异步删除还是同步删除,freeMemoryIfNeeded 函数都会在调用删除函数前,调用 zmalloc_used_memory 函数(在zmalloc.c文件中)计算当前使用的内存量。然后,它在调用删除函数后,会再次调用 zmalloc_used_memory 函数计算此时的内存使用量,并计算删除操作导致的内存使用量差值,这个差值就是通过删除操作而被释放的内存量。

所以,freeMemoryIfNeeded 函数最后会把这部分释放的内存量和已释放的内存量相加,得到最新的内存释放量。这部分的执行逻辑如以下代码所示:

delta = (long long) zmalloc_used_memory(); //获取当前内存使用量
if (server.lazyfree_lazy_eviction)
      dbAsyncDelete(db,keyobj);  //如果启用了惰性删除,则进行异步删除
else
     dbSyncDelete(db,keyobj); //否则,进行同步删除
delta -= (long long) zmalloc_used_memory(); //根据当前内存使用量计算数据删除前后释放的内存量
mem_freed += delta; //更新已释放的内存量

数据删除操作

删除操作实际上是包括了两步子操作。

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

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

对于 Redis 源码来说,它是使用了 dictGenericDelete 函数,来实现前面介绍的这两个子操作。dictGenericDelete 函数是在 dict.c 文件中实现的。

首先,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,再找下一个
   }
   ...
}

基于异步删除的数据淘汰

主要可以分成三步:

第一步,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 函数会创建惰性删除任务,并交给后台线程来完成。

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

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

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

基于同步删除的数据淘汰

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

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

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

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


此文章为10月Day17学习笔记,内容来源于极客时间《Redis 源码剖析与实战》