理解Redis的内存回收机制

以前看到过一道面试题:Redis的过时策略都有哪些?内存淘汰机制都有哪些?手写一下LRU代码实现?笔者结合在工做上遇到的问题学习分析,但愿看完这篇文章能对你们有所帮助。面试

从一次不可描述的故障提及

问题描述:一个依赖于定时器任务的生成的接口列表数据,时而有,时而没有。redis

怀疑是Redis过时删除策略

排查过程长,由于手动执行定时器,set数据没有报错,可是set数据以后不生效。算法

set没报错,可是set完再查的状况下没数据,开始怀疑Redis的过时删除策略(准确来讲应该是Redis的内存回收机制中的数据淘汰策略触发内存上限淘汰数据。),致使新加入Redis的数据都被丢弃了。最终发现故障的缘由是由于配置错了,致使数据写错地方,并非Redis的内存回收机制引发。数据库

经过此次故障后思考总结,若是下一次遇到相似的问题,在怀疑Redis的内存回收以后,如何有效地证实它的正确性?如何快速证实猜想的正确与否?以及什么状况下怀疑内存回收才是合理的呢?下一次若是再次遇到相似问题,就可以更快更准地定位问题的缘由。另外,Redis的内存回收机制原理也须要掌握,明白是什么,为何。数据结构

花了点时间查阅资料研究Redis的内存回收机制,并阅读了内存回收的实现代码,经过代码结合理论,给你们分享一下Redis的内存回收机制。dom

为何须要内存回收?

一、在Redis中,set指令能够指定key的过时时间,当过时时间到达之后,key就失效了;异步

二、Redis是基于内存操做的,全部的数据都是保存在内存中,一台机器的内存是有限且很宝贵的。函数

基于以上两点,为了保证Redis能继续提供可靠的服务,Redis须要一种机制清理掉不经常使用的、无效的、多余的数据,失效后的数据须要及时清理,这就须要内存回收了。oop

Redis的内存回收机制

Redis的内存回收主要分为过时删除策略和内存淘汰策略两部分。学习

过时删除策略

删除达到过时时间的key。

一、定时删除

对于每个设置了过时时间的key都会建立一个定时器,一旦到达过时时间就当即删除。该策略能够当即清除过时的数据,对内存较友好,可是缺点是占用了大量的CPU资源去处理过时的数据,会影响Redis的吞吐量和响应时间。

二、惰性删除

当访问一个key时,才判断该key是否过时,过时则删除。该策略能最大限度地节省CPU资源,可是对内存却十分不友好。有一种极端的状况是可能出现大量的过时key没有被再次访问,所以不会被清除,致使占用了大量的内存。

在计算机科学中,懒惰删除(英文:lazy deletion)指的是从一个散列表(也称哈希表)中删除元素的一种方法。在这个方法中,删除仅仅是指标记一个元素被删除,而不是整个清除它。被删除的位点在插入时被看成空元素,在搜索之时被看成已占据。

三、按期删除

每隔一段时间,扫描Redis中过时key字典,并清除部分过时的key。该策略是前二者的一个折中方案,还能够经过调整定时扫描的时间间隔和每次扫描的限定耗时,在不一样状况下使得CPU和内存资源达到最优的平衡效果。

在Redis中,同时使用了按期删除和惰性删除。

过时删除策略原理

为了你们听起来不会以为疑惑,在正式介绍过时删除策略原理以前,先给你们介绍一点可能会用到的相关Redis基础知识。

redisDb结构体定义

咱们知道,Redis是一个键值对数据库,对于每个redis数据库,redis使用一个redisDb的结构体来保存,它的结构以下:

typedef struct redisDb {

        dict *dict;                /* 数据库的键空间,保存数据库中的全部键值对 */

        dict *expires;              /* 保存全部过时的键 */

        dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/

        dict *ready_keys;          /* Blocked keys that received a PUSH */

        dict *watched_keys;        /* WATCHED keys for MULTI/EXEC CAS */

        int id;                    /* 数据库ID字段,表明不一样的数据库 */

        long long avg_ttl;          /* Average TTL, just for stats */

} redisDb;

从结构定义中咱们能够发现,对于每个Redis数据库,都会使用一个字典的数据结构来保存每个键值对,dict的结构图以下:

 

 
 

以上就是过时策略实现时用到比较核心的数据结构。程序=数据结构+算法,介绍完数据结构之后,接下来继续看看处理的算法是怎样的。

expires属性

redisDb定义的第二个属性是expires,它的类型也是字典,Redis会把全部过时的键值对加入到expires,以后再经过按期删除来清理expires里面的值。加入expires的场景有:

一、set指定过时时间expire

若是设置key的时候指定了过时时间,Redis会将这个key直接加入到expires字典中,并将超时时间设置到该字典元素。

二、调用expire命令

显式指定某个key的过时时间

三、恢复或修改数据

从Redis持久化文件中恢复文件或者修改key,若是数据中的key已经设置了过时时间,就将这个key加入到expires字典中

以上这些操做都会将过时的key保存到expires。redis会按期从expires字典中清理过时的key。

Redis清理过时key的时机

一、Redis在启动的时候,会注册两种事件,一种是时间事件,另外一种是文件事件。(可参考启动Redis的时候,Redis作了什么)时间事件主要是Redis处理后台操做的一类事件,好比客户端超时、删除过时key;文件事件是处理请求。

在时间事件中,redis注册的回调函数是serverCron,在定时任务回调函数中,经过调用databasesCron清理部分过时key。(这是按期删除的实现。)

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData)

{

    …

    /* Handle background operations on Redis databases. */

    databasesCron();

    ...

}

二、每次访问key的时候,都会调用expireIfNeeded函数判断key是否过时,若是是,清理key。(这是惰性删除的实现。)

robj *lookupKeyRead(redisDb *db, robj *key) {

    robj *val;

    expireIfNeeded(db,key);

    val = lookupKey(db,key);

    ...

    return val;

}

三、每次事件循环执行时,主动清理部分过时key。(这也是惰性删除的实现。)

void aeMain(aeEventLoop *eventLoop) {

    eventLoop->stop = 0;

    while (!eventLoop->stop) {

        if (eventLoop->beforesleep != NULL)

            eventLoop->beforesleep(eventLoop);

        aeProcessEvents(eventLoop, AE_ALL_EVENTS);

    }

}

void beforeSleep(struct aeEventLoop *eventLoop) {

      ...

      /* Run a fast expire cycle (the called function will return

        - ASAP if a fast cycle is not needed). */

      if (server.active_expire_enabled && server.masterhost == NULL)

          activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);

      ...

  }

过时策略的实现

咱们知道,Redis是以单线程运行的,在清理key是不能占用过多的时间和CPU,须要在尽可能不影响正常的服务状况下,进行过时key的清理。过时清理的算法以下:

一、server.hz配置了serverCron任务的执行周期,默认是10,即CPU空闲时每秒执行十次。

二、每次清理过时key的时间不能超过CPU时间的25%:timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;

    好比,若是hz=1,一次清理的最大时间为250ms,hz=10,一次清理的最大时间为25ms。

三、若是是快速清理模式(在beforeSleep函数调用),则一次清理的最大时间是1ms。

四、依次遍历全部的DB。

五、从db的过时列表中随机取20个key,判断是否过时,若是过时,则清理。

六、若是有5个以上的key过时,则重复步骤5,不然继续处理下一个db

七、在清理过程当中,若是达到CPU的25%时间,退出清理过程。

从实现的算法中能够看出,这只是基于几率的简单算法,且是随机的抽取,所以是没法删除全部的过时key,经过调高hz参数能够提高清理的频率,过时key能够更及时的被删除,但hz过高会增长CPU时间的消耗。

删除key

Redis4.0之前,删除指令是del,del会直接释放对象的内存,大部分状况下,这个指令很是快,没有任何延迟的感受。可是,若是删除的key是一个很是大的对象,好比一个包含了千万元素的hash,那么删除操做就会致使单线程卡顿,Redis的响应就慢了。为了解决这个问题,在Redis4.0版本引入了unlink指令,能对删除操做进行“懒”处理,将删除操做丢给后台线程,由后台线程来异步回收内存。

实际上,在判断key须要过时以后,真正删除key的过程是先广播expire事件到从库和AOF文件中,而后在根据redis的配置决定当即删除仍是异步删除。

若是是当即删除,Redis会当即释放key和value占用的内存空间,不然,Redis会在另外一个bio线程中释放须要延迟删除的空间。

小结

总的来讲,Redis的过时删除策略是在启动时注册了serverCron函数,每个时间时钟周期,都会抽取expires字典中的部分key进行清理,从而实现按期删除。另外,Redis会在访问key时判断key是否过时,若是过时了,就删除,以及每一次Redis访问事件到来时,beforeSleep都会调用activeExpireCycle函数,在1ms时间内主动清理部分key,这是惰性删除的实现。

Redis结合了按期删除和惰性删除,基本上能很好的处理过时数据的清理,可是实际上仍是有点问题的,若是过时key较多,按期删除漏掉了一部分,并且也没有及时去查,即没有走惰性删除,那么就会有大量的过时key堆积在内存中,致使redis内存耗尽,当内存耗尽以后,有新的key到来会发生什么事呢?是直接抛弃仍是其余措施呢?有什么办法能够接受更多的key?

内存淘汰策略

Redis的内存淘汰策略,是指内存达到maxmemory极限时,使用某种算法来决定清理掉哪些数据,以保证新数据的存入。

Redis的内存淘汰机制

noeviction: 当内存不足以容纳新写入数据时,新写入操做会报错。

allkeys-lru:当内存不足以容纳新写入数据时,在键空间(server.db[i].dict)中,移除最近最少使用的 key(这个是最经常使用的)。

allkeys-random:当内存不足以容纳新写入数据时,在键空间(server.db[i].dict)中,随机移除某个 key。

volatile-lru:当内存不足以容纳新写入数据时,在设置了过时时间的键空间(server.db[i].expires)中,移除最近最少使用的 key。

volatile-random:当内存不足以容纳新写入数据时,在设置了过时时间的键空间(server.db[i].expires)中,随机移除某个 key。

volatile-ttl:当内存不足以容纳新写入数据时,在设置了过时时间的键空间(server.db[i].expires)中,有更早过时时间的 key 优先移除。

在配置文件中,经过maxmemory-policy能够配置要使用哪个淘汰机制。

何时会进行淘汰?

Redis会在每一次处理命令的时候(processCommand函数调用freeMemoryIfNeeded)判断当前redis是否达到了内存的最大限制,若是达到限制,则使用对应的算法去处理须要删除的key。伪代码以下:

int processCommand(client *c)

{

    ...

    if (server.maxmemory) {

        int retval = freeMemoryIfNeeded(); 

    }

    ...

}

LRU实现原理

在淘汰key时,Redis默认最经常使用的是LRU算法(Latest Recently Used)。Redis经过在每个redisObject保存lru属性来保存key最近的访问时间,在实现LRU算法时直接读取key的lru属性。

具体实现时,Redis遍历每个db,从每个db中随机抽取一批样本key,默认是3个key,再从这3个key中,删除最近最少使用的key。实现伪代码以下:

keys = getSomeKeys(dict, sample)

key = findSmallestIdle(keys)

remove(key)

3这个数字是配置文件中的maxmeory-samples字段,也是能够能够设置采样的大小,若是设置为10,那么效果会更好,不过也会耗费更多的CPU资源。

以上就是Redis内存回收机制的原理介绍,了解了上面的原理介绍后,回到一开始的问题,在怀疑Redis内存回收机制的时候能不能及时判断故障是否是由于Redis的内存回收机制致使的呢?

回到问题原点

如何证实故障是否是由内存回收机制引发的?

根据前面分析的内容,若是set没有报错,可是不生效,只有两种状况:

一、设置的过时时间太短,好比,1s?

二、内存超过了最大限制,且设置的是noeviction或者allkeys-random。

所以,在遇到这种状况,首先看set的时候是否加了过时时间,且过时时间是否合理,若是过时时间较短,那么应该检查一下设计是否合理。

若是过时时间没问题,那就须要查看Redis的内存使用率,查看Redis的配置文件或者在Redis中使用info命令查看Redis的状态,maxmemory属性查看最大内存值。若是是0,则没有限制,此时是经过total_system_memory限制,对比used_memory与Redis最大内存,查看内存使用率。

若是当前的内存使用率较大,那么就须要查看是否有配置最大内存,若是有且内存超了,那么就能够初步断定是内存回收机制致使key设置不成功,还须要查看内存淘汰算法是否noeviction或者allkeys-random,若是是,则能够确认是redis的内存回收机制致使。若是内存没有超,或者内存淘汰算法不是上面的二者,则还须要看看key是否已通过期,经过ttl查看key的存活时间。若是运行了程序,set没有报错,则ttl应该立刻更新,不然说明set失败,若是set失败了那么就应该查看操做的程序代码是否正确了。

 

 
 

总结

Redis对于内存的回收有两种方式,一种是过时key的回收,另外一种是超过redis的最大内存后的内存释放。

对于第一种状况,Redis会在:

一、每一次访问的时候判断key的过时时间是否到达,若是到达,就删除key

二、redis启动时会建立一个定时事件,会按期清理部分过时的key,默认是每秒执行十次检查,每次过时key清理的时间不超过CPU时间的25%,即若hz=1,则一次清理时间最大为250ms,若hz=10,则一次清理时间最大为25ms。

对于第二种状况,redis会在每次处理redis命令的时候判断当前redis是否达到了内存的最大限制,若是达到限制,则使用对应的算法去处理须要删除的key。