redis數據記錄過期源代碼分析

最近在分析redis源代碼,一直想寫一點相關的東西,空不出時間來整理。今天好不容易空出時間來,把自己對redis關於記錄過期和過期檢測的流程理解寫來。用過redis的人都知道,redis對相關記錄的過期設置和memcached是相似的。具體的redis命令如下:

SET key1 "zerok"  EX 60

上面命令是設置一個key1的字符類記錄,生命週期爲60秒。這個命令過程在redis內部代碼調用是如下流程:

setCommand() ->setGenericCommand()->setKey();

這就是內部調用關係,SET實現的關鍵是在setGenericCommand()在此函數。代碼如下:

void setGenericCommand(redisClient *c, int nx, robj *key, robj *val, robj *expire, int unit)        
{
    long long milliseconds = 0; /* initialized to avoid an harmness warning */
    // 如果帶有 expire 參數,那麼將它從 sds 轉爲 long long 類型
    if (expire) {
        if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != REDIS_OK)
            return;
        if (milliseconds <= 0) {
            addReplyError(c,"invalid expire time in SETEX");
            return;
        }
        // 決定過期時間是秒還是毫秒
        if (unit == UNIT_SECONDS) milliseconds *= 1000;
    }
    // 如果給定了 nx 參數,並且 key 已經存在,那麼直接向客戶端返回
    if (nx && lookupKeyWrite(c->db,key) != NULL) {
        addReply(c,shared.czero);
        return;
    }
    // 設置 key-value 對
    setKey(c->db,key,val);
    server.dirty++;
    // 爲 key 設置過期時間
    if (expire) setExpire(c->db,key,mstime()+milliseconds);
    // 向客戶端返回回覆
    addReply(c, nx ? shared.cone : shared.ok);
}
如果是設置了過期時間,就會調用setExpire,這個函數會進行如下操作:

void setExpire(redisDb *db, robj *key, long long when) {
    dictEntry *kde, *de;
    /* Reuse the sds from the main dict in the expire dict */
    kde = dictFind(db->dict,key->ptr);
    redisAssertWithInfo(NULL,key,kde != NULL);
    de = dictReplaceRaw(db->expires,dictGetKey(kde));
    dictSetSignedIntegerVal(de,when);
}
會將key和過期時間隱射到一個過期字典中(db->expires,具體定義在redisDb中).這樣設置過期就完成了。


那麼redis是怎麼處理過期字典的呢?redis有兩種情況會讓key過期,一種是在查詢對應的key的時候,會檢測key是否過期了。第二中情況是週期性檢查key過期。我們先看第一種。假如我們輸入:

GET key1

在redis源代碼中,會有如下調用過程:getGenericCommand()->lookupKeyReadOrReply()->lookupKeyRead()->lookupKey();

整個查找過程關鍵實現是在lookupKeyRead(),代碼如下:

robj *lookupKeyRead(redisDb *db, robj *key) {
    robj *val;
    // 檢查 key 是否過期,如果是的話,將它刪除
    expireIfNeeded(db,key);
    // 查找 key ,並根據查找結果更新命中/不命中數
    val = lookupKey(db,key);
    if (val == NULL)
        server.stat_keyspace_misses++;
    else
        server.stat_keyspace_hits++;

    // 返回 key 的值
    return val;
}
其中expireIfNeeded是檢查是否過期,代碼如下:

int expireIfNeeded(redisDb *db, robj *key) {
    // 取出 key 的過期時間
    long long when = getExpire(db,key);
    // key 沒有過期時間,直接返回
    if (when < 0) return 0; /* No expire for this key */
    // 不要在服務器載入數據時執行過期
    if (server.loading) return 0;

    // 如果服務器作爲附屬節點運行,那麼直接返回
    // 因爲附屬節點的過期是由主節點通過發送 DEL 命令來刪除的
    // 不必自主刪除,slave db
    if (server.masterhost != NULL) {
        // 返回一個理論上正確的值,但不執行實際的刪除操作
        return mstime() > when;
    }

    /* Return when this key has not expired */
    // 未過期
    if (mstime() <= when) return 0;

    /* Delete the key */
    server.stat_expiredkeys++;

    // 傳播過期命令
    propagateExpire(db,key);

    // 從數據庫中刪除 key
    return dbDelete(db,key);
}
除了lookupKeyRead以外,還有lookupKeyWrite、dbRandomKey、existsCommand、keysCommand等函數會調用expireIfNeeded。redis用此方法檢查過期時間是可以提高效率,不需要每次都通過週期檢測來。


還有一種就是週期性檢測過期,週期性過期是通過週期心跳函數(serverCron)來觸發的。首先redis會在初始化的時候調用: 
    aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);

這是在ae的異步模型插入一個1毫秒觸發一次的serverCron。接下來的流程如下;

aeLoopEvent->serverCron->activeExpireCycle

其中activeExpireCycle整個檢測的關鍵函數實現:

void activeExpireCycle(void) {
    int j, iteration = 0;
    long long start = ustime(), timelimit;
    // 這個函數可以使用的時長(毫秒)
    timelimit = 1000000*REDIS_EXPIRELOOKUPS_TIME_PERC/REDIS_HZ/100;
    if (timelimit <= 0) timelimit = 1;
    for (j = 0; j < server.dbnum; j++) {
        int expired;
        redisDb *db = server.db+j;
        /* Continue to expire if at the end of the cycle more than 25%
         * of the keys were expired. */
        do {
            unsigned long num = dictSize(db->expires);
            unsigned long slots = dictSlots(db->expires);
            long long now = mstime();
            // 過期字典裏只有 %1 位置被佔用,調用隨機 key 的消耗比較高
            // 等 key 多一點再來
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;
            // 從過期字典中隨機取出 key ,檢查它是否過期
            expired = 0;    // 被刪除 key 計數
            if (num > REDIS_EXPIRELOOKUPS_PER_CRON) // 最多每次可查找的次數
                num = REDIS_EXPIRELOOKUPS_PER_CRON;
            while (num--) {
                dictEntry *de;
                long long t;

                // 隨機查找帶有 TTL 的 key ,看它是否過期
                // 如果數據庫爲空,跳出
                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++;
                }
            }
            // 每次進行 16 次循環之後,檢查時間是否超過,如果超過,則退出
            iteration++;
            if ((iteration & 0xf) == 0 && /* check once every 16 cycles. */
                (ustime()-start) > timelimit) return;

        } while (expired > REDIS_EXPIRELOOKUPS_PER_CRON/4);
    }
}

檢查到至少兩個超時或者檢查完16個循環且運行時長超過指定timelimit指標就結束。因爲serverCron是毫秒級間隔的觸發,所以必須保證activeExpireCycle儘量不堵塞才能保證redis整個服務的高效率。


總結,從代碼上看,redis主要檢查過期應該是是依賴第一種情況,第二種情況是防止數據長時間未訪問的情況下內存佔用過高做的舉措,而且不過多佔用主線程處理時間。redis在整個設計的過程中非常精巧,除了數據過期檢測以外,還有其他大量基於單線程單進程分時複用的精妙寫法,以後用其他篇幅來介紹。



發佈了39 篇原創文章 · 獲贊 76 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章