深刻理解Redis主键失效原理及实现机制

      做为一种按期清理无效数据的重要机制,主键失效存在于大多数缓存系统中,Reids也不例外。在Redis提供的诸多命令中, EXPIRE、 EXPIREAT、 PEXPIRE、 PEXPIREAT以及SETEX和PSETEX都可以用来设置一条Key-Value对的失效时间,而一条Key-Value对一旦被关联了失效时间就会在到期后自动删除(或者说变得没法访问更为准确)。能够说,主键失效这个概念仍是比较容易理解的,可是在具体实现到Redis中又是如何呢?最近本博主就对Redis中的主键失效机制产生了几个疑问,并根据这些疑问对其进行了仔细的探究,现总结所得以下,以飨各位看客。
    1、除了调用PERSIST命令外,还有没有其余状况会撤销一个主键的失效时间?答案是确定的。首先,在经过DEL命令删除一个主键时,失效时间天然会被撤销(这不是废话么,哈哈)。其次,在一个设置了失效时间的主键被更新覆盖时,该主键的失效时间也会被撤销(这貌似也是废话,哈哈)。但须要注意的是,这里所说的是主键被更新覆盖,而不是主键对应的Value被更新覆盖,所以SET、MSET或者是GETSET可能会致使主键被更新覆盖,而像INCR、DECR、LPUSH、HSET等都是更新主键对应的值,这类操做是不会触碰主键的失效时间的。此外,还有一个特殊的命令就是RENAME,当咱们使用RENAME对一个主键进行重命名后,以前关联的失效时间会自动传递给新的主键,可是若是一个主键是被RENAME所覆盖的话(如主键hello可能会被命令RENAME world hello所覆盖),这时被覆盖主键的失效时间会被自动撤销,而新的主键则继续保持原来主键的特性。
    2、Redis中的主键失效是如何实现的,即失效的主键是如何删除的?实际上,Redis删除失效主键的方法主要有两种:1)消极方法(passive way),在主键被访问时若是发现它已经失效,那么就删除它;2)积极方法(active way),周期性地从设置了失效时间的主键中选择一部分失效的主键删除。接下来咱们就经过代码来探究一下这两种方法的具体实现,但在此以前,咱们先看一看Redis是如何管理和维护主键的吧(注:本博文中的源码所有来自Redis-2.6.12)。
    代码段一给出了Redis中关于数据库的结构体定义,这个结构体定义中除了id之外都是指向字典的指针,其中咱们只看dict和expires,前者用来维护一个Redis数据库中包含的全部Key-Value对(其结构能够理解为dict[key]:value,即主键与值之间的映射),后者则用于维护一个Redis数据库中设置了失效时间的主键(其结构能够理解为expires[key]:timeout,即主键与失效时间的映射)。当咱们使用SETEX和PSETEX命令向系统插入数据时,Redis首先将Key和Value添加到dict这个字典表中,而后将Key和失效时间添加到expires这个字典表中。当咱们使用 EXPIRE、 EXPIREAT、 PEXPIRE和 PEXPIREAT命令设置一个主键的失效时间时,Redis首先到dict这个字典表中查找要设置的主键是否存在,若是存在就将这个主键和失效时间添加到expires这个字典表。简单地总结来讲就是,设置了失效时间的主键和具体的失效时间所有都维护在expires这个字典表中。

代码段一:

typedef struct redisDb {
     dict *dict;                
     dict *expires;              
    dict *blocking_keys;        
    dict *ready_keys;          
    dict *watched_keys;        
    int id;
} redisDb;

    在大体了解了Redis是如何维护设置了失效时间的主键以后,咱们就先来看一看Redis是如何实现消极地删除失效主键的。代码段二给出了一个名为expireIfNeeded的函数,这个函数在任何访问数据的函数中都会被调用,也就是说Redis在实现GET、MGET、HGET、LRANGE等全部涉及到读取数据的命令时都会调用它,它存在的意义就是在读取数据以前先检查一下它有没有失效,若是失效了就删除它。代码段二中给出了expireIfNeeded函数的全部相关描述,这里就再也不重复它的实现方法了。这里须要说明的是在expireIfNeeded函数中调用的另一个函数propagateExpire,这个函数用来在正式删除失效主键以前广播这个主键已经失效的信息,这个信息会传播到两个目的地:一个是发送到AOF文件,将删除失效主键的这一操做以DEL Key的标准命令格式记录下来;另外一个就是发送到当前Redis服务器的全部Slave,一样将删除失效主键的这一操做以DEL Key的标准命令格式告知这些Slave删除各自的失效主键。从中咱们能够知道,全部做为Slave来运行的Redis服务器并不须要经过消极方法来删除失效主键,它们只须要对Master惟命是从就OK了!
    
代码段二: 
 
int expireIfNeeded(redisDb *db, robj *key) {
     获取主键的失效时间
    long long when = getExpire(db,key);
     假如失效时间为负数,说明该主键未设置失效时间(失效时间默认为-1),直接返回0
    if (when < 0) return 0;
     假如Redis服务器正在从RDB文件中加载数据,暂时不进行失效主键的删除,直接返回0
    if (server.loading) return 0;
     假如当前的Redis服务器是做为Slave运行的,那么不进行失效主键的删除,由于Slave
    上失效主键的删除是由Master来控制的,可是这里会将主键的失效时间与当前时间进行
    一下对比,以告知调用者指定的主键是否已经失效了
    if (server.masterhost != NULL) {
        return mstime() > when;
    }
     若是以上条件都不知足,就 将主键的失效时间与当前时间进行对比,若是发现指定的主键
    还未失效就直接返回0
    if (mstime() <= when) return 0;
     若是发现主键确实已经失效了,那么首先更新关于失效主键的统计个数,而后将该主键失
    效的信息 进行广播,最后将该主键从数据库中删除
    server.stat_expiredkeys++;
    propagateExpire(db,key);
    return dbDelete(db,key);
}

代码段三:

void propagateExpire(redisDb *db, robj *key) {
    robj *argv[2];
     shared.del是在Redis服务器启动之初就已经初始化好的一个经常使用Redis对象,即DEL命令
    argv[0] = shared.del;
    argv[1] = key;
    incrRefCount(argv[0]);
    incrRefCount(argv[1]);
     检查Redis服务器是否开启了AOF,若是开启了就为失效主键记录一条DEL日志
    if (server.aof_state != REDIS_AOF_OFF)
        feedAppendOnlyFile(server.delCommand,db->id,argv,2);
     检查Redis服务器是否拥有Slave,若是是就向全部Slave发送DEL失效主键的命令,这就是
    上面expireIfNeeded函数中发现本身是Slave时无需主动删除失效主键的缘由了,由于它
    只需遵从Master发送过来的命令就OK了
    if (listLength(server.slaves))
        replicationFeedSlaves(server.slaves,db->id,argv,2);
    decrRefCount(argv[0]);
    decrRefCount(argv[1]);
}

    以上咱们经过对expireIfNeeded函数的介绍了解了Redis是如何以一种消极的方式删除失效主键的,可是仅仅经过这种方式显然是不够的,由于若是某些失效的主键迟迟等不到再次访问的话,Redis就永远不会知道这些主键已经失效,也就永远也不会删除它们了,这无疑会致使内存空间的浪费。所以,Redis还准备了一招积极的删除方法,该方法利用Redis的时间事件来实现,即每隔一段时间就中断一下完成一些指定操做,其中就包括检查并删除失效主键。这里咱们说的时间事件的回调函数就是serverCron,它在Redis服务器启动时建立,每秒的执行次数由宏定义REDIS_DEFAULT_HZ来指定,默认每秒钟执行10次。代码段四给出该时间事件建立时的程序代码,该代码在redis.c文件的initServer函数中。实际上,serverCron这个回调函数不只要进行失效主键的检查与删除,还要进行统计信息的更新、客户端链接超时的控制、BGSAVE和AOF的触发等等,这里咱们仅关注删除失效主键的实现,也就是函数activeExpireCycle。

代码段四:

if( aeCreateTimeEvent(server.el, 1,  serverCron, NULL, NULL) == AE_ERR) {
        redisPanic("create time event failed");
        exit(1);
}

    代码段五给出了函数activeExpireCycle的实现及其详细描述,其主要实现原理就是遍历处理Redis服务器中每一个数据库的expires字典表中,从中尝试着随机抽样REDIS_EXPIRELOOKUPS_PER_CRON(默认值为10)个设置了失效时间的主键,检查它们是否已经失效并删除掉失效的主键,若是失效的主键个数占本次抽样个数的比例超过25%,Redis会认为当前数据库中的失效主键依然不少,因此它会继续进行下一轮的随机抽样和删除,直到刚才的比例低于25%才中止对当前数据库的处理,转向下一个数据库。这里咱们须要注意的是,activeExpireCycle函数不会试图一次性处理Redis中的全部数据库,而是最多只处理REDIS_DBCRON_DBS_PER_CALL(默认值为16),此外activeExpireCycle函数还有处理时间上的限制,不是想执行多久就执行多久,凡此种种都只有一个目的,那就是避免失效主键删除占用过多的CPU资源。代码段五有对activeExpireCycle全部代码的详细描述,从中能够了解该函数的具体实现方法。

代码段五:

void activeExpireCycle(void) {
     由于每次调用activeExpireCycle函数不会一次性检查全部Redis数据库,因此须要记录下
    每次函数调用处理的最后一个Redis数据库的编号,这样下次调用activeExpireCycle函数
    还能够从这个数据库开始继续处理,这就是current_db被声明为static的缘由,而另一
    个变量timelimit_exit是为了记录上一次调用 activeExpireCycle函数的执行时间是否达
    到时间限制了,因此也须要声明为static
    static unsigned int current_db = 0;
    static int timelimit_exit = 0;      
    unsigned int j, iteration = 0;
     每次调用activeExpireCycle函数处理的Redis数据库个数为REDIS_DBCRON_DBS_PER_CALL
    unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
    long long start = ustime(), timelimit;
     若是当前Redis服务器中的数据库个数小于REDIS_DBCRON_DBS_PER_CALL,则处理所有数据库,
    若是上一次调用activeExpireCycle函数的执行时间达到了时间限制,说明失效主键较多,也
    会选择处理所有数据库
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;
     执行activeExpireCycle函数的最长时间(以微秒计),其中 REDIS_EXPIRELOOKUPS_TIME_PERC
    是单位时间内可以分配给 activeExpireCycle函数执行的CPU时间比例,默认值为25,server.hz
    即为一秒内 activeExpireCycle的调用次数,因此这个计算公式更明白的写法应该是这样的,即
    (1000000 * ( REDIS_EXPIRELOOKUPS_TIME_PERC / 100))  server.hz
    timelimit = 1000000*REDIS_EXPIRELOOKUPS_TIME_PERC/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;
     遍历处理每一个Redis数据库中的失效数据
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        redisDb *db = server.db+(current_db % server.dbnum);
         此处马上就将current_db加一,这样能够保证即便此次没法在时间限制内删除完全部当前
       数据库中的失效主键,下一次调用activeExpireCycle同样会从下一个数据库开始处理,
       从 而保证每一个数据库都有被处理的机会
        current_db++;
         开始处理当前数据库中的失效主键
        do {
            unsigned long num, slots;
            long long now;
             若是expires字典表大小为0,说明该数据库中没有设置失效时间的主键,直接检查下
           一数据库
            if ((num = dictSize(db->expires)) == 0) break;
            slots = dictSlots(db->expires);
            now = mstime();
             若是expires字典表不为空,可是其填充率不足1%,那么随机选择主键进行检查的代价
           会很高,因此这里直接检查下一数据库
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;
            expired = 0;
             若是expires字典表中的entry个数不足以达到抽样个数,则选择所有key做为抽样样本
            if (num > REDIS_EXPIRELOOKUPS_PER_CRON)
                num = REDIS_EXPIRELOOKUPS_PER_CRON;
            while (num--) {
                dictEntry *de;
                long long t;
                 随机获取一个设置了失效时间的主键,检查其是否已经失效
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                t = dictGetSignedIntegerVal(de);
                if (now > t) {
             发现该主键确实已经失效,删除该主键
                    sds key = dictGetKey(de);
                    robj *keyobj = createStringObject(key,sdslen(key));
                     一样要在删除前广播该主键的失效信息
                    propagateExpire(db,keyobj);
                    dbDelete(db,keyobj);
                    decrRefCount(keyobj);
                    expired++;
                    server.stat_expiredkeys++;
                }
            }
             每进行一次抽样删除后对iteration加一,每16次抽样删除后检查本次执行时间是否
           已经达到时间限制,若是已达到时间限制,则记录本次执行达到时间限制并退出
            iteration++;
            if ((iteration & 0xf) == 0 &&
                (ustime()-start) > timelimit)
            {
                timelimit_exit = 1;
                return;
            }
         若是失效的主键数占抽样数的百分比大于25%,则继续抽样删除过程
        } while (expired > REDIS_EXPIRELOOKUPS_PER_CRON/4); 
    }
}
    3、Memcached删除失效主键的方法与Redis有何异同?首先,Memcached在删除失效主键时也是采用的消极方法,即Memcached内部也不会监视主键是否失效,而是在经过Get访问主键时才会检查其是否已经失效。其次,Memcached与Redis在主键失效机制上的最大不一样是,Memcached不会像Redis那样真正地去删除失效的主键,而只是简单地将失效主键占用的空间回收。这样当有新的数据写入到系统中时,Memcached会优先使用那些失效主键的空间。若是失效主键的空间用光了,Memcached还能够经过LRU机制来回收那些长期得不到访问的空间,所以Memcached并不须要像Redis中那样的周期性删除操做,这也是由Memcached使用的内存管理机制决定的。同时,这里须要指出的是Redis在出现OOM时一样能够经过配置maxmemory-policy这个参数来决定是否采用LRU机制来回收内存空间(感谢@Jonathan_Dai同窗在博文 http://xenojoshua.com/2013/07/redis-lru/中对原文的指正 深刻理解Redis中的主键失效及其实现机制 深刻理解Redis中的主键失效及其实现机制 深刻理解Redis中的主键失效及其实现机制)!
    4、Redis的主键失效机制会不会影响系统性能?经过以上对Redis主键失效机制的介绍,咱们知道虽然Redis会按期地检查设置了失效时间的主键并删除已经失效的主键,可是经过对每次处理数据库个数的限制、activeExpireCycle函数在一秒钟内执行次数的限制、分配给activeExpireCycle函数CPU时间的限制、继续删除主键的失效主键数百分比的限制,Redis已经大大下降了主键失效机制对系统总体性能的影响,可是若是在实际应用中出现大量主键在短期内同时失效的状况仍是会使得系统的响应能力下降,因此这种状况无疑应该避免。

相关文章
相关标签/搜索