制定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的博客