從零手寫緩存框架(14)redis漸進式rehash詳解

redis 的 rehash 設計

本文思惟導圖以下:java

HashMap 的 rehash 回顧

讀過 HashMap 源碼的同窗,應該都知道 map 在擴容的時候,有一個 rehash 的過程。git

沒有讀過也沒有關係,能夠花時間閱讀下 從零開始手寫 redis(13) HashMap源碼詳解 簡單瞭解下整個過程便可。github

HashMap 的擴容簡介

這裏簡單介紹下:redis

擴容(resize)就是從新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組沒法裝載更多的元素時,對象就須要擴大數組的長度,以便能裝入更多的元素。數據庫

固然Java裏的數組是沒法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像咱們用一個小桶裝水,若是想裝更多的水,就得換大水桶。數組

redis 中的擴容設計

HashMap 的擴容須要對集合中大部分的元素進行從新計算,可是對於 redis 這種企業級應用,特別是單線程的應用,若是像傳統的 rehash 同樣把全部元素來一遍的話,估計要十幾秒的時間。安全

十幾秒對於常見的金融、電商等相對高併發的業務場景,是沒法忍受的。服務器

那麼 redis 的 rehash 是如何實現的呢?數據結構

實際上 redis 的 rehash 動做並非一次性、集中式地完成的, 而是分屢次、漸進式地完成的併發

這裏補充一點,不僅僅是擴容,縮容也是同樣的道理,兩者都須要進行 rehash。

只增不降就是對內存的浪費,浪費就是犯罪,特別是內存還這麼貴。

ps: 這種思想和 key 淘汰有殊途同歸之妙,一口吃不了一個大胖子,一次搞不定,那就 1024 次,慢慢來總能解決問題。

Redis 的漸進式 rehash

這部分直接選自經典入門書籍《Redis 設計與實現》

爲何要漸進式處理?

實際上 redis 內部有兩個 hashtable,咱們稱之爲 ht[0] 和 ht[1]。傳統的 HashMap 中只有一個。

爲了不 rehash 對服務器性能形成影響, 服務器不是一次性將 ht[0] 裏面的全部鍵值對所有 rehash 到 ht[1] , 而是分屢次、漸進式地將 ht[0] 裏面的鍵值對慢慢地 rehash 到 ht[1] 。

詳細步驟

哈希表漸進式 rehash 的詳細步驟:

(1)爲 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。

(2)在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 , 表示 rehash 工做正式開始。

(3)在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操做時, 程序除了執行指定的操做之外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的全部鍵值對 rehash 到 ht[1] , 當 rehash 工做完成以後, 程序將 rehashidx 屬性的值增1。

(4)隨着字典操做的不斷執行, 最終在某個時間點上, ht[0] 的全部鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設爲 -1 , 表示 rehash 操做已完成。

漸進式 rehash 的好處在於它採起分而治之的方式, 將 rehash 鍵值對所需的計算工做均灘到對字典的每一個添加、刪除、查找和更新操做上, 從而避免了集中式 rehash 而帶來的龐大計算量。

rehash 間的操做怎麼兼容呢?

由於在進行漸進式 rehash 的過程當中, 字典會同時使用 ht[0] 和 ht[1] 兩個哈希表, 那這期間的操做如何保證正常進行呢?

(1)查詢一個信息

這個相似於咱們的數據庫信息等遷移,先查詢一個庫,沒有的話,再去查詢另外一個庫。

ht[0] 中沒找到,咱們去 ht[1] 中查詢便可。

(2)新數據怎麼辦?

這個和數據遷移同樣的道理。

當咱們有新舊的兩個系統時,新來的用戶等信息直接落在新系統便可,

這一措施保證了 ht[0] 包含的鍵值對數量會只減不增, 並隨着 rehash 操做的執行而最終變成空表。

一圖勝千言

咱們來看圖:

(1)準備 rehash

輸入圖片說明

(2)rehash index=0

輸入圖片說明

(3)rehash index=1

輸入圖片說明

(4)rehash index=2

輸入圖片說明

(5)rehash index=3

輸入圖片說明

(6)rehash 完成

輸入圖片說明

縮容擴容的思考

看完了上面的流程,不知道你對 rehash 是否有一個大概了思路呢?

下面讓咱們來一塊兒思考下幾個縮擴容的問題。

輸入圖片說明

何時擴容呢?

何時判斷?

redis 在每次執行 put 操做的時候,就能夠檢查是否須要擴容。

其實也很好理解,put 插入元素的時候,判斷是否須要擴容,而後開始擴容,是直接的一種思路。

留一個思考題:咱們能夠在其餘的時候判斷嗎?

redis 判斷是否須要擴容的源碼

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    // 若是正在進行漸進式擴容,則返回OK
    if (dictIsRehashing(d)) return DICT_OK;

    /* If the hash table is empty expand it to the initial size. */
    // 若是哈希表ht[0]的大小爲0,則初始化字典
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    /*
     * 若是哈希表ht[0]中保存的key個數與哈希表大小的比例已經達到1:1,即保存的節點數已經大於哈希表大小
     * 且redis服務當前容許執行rehash,或者保存的節點數與哈希表大小的比例超過了安全閾值(默認值爲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);
    }
    return DICT_OK;
}

擴容的條件總結下來就是兩句話:

(1)服務器目前沒有在執行 BGSAVE/BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 1;

(2)服務器目前正在執行 BGSAVE/BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 5;

這裏其實體現了做者的一種設計思想:若是負載因子超過5,說明信息已經不少了,管你在不在保存,都要執行擴容,優先保證服務可用性。若是沒那麼高,那就等持久化完成再作 rehash。

咱們本身在實現的時候能夠簡化一下,好比只考慮狀況2。

擴容到原來的多少?

知道了何時應該開始擴容,可是要擴容到多大也是值得思考的一個問題。

擴容的過小,會致使頻繁擴容,浪費性能。

擴容的太大,會致使資源的浪費。

其實這個最好的方案是結合咱們實際的業務,不過這部分對用戶是透明的。

通常是擴容爲原來的兩倍。

爲何須要擴容?

咱們在實現 ArrayList 的時候須要擴容,由於數據放不下了。

咱們知道 HashMap 的底層是數組 + 鏈表(紅黑樹)的數據結構。

那麼會存在放不下的狀況嗎?

我的理解實際上不會。由於鏈表能夠一直加下去。

那爲何須要擴容呢?

實際上更多的是處於性能的考慮。咱們使用 HashMap 就是爲了提高性能,若是一直不擴容,能夠理解爲元素都 hash 到相同的 bucket 上,這時就退化成了一個鏈表。

這會致使查詢等操做性能大大下降。

何時縮容呢?

什麼時候判斷

看了前面的擴容,咱們比較直觀地方式是在用戶 remove 元素的時候執行是否須要縮容。

不過 redis 並不徹底等同於傳統的 HashMap,還有數據的淘汰和過時,這些是對用戶透明的。

redis 採用的方式其實是一個定時任務。

我的理解內存縮容很重要,可是沒有那麼緊急,咱們能夠 1min 掃描一次,這樣能夠節省機器資源。

實際工做中,通常 redis 的內存都是逐步上升的,或者穩定在一個範圍內,不多去大批量刪除數據。(除非數據搞錯了,我就遇到過一次,數據同步錯地方了)。

因此數據刪除,通常幾分鐘內給用戶一個反饋就行。

知其然,知其因此然。

咱們懂得了這個道理也就懂得了爲何有時候刪除 redis 的幾百萬 keys,內存也不是直接降下來的緣由。

縮容的條件

/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL
 * we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {
    if (htNeedsResize(server.db[dbid].dict))
        dictResize(server.db[dbid].dict);
    if (htNeedsResize(server.db[dbid].expires))
        dictResize(server.db[dbid].expires);
}

/* Hash table parameters */
#define HASHTABLE_MIN_FILL        10      /* Minimal hash table fill 10% */
int htNeedsResize(dict *dict) {
    long long size, used;

    size = dictSlots(dict);
    used = dictSize(dict);
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}

/* Resize the table to the minimal size that contains all the elements,
 * but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
    int minimal;

    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal);
}

和擴容相似,不過這裏的縮容比例不是 5 倍,而是當哈希表保存的key數量與哈希表的大小的比例小於 10% 時須要縮容。

縮容到多少?

最簡單的方式是直接變爲原來的一半,不過這麼作有時候也不是那麼好用。

redis 是縮容後的大小爲第一個大於等於當前key數量的2的n次方。

這個可能不太好理解,舉幾個數字就懂了:

keys數量 縮容大小
3 4
4 4
5 8
9 16

主要保障如下3點:

(1)縮容以後,要大於等於 key 的數量

(2)儘量的小,節約內存

(3)2 的倍數。

第三個看過 HashMap 源碼講解的小夥伴應該深有體會。

固然也不能過小,redis 限制的最小爲 4。

實際上若是 redis 中只放 4 個 key,實在是殺雞用牛刀,通常不會這麼小。

咱們在實現的時候,直接參考 jdk 好了,給個最小值限制 8。

爲何須要縮容?

最核心的目的就是爲了節約內存,其實還有一個緣由,叫 small means fast(小便是快——老馬)。

漸進式 ReHash 實現的思考

好了,擴容和縮容就聊到這裏,那麼這個漸進式 rehash 到底怎麼一個漸進法?

什麼是漸進式

擴容前

不須要擴容時應該有至少須要初始化兩個元素:

hashtable[0] = new HashTable(size);
hashIndex=-1;

hashtable[1] = null;

hashtable 中存儲着當前的元素信息,hashIndex=-1 標識當前沒有在進行擴容。

擴容準備

當須要擴容的時候,咱們再去建立一個 hashtable[1],而且 size 是原來的 2倍。

hashtable[0] = new HashTable(size);

hashtable[1] = new HashTable(2 * size);

hashIndex=-1;

主要是爲了節約內存,使用惰性初始化的方式建立 hashtable。

擴容時

調整 hashIndex=0...size,逐步去 rehash 到新的 hashtable[1]

新的插入所有放入到 hashtable[1]

擴容後

擴容後咱們應該把 hashtable[0] 的值更新爲 hashtable[1],而且釋放掉 hashtable[1] 的資源。

而且設置 hashIndex=-1,標識已經 rehash 完成

hashtable[0] = hashtable[1];
hashIndex=-1;

hashtable[1] = null;

這樣總體的實現思路就已經差很少了,光說不練假把式,咱們下一節就來本身實現一個漸進式 rehash 的 HashMap。

至於如今,先讓 rehash 的思路飛一下子~

6

小結

本節咱們對 redis rehash 的原理進行了講解,其中也加入了很多本身的思考。

文章的結尾,也添加了簡單的實現思路,固然實際實現還會有不少問題須要解決。

下一節咱們將一塊兒手寫一個漸進式 rehash 的 HashMap,感興趣的夥伴能夠關注一波,即便獲取最新動態~

開源地址:https://github.com/houbb/cache

以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波。你的鼓勵,是我最大的動力~

不知道你有哪些收穫呢?或者有其餘更多的想法,歡迎留言區和我一塊兒討論,期待與你的思考相遇。

深刻學習

相關文章
相關標籤/搜索