Redis系列(三)--过期策略

制定Redis过期策略,是整个Redis缓存策略的关键之一,因为内存来说,公司不可能无限大,所以就要对key进行一系列的管控。

文章结构:(1)理解Redis过期设置API(命令与Java描述版本);(2)理解Redis内部的过期策略;(3)对开发需求而言,Redis过期策略的设计实现经验。


本系列文章:

(1) Redis系列(一)--安装、helloworld以及读懂配置文件

(2)Redis系列(二)--缓存设计(整表缓存以及排行榜缓存方案实现)

一、理解Redis过期设置API(命令与Java描述版本):

(1)TTL命令:

redis 127.0.0.1:6379> TTL KEY_NAME

返回值

当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以秒为单位,返回 key 的剩余生存时间。

注意:在 Redis 2.8 以前,当 key 不存在,或者 key 没有设置剩余生存时间时,命令都返回 -1 。

(2)EXPIRE命令

定义:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。

redis 127.0.0.1:6379> EXPIRE runooobkey 60
(integer) 1

返回值

设置成功返回 1 。 当 key 不存在或者不能为 key 设置过期时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的过期时间)返回 0 。

key生存时间注意点:

生存时间可以通过使用 DEL 命令来删除整个 key 来移除,或者被 SET 和 GETSET 命令覆写(overwrite),这意味着,如果一个命令只是修改(alter)一个带生存时间的 key 的值而不是用一个新的 key 值来代替(replace)它的话,那么生存时间不会被改变。

比如说,对一个 key 执行 INCR 命令,对一个列表进行 LPUSH 命令,或者对一个哈希表执行 HSET 命令,这类操作都不会修改 key 本身的生存时间。

另一方面,如果使用 RENAME 对一个 key 进行改名,那么改名后的 key 的生存时间和改名前一样。

RENAME 命令的另一种可能是,尝试将一个带生存时间的 key 改名成另一个带生存时间的 another_key ,这时旧的 another_key (以及它的生存时间)会被删除,然后旧的 key 会改名为 another_key ,因此,新的 another_key 的生存时间也和原本的 key 一样。

(3)PEXPIRE命令

设置成功返回 1 。 当 key 不存在或者不能为 key 设置过期时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的过期时间)返回 0 。

(4)PERSIST 命令

返回值:

当过期时间移除成功时,返回 1 。 如果 key 不存在或 key 没有设置过期时间,返回 0 。

127.0.0.1:6379> PEXPIRE k2 10000000
(integer) 1

(5)SETEX命令

用于在Redis键中的指定超时,设置键的字符串值

返回值:

字符串,如果在键中设置了值则返回OK。如果值未设置则返回 Null。

127.0.0.1:6379> SETEX k1 100 v1
OK
127.0.0.1:6379> ttl k1
(integer) 92
127.0.0.1:6379> get k1
"v1"

(6)补充:(精度不同的时间设置):

EXPIREAT <key> < timestamp> 命令用于将键key 的过期时间设置为timestamp所指定的秒数时间戳。

PEXPIREAT <key> < timestamp > 命令用于将键key 的过期时间设置为timestamp所指定的毫秒数时间戳。

例子:

    //TTL命令
127.0.0.1:6379> FLUSHDB
OK
127.0.0.1:6379> ttl key
(integer) -2
127.0.0.1:6379> set key value
OK
127.0.0.1:6379> ttl key
(integer) -1


//expire命令
127.0.0.1:6379> expire key 10
(integer) 1
127.0.0.1:6379> ttl key
(integer) 7
127.0.0.1:6379> ttl key
(integer) 3
127.0.0.1:6379> ttl key
(integer) -2


//PEXPIRE命令
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> PEXPIRE k2 10000000
(integer) 1
127.0.0.1:6379> ttl k2
(integer) 9994


//PERSIST 命令
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> EXPIRE k1 100
(integer) 1
127.0.0.1:6379> ttl k1
(integer) 86
127.0.0.1:6379> PERSIST k1
(integer) 1
127.0.0.1:6379> ttl k1
(integer) -1

(6)Java代码控制:

    @Autowired
    private JedisPool jedisPool;
    Jedis jedis = jedisPool.getResource();

        System.out.println("判断key是否存在:"+shardedJedis.exists("key"));
        // 设置 key001的过期时间
        System.out.println("设置 key的过期时间为5秒:"+jedis.expire("key", 5));
          // 查看某个key的剩余生存时间,单位【秒】.永久生存或者不存在的都返回-1
        System.out.println("查看key的剩余生存时间:"+jedis.ttl("key"));
        // 移除某个key的生存时间
        System.out.println("移除key的生存时间:"+jedis.persist("key"));
        System.out.println("查看key的剩余生存时间:"+jedis.ttl("key"));
        // 查看key所储存的值的类型
        System.out.println("查看key所储存的值的类型:"+jedis.type("key"));

二、理解Redis内部的过期策略:

(1)总述:

Redis采用的是定期删除策略和懒汉式的策略互相配合。

注意!是Redis内部自主完成!是Redis内部自主完成!是Redis内部自主完成!

我们只可以通过调整外围参数,以及设计数据淘汰模式去调控我们的Redis缓存系统过期策略。

(2)定期删除策略:

1)含义:每隔一段时间执行一次删除过期key操作

2)优点:

通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用--处理"定时删除"的缺点

定期删除过期key--处理"懒汉式删除"的缺点

3)缺点:

在内存友好方面,会造成一定的内存占用,但是没有懒汉式那么占用内存(相对于定时删除则不如)

在CPU时间友好方面,不如"懒汉式删除"(会定期的去进行比较和删除操作,cpu方面不如懒汉式,但是比定时好)

4)关键点:

合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)(这个要根据服务器运行情况来定了),每次执行时间太长,或者执行频率太高对cpu都是一种压力。

每次进行定期删除操作执行之后,需要记录遍历循环到了哪个标志位,以便下一次定期时间来时,从上次位置开始进行循环遍历。

对于懒汉式删除而言,并不是只有获取key的时候才会检查key是否过期,在某些设置key的方法上也会检查(例子:setnx key2 value2:如果设置的key2已经存在,那么该方法返回false,什么都不做;如果设置的key2不存在,那么该方法设置缓存key2-value2。假设调用此方法的时候,发现redis中已经存在了key2,但是该key2已经过期了,如果此时不执行删除操作的话,setnx方法将会直接返回false,也就是说此时并没有重新设置key2-value2成功,所以对于一定要在setnx执行之前,对key2进行过期检查)。

5)删除键流程(简单而言,对指定个数个库的每一个库随机删除小于等于指定个数个过期key):

1. 遍历每个数据库(就是redis.conf中配置的"database"数量,默认为16)

2. 检查当前库中的指定个数个key(默认是每个库检查20个key,注意相当于该循环执行20次,循环体是下边的描述)

如果当前库中没有一个key设置了过期时间,直接执行下一个库的遍历
随机获取一个设置了过期时间的key,检查该key是否过期,如果过期,删除key
判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除。

对于定期删除,在程序中有一个全局变量current_db来记录下一个将要遍历的库,假设有16个库,我们这一次定期删除遍历了10个,那此时的current_db就是11,下一次定期删除就从第11个库开始遍历,假设current_db等于15了,那么之后遍历就再从0号库开始(此时current_db==0)

6)源码机制阅读:

定期删除策略:此部分转载部分此博主此文章

在redis源码中,实现定期淘汰策略的是函数activeExpireCycle,每当周期性函数serverCron执行时,该函数会调用databasesCron函数;然后databasesCron会调用activeExpireCycle函数进行主动的过期键删除。具体方法是在规定的时间内,多次从expires中随机挑一个键,检查它是否过期,如果过期则删除。

首先这个函数有两种执行模式,一个是快速模式一个是慢速模式,体现在代码中就是timelimit这个变量中,这个变量是用来约束这个函数的运行时间的,我们可以考虑这样一个场景,就是数据库中有很多过期的键需要清理,那么这个函数就会一直运行很长时间,这样一直占用CPU显然是不合理的,所以需要这个变量来约束,当函数运行时间超过了这个阈值,就算还有很多过期键没有清理,函数也强制退出。

在快速模式下,timelimit的值是固定的,是一个预定义的常量ACTIVE_EXPIRE_CYCLE_FAST_DURATION,在慢速模式下,这个变量的值是通过下面的代码计算的。

timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;

他的计算依据是之前预定义好的每次迭代只能占用的CPU时间比例,以及这个函数被调用的频率。

Redis中也可能有多个数据库,所以这个函数会遍历多个数据库来清楚过期键 ,但是是根据下面代码的原则来确定要遍历的数据库的个数的。

 if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;

dbs_per_call变量就是函数会遍历的数据库的个数,他有一个预定义的值REDIS_DBCRON_DBS_PER_CALL,但是如果这个值大于现在redis中本身的数据库的个数,我们就要将它的值变成当前的数据库的实际个数,或者上次的函数是因为超时强制退出了,说明可能有的数据库在上次函数调用时没有遍历到,里面的过期键没有清理掉,所以也要将这次遍历的数据库的个数改成实际数据库的个数。

for (j = 0; j < dbs_per_call; j++) {
    int expired;
    redisDb *db = server.db+(current_db % server.dbnum);
      current_db++;

上面代码可以看出:数据库的遍历是在这个大的for循环里,其中值得留意的是current_db这个变量是一个static变量,这么做的好处是,如果真的发生了我们上面说的情况,上一次函数调用因为超时而强制退出,这个变量就会记录下这一次函数应该从哪个数据库开始遍历,这样会使得函数用在每个数据库的时间尽量平均,就不会出现有的数据库里面的过期键一直没有清理的情况。

每个数据库的过期键清理的操作是在下面的这个do while 循环中(由于代码过长,所以中间有很多代码我把它隐藏了,现在看到的只是一个大框架,稍后我会对其中的部分详细讲解)

do {
    ... 
    /* If there is nothing to expire try next DB ASAP. */
    if ((num = dictSize(db->expires)) == 0) {
    ... 
    }
    slots = dictSlots(db->expires);
    now = mstime();

    if (num && slots > DICT_HT_INITIAL_SIZE &&
        (num*100/slots < 1)) break;
        ...
    if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
        num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

    while (num--) {
     ... 
    }
    /* Update the average TTL stats for this database. */
    if (ttl_samples) {
    ...
    }
    iteration++;
    if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
    ...
    }
    if (timelimit_exit) return;

} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);

注意while循环条件,ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP是我们每个循环希望查到的过期键的个数,如果我们每次循环过后,被清理的过期键的个数超过了我们期望的四分之一,我们就会继续这个循环,因为这说明当前数据库中过期键的个数比较多,需要继续清理,如果没有达到我们期望的四分之一,就跳出while循环,遍历下一个数据库。

这个函数最核心的功能就是清除过期键,这个功能的实现就是在while(num--)这个循环里面。

while (num--) {
    dictEntry *de;
    long long ttl;

    if ((de = dictGetRandomKey(db->expires)) == NULL) break;
    ttl = dictGetSignedIntegerVal(de)-now;
    if (activeExpireCycleTryExpire(db,de,now)) expired++;
    if (ttl < 0) ttl = 0;
    ttl_sum += ttl;
    ttl_samples++;
}

他先从数据库中设置了过期时间的键的集合中随机抽取一个键,然后调用activeExpireCycleTryExpire函数来判断这个键是否过期,如果过期就删除键,activeExpireCycleTryExpire函数的源码如下:

int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {
        sds key = dictGetKey(de);
        robj *keyobj = createStringObject(key,sdslen(key));

        propagateExpire(db,keyobj);
        dbDelete(db,keyobj);
        notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
            "expired",keyobj,db->id);
        decrRefCount(keyobj);
        server.stat_expiredkeys++;
        return 1;
    } else {
        return 0;
    }
}

这个函数的逻辑很简单,就是先获取键de的过期时间,和现在的时间比较,如果过期,就生成该键de的对象,然后传播该键de的过期信息,并且删除这个键,然后增加过期键总数。

最后就是控制函数运行时间的部分了,代码如下:

/* We can't block forever here even if there are many keys to
 * expire. So after a given amount of milliseconds return to the
 * caller waiting for the other active expire cycle. */
iteration++;
if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
    long long elapsed = ustime()-start;

    latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);
    if (elapsed > timelimit) timelimit_exit = 1;
}
if (timelimit_exit) return;     

这里有一个迭代次数的变量iteration,每迭代16次就来计算函数已经运行的时间,如果这个时间超过了之前的限定时间timelimit,就将timelimit_exit这个标志置为1,说明程序超时,需要强制退出了。

(3)懒惰淘汰策略:

1)含义:key过期的时候不删除,每次通过key获取值的时候去检查是否过期,若过期,则删除,返回null。

2)优点:删除操作只发生在通过key取值的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)

3)缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)

4)懒惰式策略删除流程:

1. 在进行get或setnx等操作时,先检查key是否过期;

2. 若过期,删除key,然后执行相应操作; 若没过期,直接执行相应操作;

5)源码阅读:

在redis源码中,实现懒惰淘汰策略的是函数expireIfNeeded,所有读写数据库命令在执行之前都会调用expireIfNeeded函数对输入键进行检查。如果过期就删除,如果没过期就正常访问。

int expireIfNeeded(redisDb *db, robj *key) {
    mstime_t when = getExpire(db,key);
    mstime_t now;

    if (when < 0) return 0; /* No expire for this key */

    /* Don't expire anything while loading. It will be done later. */
    if (server.loading) return 0;

    /* If we are in the context of a Lua script, we claim that time is
     * blocked to when the Lua script started. This way a key can expire
     * only the first time it is accessed and not in the middle of the
     * script execution, making propagation to slaves / AOF consistent.
     * See issue #1525 on Github for more information. */
    now = server.lua_caller ? server.lua_time_start : mstime();

    /* If we are running in the context of a slave, return ASAP:
     * the slave key expiration is controlled by the master that will
     * send us synthesized DEL operations for expired keys.
     *
     * Still we try to return the right information to the caller,
     * that is, 0 if we think the key should be still valid, 1 if
     * we think the key is expired at this time. */
    /*如果我们正在slaves上执行读写命令,就直接返回,
     *因为slaves上的过期是由master来发送删除命令同步给slaves删除的,
     *slaves不会自主删除*/
    if (server.masterhost != NULL) return now > when;
    /*只是回了一个判断键是否过期的值,0表示没有过期,1表示过期
     *但是并没有做其他与键值过期相关的操作*/

    /* Return when this key has not expired */
    /*如果没有过期,就返回当前键*/
    if (now <= when) return 0;

    /* Delete the key */
    /*增加过期键个数*/
    server.stat_expiredkeys++;
    /*传播键过期的消息*/
    propagateExpire(db,key);
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,"expired",key,db->id);
    /*删除过期键*/
    return dbDelete(db,key);
}

以上是expireIfNeeded函数的源码,源码中的注释已经很清楚的描述出了它的逻辑,我只是将他翻译成中文,然后加了一点自己的注释。值得注意的如果是slaves,它是不能自主删除键的,需要由master发del命令,然后同步到所有的slaves,这样就不会造成主从数据不一致的问题。

(4)策略总述:

懒惰淘汰机制和定时淘汰机制是一起合作的,就好像你开一家餐馆一样,定时淘汰机制就是你每隔几小时去查看所有的菜品是否还有,如果有的菜品现在卖光了,就将他从菜单上划掉。懒惰淘汰机制就是有客人要点宫保鸡丁,你马上去查看还有没有,如果今天的已经卖完了,就告诉客人不好意思,我们卖完了,然后将宫保鸡丁从菜单上划掉。只有等下次有原料再做的时候,才又把它放到菜单上去。

所以,在实际中,如果我们要自己设计过期策略,在使用懒汉式删除+定期删除时,控制时长和频率这个尤为关键,需要结合服务器性能,已经并发量等情况进行调整,以致最佳。


三、对开发需求而言,Redis过期策略的设计实现经验:代码在此工程里

(1)分析缓存键值的客户方角度,调和服务器内存压力

基于服务器内存是有限的,但是缓存是必须的,所以我们就要结合起来选择一个平衡点。所以一般来说,我们采取高访问量缓存策略---就是给那些经常被访问的数据,维持它较长的key生存周期。

(2)估算过期时间

这个就要结合我们自己的业务去估量了。

参考因素:数据的访问量、并发量,数据的变化更新的时间,服务器数据内存大小......

(3)Java演示一策略做法。

每次访问刷新对应key生存时间:

针对经常访问的数据的策略

//加进redis时,设置生存时间
@Override
    public String set(String key, String value) {
        Jedis jedis = jedisPool.getResource();
        String string = jedis.set(key, value);
        jedis.expire(key,5);
        System.out.println("key :  "+key);
        System.out.println("查看key的剩余生存时间:"+jedis.ttl(key));
        jedis.close();
        return string;
    }
    //从redis获取时
 @Override
    public String get(String key) {
        Jedis jedis = jedisPool.getResource();
        String string = jedis.get(key);
        jedis.expire(key,5);//每次访问刷新时间
        jedis.close();
        return string;
    }

源码下载:Redis系列(三)--过期策略的数据库以及部分实现代码

好了,Redis系列(三)--过期策略讲完了,这是redis使用优化必须理解的原理,这是积累的必经一步,我会继续出这个系列文章,分享经验给大家。欢迎在下面指出错误,共同学习!!你的点赞是对我最好的支持!!

更多内容,可以访问JackFrost的博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,681评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,710评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,623评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,202评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,232评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,368评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,795评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,461评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,647评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,476评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,525评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,226评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,785评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,857评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,090评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,647评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,215评论 2 341

推荐阅读更多精彩内容

  • 分布式缓存技术PK:选择Redis还是Memcached? 经平台同意授权转载 作者:田京昆(腾讯后台研发工程师)...
    meng_philip123阅读 68,904评论 7 60
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,571评论 18 139
  • 转载地址:http://gnucto.blog.51cto.com/3391516/998509 Redis与Me...
    Ddaidai阅读 21,440评论 0 82
  • 本文为笔者对在学习Redis过程中所收集资料的一个总结,目的是为了以后方便回顾相关的知识,大部分为非原创内容。特此...
    EakonZhao阅读 14,403评论 0 9
  • 一只狗昨天叫唤了一夜。从天刚黑叫到凌晨三点多中间一刻也没停。可真是天生一副好嗓子。 这让我想起前些日子每天步行走过...
    绿萝宝贝阅读 1,173评论 6 3