Redis源碼剖析之數據過期(expire)

我之前統計過我們線上某redis數據被訪問的時間分佈,大概90%的請求只會訪問最新15分鐘的數據,99%的請求訪問最新1小時的數據,只有不到千分之一的請求會訪問超過1天的數據。我們之前這份數據存了兩天(近500g內存數據),如果算上主備的話用掉了120多個Redis實例(一個實例8g內存),光把過期時間從2天改成1天就能省下60多個redis實例,而且對原業務也沒有啥太大影響。

當然Redis已經實現了數據過期的自動清理機制,我所要做的只是改下數據寫入時的過期時間而已。假設Redis沒有數據過期的機制,我們要怎麼辦? 大概一想就知道很麻煩,仔細想的話還得考慮很多的細節。所以我覺得過期數據在緩存系統中是不起眼但非常重要的功能,除了省事外,它也能幫我們節省很多成本。接下來我們看下Redis中是如何實現數據過期的。

實時清理

衆所周知,Redis核心流程是單線程執行的,它基本上是處理完一條請求再出處理另外一條請求,處理請求的過程並不僅僅是響應用戶發起的請求,Redis也會做好多其他的工作,當前其中就包括數據的過期。

Redis在讀寫某個key的時候,它就會調用expireIfNeeded()先判斷這個key是否已經過期了,如果已過期,就會執行刪除。

int expireIfNeeded(redisDb *db, robj *key) {
    if (!keyIsExpired(db,key)) return 0;

    /* 如果是在slave上下文中運行,直接返回1,因爲slave的key過期是由master控制的,
     * master會給slave發送數據刪除命令。 
     * 
     * 如果返回0表示數據不需要清理,返回1表示數據這次標記爲過期 */
    if (server.masterhost != NULL) return 1;
    if (checkClientPauseTimeoutAndReturnIfPaused()) return 1;

    /* 刪除key */
    server.stat_expiredkeys++;
    propagateExpire(db,key,server.lazyfree_lazy_expire);
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                               dbSyncDelete(db,key);
    if (retval) signalModifiedKey(NULL,db,key);
    return retval;
}

判斷是否過期也很簡單,Redis在dictEntry中存儲了上次更新的時間戳,只需要判斷當前時間戳和上次更新時間戳之間的gap是否超過設定的過期時間即可。

我們重點來關注下這行代碼。

int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                               dbSyncDelete(db,key);

lazyfree_lazy_expire 是Redis的配置項之一,它的作用是是否開啓惰性刪除(默認不開啓),很顯然如果開啓就會執行異步刪除,接下來我們詳細說下Redis的惰性刪除。

惰性刪除

何爲惰性刪除,從本質上講惰性刪除就是新開一個線程異步處理數據刪除的任務。爲什麼要有惰性刪除?衆所周知,Redis核心流程是單線程執行,如果某個一步執行特別耗時,會直接影響到Redis的性能,比如刪除一個幾個G的hash key,那這個實例不直接原地昇天。。 針對這種情況,需要新開啓一個線程去異步刪除,防止阻塞出Redis的主線程,當然Redis實際實現的時候dbAsyncDelete()並不完全是異步,我們直接看代碼。

#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
    /* 從db->expires中刪除key,只是刪除其指針而已,並沒有刪除實際值 */
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);

    /* If the value is composed of a few allocations, to free in a lazy way
     * is actually just slower... So under a certain limit we just free
     * the object synchronously. */
    /*
    * 在字典中摘除這個key(沒有真正刪除,只是查不到而已),如果被摘除的dictEntry不爲
    * 空就去執行下面的釋放邏輯 
    */
    dictEntry *de = dictUnlink(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        /* Tells the module that the key has been unlinked from the database. */
        moduleNotifyKeyUnlink(key,val);

        /* lazy_free並不是完全異步的,而是先評估釋放操作所需工作量,如果影響較小就直接在主線程中刪除了 */
        size_t free_effort = lazyfreeGetFreeEffort(key,val);

        /* 如果釋放這個對象需要做大量的工作,就把他放到異步線程裏做
         * 但如果這個對象是共享對象(refcount > 1)就不能直接釋放了,當然這很少發送,但有可能redis
         * 核心會調用incrRefCount來保護對象,然後調用dbDelete。這我只需要直接調用dictFreeUnlinkedEntry,
         * 等價於調用decrRefCount */
        if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
            atomicIncr(lazyfree_objects,1);
            bioCreateLazyFreeJob(lazyfreeFreeObject,1, val);
            dictSetVal(db->dict,de,NULL);
        }
    }

    /* 釋放鍵值對所佔用的內存,如果是lazyFree,val已經是null了,只需要釋放key的內存即可 */
    if (de) {
        dictFreeUnlinkedEntry(db->dict,de);
        if (server.cluster_enabled) slotToKeyDel(key->ptr);
        return 1;
    } else {
        return 0;
    }
}
  1. 首先在db->expires中把這個key給刪除掉,(db->expires保存了所有帶有過期時間數據的key,方便做數據過期)
  2. 然後把這個數據節點從db中摘掉,數據實際還在內存裏,只是查不到而已。
  3. 接下來就是要清理數據了,redis並不是直接把清理工作放到異步線程裏做,而是調用lazyfreeGetFreeEffort()來評估清理工作對性能的影響,如果影響較小,就直接在主線程裏做了。反之影響太大才會將刪除的任務提交到異步線程裏。
  4. 釋放key和val佔用的內存空間,如果是異步刪除,val已經是null,這裏只需要釋放key佔用的空間即可。

這裏第三步中爲什麼異步刪除不完全是異步? 我覺得還是得從異步任務提交bioCreateLazyFreeJob()中一窺端倪。

void bioCreateLazyFreeJob(lazy_free_fn free_fn, int arg_count, ...) {
    va_list valist;
    /* Allocate memory for the job structure and all required
     * arguments */
    struct bio_job *job = zmalloc(sizeof(*job) + sizeof(void *) * (arg_count));
    job->free_fn = free_fn;

    va_start(valist, arg_count);
    for (int i = 0; i < arg_count; i++) {
        job->free_args[i] = va_arg(valist, void *);
    }
    va_end(valist);
    bioSubmitJob(BIO_LAZY_FREE, job);
} 

void bioSubmitJob(int type, struct bio_job *job) {
    job->time = time(NULL);
    // 多線程需要加鎖,把待處理的job添加到隊列末尾
    pthread_mutex_lock(&bio_mutex[type]);
    listAddNodeTail(bio_jobs[type],job);
    bio_pending[type]++;
    pthread_cond_signal(&bio_newjob_cond[type]);
    pthread_mutex_unlock(&bio_mutex[type]);
}

我理解,在異步刪除的時候需要加鎖將異步任務提交到隊列裏,如果加鎖和任務提交所帶來的性能影響大於直接刪除的影響,那麼異步刪除還不如同步呢。

定期抽樣刪除

這裏思考下另外一個問題,如果數據寫入後就再也沒有讀寫了,是不是實時清理的功能就無法觸及到這些數據,然後這些數據就永遠都會佔用空間。針對這種情況,Redis也實現了定期刪除的策略。衆所周知,Redis核心流程是單線程執行,所以註定它不能長時間停下來去幹某個特定的工作,所以Redis定期清理也是每次只做一點點。

/* 有兩種清理模式,快速清理和慢速清理 */
void activeExpireCycle(int type) {
    /* Adjust the running parameters according to the configured expire
     * effort. The default effort is 1, and the maximum configurable effort
     * is 10. */
    unsigned long
    effort = server.active_expire_effort-1, /* Rescale from 0 to 9. */
    config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
                           ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP/4*effort,  // 每次抽樣的數據量大小 
    config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
                                 ACTIVE_EXPIRE_CYCLE_FAST_DURATION/4*effort, // 每次清理的持續時間
    config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
                                  2*effort,  // 最大CPU週期使用率 
    config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE-
                                    effort; // 可接受的過期數據佔比 

    /* This function has some global state in order to continue the work
     * incrementally across calls. */
    static unsigned int current_db = 0; /* Last DB tested. */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? */
    static long long last_fast_cycle = 0; /* When last fast cycle ran. */

    int j, iteration = 0;
    int dbs_per_call = CRON_DBS_PER_CALL;
    long long start = ustime(), timelimit, elapsed;

    /* When clients are paused the dataset should be static not just from the
     * POV of clients not being able to write, but also from the POV of
     * expires and evictions of keys not being performed. */
    if (checkClientPauseTimeoutAndReturnIfPaused()) return;
    // 快速清理 
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        /* Don't start a fast cycle if the previous cycle did not exit
         * for time limit, unless the percentage of estimated stale keys is
         * too high. Also never repeat a fast cycle for the same period
         * as the fast cycle total duration itself. */
        // 如果上次執行沒有觸發timelimit_exit, 跳過執行
        if (!timelimit_exit &&
            server.stat_expired_stale_perc < config_cycle_acceptable_stale)
            return;
        // 兩個快速清理週期內不執行快速清理
        if (start < last_fast_cycle + (long long)config_cycle_fast_duration*2)
            return;

        last_fast_cycle = start;
    }

    /* We usually should test CRON_DBS_PER_CALL per iteration, with
     * two exceptions:
     *
     * 1) Don't test more DBs than we have.
     * 2) If last time we hit the time limit, we want to scan all DBs
     * in this iteration, as there is work to do in some DB and we don't want
     * expired keys to use memory for too much time. */
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;

    /* We can use at max 'config_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. 
     * config_cycle_slow_time_perc是清理所能佔用的CPU週期數配置,這裏將週期數轉化爲具體的時間 */
    timelimit = config_cycle_slow_time_perc*1000000/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = config_cycle_fast_duration; /* in microseconds. */

    /* Accumulate some global stats as we expire keys, to have some idea
     * about the number of keys that are already logically expired, but still
     * existing inside the database. */
    long total_sampled = 0;
    long total_expired = 0;
    
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        /* Expired and checked in a single loop. */
        unsigned long expired, sampled;
        
        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. */
        current_db++;

        /* Continue to expire if at the end of the cycle there are still
         * a big percentage of keys to expire, compared to the number of keys
         * we scanned. The percentage, stored in config_cycle_acceptable_stale
         * is not fixed, but depends on the Redis configured "expire effort". */
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            iteration++;

            /* 如果沒有可清理的,直接結束 */
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            slots = dictSlots(db->expires);
            now = mstime();

            /* 如果slot的填充率小於1%,採樣的成本太高,跳過執行,等待下次合適的機會。*/
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;

            /* 記錄本次採樣的數據和其中過期的數量 */
            expired = 0;
            sampled = 0;
            ttl_sum = 0;
            ttl_samples = 0;
            // 每次最多抽樣num個 
            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

            /* 這裏因爲性能考量,我們訪問了hashcode的的底層實現,代碼和dict.c有些類型,
             * 但十幾年內很難改變。 
             * 
             * 注意:hashtable很多特定的地方是空的,所以我們的終止條件需要考慮到已掃描的bucket
             * 數量。 但實際上掃描空bucket是很快的,因爲都是在cpu 緩存行裏線性掃描,所以可以多
             * 掃一些bucket */
            long max_buckets = num*20;
            long checked_buckets = 0;
            // 這裏有採樣數據和bucket數量的限制。 
            while (sampled < num && checked_buckets < max_buckets) {
                for (int table = 0; table < 2; table++) {
                    if (table == 1 && !dictIsRehashing(db->expires)) break;

                    unsigned long idx = db->expires_cursor;
                    idx &= db->expires->ht[table].sizemask;
                    dictEntry *de = db->expires->ht[table].table[idx];
                    long long ttl;

                    /* 遍歷當前bucket中的所有entry*/
                    checked_buckets++;
                    while(de) {
                        /* Get the next entry now since this entry may get
                         * deleted. */
                        dictEntry *e = de;
                        de = de->next;

                        ttl = dictGetSignedIntegerVal(e)-now;
                        if (activeExpireCycleTryExpire(db,e,now)) expired++;
                        if (ttl > 0) {
                            /* We want the average TTL of keys yet
                             * not expired. */
                            ttl_sum += ttl;
                            ttl_samples++;
                        }
                        sampled++;
                    }
                }
                db->expires_cursor++;
            }
            total_expired += expired;
            total_sampled += sampled;

            /* 更新ttl統計信息 */
            if (ttl_samples) {
                long long avg_ttl = ttl_sum/ttl_samples;

                /* Do a simple running average with a few samples.
                 * We just use the current estimate with a weight of 2%
                 * and the previous estimate with a weight of 98%. */
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
            }

            /* 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. 
             * 不能一直阻塞在這裏做清理工作,如果超時了要結束清理循環*/
            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                elapsed = ustime()-start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    server.stat_expired_time_cap_reached_count++;
                    break;
                }
            }
            /*
             * 如果過期key數量超過採樣數的10%+effort,說明過期測數量較多,要多清理下,所以
             * 繼續循環做一次採樣清理。     
             */
        } while (sampled == 0 ||
                 (expired*100/sampled) > config_cycle_acceptable_stale);
    }

    elapsed = ustime()-start;
    server.stat_expire_cycle_time_used += elapsed;
    latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);

    /* Update our estimate of keys existing but yet to be expired.
     * Running average with this sample accounting for 5%. */
    double current_perc;
    if (total_sampled) {
        current_perc = (double)total_expired/total_sampled;
    } else
        current_perc = 0;
    server.stat_expired_stale_perc = (current_perc*0.05)+
                                     (server.stat_expired_stale_perc*0.95);
}

代碼有些長,大致總結下其執行流程,細節見代碼註釋。

  1. 首先通過配置或者默認值計算出幾個參數,這幾個參數直接或間接決定了這些執行的終止條件,分別如下。
  • config_keys_per_loop: 每次循環抽樣的數據量
  • config_cycle_fast_duration: 快速清理模式下每次清理的持續時間
  • config_cycle_slow_time_perc: 慢速清理模式下每次清理最大消耗CPU週期數(cpu最大使用率)
  • config_cycle_acceptable_stale: 可接受的過期數據量佔比,如果本次採樣中過期數量小於這個閾值就結束本次清理。
  1. 根據上述參數計算出終止條件的具體值(最大采樣數量和超時限制)。
  2. 遍歷清理所有的db。
  3. 針對每個db在終止條件的限制下循環清理。

總結

Redis的數據過期策略比較簡單,代碼也不是特別多,但一如既然處處貫穿者性能的考慮。當然Redis只是提供了這樣一個功能,如果想用好的話還得根據具體的業務需求和實際的數據調整過期時間的配置,就好比我在文章開頭舉的那個例子。

本文是Redis源碼剖析系列博文,同時也有與之對應的Redis中文註釋版,有想深入學習Redis的同學,歡迎star和關注。
Redis中文註解版倉庫:https://github.com/xindoo/Redis
Redis源碼剖析專欄:https://zxs.io/s/1h
如果覺得本文對你有用,歡迎一鍵三連

本文來自https://blog.csdn.net/xindoo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章