面試官:說說Redis的Hash底層 我:......(來自閱文的面試題)

redis源碼分析系列文章

String底層實現——動態字符串SDSredis

Redis的雙向鏈表一文全知道數組

前言

hello,各位小可愛們,又見面了。今天這篇文章來自去年面試閱文的面試題,結果被虐了。這一part不說了,下次專門開一篇,寫下我面試被虐的名場面,尷尬的不行,全程尬聊。哈哈哈哈,話很少說,開始把。😂bash



今天要寫Redis的Hash類型,若是有對Redis不熟悉,或者對其餘數據類型感興趣的,能夠移步上面的系列文章。(最上面的最上面最上面,重要的事情說三遍)函數


在Redis中Hash類型的應用很是普遍,其中key到value的映射就經過字典結構來維護的。記筆記,此處要考。源碼分析


API使用

API的使用比較簡單,因此如下就粗略的寫了。post

插入數據hset

使用hset命令往myhash中插入兩個key,value的鍵值對,分別是(name,zhangsan)和(age,20),返回值當前的myhash的長度。ui

獲取數據hget

使用hget命令獲取myhash中key爲name的value值。spa


獲取全部數據hgetall

使用hgetall命令獲取myhash中全部的key和value值。操作系統

獲取全部key

使用hkeys命令獲取myhash中全部的key值。


獲取長度

使用hlen命令獲取myhash的長度。

獲取全部value

使用hvals命令獲取myhash中全部的value值。


具體邏輯圖

hash的底層主要是採用字典dict的結構,總體呈現層層封裝。

首先dict有四個部分組成,分別是dictType(類型,不咋重要),dictht(核心),rehashidx(漸進式hash的標誌),iterators(迭代器),這裏面最重要的就是dictht和rehashidx。

接下來是dictht,其有兩個數組構成,一個是真正的數據存儲位置,還有一個用於hash過程,包括的變量分別是真正的數據table和一些常見變量。

最後數據節點,和上篇說的雙向鏈表同樣,每一個節點都有next指針,方便指向下一個節點,這樣目的是爲了解決hash碰撞。具體的能夠看下圖。

這邊看不懂不要緊,後面會針對每一個模塊詳細說明。(千萬不要看到這裏就跳過啦☺)

雙向鏈表的定義

字典結構體dict

咱們先看字典結構體dict,其包括四個部分,重點是dictht[2](真正的數據)和rehashidx(漸進式hash的標誌)。具體圖以下。


具體代碼以下:

//字典結構體 
 typedef struct dict {
    dictType *type;//類型,包括一些自定義函數,這些函數使得key和value可以存儲 
    void *privdata;//私有數據 
    dictht ht[2];//兩張hash表 
    long rehashidx; //漸進式hash標記,若是爲-1,說明沒在進行hash
    unsigned long iterators; //正在迭代的迭代器數量
} dict;複製代碼

數組結構體dictht

dictht主要包括四個部分,1是真正的數據dictEntry類型的數組,裏面存放的是數據節點;2是數組長度size;3是進行hash運算的參數sizemask,這個不咋重要,只要記住等於size-1;4是數據節點數量used,當前有多少個數據節點。


具體代碼以下:

//hash結構體 
typedef struct dictht {
    dictEntry **table;//真正數據的數組 
    unsigned long size;//數組的大小 
    unsigned long sizemask;//用戶將hash映射到table的位置索引,他的值老是等於size-1 
    unsigned long used;//已用節點數量 
} dictht;複製代碼

數據節點dictEntry

dictEntry爲真正的數據節點,包括key,value和next節點。


//每一個節點的結構體  
typedef struct dictEntry {
    void *key; //key
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;//value
    struct dictEntry *next; //下一個數據節點的地址
} dictEntry;複製代碼


擴容過程和漸進式Hash圖解

咱們先來第一個部分,dictht[2]爲何會要2個數組存放,真正的數據只要一個數組就夠了?


這其實和Java的HashMap類似,都是數據加鏈表的結構,隨着數據量的增長,hash碰撞發生的就越頻繁,每一個數組後面的鏈表就越長,整個鏈表顯得很是累贅。若是業務須要大量查詢操做,由於是鏈表,只能從頭部開始查詢,等一個數組的鏈表所有查詢完才能開始下一個數組,這樣查詢時間將無線拉長。

這無疑是要進行擴容,因此第一個數組存放真正的數據,第二個數組用於擴容用。第一個數組中的節點通過hash運算映射到第二個數組上,而後依次進行。那麼過程當中還能對外提供服務嗎?答案是能夠的,由於他能夠隨時中止,這就到了下一個變量rehashidx。(一點都不生硬的轉場,哈哈哈)

rehashidx實際上是一個標誌量,若是爲-1說明當前沒有擴容,若是不爲-1則表示當前擴容到哪一個下標位置,方便下次進行從該下標位置繼續擴容。

這樣說是否是太抽象了,仍是一臉懵逼,貼心的送上擴容過程全解,必定要點贊評論多誇誇我哦。(愈來愈不要臉了。。。)


步驟1

首先是未擴容前,rehashidx爲-1,表示未擴容,第一個數組的dictEntry長度爲4,一共有5個節點,因此used爲5。


步驟2

當發生擴容了,rahashidx爲第一個數組的第一個下標位置,即0。擴容以後的大小爲大於used*2的2的n次方的最小值,即能包含這些節點*2的2的倍數的最小值。由於當前爲5個數據節點,因此used*2=10,擴容後的數組大小爲大於10的2的次方的最小值,爲16。從第一個數組0下標位置開始,查找第一個元素,找到key爲name,value爲張三的節點,將其hash過,找到在第二個數組的下標爲1的位置,將節點移過去,實際上是指針的移動。這邊就簡單說了。



步驟3

key爲name,value爲張三的節點移動結束後,繼續移動第一個數組dictht[0]的下標爲0的後續節點,移動步驟和上面相同。


步驟4

繼續移動第一個數組dictht[0]的下標爲0的後續節點都移動完了,開始移動下標爲1的節點,發現其沒有數據,因此移動下標爲2的節點,同時修改rehashidx爲2,移動步驟和上面相同。


整個過程的重點在於rehashidx,其爲第一個數組正在移動的下標位置,若是當前內存不夠,或者操做系統繁忙,擴容的過程能夠隨時中止。

中止以後若是對該對象進行操做,那是什麼樣子的呢?

  • 若是是新增,則直接新增後第二個數組,由於若是新增到第一個數組,之後仍是要移過來,不必浪費時間
  • 若是是刪除,更新,查詢,則先查找第一個數組,若是沒找到,則再查詢第二個數組。

字典的實現(源碼分析)

建立並初始化字典

首先分配內存,接着調用初始化方法_dictInit,主要是賦值操做,重點看下rehashidx賦值爲-1(這驗證了剛纔的圖解,-1表示未進行hash擴容),最後返回是否建立成功。

/* 建立並初始化字典 */
dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));
    _dictInit(d,type,privDataPtr);
    return d;
}

/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    d->type = type;
    d->privdata = privDataPtr;
    d->rehashidx = -1;//賦值爲-1,表示未進行hash
    d->iterators = 0;
    return DICT_OK;
}複製代碼

擴容

dict裏面有一個靜態方法_dictExpandIfNeed,判斷是否須要擴容。

首先判斷經過dictIsRehashing方法,判斷是否處於hash狀態,其調用的是宏常量#define dictIsRehashing(d) ((d)->rehashidx != -1),即判斷rehashidx是否爲-1,若是爲-1,即不處於hash狀態,if條件爲false,能夠進行擴容,若是不爲-1,即處於hash狀態,if條件爲true,不能夠進行擴容,直接返回常量DICT_OK。

接着判斷第一個數組的size是否爲0,若是爲0,則擴容爲默認大小4,若是不爲0,則執行下面的代碼。

再接着判斷是否須要擴容,if中有三個條件,具體的分析以下。

最後就是調用dictExpand擴容方法了,參數爲數據節點的雙倍大小ht[0].used*2。此處驗證了上面擴容過程的數組大小16。

擴容方法比較簡單點,獲取擴容後的大小,將第二個設置新的大小。

這樣講感受有點空,看下流程圖。

擴容流程圖


具體代碼:

static int _dictExpandIfNeeded(dict *d)
{
    //判斷是否處於擴容狀態中,經過調用宏常量#define dictIsRehashing(d) ((d)->rehashidx != -1)
    //來判斷是否能夠擴容
    if (dictIsRehashing(d)) return DICT_OK;

    //判斷第一個數組size是否爲0,若是爲0,則調用擴容方法,大小爲宏常量
    //#define DICT_HT_INITIAL_SIZE 4
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    //下面先列出if條件中所使用到的參數 
    // static int dict_can_resize = 1;數值爲1表示能夠擴容
    //static unsigned int dict_force_resize_ratio = 5;
    //咱們來分析if條件,若是第一個數組的全部節點數量大於等於第一個數組的大小(表示節點數據已經有些多)
    //而且可用擴容(數值爲1)或者全部節點數量除以數組大小大於5
    //這個條件表示擴容那個的條件,第一個就是節點必要大於等於數組長度,
    //第二點就再能夠擴容和數據太多,超過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;
}

int dictExpand(dict *d, unsigned long size)
{
    dictht n;
    //獲取擴容後真正的大小,找到比size大的最小值,且是2的倍數
    unsigned long realsize = _dictNextPower(size);

    //一些判斷條件
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

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

    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    //第一個hash爲null,說明在初始化 
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }
    //正在hash,給第二個hash的長度設置新的, 
    d->ht[1] = n;
    d->rehashidx = 0;//設置當前正在hash 
    return DICT_OK;
}

/* 找到比size大的最小值,且是2的倍數 */
static unsigned long _dictNextPower(unsigned long size)
{
    unsigned long i = DICT_HT_INITIAL_SIZE;

    if (size >= LONG_MAX) return LONG_MAX;
    while(1) {
        if (i >= size)
            return i;
        i *= 2;
    }
}

複製代碼


漸進式hash

漸進式hash過程已經經過上面圖解說明,如下主要看下代碼是如何實現的,以及過程是否是對的。

擴容以後就是執行dictRehash方法,參數包括待移動的哈希表d和步驟數字n。

首先判斷標誌量rehashidx是否等於-1,若是等於-1,則表示hash完成,若是不等於-1,則執行下面的代碼。

接着進行循環,遍歷第一個數組上的每一個下標,每次移動下標位置,都須要更新rehashidx值,每次加1。

再接着進行第二個循環,遍歷下標的鏈表每一個節點,完成數據的遷移,主要是指針的移動和一些參數的修改。

最後,返回int數值,若是爲0表示整個數據所有hash完成,若是返回1則表示部分hash結束,並無所有完成,下次能夠經過rehashidx值繼續hash。

具體代碼以下:

//從新hash這個哈希表
 // Redis的哈希表結構公有兩個table數組,t0和t1,日常只使用一個t0,當須要重hash時則重hash到另外一個table數組中
 //參數列表
 // 1. d: 待移動的哈希表,結構中存有目前已經重hash到哪一個桶了
  //  2. n: N步進行rehash 
// 返回值 返回0說明整個表都重hash完成了,返回1表明未完成
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; 
    //若是當前rehashidx=-1,則返回0,表示hash完成 
    if (!dictIsRehashing(d)) return 0;
	//分n步,並且ht[0]還有沒有移動的節點 
    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        //第一個循環用來更新 rehashidx 的值,由於有些桶爲空,因此 rehashidx並不是每次都比原來前進一個位置,而是有可能前進幾個位置,但最多不超過 10。
        //將rehashidx移動到ht[0]有節點的下標,也就是table[d->rehashidx]非空
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];  //第二個循環用來將ht[0]表中每次找到的非空桶中的鏈表(或者就是單個節點)拷貝到ht[1]中

        /* 利用循環講數據節點移過去 */
        while(de) {
            unsigned int h;

            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

    return 1;
}
複製代碼

總結

該篇主要講了Redis的Hash數據類型的底層實現字典結構Dict,先從Hash的一些API使用,引出字典結構Dict,剖析了其三個主要組成部分,字典結構體Dict,數組結構體Dictht,數據節點結構體DictEntry,進而經過多幅過程圖解釋了擴容過程和rehash過程,最後結合源碼對字典進行描述,如建立過程,擴容過程,漸進式hash過程,中間穿插流程圖講解。

若是以爲寫得還行,麻煩給個贊👍,您的承認纔是我寫做的動力!

若是以爲有說的不對的地方,歡迎評論指出。

好了,拜拜咯。

求個關注

相關文章
相關標籤/搜索