Redis中的鍵空間與鍵過期

本章主要來介紹一下Redis的數據庫對象,主要關注Redis對於存儲在服務器中的鍵值對數據如何進行管理。
Redis中的數據庫是一個redisDb結構體的對象。首先先來看一下客戶端、服務器與數據庫的關係,服務器中有一個變量保存這redisDb數組,默認情況下會創建16個對象,客戶端有一個redisDb指針,指向所對應的數據庫,同時可以用SELECT命令切換數據庫。

Redis中的鍵空間

Redis中的鍵空間就是redisDb的dict成員變量,其是一個字典類型,用於存儲數據庫中的鍵值對,簡單而言,dict的鍵對象就存儲數據庫中的鍵,dict的值對象就存儲數據庫中的值。blocking_keys是阻塞狀態的鍵,ready_keys是可以解除阻塞的鍵,watched_keys是正在被監視的key等。

typedef struct redisDb {
    // 數據庫鍵空間,保存着數據庫中的所有鍵值對
    dict *dict;                 /* The keyspace for this DB */
    // 鍵的過期時間,字典的鍵爲鍵,字典的值爲過期事件 UNIX 時間戳
    dict *expires;              /* Timeout of keys with a timeout set */
    // 正處於阻塞狀態的鍵
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP) */
    // 可以解除阻塞的鍵
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    // 正在被 WATCH 命令監視的鍵
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    struct evictionPoolEntry *eviction_pool;    /* Eviction pool of keys */
    // 數據庫號碼
    int id;                     /* Database ID */
    // 數據庫的鍵的平均 TTL ,統計信息
    long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

Redis中的過期鍵

Redis可以用EXPIRE命令和PEXPIRE命令給已經存在的鍵值對設定生存時間,當超過設置的生存時間,系統就會自動的將鍵值對從內存中刪除,redisDb的expires保存數據庫中所有鍵的生存時間。
如何判斷一個鍵值對是否過期:首先查看鍵是否存在expires中,如果存在就比較過期時間是否大於當前UNIX時間戳,如果大於則說明其過期,否則鍵未過期。過期鍵刪除策略有下面三種:

  1. 定時刪除。在設置鍵過期時間的同時設置一個定時器,當定時器過期時候立刻執行刪除策略。優點是能保證過期的鍵值對立刻從內存中刪除,缺點是每一個定時器都需要佔據cpu的資源。
  2. 惰性刪除。每次從鍵空間獲取值的時候纔去查看該鍵是否過期。優點是對cpu友好,不會浪費多餘時間,缺點是實時性低,過期鍵值對如果不訪問就會一直存放在內存中。
  3. 定期刪除。每隔一段時間纔去檢查數據庫中的過期鍵值對。是前兩種方法的結合。
    目前Redis採用惰性刪除策略和定期刪除策略。首先來看一下Redis中惰性刪除策略的實現,每個Redis的讀寫都先調用expireIfNeeded函數來對即將訪問的鍵值對做一個過濾,如果已經過期則刪除,如果沒有過期則繼續執行。
/*
 * 檢查 key 是否已經過期,如果是的話,將它從數據庫中刪除。
 * 返回 0 表示鍵沒有過期時間,或者鍵未過期。
 * 返回 1 表示鍵已經因爲過期而被刪除了。
 */
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 */
    // 如果服務器正在進行載入,那麼不進行任何過期檢查
    if (server.loading) return 0;
    /*lua腳本*/
    now = server.lua_caller ? server.lua_time_start : mstime();
    // 當服務器運行在 replication 模式時
    // 附屬節點並不主動刪除 key
    // 它只返回一個邏輯上正確的返回值
    // 真正的刪除操作要等待主節點發來刪除命令時才執行
    // 從而保證數據的同步
    if (server.masterhost != NULL) return now > when;
    // 運行到這裏,表示鍵帶有過期時間,並且服務器爲主節點
    // 如果未過期,返回 0
    if (now <= when) return 0;
    /* 刪除key */
    server.stat_expiredkeys++;
    // 向 AOF 文件和附屬節點傳播過期信息
    propagateExpire(db,key);
    // 發送事件通知
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);
    // 將過期鍵從數據庫中刪除
    return dbDelete(db,key);
}

Redis中定期刪除策略的實現,默認情況下,Redis服務器每隔100毫秒就會執行一次serverCron,serverCron中就會調用activeExpireCycle。activeExpireCycle函數就是定期刪除的具體實現,其主要思路就是循環遍歷16個db,每個db中再在隨機抽選鍵值對查看其是否過期。

/*
 * 函數嘗試刪除數據庫中已經過期的鍵。
 * 當帶有過期時間的鍵比較少時,函數運行得比較保守,
 * 如果帶有過期時間的鍵比較多,那麼函數會以更積極的方式來刪除過期鍵,
 * 從而可能地釋放被過期鍵佔用的內存。
 *
 * 每次循環中被測試的數據庫數目不會超過 REDIS_DBCRON_DBS_PER_CALL 。
 *
 * 如果 timelimit_exit 爲真,那麼說明還有更多刪除工作要做,
 * 那麼在 beforeSleep() 函數調用時,程序會再次執行這個函數。
 * 過期循環的類型:
 *
 * 如果循環的類型爲 ACTIVE_EXPIRE_CYCLE_FAST ,
 * 那麼函數會以“快速過期”模式執行,
 * 執行的時間不會長過 EXPIRE_FAST_CYCLE_DURATION 毫秒,
 * 並且在 EXPIRE_FAST_CYCLE_DURATION 毫秒之內不會再重新執行。
 *
 * 如果循環的類型爲 ACTIVE_EXPIRE_CYCLE_SLOW ,
 * 那麼函數會以“正常過期”模式執行,
 * 函數的執行時限爲 REDIS_HS 常量的一個百分比,
 * 這個百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定義。
 */

void activeExpireCycle(int type) {
    /* This function has some global state in order to continue the work
     * incrementally across calls. */
    // 靜態變量,用來累積函數連續執行時的數據
    static unsigned int current_db = 0; /* 上一個被測試的db. */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? */
    static long long last_fast_cycle = 0; /* When last fast cycle ran. */

    unsigned int j, iteration = 0;
    // 默認每次處理的數據庫數量
    unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
    // 函數開始的時間
    long long start = ustime(), timelimit;

    // 快速模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        /* Don't start a fast cycle if the previous cycle did not exited
         * for time limt. Also don't repeat a fast cycle for the same period
         * as the fast cycle total duration itself. */
        // 如果上次函數沒有觸發 timelimit_exit ,那麼不執行處理
        if (!timelimit_exit) return;
        // 如果距離上次執行未夠一定時間,那麼不執行處理
        if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
        // 運行到這裏,說明執行快速處理,記錄當前時間
        last_fast_cycle = start;
    }

    /* 
     * 一般情況下,函數只處理 REDIS_DBCRON_DBS_PER_CALL 個數據庫,
     * 除非:
     * 1) 當前數據庫的數量小於 REDIS_DBCRON_DBS_PER_CALL
     * 2) 如果上次處理遇到了時間上限,那麼這次需要對所有數據庫進行掃描,這可以避免過多的過期鍵佔用空間
     */
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;

    /* We can use at max ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC percentage of CPU time
     * per iteration. Since this function gets called with a frequency of
     * server.hz times per second, the following is the max amount of
     * microseconds we can spend in this function. */
    // 函數處理的微秒時間上限
    // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默認爲 25 ,也即是 25 % 的 CPU 時間
    timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;

    // 如果是運行在快速模式之下
    // 那麼最多隻能運行 FAST_DURATION 微秒 
    // 默認值爲 1000 (微秒)
    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */

    // 遍歷數據庫
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        // 指向要處理的數據庫
        redisDb *db = server.db+(current_db % server.dbnum);

        /* Increment the DB now so we are sure if we run out of time
         * in the current DB we'll restart from the next. This allows to
         * distribute the time evenly across DBs. */
        // 爲 DB 計數器加一,如果進入 do 循環之後因爲超時而跳出
        // 那麼下次會直接從下個 DB 開始處理
        current_db++;

        /* Continue to expire if at the end of the cycle more than 25%
         * of the keys were expired. */
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;

            /* If there is nothing to expire try next DB ASAP. */
            // 獲取數據庫中帶過期時間的鍵的數量
            // 如果該數量爲 0 ,直接跳過這個數據庫
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            // 獲取數據庫中鍵值對的數量
            slots = dictSlots(db->expires);
            // 當前時間
            now = mstime();

            /* When there are less than 1% filled slots getting random
             * keys is expensive, so stop here waiting for better times...
             * The dictionary will be resized asap. */
            // 這個數據庫的使用率低於 1% ,掃描起來太費力了(大部分都會 MISS)
            // 跳過,等待字典收縮程序運行
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;

            /* The main collection cycle. Sample random keys among keys
             * with an expire set, checking for expired ones. 
             *
             * 樣本計數器
             */
            // 已處理過期鍵計數器
            expired = 0;
            // 鍵的總 TTL 計數器
            ttl_sum = 0;
            // 總共處理的鍵計數器
            ttl_samples = 0;

            // 每次最多隻能檢查 LOOKUPS_PER_LOOP 個鍵
            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

            // 開始遍歷數據庫
            while (num--) {
                dictEntry *de;
                long long ttl;

                // 從 expires 中隨機取出一個帶過期時間的鍵
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                // 計算 TTL
                ttl = dictGetSignedIntegerVal(de)-now;
                // 如果鍵已經過期,那麼刪除它,並將 expired 計數器增一
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl < 0) ttl = 0;
                // 累積鍵的 TTL
                ttl_sum += ttl;
                // 累積處理鍵的個數
                ttl_samples++;
            }

            /* Update the average TTL stats for this database. */
            // 爲這個數據庫更新平均 TTL 統計數據
            if (ttl_samples) {
                // 計算當前平均值
                long long avg_ttl = ttl_sum/ttl_samples;
                
                // 如果這是第一次設置數據庫平均 TTL ,那麼進行初始化
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                /* Smooth the value averaging with the previous one. */
                // 取數據庫的上次平均 TTL 和今次平均 TTL 的平均值
                db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
            }

            /* 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++;
            // 每遍歷 16 次執行一次
            if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
                (ustime()-start) > timelimit)
            {
                // 如果遍歷次數正好是 16 的倍數
                // 並且遍歷的時間超過了 timelimit
                // 那麼斷開 timelimit_exit
                timelimit_exit = 1;
            }
            // 已經超時了,返回
            if (timelimit_exit) return;
            /* We don't repeat the cycle if there are less than 25% of keys
             * found expired in the current DB. */
            // 如果已刪除的過期鍵佔當前總數據庫帶過期時間的鍵數量的 25 %
            // 那麼不再遍歷
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
}

除了兩種過期策略以外,RDB和AOF兩種持久化策略也可以對過期鍵值對處理。比如:當生成RDB文件和啓動服務器時候載入RDB文件時候都會判斷鍵值是否過期,如果過期則跳過不處理。如果在複製同步的模式下,從服務器不會主動刪除過期鍵,只有主服務器刪除了過期鍵,然後通過命令傳播的方式傳輸命令給從服務器,然後從服務器纔會刪除過期鍵。

參考:

  1. 《Redis設計與實現》
  2. 關於Redis數據過期策略
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章