Redis Rehash機制的探索和實踐(京東Redis 集羣)

背景

京東內部Redis 集羣平臺是京東技術團隊基於Redis Cluster打造的緩存系統。經過不斷的迭代研發,目前已形成一整套自動化運維體系:涵蓋一鍵運維集羣、細粒度的監控、支持自動擴縮容以及熱點Key監控等完整的解決方案。同時服務端通過Docker進行部署,最大程度的提高運維的靈活性。分佈式緩存Redis 緩存產品自2014年上線至今,已在京東內部廣泛使用,存儲容量超過80T,日均調用量也超過萬億次,逐步成爲京東目前最主要的緩存系統之一。

隨着使用的量和場景不斷深入,Redis團隊也不斷髮現Redis的若干"坑"和不足,因此也在持續的改進Redis以支撐內部快速發展的業務需求。本文嘗試分享在運維過程中踩過的Redis Rehash機制的一些坑以及我們的解決方案,其中在高負載情況下物理機發生丟包的現象和解決方案已經寫成博客。感興趣的同學可以參考:Redis 高負載下的中斷優化

案例

Redis 滿容狀態下由於Rehash導致大量Key驅逐

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/evicted_keys.png

我們先來看一張監控圖(上圖,我們線上真實案例),Redis在滿容有驅逐策略的情況下,Master/Slave 均有大量的Key驅逐淘汰,導致Master/Slave 主從不一致。

Root Cause 定位

由於Slave內存區域比Master少一個repl-backlog buffer(線上一般配置爲128M),正常情況下Master到達滿容後根據驅逐策略淘汰Key並同步給Slave。所以Slave這種情況下不會因滿容觸發驅逐。

按照以往經驗,排查思路主要聚焦在造成Slave內存陡增的問題上,包括客戶端連接、輸入/輸出緩衝區、業務數據存取訪問、網路抖動等導致Redis內存陡增的所有外部因素,通過Redis監控和業務鏈路監控均沒有定位成功。

於是,通過梳理Redis源碼,我們嘗試將目光投向了Redis會佔用內存開銷的一個重要機制——Redis Rehash。

Redis Rehash 內部實現

在Redis中,鍵值對(Key-Value Pair)存儲方式是由字典(Dict)保存的,而字典底層是通過哈希表來實現的。通過哈希表中的節點保存字典中的鍵值對。類似Java中的HashMap,將Key通過哈希函數映射到哈希表節點位置。

接下來我們一步步來分析Redis Dict Reash的機制和過程。

(1) Redis 哈希表結構體:

/* hash表結構定義 */

typedef struct dictht {

    dictEntry **table;   // 哈希表數組

    unsigned long size;  // 哈希表的大小

    unsigned long sizemask; // 哈希表大小掩碼

    unsigned long used;  // 哈希表現有節點的數量

} dictht;

實體化一下,如下圖所指一個大小爲4的空哈希表(Redis默認初始化值爲4):
https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/dictht.png

(2) Redis 哈希桶
Redis 哈希表中的table數組存放着哈希桶結構(dictEntry),裏面就是Redis的鍵值對;類似Java實現的HashMap,Redis的dictEntry也是通過鏈表(next指針)方式來解決hash衝突:

/* 哈希桶 */

typedef struct dictEntry {

    void *key;     // 鍵定義

    // 值定義

    union {

        void *val;    // 自定義類型

        uint64_t u64; // 無符號整形

        int64_t s64;  // 有符號整形

        double d;     // 浮點型

    } v;    

    struct dictEntry *next;  //指向下一個哈希表節點

} dictEntry;

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/dictentry.png

(3) 字典
Redis Dict 中定義了兩張哈希表,是爲了後續字典的擴展作Rehash之用:

/* 字典結構定義 */

typedef struct dict {

    dictType *type;  // 字典類型

    void *privdata;  // 私有數據

    dictht ht[2];    // 哈希表[兩個]

    long rehashidx;   // 記錄rehash 進度的標誌,值爲-1表示rehash未進行

    int iterators;   //  當前正在迭代的迭代器數

} dict;

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/dict.png

總結一下:

  • 在Cluster模式下,一個Redis實例對應一個RedisDB(db0);
  • 一個RedisDB對應一個Dict;
  • 一個Dict對應2個Dictht,正常情況只用到ht[0];ht[1] 在Rehash時使用。

如上,我們回顧了一下Redis KV存儲的實現。(Redis內部還有其他結構體,由於跟Rehash不涉及,這裏不再贅述)

我們知道當HashMap中由於Hash衝突(負載因子)超過某個閾值時,出於鏈表性能的考慮,會進行Resize的操作。Redis也一樣【Redis中通過dictExpand()實現】,很多好的設計都相互借鑑和參考。我們看一下Redis中的實現方式:

/* 根據相關觸發條件擴展字典 */

static int _dictExpandIfNeeded(dict *d)

{

    if (dictIsRehashing(d)) return DICT_OK;  // 如果正在進行Rehash,則直接返回

    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);  // 如果ht[0]字典爲空,則創建並初始化ht[0] 

    /* (ht[0].used/ht[0].size)>=1前提下,

       當滿足dict_can_resize=1ht[0].used/t[0].size>5時,便對字典進行擴展 */

    if (d->ht[0].used >= d->ht[0].size &&

        (dict_can_resize ||

         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))

    {

        return dictExpand(d, d->ht[0].used*2);   // 擴展字典爲原來的2

    }

    return DICT_OK;

}

 

 

...

 

/* 計算存儲Keybucket的位置 */

static int _dictKeyIndex(dict *d, const void *key)

{

    unsigned int h, idx, table;

    dictEntry *he;

 

    /* 檢查是否需要擴展哈希表,不足則擴展 */

    if (_dictExpandIfNeeded(d) == DICT_ERR) 

        return -1;

    /* 計算Key的哈希值 */

    h = dictHashKey(d, key);

    for (table = 0; table <= 1; table++) {

        idx = h & d->ht[table].sizemask;  //計算Keybucket位置

        /* 檢查節點上是否存在新增的Key */

        he = d->ht[table].table[idx];

        /* 在節點鏈表檢查 */

        while(he) {

            if (key==he->key || dictCompareKeys(d, key, he->key))

                return -1;

            he = he->next;

        }

        if (!dictIsRehashing(d)) break;  // 掃完ht[0]後,如果哈希表不在rehashing,則無需再掃ht[1]

    }

    return idx;

}

 

...

 

/* Key插入哈希表 */

dictEntry *dictAddRaw(dict *d, void *key)

{

    int index;

    dictEntry *entry;

    dictht *ht;

 

    if (dictIsRehashing(d)) _dictRehashStep(d);  // 如果哈希表在rehashing,則執行單步rehash

 

    /* 調用_dictKeyIndex() 檢查鍵是否存在,如果存在則返回NULL */

    if ((index = _dictKeyIndex(d, key)) == -1)

        return NULL;

 

 

    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];

    entry = zmalloc(sizeof(*entry));   // 爲新增的節點分配內存

    entry->next = ht->table[index];  //  將節點插入鏈表表頭

    ht->table[index] = entry;   // 更新節點和桶信息

    ht->used++;    //  更新ht

 

    /* 設置新節點的鍵 */

    dictSetKey(d, entry, key);

    return entry;

}

 

...

/* 添加新鍵值對 */

int dictAdd(dict *d, void *key, void *val)

{

    dictEntry *entry = dictAddRaw(d,key);  // 添加新鍵

 

    if (!entry) return DICT_ERR;  // 如果鍵存在,則返回失敗

    dictSetVal(d, entry, val);   // 鍵不存在,則設置節點值

    return DICT_OK;

}

繼續dictExpand的源碼實現:

int dictExpand(dict *d, unsigned long size)

{

    dictht n; // 新哈希表

    unsigned long realsize = _dictNextPower(size);  // 計算擴展或縮放新哈希表的大小(調用下面函數_dictNextPower())

 

    /* 如果正在rehash或者新哈希表的大小小於現已使用,則返回error */

    if (dictIsRehashing(d) || d->ht[0].used > size)

        return DICT_ERR;

 

    /* 如果計算出哈希表size與現哈希表大小一樣,也返回error */

    if (realsize == d->ht[0].size) return DICT_ERR;

 

    /* 初始化新哈希表 */

    n.size = realsize;

    n.sizemask = realsize-1;

    n.table = zcalloc(realsize*sizeof(dictEntry*));  // table指向dictEntry 分配內存

    n.used = 0;

 

    /* 如果ht[0] 爲空,則初始化ht[0]爲當前鍵值對的哈希表 */

    if (d->ht[0].table == NULL) {

        d->ht[0] = n;

        return DICT_OK;

    }

 

    /* 如果ht[0]不爲空,則初始化ht[1]爲當前鍵值對的哈希表,並開啓漸進式rehash模式 */

    d->ht[1] = n;

    d->rehashidx = 0;

    return DICT_OK;

}

...

static unsigned long _dictNextPower(unsigned long size) {

    unsigned long i = DICT_HT_INITIAL_SIZE;  // 哈希表的初始值:4

 

 

    if (size >= LONG_MAX) return LONG_MAX;

    /* 計算新哈希表的大小:第一個大於等於size2N 次方的數值 */

    while(1) {

        if (i >= size)

            return i;

        i *= 2;

    }

}

總結一下具體邏輯實現:

v |= ~m1;
            v = rev(v);
            v++;
            v = rev(v);

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/dictexpand.png

可以確認當Redis Hash衝突到達某個條件時就會觸發dictExpand()函數來擴展HashTable。

DICT_HT_INITIAL_SIZE初始化值爲4,通過上述表達式,取當4*2^n >= ht[0].used*2的值作爲字典擴展的size大小。即爲:ht[1].size 的值等於第一個大於等於ht[0].used*2的2^n的數值。

Redis通過dictCreate()創建詞典,在初始化中,table指針爲Null,所以兩個哈希表ht[0].table和ht[1].table都未真正分配內存空間。只有在dictExpand()字典擴展時纔給table分配指向dictEntry的內存。

由上可知,當Redis觸發Resize後,就會動態分配一塊內存,最終由ht[1].table指向,動態分配的內存大小爲:realsize*sizeof(dictEntry*),table指向dictEntry*的一個指針,大小爲8bytes(64位OS),即ht[1].table需分配的內存大小爲:8*2*2^n (n大於等於2)。

梳理一下哈希表大小和內存申請大小的對應關係:

ht[0].size

觸發Resize時,ht[1]需分配的內存

4

64bytes

8

128bytes

16

256bytes

...

...

65536

1024K

...

...

8388608

128M

16777216

256M

33554432

512M

67108864

1024M

...

...

復現驗證

我們通過測試環境數據來驗證一下,當Redis Rehash過程中,內存真正的佔用情況。

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/rehashkeys.png

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/rehashsize.png

上述兩幅圖中,Redis Key個數突破Redis Resize的臨界點,當Key總數穩定且Rehash完成後,Redis內存(Slave)從3586M降至爲3522M:3586-3522=64M。即驗證上述Redis在Resize至完成的中間狀態,會維持一段時間內存消耗,且佔用內存的值爲上文列表相應的內存空間。

進一步觀察一下Redis內部統計信息:

/* Redis節點800萬左右Key時候的Dict狀態信息:只有ht[0]信息。*/

"[Dictionary HT]

Hash table 0 stats (main hash table):

 table size: 8388608

 number of elements: 8003582

 different slots: 5156314

 max chain length: 9

 avg chain length (counted): 1.55

 avg chain length (computed): 1.55

 Chain length distribution:

   0: 3232294 (38.53%)

   1: 3080243 (36.72%)

   2: 1471920 (17.55%)

   3: 466676 (5.56%)

   4: 112320 (1.34%)

   5: 21301 (0.25%)

   6: 3361 (0.04%)

   7: 427 (0.01%)

   8: 63 (0.00%)

   9: 3 (0.00%)

"

 

/* Redis節點840萬左右Key時候的Dict狀態信息正在Rehasing中,包含了ht[0]ht[1]信息。*/

"[Dictionary HT]

[Dictionary HT]

Hash table 0 stats (main hash table):

 table size: 8388608

 number of elements: 8019739

 different slots: 5067892

 max chain length: 9

 avg chain length (counted): 1.58

 avg chain length (computed): 1.58

 Chain length distribution:

   0: 3320716 (39.59%)

   1: 2948053 (35.14%)

   2: 1475756 (17.59%)

   3: 491069 (5.85%)

   4: 123594 (1.47%)

   5: 24650 (0.29%)

   6: 4135 (0.05%)

   7: 553 (0.01%)

   8: 78 (0.00%)

   9: 4 (0.00%)

Hash table 1 stats (rehashing target):

 table size: 16777216

 number of elements: 384321

 different slots: 305472

 max chain length: 6

 avg chain length (counted): 1.26

 avg chain length (computed): 1.26

 Chain length distribution:

   0: 16471744 (98.18%)

   1: 238752 (1.42%)

   2: 56041 (0.33%)

   3: 9378 (0.06%)

   4: 1167 (0.01%)

   5: 119 (0.00%)

   6: 15 (0.00%)

"

 

/* Redis節點840萬左右Key時候的Dict狀態信息(Rehash完成後);ht[0].size8388608擴展到了16777216*/

"[Dictionary HT]

Hash table 0 stats (main hash table):

 table size: 16777216

 number of elements: 8404060

 different slots: 6609691

 max chain length: 7

 avg chain length (counted): 1.27

 avg chain length (computed): 1.27

 Chain length distribution:

   0: 10167525 (60.60%)

   1: 5091002 (30.34%)

   2: 1275938 (7.61%)

   3: 213024 (1.27%)

   4: 26812 (0.16%)

   5: 2653 (0.02%)

   6: 237 (0.00%)

   7: 25 (0.00%)

"

經過Redis Rehash內部機制的深入、Redis狀態監控和Redis內部統計信息,我們可以得出結論:
當Redis 節點中的Key總量到達臨界點後,Redis就會觸發Dict的擴展,進行Rehash。申請擴展後相應的內存空間大小。

如上,Redis在滿容驅逐狀態下,Redis Rehash是導致Redis Master和Slave大量觸發驅逐淘汰的根本原因。

除了導致滿容驅逐淘汰,Redis Rehash還會引起其他一些問題:

  • 在tablesize級別與現有Keys數量不在同一個區間內,主從切換後,由於Redis全量同步,從庫tablesize降爲與現有Key匹配值,導致內存傾斜;
  • Redis Cluster下的某個分片由於Key數量相對較多提前Resize,導致集羣分片內存不均。
    等等...

Redis Rehash機制優化

那麼針對在Redis滿容驅逐狀態下,如何避免因Rehash而導致Redis抖動的這種問題。

  • 我們在Redis Rehash源碼實現的邏輯上,加上了一個判斷條件,如果現有的剩餘內存不夠觸發Rehash操作所需申請的內存大小,即不進行Resize操作;
  • 通過提前運營進行規避,比如容量預估時將Rehash佔用的內存考慮在內,或者通過監控定時擴容。

Redis Rehash機制除了會影響上述內存管理和使用外,也會影響Redis其他內部與之相關聯的功能模塊。下面我們分享一下由於Rehash機制而踩到的第二個坑。

Redis使用Scan清理Key由於Rehash導致清理數據不徹底

Redis 平臺提供給業務清理Key的API後臺邏輯,是通過Scan來實現的。實際線上運行效果並不是每次都能完全清理乾淨。即通過Scan掃描清理相匹配的Key,較低頻率會有遺漏、Key未被全部清理掉的現象。有了前幾次的相關經驗後,我們直接從原理入手。

Scan原理

爲了高效地匹配出數據庫中所有符合給定模式的Key,Redis提供了Scan命令。該命令會在每次調用的時候返回符合規則的部分Key以及一個遊標值Cursor(初始值使用0),使用每次返回Cursor不斷迭代,直到Cursor的返回值爲0代表遍歷結束。

Redis官方定義Scan特點如下:

  1. 整個遍歷從開始到結束期間, 一直存在於Redis數據集內的且符合匹配模式的所有Key都會被返回;
  2. 如果發生了rehash,同一個元素可能會被返回多次,遍歷過程中新增或者刪除的Key可能會被返回,也可能不會。

具體實現

上述提及Redis的Keys是以Dict方式來存儲的,正常只要一次遍歷Dict中所有Hash桶就可以完整掃描出所有Key。但是在實際使用中,Redis Dict是有狀態的,會隨着Key的增刪不斷變化。

接下來根據Dict四種狀態來分析一下Scan的不同實現。

Dict的四種狀態場景:

  1. 字典tablesize保持不變,沒有擴縮容;
  2. 字典Resize,Dict擴大了(完成狀態);
  3. 字典Resize,Dict縮小了(完成狀態);
  4. 字典正在Rehashing(擴展或收縮)。

(1) 字典tablesize保持不變,在Redis Dict穩定的狀態下,直接順序遍歷即可;
(2) 字典Resize,Dict擴大了,如果還是按照順序遍歷,就會導致掃描大量重複Key。比如字典tablesize從8變成了16,假設之前訪問的是3號桶,那麼表擴展後則是繼續訪問4~15號桶;但是,原先的0~3號桶中的數據在Dict長度變大後被遷移到8~11號桶中,因此,遍歷8~11號桶的時候會有大量的重複Key被返回;
(3) 字典Resize,Dict縮小了,如果還是按照順序遍歷,就會導致大量的Key被遺漏。比如字典tablesize從8變成了4,假設當前訪問的是3號桶,那麼下一次則會直接返回遍歷結束了;但是之前4~7號桶中的數據在縮容後遷移帶可0~3號桶中,因此這部分Key就無法掃描到;
(4) 字典正在Rehashing,這種情況如(2)和(3)情況一下,要麼大量重複掃描、要麼遺漏很多Key。

那麼在Dict非穩定狀態,即發生Rehash的情況下,Scan要如何保證原有的Key都能遍歷出來,又盡少可能重複掃描呢?Redis Scan通過Hash桶掩碼的高位順序訪問來解決。

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/scan3.png

高位順序訪問即按照Dict sizemask(掩碼),在有效位(上圖中Dict sizemask爲3)上從高位開始加一枚舉;低位則按照有效位的低位逐步加一訪問。
低位序:0→1→2→3→4→5→6→7
高位序:0→4→2→6→1→5→3→7

Scan採用高位序訪問的原因,就是爲了實現Redis Dict在Rehash時儘可能少重複掃描返回Key。

舉個例子,如果Dict的tablesize從8擴展到了16,梳理一下Scan掃描方式:

  1. Dict(8) 從Cursor 0開始掃描;
  2. 準備掃描Cursor 6時發生Resize,擴展爲之前的2倍,並完成Rehash;
  3. 客戶端這時開始從Dict(16)的Cursor 6繼續迭代;
  4. 這時按照 6→14→1→9→5→13→3→11→7→15 Scan完成。

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/scan4.png

可以看出,高位序Scan在Dict Rehash時即可以避免重複遍歷,又能完整返回原始的所有Key。同理,字典縮容時也一樣,字典縮容可以看出是反向擴容。

上述是Scan的理論基礎,我們看一下Redis源碼如何實現。

(1) 非Rehashing 狀態下的實現:

 if (!dictIsRehashing(d)) {     // 判斷是否正在rehashing,如果不在則只有ht[0]

        t0 = &(d->ht[0]);  // ht[0]

        m0 = t0->sizemask;  // 掩碼

 

        /* Emit entries at cursor */

        de = t0->table[v & m0];  // 目標桶

        while (de) {          

            fn(privdata, de);

            de = de->next;       // 遍歷桶中所有節點,並通過回調函數fn()返回

        }

     ...

      /* 反向二進制迭代算法具體實現邏輯——遊標實現的精髓 */

     /* Set unmasked bits so incrementing the reversed cursor

     * operates on the masked bits of the smaller table */

    v |= ~m0;

 

    /* Increment the reverse cursor */

    v = rev(v);

    v++;

    v = rev(v);

 

    return v;

}

源碼中Redis將Cursor的計算通過Reverse Binary Iteration(反向二進制迭代算法)來實現上述的高位序掃描方式。

(2) Rehashing 狀態下的實現:

...

  else {    // 否則說明正在rehashing,就存在兩個哈希表ht[0]ht[1]

        t0 = &d->ht[0];

        t1 = &d->ht[1];  // 指向兩個哈希表

 

        /* Make sure t0 is the smaller and t1 is the bigger table */

        if (t0->size > t1->size) {  確保t0小於t1

            t0 = &d->ht[1];

            t1 = &d->ht[0]; 

        }

 

        m0 = t0->sizemask;

        m1 = t1->sizemask;  // 相對應的掩碼

 

        /* Emit entries at cursor */

        /* 迭代(小表)t0桶中的所有節點 */

        de = t0->table[v & m0];

        while (de) {  

            fn(privdata, de);

            de = de->next;

        }

 

        /* Iterate over indices in larger table that are the expansion

         * of the index pointed to by the cursor in the smaller table */

        /* */

 

        do {

            /* Emit entries at cursor */

            /* 迭代(大表)t1 中所有節點,循環迭代,會把小表沒有覆蓋的slot全部掃描一遍 */

            de = t1->table[v & m1];

            while (de) {

                fn(privdata, de);

                de = de->next;

            }

 

            /* Increment bits not covered by the smaller mask */

            v = (((v | m0) + 1) & ~m0) | (v & m0);

 

            /* Continue while bits covered by mask difference is non-zero */

        } while (v & (m0 ^ m1));

    }

 

    /* Set unmasked bits so incrementing the reversed cursor

     * operates on the masked bits of the smaller table */

    v |= ~m0;

 

    /* Increment the reverse cursor */

    v = rev(v);

    v++;

    v = rev(v);

 

    return v;

如上Rehashing時,Redis 通過else分支實現該過程中對兩張Hash表進行掃描訪問。

梳理一下邏輯流程:

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/dictscan.png

Redis在處理dictScan()時,上面細分的四個場景的實現分成了兩個邏輯:

  1. 此時不在Rehashing的狀態:
    這種狀態,即Dict是靜止的。針對這種狀態下的上述三種場景,Redis採用上述的Reverse Binary Iteration(反向二進制迭代算法):
    Ⅰ. 首先對遊標(Cursor)二進制位翻轉;
    Ⅱ. 再對翻轉後的值加1;
    Ⅲ. 最後再次對Ⅱ的結果進行翻轉。

通過窮舉高位,依次向低位推進的方式(即高位序訪問的實現)來確保所有元素都會被遍歷到。

這種算法已經儘可能減少重複元素的返回,但是實際實現和邏輯中還是會有可能存在重複返回,比如在Dict縮容時,高位合併到低位桶中,低位桶中的元素就會被重複取出。

  1. 正在Rehashing的狀態:
    Redis在Rehashing狀態的時候,dictScan()實現通過一次性掃描現有的兩種字典表,避免中間狀態無法維護。
    具體實現就是在遍歷完小表Cursor位置後,將小表Cursor位置可能Rehash到的大表所有位置全部遍歷一遍,然後再返回遍歷元素和下一個小表遍歷位置。

Root Cause 定位

Rehashing狀態時,遊標迭代主要邏輯代碼實現:

             /* Increment bits not covered by the smaller mask */

            v = (((v | m0) + 1) & ~m0) | (v & m0);   //BUG

Ⅰ. v低位加1向高位進位;
Ⅱ. 去掉v最前面和最後面的部分,只保留v相較於m0的高位部分;
Ⅲ. 保留v的低位,高位不斷加1。即低位不變,高位不斷加1,實現了小表到大表桶的關聯。

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/scan5.png

舉個例子,如果Dict的tablesize從8擴展到了32,梳理一下Scan掃描方式:

  1. Dict(8) 從Cursor 0開始掃描;
  2. 準備掃描Cursor 4時發生Resize,擴展爲之前的4倍,Rehashing;
  3. 客戶端先訪問Dict(8)中的4號桶;
  4. 然後再到Dict(32)上訪問:4→12→20→28。

這裏可以看到大表的相關桶的順序並非是按照之前所述的二進制高位序,實際上是按照低位序來遍歷大表中高出小表的有效位。

大表t1高位都是向低位加1計算得出的,掃描的順序卻是從低位加1,向高位進位。Redis針對Rehashing時這種邏輯實現在擴容時是可以運行正常的,但是在縮容時高位序和低位序的遍歷在大小表上的混用在一定條件下會出現問題。

https://tech.meituan.com/img/Redis_Rehash_Practice_Optimization/scan6.png

再次示例,Dict的tablesize從32縮容到8:

  1. Dict(32) 從Cursor 0開始掃描;
  2. 準備掃描Cursor 20時發生Resize,縮容至原來的四分之一即tablesize爲8,Rehashing;
  3. 客戶端發起Cursor 20,首先訪問Dict(8)中的4號桶;
  4. 再到Dict(32)上訪問:20→28;
  5. 最後返回Cursor = 2。

可以看出大表中的12號桶沒有被訪問到,即遍歷大表時,按照低位序訪問會遺漏對某些桶的訪問。

上述這種情況發生需要具備一定的條件:

  1. 在Dict縮容Rehash時Scan;
  2. Dict縮容至至少原Dict tablesize的四分之一,只有在這種情況下,大表相對小表的有效位纔會高出二位以上,從而觸發跳過某個桶的情況;
  3. 如果在Rehash開始前返回的Cursor是在小表能表示的範圍內(即不超過7),那麼在進行高位有效位的加一操作時,必然都是從0開始計算,每次加一也必然能夠訪問的全所有的相關桶;如果在Rehash開始前返回的cursor不在小表能表示的範圍內(比如20),那麼在進行高位有效位加一操作的時候,就有可能跳過 ,或者重複訪問某些桶的情況。

可見,只有滿足上述三種情況纔會發生Scan遍歷過程中漏掉了一些Key的情況。在執行清理Key的時候,如果清理的Key數量很大,導致了Redis內部的Hash表縮容至少原Dict tablesize的四分之一,就可能存在一些Key被漏掉的風險。

Scan源碼優化

修復邏輯就是全部都從高位開始增加進行遍歷,即大小表都使用高位序訪問,修復源碼如下:

unsigned long dictScan(dict *d,

                       unsigned long v,

                       dictScanFunction *fn,

                       dictScanBucketFunction* bucketfn,

                       void *privdata)

{

    dictht *t0, *t1;

    const dictEntry *de, *next;

    unsigned long m0, m1;

 

    if (dictSize(d) == 0) return 0;

 

    if (!dictIsRehashing(d)) {

        t0 = &(d->ht[0]);

        m0 = t0->sizemask;

 

        /* Emit entries at cursor */

        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);

        de = t0->table[v & m0];

        while (de) {

            next = de->next;

            fn(privdata, de);

            de = next;

        }

 

        /* Set unmasked bits so incrementing the reversed cursor

         * operates on the masked bits */

        v |= ~m0;

 

        /* Increment the reverse cursor */

        v = rev(v);

        v++;

        v = rev(v);

 

    } else {

        t0 = &d->ht[0];

        t1 = &d->ht[1];

 

        /* Make sure t0 is the smaller and t1 is the bigger table */

        if (t0->size > t1->size) {

            t0 = &d->ht[1];

            t1 = &d->ht[0];

        }

 

        m0 = t0->sizemask;

        m1 = t1->sizemask;

 

        /* Emit entries at cursor */

        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);

        de = t0->table[v & m0];

        while (de) {

            next = de->next;

            fn(privdata, de);

            de = next;

        }

 

        /* Iterate over indices in larger table that are the expansion

         * of the index pointed to by the cursor in the smaller table */

        do {

            /* Emit entries at cursor */

            if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);

            de = t1->table[v & m1];

            while (de) {

                next = de->next;

                fn(privdata, de);

                de = next;

            }

 

            /* Increment the reverse cursor not covered by the smaller mask.*/

            v |= ~m1;

            v = rev(v);

            v++;

            v = rev(v);

 

            /* Continue while bits covered by mask difference is non-zero */

        } while (v & (m0 ^ m1));

    }

 

    return v;

}

至此,基於Redis Rehash以及Scan實現中涉及Rehash的兩個機制已經基本瞭解和優化完成。

總結

本文主要闡述了因Redis的Rehash機制踩到的兩個坑,從現象到原理進行了詳細的介紹。這裏簡單總結一下,第一個案例會造成線上集羣進行大量淘汰,而且產生主從不一致的情況,在業務層面也會發生大量超時,影響業務可用性,問題嚴重,非常值得大家關注;第二個案例會造成數據清理無法完全清理,但是可以再利用Scan清理一遍也能夠清理完畢。
注:本文中源碼基於Redis 3.2.8。

 

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