Redis源碼閱讀【5-字典】

Redis源碼閱讀【1-簡單動態字符串】
Redis源碼閱讀【2-跳躍表】
Redis源碼閱讀【3-Redis編譯與GDB調試】
Redis源碼閱讀【4-壓縮列表】
Redis源碼閱讀【5-字典】
Redis源碼閱讀【6-整數集合】
Redis源碼閱讀【7-quicklist】
Redis源碼閱讀【8-命令處理生命週期-1】
Redis源碼閱讀【8-命令處理生命週期-2】
Redis源碼閱讀【8-命令處理生命週期-3】
Redis源碼閱讀【8-命令處理生命週期-4】
Redis源碼閱讀【番外篇-Redis的多線程】
建議搭配源碼閱讀源碼地址

在前面介紹完Redis的【動態字符串sds】,以空間換時間的【跳躍表】,和極限壓縮內存使用的【壓縮列表】後,現在介紹Redis下一個重量級數據結構——【字典】,這一章對於許多讀過Java-JDK中HashMap或者HashTable源碼的同學來說應該並不陌生,甚至相似度很高。同時沒讀過HashMap或者HashTable的同學,希望看完這章後續對你理解HashMap或者HashTable有所幫助

1、什麼是字典

字典又叫做散列表,是用來存儲健值對(key-value)的一種數據結構,基本上在許多高級語言中都有這樣一種結構,而Redis作爲一個幾乎是使用key-value來存儲的內存形數據庫,必然是非常重要的,對Redis數據庫進行任何的增,刪,改,查,實際就是對字典的增,刪,改,查。但是C語言本身沒有散列這一種數據結構,所以Redis的實現了自己的字典。

在開始瞭解字典之前,我們先了解一下,Redis具有的特點:

1、可以存儲海量數據,健值是映射關係,可以在O(1)的時間複雜度取出或者插入關聯值
2、健值對中健的類型可以是字符串,整型,浮點型等,且健是唯一的。
3、健值對中值的類型可以爲String,Hash,List,Set,SortedSet

根據上面三個特徵,我們想一下在C語言中,【字典】這個數據結構我們要怎麼實現呢?都需要哪些字段呢?Redis的底層是C語言寫的,要實現第一個特徵:可以存儲海量數據,所以此時我們需要一個根節點,或者一個數據節點,我們稱爲data,用於存儲海量數據的內存地址。
但是現在又引出另一個難題了,海量數據如何保證以相近O(1)的時間複雜度去操作數據呢?我們前面使用的【跳躍表】也只是幾乎接近O(logN),而且跳錶過於浪費空間不適合作爲數據庫的主要數據結構,那麼我們想,曾經有什麼樣的數據結構能達到O(1)的時間複雜度呢?這個時候我們想起了一個數據結構:【數組】

1.1、數組

有限個類型相同的對象的集合稱爲數組。組成數據的各個對象稱爲數組的元素,用於區分數據的各個元素的數字編號稱爲數組下標。元素的類型可爲:數值,字符,指針,結構體等等。其中指針和結構體類型就保證了,我們的字典的value可以是多種類型的數據結構,同時數組是連續的一段內存空間,通過偏移量就能在O(1)時間複雜度內找到相應的元素,假設一個int類型並且長度爲10的數組,內存結構如下圖:
在這裏插入圖片描述
當需要對數組a中的元素進行操作時,C語言需通過下標找到其對應的內存地址,然後才能對這塊內存進行相應的操作。例如,讀取 a[9] 的值,在C語言中是會轉換成 *(a+9) 的形式,a[9] 和 *(a+9)是等價的,也就是說,要得到a[9]的地址,可以通過對數組a的首地址偏移9個元素就行。

當一個數組中的數據非常海量的時候,通過頭指針 + 偏移量的方式也能以O(1)的時間複雜度定位到數據所在的內存地址,然後進行操作,這也滿足了我們前面提到的第一個條件,以O(1)的時間複雜度操作數據

2、字典的結構

通過前面對字典特徵的定義和數組的介紹,我們大概能先得到一個大概的Redis數組結構的示意圖如下:
在這裏插入圖片描述
這個結構看上去很完美,沒毛病,但是有一個嚴重的問題就是:我們存儲的key是字符串,而數組的下標卻是有序的數字索引,我們怎麼樣才能將不規則的key匹配到這個數組中去呢?

2.1、Hash函數

Hash稱之位散列,做用是把任意長度的輸入通過Hash散列算法轉換成固定類型,固定長度的散列值,換句話說,Hash函數可以把不同健轉換成唯一的整型數據。散列函數一般有以下特徵:

	1、相同的輸入經Hash計算後得出相同的輸出
	2、不同的輸入經Hash計算後一般得出不同的值,但也可能相同

所以,好的Hash算法是經過Hash計算後得出的值具有強隨機分佈性。這裏Redis使用的是times33散列算法 ,其核心算法是:hash(i)=hash(i-1)*33+str[i]

注:與Java中JDKHash算法不同,HashMap是使用HashCode的高16位和低16位異或的出來的

hash的實現代碼如下(dict.c:53):

static unsigned int dictGenHashFunction(const unsigned char *buf, int len) {
    unsigned int hash = 5381;

    while (len--)
        hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */
    return hash;
}

dictGenHashFunction 函數的主要做用是,入參是任意長度的字符串,通過Hash計算後返回無符號整數。因此,我們可以通過Hash函數,將任意輸入的鍵轉換爲整型數據,使其可以當作數組的下標使用。但是,如果直接使用Hash值作爲數組下標也不合理,hash本身可以是一個long類型的整數,未免太大了,或者我們無法出類似於如此大的數組,去存儲。於是,redis這裏使用了一種方式:下標取餘,通過使用hash % 數組長度來得出數組的下標。最終結構如下圖:

2.2、Hash衝突

由前面得知,Hash函數是根據Hash算法算出來的,Hash本身是可能產生衝突的,即:多個不同的輸入具有相同的輸出,也有可能是取餘操作的時候導致的hash衝突。hash衝突的本質就是,不同的key,具有相同的數組下標。
爲了解決Hash衝突,所以數組中的元素除了應把健值對中的存儲外,還應該存儲一個next指針,next指針可以把衝突的鍵值對串成一個單鏈表,信息用於判斷是否爲當前要查找的鍵。此時數組中元素的字段也明確了,字典數據結構示意圖如下圖:
在這裏插入圖片描述
當根據鍵去找值時,分如下幾步:

第1步:鍵通過Hash,取餘等操作得到索引值,根據索引值找到對應元素。
第2步:判斷元素中鍵與查找的值是否相等,相等則讀取元素中的值返回,否則判斷next指針是否有值,如有值則繼續取next值,直到找到或者沒找到對應的鍵,沒找到返回null

3、Redis字典

Redis的字典也是通過Hash函數來實現的,由於Redis需要考慮的因素更多,所以字典的實現會比上面的更加複雜,但是原理是一樣的。Redis的字典實現主要分爲三個部分:字典Hash表Hash表節點,字典嵌入了兩個Hash表,Hash表中的table字段存放着Hash表節點,Hash表節點對應存儲健值對。

3.1、Hash表

Hash表結構,與前面介紹的結構類似,在Redis源碼中取名爲Hash表,其數據結構如下:

//Redis 字典 Hash表的定義
typedef struct dictht {
    dictEntry **table;     //指針數組,用於存儲鍵值對
    unsigned long size;    //table數組大小
    unsigned long sizemask;//掩碼 = size - 1
    unsigned long used;    //table數組已存元素個數,包含next單鏈表的數據
} dictht;

Hash表的結構整體佔用32個字節,其中table字段是數組,做用是存儲健值對,該數組中的元素指向的是dictEntry的結構體,每個dictEntry裏面存有健值對。size表示table數組的總大小。used字段記錄着table數組以存健值對個數

sizemask字段用來計算鍵的索引值,sizemask的值恆等於size -1。我們知道,索引值是鍵Hash值與數組總容量取餘之後的值,而Redis爲提高性能對這個計算進行了優化,具體計算步驟如下:

第1步:人爲設定Hash表的數組容量初始值爲4,隨着健值對存儲量的增加,就需對Hash表擴容,新擴容的容量大小設定爲當前容量大小的一倍,也就是說,Hash表的容量大小隻能爲4,8,16,32…。而sizemask掩碼值就只能爲3,7,15,31…,對應的二進制爲11,111,1111,11111…,因此掩碼值的二進制肯定是每一位都爲1。(這裏其實有點像HashMap的位移)
第2步:索引值 = Hash值 & 掩碼值,對應Redis源碼爲:idx = hash & d->ht[table].sizemask,其計算結果等同Hash值與Hash表容量取餘,而計算機的位運算要比取餘運算快的多。

結構如下圖:
在這裏插入圖片描述

3.2、Hash表節點

Hash表中的元素是用dictEntry結構體來封裝的,主要作用是存儲健值對,具體結構如下:

//Hash表元素結構體
typedef struct dictEntry {
    void *key;      //存儲鍵
    union {
        void *val;    //db.dict 中的val
        uint64_t u64; 
        int64_t s64;  //db.expires 中存儲過期時間
        double d;
    } v;              // 值,是個聯合體
    struct dictEntry *next; //用於解決衝突的next指針
} dictEntry;

Hash表中元素結構體和我們前面定義的元素結構體類似,整體佔24字節,key字端存儲的是鍵值對中的鍵。v聯合體存儲的是健值對中的值,在不同場景下使用不同字段。例如,用字典存儲整個Redis數據庫所有的健值對時,用的是*val字段,可以指向不同類型的值,再比如,字典被用記錄鍵的過期時間時,用的是s64字段存儲,當出現Hash衝突時,next字段用來指向衝突的元素,通過頭插法,形成單鏈表。如下圖:
在這裏插入圖片描述

3.3、字典結構

Redis字典實現除了包含前面介紹的兩個結構體Hash表以及Hash表節點外,還在最外面層封裝了一個叫字典的結構體。其主要作用是對散列表再進行一層封裝,當字典需要進行一些特殊操作時要用到裏面的輔助字段。具體結構如下:

//字典結構體的定義
typedef struct dict {
    dictType *type;  //該字典對應的特定操作函數
    void *privdata;  //該字典依賴的數據
    dictht ht[2];   //Hash表,健值對存儲在這裏,(爲什麼是兩個呢?這個和rehash有關)
    long rehashidx; //用來標記字典當前是否在rehash,存儲的值代表當前rehash的位置,-1表示當前沒有進行rehash
    unsigned long iterators; //當前運行的迭代器數量 
} dict;

字典整體結構佔用96字節,其中type指向dictType結構體,dictType定義如下:

//該字典對應的特定操作函數
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);               //該字典對應的Hash函數
    void *(*keyDup)(void *privdata, const void *key);        //鍵對應的複製函數
    void *(*valDup)(void *privdata, const void *obj);        //值對應的複製函數
    int (*keyCompare)(void *privdata, const void *key1, const void *key2); //健的對比函數
    void (*keyDestructor)(void *privdata, void *key);        //健的銷燬函數
    void (*valDestructor)(void *privdata, void *obj);        //值的銷燬函數
} dictType;

Redis字典這個數據結構,除了主數據庫的K-V數據存儲外,還有很多其他地方會用到。例如,Redis的哨兵模式,就使用字典管理所有master節點以及slave節點,再如數據庫中健值對的值爲Hash類型時,存儲這個Hash類型的值也是用字典。在不同的應用中,字典中的健值形態可能不同,而dictType結構體,則是爲了實現各種形態的字典而抽象出來的一組操作函數。

1、[privdata 字段]: 私有數據,配合type字段指向的函數一起使用。
2、[ht 字段]:是個大小爲2的數組,該數組存儲的元素類型爲dictht,雖然有兩個元素,但一般情況下只會使用ht[0],只有當字典擴容,縮容需要進行rehash的時候,纔會使用ht[1]。
3、[rehashidx字段]:用來標記字典是否正在rehash,沒進行rehash的時,值爲-1,否則該值用來表示Hash表ht[0]執行rehash到了哪個元素,並記錄該元素的數組下標值 
4、[iterators字段]:用來記錄當前運行的安全迭代器數,當有安全迭代器綁定到該字典時,會暫停rehash操作。Redis很多場景下都會用到迭代器,例如:執行keys命令會創建一個安全的迭代器,此時iterators會加1,命令執行完時候數量會-1,而執行sort命令時會創建普通迭代器,該字段不會改變

完整的字典結構如下圖:
在這裏插入圖片描述

4、Redis字典操作

前面介紹了字典原理,Hash表以及字典的結構,那麼單純瞭解結構是不夠的,我們也知道Redis是一個支持 增,刪,改,查 的數據庫,那麼下面我們將介紹Redis的字典生成銷燬以及操作。

4.1、字典初始化

在redis-serve啓動中,整個數據庫會先初始化一個空的字典用於存儲整個數據庫的健值對。初始化一個空字典,調用的是dict.h中的dictCreate函數,實現如下:

dict *dictCreate(dictType *type,void *privDataPtr){
    dict *d = zmalloc(sizeof(*d)); //分配96字節
    _dictInit(d,type,privDataPtr); //初始化結構體
    return d;
}
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
    _dictReset(&d->ht[0]); //初始化 ht[0]
    _dictReset(&d->ht[1]); //初始化 ht[1]
    d->type = type;  //該字典對應的特定操作函數
    d->privdata = privDataPtr; //初始化該字典依賴的數據
    d->rehashidx = -1; //設置rehash值
    d->iterators = 0; //設置迭代器數量
    return DICT_OK;
}
static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

dictCreate函數初始化一個空字典的主要步驟爲:申請空間調用_dictInit給字典的各個字段賦值。初始化後字段的如下圖所示
在這裏插入圖片描述

4.2、添加元素

redis-server啓動完畢後,再啓動redis-cli連上Server,執行命令 set k1 v1 ,最終會執行到setKey(redisDb *db,robj *key,robj *val) 函數,前文介紹字典的特性時提到過,每個鍵必須是唯一的,所以元素添加需要經過以下幾步來完成:先判斷該鍵是否存在,存在則執行修改,否則添加健值對。而setKey函數的主要邏輯也是如此,其主要流程如下。

第一步:調用dictFind函數查找鍵是否存在,是則調用dbOverwrite函數修改鍵值對,否則調用dbAdd函數添加元素
第二步:dbAdd 最終調用dict.h文件中的dictAdd函數插入鍵值對。

dictAdd函數的插入代碼如下:

//字典添加元素(在這個函數調用之前已經調用了dictFind)
int dictAdd(dict *d, void *key, void *val)
{
    dictEntry *entry = dictAddRaw(d,key,NULL); //創建待添加的元素

    if (!entry) return DICT_ERR;
    dictSetVal(d, entry, val); //設置值
    return DICT_OK;
}

完整代碼:

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;
    
    //判斷當前是否正在rehash,如果是調用一次rehash
    if (dictIsRehashing(d)) _dictRehashStep(d);

	//定位對應的table index
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1) 
     	//查找鍵,找到則直接返回-1,並把老節點存入existing字段,否則把新節點的索引值返回。如果遇到Hash表容量不足,則進行擴容
        return NULL;
        
    //判斷是否rehash 如果是插入 ht[1] 否則插入 ht[0]  ,因爲上面結束後可能rehash已經結束
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; 
    
    entry = zmalloc(sizeof(*entry)); //分配空間
    entry->next = ht->table[index]; 
    ht->table[index] = entry;
    ht->used++;

    dictSetKey(d, entry, key); //設置鍵
    return entry;
}

//查找鍵,找到則直接返回-1,並把老節點存入existing字段,否則把新節點的索引值返回。如果遇到Hash表容量不足,則進行擴容
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
    unsigned long idx, table;
    dictEntry *he;
    if (existing) *existing = NULL;
	//判斷是否需要拓展Hash表
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
    for (table = 0; table <= 1; table++) {
    	//定位到相應hash表的index
        idx = hash & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
        	//判斷是否找到已經存在的鍵
            if (key==he->key || dictCompareKeys(d, key, he->key)) {
            	//將已經存在的鍵存儲在existing中
                if (existing) *existing = he;
                return -1;
            }
            he = he->next;
        }
        //如果正在rehash 還要取 ht[1]裏面去找
        if (!dictIsRehashing(d)) break;
    }
    return idx;
}

//拓展Hash表
static int _dictExpandIfNeeded(dict *d)
{
    if (dictIsRehashing(d)) return DICT_OK;

    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    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;
}

//拓展或者創建hash表
int dictExpand(dict *d, unsigned long size){
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    dictht n; 
    //重新計算擴容後的值,必須爲2的冪
    unsigned long realsize = _dictNextPower(size);

    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;
    
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    d->ht[1] = n;
    d->rehashidx = 0; //剛剛開始擴容設置爲0
    return DICT_OK;
}

//調用rehash入口
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

//字典rehash
int dictRehash(dict *d, int n) {
	//當前rehash的最大數量 buckets 爲單位 每個buckets rehash 10個
    int empty_visits = n*10;  
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
        assert(d->ht[0].size > (unsigned long)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];      
        while(de) {
            uint64_t h;

            nextde = de->next;       
            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;
}

dictAddRaw 函數主要作用是添加或查找鍵,添加成功返回新節點,查找成功返回NULL並把老節點存入existing 字段。該函數中比較核心的是調用 _dictKeyIndex 函數,作用是得到鍵的索引值,索引值獲取與前文介紹的函數類似,主要有這麼兩步:

//第一步:調用該字典的Hash函數得到鍵的Hash值
dictHashKey(d,key)
//第二步:用健的Hash值與字典掩碼取與,得到索引值
idx = hash & d->ht[table].sizemask;

dictAddRaw函數拿到鍵的索引值後則可直接定位健值對要存入的位置,新創建一個節點存入即可。

4.2.1、字典擴容

上面的代碼也列出來,當添加元素的時候,當元素達到容量的上限時或者容量不足的時候,字典本身是有可能會進行擴容的,擴容的代碼就是上面的 dictExpand

//拓展或者創建hash表
int dictExpand(dict *d, unsigned long size){
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;

    dictht n; 
    //重新計算擴容後的值,必須爲2的冪
    unsigned long realsize = _dictNextPower(size);

    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;
    
    if (d->ht[0].table == NULL) {
        d->ht[0] = n;
        return DICT_OK;
    }

    d->ht[1] = n; //擴容後新內存放入到 ht[1]中
    d->rehashidx = 0; //剛剛開始擴容設置爲0
    return DICT_OK;
}

擴容的主要流程爲:

1、申請一塊新內存,默認大小爲4,每次擴展一倍
2、把新申請的內存地址賦值給ht[1],並把rehashidx 設置爲 0

擴容後,字典容量及掩碼都會發生改變,同一個鍵與掩碼經過位運算後得到的索引值就會發生改變,從而導致根據健查找不到值的情況。解決這個問題的辦法是,新擴容的Hash表爲ht[1],原本ht[0]的數據不受到影響,直到ht[0]的數據都被遷移一份到ht[1]中後,才切換爲擴容後的表。

4.2.2、漸進式rehash

rehash除了擴容時會觸發,縮容時也會觸發。Redis整個rehash的實現,主要分爲以下幾步完成。

1、給Hash表ht[1]申請足夠的空間,擴容時空間大小爲當前容量*2,當使用空間不足10%時,進行縮容,縮容的大小爲,剛好爲能容納used*2的整數

2、進行rehash操作調用的是dictRehash函數,重新計算ht[0]中每個健的Hash值與索引值,並且依次添加到ht[1]中,並把舊值刪除,把字典rehashidx修改爲ht[0]正在rehash操作節點的索引值

3、rehash操作後,情況ht[0],然後對調ht[1]和ht[0]的索引,並把rehashidx值設置爲*-1	

我們知道,Redis爲了提供高性能的線上服務,而且是單進程模式,當數據庫中的數據達到百萬,千萬,億級別的時候,整個rehash的過程會非常緩慢,這裏redis用了一個巧妙的方式,利用分治的思想進行rehash操作,大致步驟如下:

執行 插入 刪除 查找 修改 等操作前,都判斷一下當前字典是否正在進行 rehash ,如果是則調用dictRehashStep 函數進行 rehash 操作(每次只對一個節點進行 rehash 操作,共執行1次)。除了這種情況外,當服務器空閒的時候,也會進行 rehash 操作,則會調用incrementallyRehash函數進行批量rehash操作(每次對100個節點進行rehash,共耗時1毫秒)。再經歷N次rehash後,整個ht[0]的數據都會遷移到ht[1]中。

dictRehashStep 代碼如下:

static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

//字典rehash
int dictRehash(dict *d, int n) {
	//當前rehash的最大數量 buckets 爲單位 每個buckets rehash 10個
    int empty_visits = n*10;  
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;
        assert(d->ht[0].size > (unsigned long)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];      
        while(de) {
            uint64_t h;

            nextde = de->next;       
            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;
}

incrementallyRehash 代碼如下:

//批量rehash
int incrementallyRehash(int dbid) {
    if (dictIsRehashing(server.db[dbid].dict)) {
        dictRehashMilliseconds(server.db[dbid].dict,1);
        return 1;
    }
  
    if (dictIsRehashing(server.db[dbid].expires)) {
        dictRehashMilliseconds(server.db[dbid].expires,1);
        return 1; 
    }
    return 0;
}

// d 是字典 ms 是期望耗時
int dictRehashMilliseconds(dict *d, int ms) {
    long long start = timeInMilliseconds(); //獲取當前時間
    int rehashes = 0;

    while(dictRehash(d,100)) {
        rehashes += 100;
        //超時就結束
        if (timeInMilliseconds()-start > ms) break;
    }
    return rehashes;
}

4.3 、查找元素

查找元素例如使用 get 指令的時候,Server最終回去調用dict.h文件中的dictFind函數,源碼如下:

//查找元素
dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    uint64_t h, idx, table;

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d); //判斷是否正在rehash 如果是調用rehash
    h = dictHashKey(d, key); // 獲取鍵對應的hash值
    for (table = 0; table <= 1; table++) { //在 ht[0] 和 ht[1] 中查找
        idx = h & d->ht[table].sizemask; //找到對應的hash 表的index
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                //查找到鍵直接返回
                return he;
            he = he->next;
        }
        //如果沒有在rehash直接結束
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

查找鍵的過程比較簡單,過程基本如下:

1、根據鍵調用Hash函數取得相應的hash值
2、根據Hash值取到索引值
3、遍歷字典的兩個Hash表,讀取索引對應的元素
4、遍歷該元素單鏈表,如找到了與自身鍵匹配的鍵,則返回該元素
5、找不到返回NULL

4.4、修改元素

Server收到set 命令後,會查詢鍵是否存在,如果已經存在會調用db.c文件中的dbOverwrite函數,其源碼如下:

//更新數據
void dbOverwrite(redisDb *db, robj *key, robj *val) {
    dictEntry *de = dictFind(db->dict,key->ptr); //查找該元素

    serverAssertWithInfo(NULL,key,de != NULL); //不存在則中斷執行
    dictEntry auxentry = *de;
    robj *old = dictGetVal(de); //獲取老節點的val字段值
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { //過期策略(後面會講)
        val->lru = old->lru;
    }
    dictSetVal(db->dict, de, val); //給節點設置新的值

    if (server.lazyfree_lazy_server_del) { //緩慢刪除(後面會講)
        freeObjAsync(old);
        dictSetVal(db->dict, &auxentry, NULL);
    }

    dictFreeVal(db->dict, &auxentry);//釋放舊的val內存
}

修改鍵的源碼,雖然沒有調用dict.h中的方法去修改字典中的元素,但修改過程基本類似,Redis修改鍵值對,整個過程主要分如下幾個步驟:

1、調用dictFind查找健值對是否存在
2、不存在則中斷執行
3、修改節點鍵值對中的值爲新值
4、釋放舊值內存

4.5、刪除元素

繼續跟進刪除字段中的健值對,例如執行 del 指令,Server收到 del命令後,最終刪除鍵值對會調用dict.h文件中的dictDelete函數,其主要執行過程爲:

1、查找該健是否存在當前字典中
2、存在則把該節點從單鏈表中刪除
3、釋放該節點對應鍵佔用的內存,值佔用的內存,以及本身dictEntry佔用的內存
4、給對應的Hash表的used字段值減1

當字典操作後,使用量不到總空間的10%的時候,就會進行縮容操作,縮容核心代碼又兩個分別爲tryResizeHashTablesdictResize

代碼如下:

//字典元素刪除
static int dictDelete(dict *ht, const void *key) {
    unsigned int h;
    dictEntry *de, *prevde;

    if (ht->size == 0)
        return DICT_ERR;
    h = dictHashKey(ht, key) & ht->sizemask;//查找hash表對應的index
    de = ht->table[h];

    prevde = NULL;
    while(de) {
        //查找到鍵
        if (dictCompareHashKeys(ht,key,de->key)) {            
            //由於是單鏈表刪除節點的時候需要記錄下上一個節點
            if (prevde)
                prevde->next = de->next;
            else
                ht->table[h] = de->next;

            dictFreeEntryKey(ht,de); //釋放鍵空間
            dictFreeEntryVal(ht,de); //釋放值空間
            free(de); //釋放本身dictEntry空間
            ht->used--;
            return DICT_OK;
        }
        prevde = de;
        de = de->next;
    }
    return DICT_ERR; 
}

//縮容入口
void tryResizeHashTables(int dbid) {
	//redisDb字典縮容(後面介紹redisDb對象的時候會講)
    if (htNeedsResize(server.db[dbid].dict))
        dictResize(server.db[dbid].dict);
     //redisDb字典過期時間縮容(後面介紹redisDb對象的時候會講)
    if (htNeedsResize(server.db[dbid].expires))
        dictResize(server.db[dbid].expires);
}

//hash表縮容
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); //和前面擴容的 dictExpand 是一樣的
}

5、字典遍歷

前面講解了字典的基本概念,基本操作,本節講解字典的遍歷操作,遍歷的原則有以下幾點。

1、不重複出現數據
2、不遺漏任何數據

目前遍歷Redis整個數據庫主要有兩種方式:【全遍歷】【間斷遍歷】

【全遍歷】:一次命令執行就遍歷整個數據庫
【間斷遍歷】:每次執行命令,只取部分數據,多次遍歷

5.1、迭代器遍歷

迭代器-可在容器上遍訪的接口,設計人員無須關心容器的內容,調用迭代器固定的接口就可遍歷數據,在很多高級語音中都有實現。
字典迭代器的主要用於迭代字典這個數據結構中的數據,既然是迭代字典中的數據,必然會出現一個問題,迭代過程中,如果發生了數據增刪,則可能導致字典觸發rehash操作,或迭代開始時字典正在rehash操作,從而導致一條數據可能被多次遍歷的情況。那Redis如何解決這個問題呢?我們首先來看看字典迭代器結構體的實現吧:

//redis 字典迭代器結構體
typedef struct dictIterator {
    dict *d;   //迭代的目標字典
    long index;  // 當前迭代到Hash表中哪個索引
    int table, safe; // table用於表示當前正在迭代的Hash表,即ht[0]與ht[1],safe用於表示當前創建的是否爲安全迭代器
    dictEntry *entry, *nextEntry; //當前節點,下一個節點
    long long fingerprint; // 字典的指紋,當字典未發生改變的時候,該值不變,發生改變的時候則值也隨之改變
} dictIterator;

整結構體佔了48字節,其中d字段指向需要迭代的字典,index字段代表當前讀取到Hash表中的哪個值,table字段表示當前正在迭代的Hash表(即ht[0]和ht[1]中的0和1),safe字段表示當前創建的迭代器是否爲安全模式,entry字段表示正在讀取的節點數據,nextEntry字段表示entry節點中的next字段所指向的數據。
fingerprint字段是一個64位的整數,表示在給定時間內字段的狀態。在這裏稱其爲字典的指紋,因爲該字段的值爲字典(dict結構體)中所有字段值組合在一起生成的Hash值,所以當字典中數據發生任何變化時,其值都會不同,生成算法不做過多解讀,讀者可參見源碼dict.c文件中的dictFingerPrint函數
爲了讓迭代過程變的簡單,Redis也提供了迭代相關的API函數,主要爲:

dictIterator *dictGetIterator(dict *d); //初始化迭代器
dictIterator *dictGetSafeIterator(dict *d); //初始化安全的迭代器
dictEntry *dictNext(dictIterator *iter); //通過迭代器獲取下一個元素
void dictReleaseIterator(dictIterator *iter); //釋放迭代器

簡單介紹完迭代器的基本結構,字段含義以及API,我們來看一下Redis如何解決增刪數據的同時不出現讀取數據重複的問題。Redis爲單進程單線程模式。不存在兩個命令同時指向的情況,因此只有當指向執行的命令在遍歷的同時刪除了數據,纔會觸發前面的問題。我們把迭代器遍歷數據分爲兩類

1、普通迭代器,只遍歷數據
2、安全迭代器,遍歷的同時刪除數據

5.1.1、普通迭代器

普通迭代器迭代字典中數據時,會對迭代器中fingerprint字段的值作嚴格的校驗,來保證迭代過程中字典結構不發生任何變化,確保讀取出的數據不出現重複。
當Redis執行部分命令時會使用普通迭代器迭代字典數據,例如sort命令。sort命令主要作用是對給定列表,集合,有序集合的元素進行排序,如果給定的是有序集合、其成員名存儲用的是字典,分值存儲用的是跳躍表,則執行sort命令讀取數據的時候會去用到迭代器來遍歷整個字典。
普通迭代器迭代數據的過程比較簡單,主要分爲如下幾個步驟。

1、調用dictGetIterator函數初始化一個普通迭代器,此時會把iter->safe值置爲0,表示初始化的迭代器爲普通迭代器,初始化後的結構示意圖如下:
在這裏插入圖片描述

2、循環調用dictNext函數依次遍歷字典中Hash表的節點,首次遍歷時會通過dictFingerprint 函數拿到當前字典的指紋值,此時結構示意圖如圖所示:
在這裏插入圖片描述

3、當調用dictNext函數遍歷完字典Hash表中節點數據後,釋放迭代器時會繼續調用dictFingerprint 函數計算字典的指紋值,並與首次拿到的指紋對比,不相等則輸出異常"===ASSERTION FAILED ===",且退出程序執行。
普通迭代器通過步驟1,步驟3的指紋對比,來限制整個迭代過程中只能進行迭代操作,即迭代過程中字典數據的修改、添加、刪除、查找等操作都不能進行,只能調用dictNext函數迭代整個字典,否則就報異常,由此來保證這次迭代器取出數據的準確性。

5.1.2、安全迭代器

安全迭代器和普通迭代器迭代數據原理類似,也是通過循環調用dictNext函數依次遍歷字典中Hash表的節點。安全迭代器確保數據的準確性,不是通過限制字典的部分操作來實現的而是通過限制rehash的進行來確保數據的準確性,因此迭代過程中可以對字典進行增、刪、改、查等操作。
我們知道,對字典的增、刪、改、查操作會調用dictRehashStep函數進行漸進式rehash操作,那如何對rehash操作進行限制呢,我們一起看下dictRehashStep函數源碼實現:

//漸進式rehash入口
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1); //字典正在運行迭代操作的安全迭代器個數
}

原理很簡單,如果當前有安全迭代器正在運行,則不進行漸進式rehash操作,rehash操作暫停,字典中的數據不會被重複遍歷,由此確保讀取數據的準確性。
當Redis執行部分命令時會使用安全迭代器迭代字典數據,例如keys命令。keys命令主要作用是通過模式匹配,返回給定模式的所有key列表,遇到過期鍵則會進行刪除操作。Redis數據健值對存儲在字典中,因此keys命令會通過安全迭代器來遍歷整個字典。安全迭代器整個迭代過程也比較簡單,主要分如下幾個步驟:

第一步、調用dictGetSafeIterator 函數初始化一個安全迭代器,此時會把iter->safe值置爲1,表示初始化的迭代器爲安全迭代器。(結構參考普通迭代器結構)
第二步、循環調用dictNext函數依次遍歷字典中Hash表的節點,首次遍歷時會把字典中iterators字段進行加1操作,確保暫停rehash
第三步、當調用dictNext函數遍歷完字段Hash表中節點數據後,釋放迭代器時會把字典中iterators字段進行減1,步驟3中對字典的iterators字段進行修改,使得迭代過程中漸進式rehash操作被中斷,由此來保證迭代器讀取數據的準確性。

5.2、間斷遍歷

前文講解了全遍歷字典的實現,但有一個問題凸顯出來,當數據庫中有海量數據的時候,執行keys命令進行一次數據庫全遍歷,耗時肯定不短,會造成短暫的Redis不可用,所以在Redis在2.8.0版本後增加了scan操作,也就是間斷遍歷。而dictScan間斷遍歷中的一種實現,主要在迭代字典中數據時使用,例如hscan命令迭代整個數據庫中的key,以及zscan命令迭代有序集合所有成員與值時,都是通過dictScan函數來實現的字典遍歷。dictScan遍歷字典過程中是可以進行rehash操作的,通過算法來保證所有的數據能被遍歷到。
下面我們來看一下dictScan函數定義:

//字典間斷遍歷
unsigned long dictScan(dict *d,   //需要遍歷的字典
                       unsigned long v, //變量標識迭代開始的遊標(整個遍歷圍繞遊標值的改動進行)
                       dictScanFunction *fn, // 函數指針,遍歷一個節點則調用該函數處理
                       dictScanBucketFunction* bucketfn, //用來整理碎片時調用
                       void *privdata) // 調用函數fn所需要的參數

變量d是當前迭代的字典,變量v標識迭代開始的遊標(即Hash表中數組索引),每次遍歷後回返回新的遊標值,整個遍歷過程都是圍繞這個遊標值的改動進行的,來保證所有的數據都能被遍歷到,fn是函數指針,每遍歷一個節點則調用該函數處理,bucketfn函數在整理碎片時調用privdata是回調函數fn所需參數。
執行hscan命令時外層調用dictScan函數示例:

		//count爲hscan命令傳入的count值,代表獲取數據的個數
  		long maxiterations = count*10; 
        do {
        	//調用dictScan函數迭代字典數據,cursor字段初始值爲hscan傳入值,代表迭代Hash數組的遊標起點值 
            cursor = dictScan(ht, cursor, scanCallback, NULL, privdata);
        } while (cursor &&
              maxiterations-- &&
              listLength(keys) < (unsigned long)count);

dictScan函數間斷遍歷字典過程中會遇到如下3種情況:

1、從迭代開始到結束,散列表沒有進行rehash
2、從迭代開始到結束,散列表進行了擴容或縮容操作,且恰好爲兩次迭代間隔期間完成了rehash操作
3、從迭代開始到結束,某次或者某幾次迭代時散列表正在進行rehash操作

5.2.1、遍歷中始終未rehash

每次迭代都沒有遇到rehash操作,也就是遍歷字典只遇到第一種或,第二種情況。其實第一種情況,只要依次按照順序遍歷Hash表ht[0]中節的點即可,第2種情況因爲在遍歷的整個過程中,期間字典可能發生了擴容或縮容操作,間斷遍歷期間 (並且已經完成),如果依次按照順序遍歷,則可能會出現數據重複讀取的現象,於是redis技巧性的使用了一種方式實現避免重複讀取,如下所示:
在這裏插入圖片描述
dictScan函數中:

  		t0 = &(d->ht[0]);
        m0 = t0->sizemask; //當前掩碼
        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]); //整理碎片使用的函數
        de = t0->table[v & m0]; //避免遍歷後遊標超出最大值
        while (de) {
            next = de->next;
            fn(privdata, de); // 依次將節點中健值對存入privdata字段中的單鏈表
            de = next;
        }    

整個迭代過程強依賴遊標值v變量,根據v找到當前需要讀取的Hash表元素,然後遍歷該元素單鏈表上所有的健值對,依次執行fn函數指針執行的函數,對健值對進行讀取操作。
爲了兼容迭代期間可能發生的縮容與擴容操作,每次迭代時都會對v變量(遊標值)進行修改,以確保迭代出的數據無遺漏,遊標具體變更算法爲:

		// 遊變變更算法
        v |= ~m0;       
        v = rev(v); //二進制逆轉
        v++;
        v = rev(v); //二進制逆轉

爲了更好的立即這個算法下面列出了兩個表描述整個過程:
假設1:Hash表大小爲4,迭代從始至終未進行擴容縮容操作
掩碼m0=0x11 ~m0=0x100,遊標值的變化如下所示:

輸入 初始值 v |= 0x100 v=rev(v) v++ v=rev(v) 最終結果
第1次遊標爲0 0x000 0x100 0x001 0x010 0x010 2
第2次遊標爲2 0x010 0x110 0x011 0x100 0x001 1
第3次遊標爲1 0x001 0x101 0x101 0x110 0x011 3
第4次遊標爲3 0x011 0x111 0x111 0x000 0x000 0

剛好把整個Hash表遍歷完成順序是:2 1 3 0

假設2:Hash表大小爲4,進行3次迭代時,Hash表擴容爲8
掩碼m0=0x11 ~m0=0x100

輸入 初始值 v |= 0x100 v=rev(v) v++ v=rev(v) 最終結果
第1次遊標爲0 0x000 0x100 0x001 0x010 0x010 2
第2次遊標爲2 0x010 0x110 0x011 0x100 0x001 1

進行第三次迭代的時候,表擴容到了8,數組的掩碼m0=0x111 ~m0=0x1000 ,接下來的遊標如下所示:

輸入 初始值 v |= 0x100 v=rev(v) v++ v=rev(v) 最終結果
第3次遊標爲1 0x0001 0x1001 0x1001 0x1010 0x0101 5
第4次遊標爲5 0x0101 0x1101 0x1011 0x1100 0x0011 3
第5次遊標爲3 0x0011 0x1011 0x1101 0x1110 0x0111 7
第6次遊標爲1 0x0111 0x1111 0x1111 0x0000 0x000 0

此時我們發現,迭代只進行6次就完成了,順序爲0 2 1 5 3 7 少遍歷 4 6,因爲遊標爲0 2 的數據已經遍歷過
0|4 和 2|6是一樣的數據,所以無需再遍歷

假設3:Hash表大小爲8 迭代到第5次的時候縮容,Hash表大小變爲4
掩碼:m0=0x111 ~m0=0x1000 遊標值的變化順序如下

輸入 初始值 v |= 0x100 v=rev(v) v++ v=rev(v) 最終結果
第1次遊標爲0 0x0000 0x1000 0x0001 0x0010 0x0100 4
第2次遊標爲4 0x0100 0x1100 0x0011 0x0100 0x0010 2
第3次遊標爲2 0x0010 0x1010 0x0101 0x0110 0x0110 6
第4次遊標爲6 0x0110 0x1110 0x0111 0x1000 0x0001 1

進行第5次迭代的時候Hash表大小縮容到4 掩碼::m0=0x11 ~m0=0x100

輸入 初始值 v |= 0x100 v=rev(v) v++ v=rev(v) 最終結果
第5次遊標爲0 0x001 0x101 0x101 0x110 0x011 3
第6次遊標爲4 0x011 0x111 0x111 0x000 0x000 0

同樣,迭代只進行6次結束,順序爲 0 4 2 6 1 3 少遍歷了0 2

總結:只要遍歷過程中沒有遇到rehash恰好在執行,通過遊標變更算法可以保證無論擴容還是縮容都不會遍歷重複數據。

5.2.2、遍歷中遇到rehash

從迭代開始到結束,某次或某幾次迭代時散列表正在進行rehash操作,rehash操作中會同時並存兩個Hash表,一張爲擴容或縮容後的表ht[1],一張爲老表ht[0]ht[0]的數據會通過漸進式rehash會逐步遷移到ht[1]中,最終完成整個遷移過程。
因爲兩張表並存,所以需要從ht[0]ht[1]中都取出數據,整個遍歷過程爲:先找到兩個散列表中的小表,先對小表遍歷,然後對大的Hash表遍歷,代碼如下dictScan

	    t0 = &(d->ht[0]);
        m0 = t0->sizemask;   
        //判斷哪個Hash表小
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }
        m0 = t0->sizemask;
        m1 = t1->sizemask;

		de = t0->table[v & m0];
		//迭代第一張小表
        while (de) {
            next = de->next;
            fn(privdata, de);
            de = next;
        }
        //迭代第二張大表
        do {            
            if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
            de = t1->table[v & m1];
            while (de) {
                next = de->next;
                fn(privdata, de);
                de = next;
            } 
            // 遊標算法          
            v |= ~m1;
            v = rev(v);
            v++;
            v = rev(v);          
        } while (v & (m0 ^ m1));

結合rehash中的遊標算法,這樣能保證不會重複遍歷相同的節點
其實大家可以這樣理解,遊標算法的主要作用就是:保證無論擴容還是縮容,都不會遍歷到已經遍歷過的bucket index,例如 遍歷過2 就不會遍歷 6 ,遍歷過 0 就不會遍歷4

6 、API列表

這裏順便介紹一下剩餘的API 主要在文件 src/dict.h

dict *dictCreate(dictType *type, void *privDataPtr);  //初始化字典
int dictExpand(dict *d, unsigned long size); 		//字典擴容
int dictAdd(dict *d, void *key, void *val); 		//添加鍵值對,已存在則不添加 
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing);//添加key,並返回新添加key對應的節點指針。若存在,則存入 existing 並返回-1
dictEntry *dictAddOrFind(dict *d, void *key);	//添加或查找key
int dictReplace(dict *d, void *key, void *val);  //添加健值對,若存在則修改,否則添加
int dictDelete(dict *d, const void *key);		//刪除節點
dictEntry *dictUnlink(dict *ht, const void *key); //刪除key但不釋放內存
void dictFreeUnlinkedEntry(dict *d, dictEntry *he);//釋放dictUnlink函數刪除key的內存
void dictRelease(dict *d);						//釋放字典
dictEntry * dictFind(dict *d, const void *key); //根據鍵查找元素
void *dictFetchValue(dict *d, const void *key); //根據鍵查找出值
int dictResize(dict *d);						//將字典表的大小調整爲包含所有元素的最小值,即收縮字典
dictIterator *dictGetIterator(dict *d);			//初始化迭代器
dictIterator *dictGetSafeIterator(dict *d);		//初始化安全迭代器
dictEntry *dictNext(dictIterator *iter);		//通過迭代器獲取下一個節點
void dictReleaseIterator(dictIterator *iter);	//釋放迭代器
dictEntry *dictGetRandomKey(dict *d);			//隨機得到一個鍵
dictEntry *dictGetFairRandomKey(dict *d);		//隨機得到一個公平鍵
unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count);//隨機得到幾個鍵
void dictGetStats(char *buf, size_t bufsize, dict *d);	//讀取字典的狀態,使用情況等
uint64_t dictGenHashFunction(const void *key, int len);	//hash函數(字母大小寫敏感)
uint64_t dictGenCaseHashFunction(const unsigned char *buf, int len);//hash函數(字母大小寫不敏感)
void dictEmpty(dict *d, void(callback)(void*));	//清空字典
void dictEnableResize(void);					//開啓Resize(調整)
void dictDisableResize(void);					//關閉Resize(調整)
int dictRehash(dict *d, int n);					//漸進式rehash n 爲幾步進行
int dictRehashMilliseconds(dict *d, int ms);	//持續式rehash ms 爲持續時間
void dictSetHashFunctionSeed(uint8_t *seed);	//設置新的散列種子
uint8_t *dictGetHashFunctionSeed(void);			//獲取當前散列種子
unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, dictScanBucketFunction *bucketfn, void *privdata); //間斷迭代數據
uint64_t dictGetHash(dict *d, const void *key);		//得到鍵的hash值
dictEntry **dictFindEntryRefByPtrAndHash(dict *d, const void *oldptr, uint64_t hash);//使用指針 + hash值取查找元素

文章內容參考來源:《Redis5涉及與源碼分析》

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