1. 程式人生 > >淺談Redis面試熱點之工程架構篇[1]

淺談Redis面試熱點之工程架構篇[1]

前言

前面用兩篇文章大致介紹了Redis熱點面試中的底層實現相關的問題,感興趣的可以回顧一下:
【決戰西二旗】|Redis面試熱點之底層實現篇
【決戰西二旗】|Redis面試熱點之底層實現篇(續)

接下來我們繼續來一起研究下Redis工程架構相關的問題,這部分內容出現的概率相對大一些,因為並不是所有人都會去研究原始碼,如果面試一味問原始碼那麼可能註定是一場尬聊。

面試時在不要求候選人對Redis非常熟練的前提下,工程問題將是不二之選,工程問題相對較多,因此本號將分幾篇學習完,今天先來一起學習第一篇。

通過本文你將瞭解到以下內容:
1.Redis的記憶體回收詳解
2.Redis的持久化機制

 

Q1:瞭解Redis的記憶體回收嗎?講講你的理解

1.1 為什麼要回收記憶體?

Redis作為記憶體型資料庫,如果單純的只進不出早晚就撐爆了,事實上很多把Redis當做主儲存DB用的傢伙們早晚會嚐到這個苦果,當然除非你家廠子確實不差錢,數T級別的記憶體都毛毛雨,或者資料增長一定程度之後不再增長的場景,就另當別論了。

對於我們這種把節約成本當做KPI的普通廠子,還是把Redis當快取用比較符合家裡的經濟條件,所以這麼看面試官的問題還算是比較貼合實際,比起那些手撕RBTree好一些,如果問題剛好在你知識射程範圍內,先給面試官點個贊再說!

為了讓Redis服務安全穩定的執行,讓使用記憶體保持在一定的閾值內是非常有必要的,因此我們就需要刪除該刪除的,清理該清理的,把記憶體留給需要的鍵值對,試想一條大河需要設定幾個警戒水位來確保不決堤不枯竭,Redis也是一樣的,只不過Redis只關心決堤即可,來一張圖:

圖中設定機器記憶體為128GB,佔用64GB算是比較安全的水平,如果記憶體接近80%也就是100GB左右,那麼認為Redis目前承載能力已經比較大了,具體的比例可以根據公司和個人的業務經驗來確定。

筆者只是想表達出於安全和穩定的考慮,不要覺得128GB的記憶體就意味著儲存128GB的資料,都是要打折的。

1.2 記憶體從哪裡回收?

Redis佔用的記憶體是分為兩部分:儲存鍵值對消耗和本身執行消耗。顯然後者我們無法回收,因此只能從鍵值對下手了,鍵值對可以分為幾種:帶過期的、不帶過期的、熱點資料、冷資料。對於帶過期的鍵值是需要刪除的,如果刪除了所有的過期鍵值對之後記憶體仍然不足怎麼辦?那隻能把部分資料給踢掉了。

人生無處不取捨,這個讓筆者腦海浮現了《泰坦尼克》,郵輪撞到了冰山頃刻間海水湧入,面臨數量不足的救生艇,人們做出了抉擇:讓女士和孩童先走,紳士們選擇留下,海上逃生場景如圖:

 

1.3 如何實施過期鍵值對的刪除?

要實施對鍵值對的刪除我們需要明白如下幾點:

  • 帶過期超時的鍵值對儲存在哪裡?
  • 如何判斷帶超時的鍵值對是否可以被刪除了?
  • 刪除機制有哪些以及如何選擇?

1.3.1 鍵值對的儲存

老規矩來到github看下原始碼,src/server.h中給的redisDb結構體給出了答案:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    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 */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

Redis本質上就是一個大的key-value,key就是字串,value有是幾種物件:字串、列表、有序列表、集合、雜湊等,這些key-value都是儲存在redisDb的dict中的,來看下黃健巨集畫的一張非常讚的圖:

看到這裡,對於刪除機制又清晰了一步,我們只要把redisDb中dict中的目標key-value刪掉就行,不過貌似沒有這麼簡單,Redis對於過期鍵值對肯定有自己的組織規則,讓我們繼續研究吧!

redisDb的expires成員的型別也是dict,和鍵值對是一樣的,本質上expires是dict的子集,expires儲存的是所有帶過期的鍵值對,稱之為過期字典吧,它才是我們研究的重點。

對於鍵,我們可以設定絕對和相對過期時間、以及檢視剩餘時間:

  • 使用EXPIRE和PEXPIRE來實現鍵值對的秒級和毫秒級生存時間設定,這是相對時長的過期設定
  • 使用EXPIREAT和EXPIREAT來實現鍵值對在某個秒級和毫秒級時間戳時進行過期刪除,屬於絕對過期設定
  • 通過TTL和PTTL來檢視帶有生存時間的鍵值對的剩餘過期時間

上述三組命令在設計快取時用處比較大,有心的讀者可以留意。

過期字典expires和鍵值對空間dict儲存的內容並不完全一樣,過期字典expires的key是指向Redis對應物件的指標,其value是long long型的unix時間戳,前面的EXPIRE和PEXPIRE相對時長最終也會轉換為時間戳,來看下過期字典expires的結構,筆者畫了個圖:

1.3.2 鍵值對的過期刪除判斷

判斷鍵是否過期可刪除,需要先查過期字典是否存在該值,如果存在則進一步判斷過期時間戳和當前時間戳的相對大小,做出刪除判斷,簡單的流程如圖:

1.3.3 鍵值對的刪除策略

經過前面的幾個環節,我們知道了Redis的兩種儲存位置:鍵空間和過期字典,以及過期字典expires的結構、判斷是否過期的方法,那麼該如何實施刪除呢?

先拋開Redis來想一下可能的幾種刪除策略:

  • 定時刪除:在設定鍵的過期時間的同時,建立定時器,讓定時器在鍵過期時間到來時,即刻執行鍵值對的刪除;
  • 定期刪除:每隔特定的時間對資料庫進行一次掃描,檢測並刪除其中的過期鍵值對;
  • 惰性刪除:鍵值對過期暫時不進行刪除,至於刪除的時機與鍵值對的使用有關,當獲取鍵時先檢視其是否過期,過期就刪除,否則就保留;

在上述的三種策略中定時刪除和定期刪除屬於不同時間粒度的主動刪除,惰性刪除屬於被動刪除。

三種策略都有各自的優缺點:

定時刪除對記憶體使用率有優勢,但是對CPU不友好,惰性刪除對記憶體不友好,如果某些鍵值對一直不被使用,那麼會造成一定量的記憶體浪費,定期刪除是定時刪除和惰性刪除的折中。

Reids採用的是惰性刪除和定時刪除的結合,一般來說可以藉助最小堆來實現定時器,不過Redis的設計考慮到時間事件的有限種類和數量,使用了無序連結串列儲存時間事件,這樣如果在此基礎上實現定時刪除,就意味著O(N)遍歷獲取最近需要刪除的資料。

但是我覺得antirez如果非要使用定時刪除,那麼他肯定不會使用原來的無序連結串列機制,所以個人認為已存在的無序連結串列不能作為Redis不使用定時刪除的根本理由,冒昧猜測唯一可能的是antirez覺得沒有必要使用定時刪除。

 

1.3.4 定期刪除的實現細節

定期刪除聽著很簡單,但是如何控制執行的頻率和時長呢?

試想一下如果執行頻率太少就退化為惰性刪除了,如果執行時間太長又和定時刪除類似了,想想還確實是個難題!並且執行定期刪除的時機也需要考慮,所以我們繼續來看看Redis是如何實現定期刪除的吧!筆者在src/expire.c檔案中找到了activeExpireCycle函式,定期刪除就是由此函式實現的,在程式碼中antirez做了比較詳盡的註釋,不過都是英文的,試著讀了一下模模糊糊弄個大概,所以學習英文並閱讀外文資料是很重要的學習途徑。

先貼一下程式碼,核心部分算上註釋大約210行,具體看下:

#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
                                                   we do extra efforts. */

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,
    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 (clientsArePaused()) 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. */
        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. */
    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 more than 25%
         * of the keys were expired. */
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            iteration++;

            /* If there is nothing to expire try next DB ASAP. */
            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, sampling the key
             * space is expensive, so stop here waiting for better times...
             * The dictionary will be resized asap. */
            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;
            sampled = 0;
            ttl_sum = 0;
            ttl_samples = 0;

            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

            /* Here we access the low level representation of the hash table
             * for speed concerns: this makes this code coupled with dict.c,
             * but it hardly changed in ten years.
             *
             * Note that certain places of the hash table may be empty,
             * so we want also a stop condition about the number of
             * buckets that we scanned. However scanning for free buckets
             * is very fast: we are in the cache line scanning a sequential
             * array of NULL pointers, so we can scan a lot more buckets
             * than keys in the same time. */
            long max_buckets = num*20;
            long checked_buckets = 0;

            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;

                    /* Scan the current bucket of the current table. */
                    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;

            /* Update the average TTL stats for this database. */
            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;
                }
            }
            /* We don't repeat the cycle for the current database if there are
             * an acceptable amount of stale keys (logically expired but yet
             * not reclained). */
        } while ((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);
}

說實話這個程式碼細節比較多,由於筆者對Redis原始碼瞭解不多,只能做個模糊版本的解讀,所以難免有問題,還是建議有條件的讀者自行前往原始碼區閱讀,拋磚引玉看下筆者的模糊版本:

  • 該演算法是個自適應的過程,當過期的key比較少時那麼就花費很少的cpu時間來處理,如果過期的key很多就採用激進的方式來處理,避免大量的記憶體消耗,可以理解為判斷過期鍵多就多跑幾次,少則少跑幾次;
  • 由於Redis中有很多資料庫db,該演算法會逐個掃描,本次結束時繼續向後面的db掃描,是個閉環的過程;
  • 定期刪除有快速迴圈和慢速迴圈兩種模式,主要採用慢速迴圈模式,其迴圈頻率主要取決於server.hz,通常設定為10,也就是每秒執行10次慢迴圈定期刪除,執行過程中如果耗時超過25%的CPU時間就停止;
  • 慢速迴圈的執行時間相對較長,會出現超時問題,快速迴圈模式的執行時間不超過1ms,也就是執行時間更短,但是執行的次數更多,在執行過程中發現某個db中抽樣的key中過期key佔比低於25%則跳過;

主體意思:定期刪除是個自適應的閉環並且概率化的抽樣掃描過程,過程中都有執行時間和cpu時間的限制,如果觸發閾值就停止,可以說是儘量在不影響對客戶端的響應下潤物細無聲地進行的。

1.3.5 DEL刪除鍵值對

在Redis4.0之前執行del操作時如果key-value很大,那麼可能導致阻塞,在新版本中引入了BIO執行緒以及一些新的命令,實現了del的延時懶刪除,最後會有BIO執行緒來實現記憶體的清理回收。

之前寫過一篇4.0版本的LazyFree相關的文章,可以看下淺析Redis 4.0新特性之LazyFree

1.4 記憶體淘汰機制

為了保證Redis的安全穩定執行,設定了一個max-memory的閾值,那麼當記憶體用量到達閾值,新寫入的鍵值對無法寫入,此時就需要記憶體淘汰機制,在Redis的配置中有幾種淘汰策略可以選擇,詳細如下:

  • noeviction: 當記憶體不足以容納新寫入資料時,新寫入操作會報錯;
  • allkeys-lru:當記憶體不足以容納新寫入資料時,在鍵空間中移除最近最少使用的 key;
  • allkeys-random:當記憶體不足以容納新寫入資料時,在鍵空間中隨機移除某個 key;
  • volatile-lru:當記憶體不足以容納新寫入資料時,在設定了過期時間的鍵空間中,移除最近最少使用的 key;
  • volatile-random:當記憶體不足以容納新寫入資料時,在設定了過期時間的鍵空間中,隨機移除某個 key;
  • volatile-ttl:當記憶體不足以容納新寫入資料時,在設定了過期時間的鍵空間中,有更早過期時間的 key 優先移除;

後三種策略都是針對過期字典的處理,但是在過期字典為空時會noeviction一樣返回寫入失敗,毫無策略地隨機刪除也不太可取,所以一般選擇第二種allkeys-lru基於LRU策略進行淘汰。

個人認為antirez一向都是工程化思維,善於使用概率化設計來做近似實現,LRU演算法也不例外,Redis中實現了近似LRU演算法,並且經過幾個版本的迭代效果已經比較接近理論LRU演算法的效果了,這個也是個不錯的內容,由於篇幅限制,本文計劃後續單獨講LRU演算法時再進行詳細討論。

1.5 過期鍵刪除和記憶體淘汰的關係

過期健刪除策略強調的是對過期健的操作,如果有健過期而記憶體足夠,Redis不會使用記憶體淘汰機制來騰退空間,這時會優先使用過期健刪除策略刪除過期健。

記憶體淘汰機制強調的是對記憶體資料的淘汰操作,當記憶體不足時,即使有的健沒有到達過期時間或者根本沒有設定過期也要根據一定的策略來刪除一部分,騰退空間保證新資料的寫入。

 

Q2:講講你對Redis持久化機制的理解。

個人認為Redis持久化既是資料庫本身的亮點,也是面試的熱點,主要考察的方向包括:RDB機制原理、AOF機制原理、各自的優缺點、工程上的對於RDB和AOF的取捨、新版本Redis混合持久化策略等,如能把握要點,持久化問題就過關了。

之前寫過一篇持久化的文章:理解Redis持久化,基本上也涵蓋了上面的幾個點,可以看一下。

巨人的肩膀

https://www.hoohack.me/2019/06/24/redis-expire-strategy

https://redisbook.readthedocs.io/en/latest/internal/db.