Redis dict詳解

dict猎物,又稱字典(dictionary)或映射(map)棍现,是集合的一種;這種集合中每個元素都是KV鍵值對横缔。
字典dict在各編程語言中都有體現(xiàn)铺遂,面向?qū)ο蟮木幊陶Z言如C++、Java中都稱其為Map茎刚。

Redis的KV存儲結(jié)構(gòu)

Redis內(nèi)存數(shù)據(jù)庫襟锐,最底層是一個redisDb;

redisDb 整體使用 dict字典 來存儲鍵值對KV;
字典中的每一項(xiàng),使用dictEntry 膛锭,代表KV鍵值捌斧;類似于HashMap中的鍵值對Entry。

why dict/map?

dict是一種用于維護(hù)key和value映射關(guān)系的數(shù)據(jù)結(jié)構(gòu)泉沾,與很多編程語言中的Map類似。
為什么dict/map 這么受歡迎呢妇押?
因?yàn)閐ict/map實(shí)現(xiàn)了key和value的映射跷究,通過key查詢value是效率非常高的操作,時間復(fù)雜度是O(C)敲霍,C是常數(shù)俊马,在沒有沖突/碰撞的情況下丁存,可以達(dá)到O(1)。

dict本質(zhì)上是為了解決算法中的查找問題(Searching)柴我,一般查找問題的解法分為兩個大類:一個是基于各種平衡樹解寝,一個是基于哈希表。

  • 平衡樹艘儒,如二叉搜索樹聋伦、紅黑樹,使用的是“二分思想”界睁;
    如果需要實(shí)現(xiàn)排序觉增,則可使用平衡樹,如:用大頂堆實(shí)現(xiàn)TreeMap翻斟;
  • 哈希表逾礁,如Java中的Map,Python中的字典dict访惜,使用的是“映射思想”嘹履;

我們平常使用的各種Map或dict,大都是基于哈希表實(shí)現(xiàn)的债热。在不要求數(shù)據(jù)有序存儲砾嫉,且能保持較低的哈希值沖突概率的前提下,基于哈希表的查找性能能做到非常高效阳柔,接近O(1)焰枢,而且容易實(shí)現(xiàn)。

Redis dict的應(yīng)用

字典dict 在 Redis 中的應(yīng)用廣泛舌剂, 使用頻率可以說和 SDS 以及雙端鏈表不相上下济锄, 基本上各個功能模塊都有用到字典的地方。

其中霍转, 字典dict的主要用途有以下兩個:

  • 實(shí)現(xiàn)數(shù)據(jù)庫鍵空間(key space)荐绝;
  • 用作 hash 鍵的底層實(shí)現(xiàn)之一;

以下兩個小節(jié)分別介紹這兩種用途避消。


Redis數(shù)據(jù)庫鍵空間(key space)

Redis 是一個鍵值對數(shù)據(jù)庫服務(wù)器低滩,服務(wù)器中每個數(shù)據(jù)庫都由 redisDB 結(jié)構(gòu)表示(默認(rèn)16個庫)。其中岩喷,redisDB 結(jié)構(gòu)的 dict 字典保存了數(shù)據(jù)庫中所有的鍵值對恕沫,這個字典被稱為鍵空間(key space)。

可以認(rèn)為纱意,Redis默認(rèn)16個庫婶溯,這16個庫在各自的鍵空間(key space)中;其實(shí)就通過鍵空間(key space)實(shí)現(xiàn)了隔離。而鍵空間(key space)底層是dict實(shí)現(xiàn)的迄委。

鍵空間(key space)除了實(shí)現(xiàn)了16個庫的隔離褐筛,還能基于鍵空間通知(Keyspace Notifications) 實(shí)現(xiàn)某些事件的訂閱通知,如某個key過期的時間叙身,某個key的value變更事件渔扎。

鍵空間通知(Keyspace Notifications),是因?yàn)殒I空間(key space)實(shí)現(xiàn)了16個庫的隔離信轿,而我們執(zhí)行Redis命令最終都是落在其中一個庫上晃痴,當(dāng)有事件發(fā)生在某個庫上時,該庫對應(yīng)的鍵空間(key space)就能基于pub/sub發(fā)布訂閱虏两,實(shí)現(xiàn)事件“廣播”愧旦。

鍵空間(key space),詳細(xì)分析定罢,可參見:Redis鍵空間通知(Keyspace Notifications)

dict 用作 hash 鍵的底層實(shí)現(xiàn)

Redis 的 hash 鍵使用以下兩種數(shù)據(jù)結(jié)構(gòu)作為底層實(shí)現(xiàn):

  • 壓縮列表ziplist 笤虫;
  • 字典dict;

因?yàn)閴嚎s列表 比字典更節(jié)省內(nèi)存祖凫,所以程序在創(chuàng)建新 Hash 鍵時琼蚯,默認(rèn)使用壓縮列表作為底層實(shí)現(xiàn), 當(dāng)有需要時惠况,才會將底層實(shí)現(xiàn)從壓縮列表轉(zhuǎn)換到字典遭庶。
ziplist 是為 Redis 節(jié)約內(nèi)存而開發(fā)的、非常節(jié)省內(nèi)存的雙向鏈表稠屠,深入學(xué)習(xí)可移步Redis的list

壓縮鏈表轉(zhuǎn)成字典(ziplist->dict)的條件

同時滿足以下兩個條件峦睡,hash 鍵才會使用ziplist:
1、key和value 長度都小于64
2权埠、鍵值對數(shù)小于512

該配置 在redis.conf

hash-max-ziplist-entries 512
hash-max-ziplist-value 64


如何實(shí)現(xiàn)字典dict/映射

dict榨了,又稱字典(dictionary)或映射(map),是集合的一種攘蔽;這種集合中每個元素都是KV鍵值對龙屉。
它是根據(jù)關(guān)鍵字值(key)而直接進(jìn)行訪問KV鍵值對的數(shù)據(jù)結(jié)構(gòu)。也就是說满俗,它通過把關(guān)鍵字值映射到一個位置來訪問記錄转捕,以加快查找的速度。這個映射函數(shù)稱為哈希函數(shù)(也稱為散列函數(shù))唆垃。
因此通常我們稱字典dict五芝,也叫哈希表。

映射過程辕万,通常使用hash算法實(shí)現(xiàn)与柑,因此也稱映射過程為哈习迹化,存放記錄的數(shù)組叫做散列表价捧、或hash表。

哈衔写粒化之后難免會產(chǎn)生一個問題结蟋,那就是對不同的關(guān)鍵字,可能得到同一個散列地址渔彰,即不同的key散列到同一個數(shù)組下標(biāo)嵌屎,這種現(xiàn)象稱為沖突,那么我們該如何去處理沖突呢恍涂?
最常用的就是鏈地址法宝惰,也常被稱為拉鏈法,就是在沖突的下標(biāo)處再沧,維護(hù)一個鏈表尼夺,所有映射到該下標(biāo)的記錄,都添加到該鏈表上炒瘸。

Redis字典dict如何實(shí)現(xiàn)的淤堵?

Redis字典dict,也是采用哈希表顷扩,本質(zhì)就是數(shù)組+鏈表拐邪。
也是眾多編程語言實(shí)現(xiàn)Map的首選方式,如Java中的HashMap隘截。

Redis字典dict 的底層實(shí)現(xiàn)扎阶,其實(shí)和Java中的ConcurrentHashMap思想非常相似。
就是用數(shù)組+鏈表實(shí)現(xiàn)了分布式哈希表婶芭。當(dāng)不同的關(guān)鍵字东臀、散列到數(shù)組相同的位置,就拉鏈雕擂,用鏈表維護(hù)沖突的記錄啡邑。當(dāng)沖突記錄越來越多、鏈表越來越長井赌,遍歷列表的效率就會降低谤逼,此時需要考慮將鏈表的長度變短

將鏈表的長度變短仇穗,一個最直接有效的方式就是擴(kuò)容數(shù)組流部。將數(shù)組+鏈表結(jié)構(gòu)中的數(shù)組擴(kuò)容,數(shù)組變長纹坐、對應(yīng)數(shù)組下標(biāo)就增多了枝冀;將原數(shù)組中所有非空的索引下標(biāo)、搬運(yùn)到擴(kuò)容后的新數(shù)組,經(jīng)過重新散列果漾,自然就把沖突的鏈表變短了球切。

如果你對Java的HashMap或ConcurrentHashMap 底層實(shí)現(xiàn)原理比較了解,那么對Redis字典dict的底層實(shí)現(xiàn)绒障,也能很快上手吨凑。

dict.h 給出了這個字典dict的定義:

/*
 * 字典
 *
 * 每個字典使用兩個哈希表,用于實(shí)現(xiàn)漸進(jìn)式 rehash
 */
typedef struct dict {

    // 特定于類型的處理函數(shù)
    dictType *type;

    // 類型處理函數(shù)的私有數(shù)據(jù)
    void *privdata;

    // 哈希表(2 個)
    dictht ht[2];

    // 記錄 rehash 進(jìn)度的標(biāo)志户辱,值為 -1 表示 rehash 未進(jìn)行
    int rehashidx;

    // 當(dāng)前正在運(yùn)作的安全迭代器數(shù)量
    int iterators;

} dict;

typedef struct dictType {
    unsigned int (*hashFunction)(const void *key);
    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;

結(jié)合上面的代碼鸵钝,可以很清楚地看出dict的結(jié)構(gòu)。一個dict由如下若干項(xiàng)組成:

  • dictType *type;一個指向dictType結(jié)構(gòu)的指針(type)庐镐。它通過自定義的方式使得dict的key和value能夠存儲任何類型的數(shù)據(jù)恩商。
  • void *privdata;一個私有數(shù)據(jù)指針(privdata)。由調(diào)用者在創(chuàng)建dict的時候傳進(jìn)來必逆。
  • dictht ht[2];兩個哈希表(ht[2])怠堪。只有在rehash的過程中,ht[0]和ht[1]才都有效末患。而在平常情況下研叫,只有ht[0]有效,ht[1]里面沒有任何數(shù)據(jù)璧针。上圖表示的就是rehash進(jìn)行到中間某一步時的情況嚷炉。
  • int rehashidx;當(dāng)前rehash索引(rehashidx)。如果rehashidx = -1探橱,表示當(dāng)前沒有在rehash過程中申屹;否則,表示當(dāng)前正在進(jìn)行rehash隧膏,且它的值記錄了當(dāng)前rehash進(jìn)行到哪一步了哗讥。
  • int iterators;當(dāng)前正在進(jìn)行遍歷的iterator的個數(shù)。這不是我們現(xiàn)在討論的重點(diǎn)胞枕,暫時忽略杆煞。

dictType結(jié)構(gòu)包含若干函數(shù)指針,用于dict的調(diào)用者對涉及key和value的各種操作進(jìn)行自定義腐泻。這些操作包含:

  • hashFunction决乎,對key進(jìn)行哈希值計算的哈希算法。
  • keyDup和valDup派桩,分別定義key和value的拷貝函數(shù)构诚,用于在需要的時候?qū)ey和value進(jìn)行深拷貝,而不僅僅是傳遞對象指針铆惑。
  • keyCompare范嘱,定義兩個key的比較操作送膳,在根據(jù)key進(jìn)行查找時會用到。
  • keyDestructor和valDestructor丑蛤,分別定義對key和value的析構(gòu)函數(shù)叠聋。
    私有數(shù)據(jù)指針(privdata)就是在dictType的某些操作被調(diào)用時會傳回給調(diào)用者。

dictht(dict hash table)哈希表

dictht 是字典 dict 哈希表的縮寫盏阶,即dict hash table晒奕。
dict.h/dictht 類型定義:

/*
 * 哈希表
 */
typedef struct dictht {

    // 哈希表節(jié)點(diǎn)指針數(shù)組(俗稱桶,bucket)
    dictEntry **table;

    // 指針數(shù)組的大小
    unsigned long size;

    // 指針數(shù)組的長度掩碼名斟,用于計算索引值
    unsigned long sizemask;

    // 哈希表現(xiàn)有的節(jié)點(diǎn)數(shù)量
    unsigned long used;

} dictht;


/*
 * 哈希表節(jié)點(diǎn)
 */
typedef struct dictEntry {
    // 鍵
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 鏈往后繼節(jié)點(diǎn)
    struct dictEntry *next;
} dictEntry;

dictht 定義一個哈希表的結(jié)構(gòu),包括以下部分:

  • 一個dictEntry指針數(shù)組(table)魄眉。key的哈希值最終映射到這個數(shù)組的某個位置上(對應(yīng)一個bucket)砰盐。如果多個key映射到同一個位置,就發(fā)生了沖突坑律,那么就拉出一個dictEntry鏈表岩梳。
  • size:標(biāo)識dictEntry指針數(shù)組的長度。它總是2的指數(shù)次冪晃择。
  • sizemask:用于將哈希值映射到table的位置索引冀值。它的值等于(size-1),比如7, 15, 31, 63宫屠,等等列疗,也就是用二進(jìn)制表示的各個bit全1的數(shù)字。每個key先經(jīng)過hashFunction計算得到一個哈希值浪蹂,然后計算(哈希值 & sizemask)得到在table上的位置抵栈。相當(dāng)于計算取余(哈希值 % size)。
  • used:記錄dict中現(xiàn)有的數(shù)據(jù)個數(shù)坤次。它與size的比值就是裝載因子古劲。這個比值越大,哈希值沖突概率越高缰猴。

Redis dictht的負(fù)載因子
我們知道當(dāng)HashMap中由于Hash沖突(負(fù)載因子)超過某個閾值時产艾,出于鏈表性能的考慮、會進(jìn)行擴(kuò)容滑绒,Redis dict也是一樣闷堡。

一個dictht 哈希表里,核心就是一個dictEntry數(shù)組蹬挤,同時用size記錄了數(shù)組大小缚窿,用used記錄了所有記錄數(shù)。

dictht的負(fù)載因子焰扳,就是used與size的比值倦零,也稱裝載因子(load factor)误续。這個比值越大,哈希值沖突概率越高扫茅。當(dāng)比值[默認(rèn)]超過5蹋嵌,會強(qiáng)制進(jìn)行rehash。


dictEntry結(jié)構(gòu)中包含k, v和指向鏈表下一項(xiàng)的next指針葫隙。k是void指針栽烂,這意味著它可以指向任何類型。v是個union恋脚,當(dāng)它的值是uint64_t腺办、int64_t或double類型時,就不再需要額外的存儲糟描,這有利于減少內(nèi)存碎片怀喉。當(dāng)然,v也可以是void指針船响,以便能存儲任何類型的數(shù)據(jù)躬拢。

next 指向另一個 dictEntry 結(jié)構(gòu), 多個 dictEntry 可以通過 next 指針串連成鏈表见间, 從這里可以看出聊闯, dictht 使用鏈地址法來處理鍵碰撞: 當(dāng)多個不同的鍵擁有相同的哈希值時,哈希表用一個鏈表將這些鍵連接起來米诉。

下圖展示了一個由 dictht 和數(shù)個 dictEntry 組成的哈希表例子:

如果再加上之前列出的 dict 類型菱蔬,那么整個字典結(jié)構(gòu)可以表示如下:

在上圖給出的示例中, 只使用了 0 號哈希表ht[0]荒辕,且rehashidx=-1表明字典未進(jìn)行 rehash汗销。
什么是rehash,下文會詳細(xì)展開抵窒。

Redis dict使用的哈希算法

前面提到弛针,一個kv鍵值對,添加到哈希表時李皇,需要用一個映射函數(shù)將key散列到一個具體的數(shù)組下標(biāo)削茁。

Redis 目前使用兩種不同的哈希算法:
1、MurmurHash2
是種32 bit 算法:這種算法的分布率和速度都非常好掉房;Murmur哈希算法最大的特點(diǎn)是碰撞率低茧跋,計算速度快。Google的Guava庫包含最新的Murmur3卓囚。
具體信息請參考 MurmurHash 的主頁: http://code.google.com/p/smhasher/ 瘾杭。
2、基于 djb 算法實(shí)現(xiàn)的一個大小寫無關(guān)散列算法:具體信息請參考 http://www.cse.yorku.ca/~oz/hash.html 哪亿。

使用哪種算法取決于具體應(yīng)用所處理的數(shù)據(jù):

  • 命令表以及 Lua 腳本緩存都用到了算法 2 粥烁。
  • 算法 1 的應(yīng)用則更加廣泛:數(shù)據(jù)庫贤笆、集群、哈希鍵讨阻、阻塞操作等功能都用到了這個算法芥永。

Redis dict各種操作

以下是用于處理 dict 的各種 API , 它們的作用及相應(yīng)的算法復(fù)雜度:

操作 函數(shù) 算法復(fù)雜度
創(chuàng)建一個新字典 dictCreate O(1)
添加新鍵值對到字典 dictAdd O(1)
添加或更新給定鍵的值 dictReplace O(1)
在字典中查找給定鍵所在的節(jié)點(diǎn) dictFind O(1)
在字典中查找給定鍵的值 dictFetchValue O(1)
從字典中隨機(jī)返回一個節(jié)點(diǎn) dictGetRandomKey O(1)
根據(jù)給定鍵钝吮,刪除字典中的鍵值對 dictDelete O(1)
清空并釋放字典 dictRelease O(N)
清空并重置(但不釋放)字典 dictEmpty O(N)
縮小字典 dictResize O(N)
擴(kuò)大字典 dictExpand O(N)
對字典進(jìn)行給定步數(shù)的 rehash dictRehash O(N)
在給定毫秒內(nèi)埋涧,對字典進(jìn)行rehash dictRehashMilliseconds O(N)

下面,會對一些關(guān)鍵步驟進(jìn)行詳細(xì)講解奇瘦。

dict的創(chuàng)建(dictCreate)

創(chuàng)建dict
dict *d = dictCreate(&hash_type, NULL);

dict *dictCreate(dictType *type,
        void *privDataPtr)
{
    dict *d = zmalloc(sizeof(*d));

    _dictInit(d,type,privDataPtr);
    return d;
}

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;
    d->iterators = 0;
    return DICT_OK;
}

static void _dictReset(dictht *ht)
{
    ht->table = NULL;
    ht->size = 0;
    ht->sizemask = 0;
    ht->used = 0;
}

dictCreate為dict的數(shù)據(jù)結(jié)構(gòu)分配空間并為各個變量賦初值棘催。其中兩個哈希表ht[0]和ht[1]起始都沒有分配空間,table指針都賦為NULL耳标。這意味著要等第一個數(shù)據(jù)插入時才會真正分配空間巧鸭。

  • ht[0]->table 的空間分配將在第一次往字典添加鍵值對時進(jìn)行;
  • ht[1]->table 的空間分配將在 rehash 開始時進(jìn)行麻捻;

添加新鍵值對到字典(dictAdd)

根據(jù)字典所處的狀態(tài), 將給定的鍵值對添加到字典可能會引起一系列復(fù)雜的操作:

  • 如果字典為未初始化(即字典的 0 號哈希表的 table 屬性為空)呀袱,則程序需要對 0 號哈希表進(jìn)行初始化贸毕;
  • 如果在插入時發(fā)生了鍵碰撞,則程序需要處理碰撞夜赵;
  • 如果插入新元素明棍,使得字典滿足了 rehash 條件,則需要啟動相應(yīng)的 rehash 程序寇僧;
    當(dāng)程序處理完以上三種情況之后摊腋,新的鍵值對才會被真正地添加到字典上。

dictAdd函數(shù)是調(diào)用 dictAddRaw實(shí)現(xiàn)的:

/* 將Key插入哈希表 */
dictEntry *dictAddRaw(dict *d, void *key) 
{ 
    int index; 
    dictEntry *entry; 
    dictht *ht; 
 
    if (dictIsRehashing(d)) _dictRehashStep(d);  // 如果哈希表在rehashing嘁傀,則執(zhí)行單步rehash
 
    /* 調(diào)用_dictKeyIndex() 檢查鍵是否存在兴蒸,如果存在則返回NULL */ 
    if ((index = _dictKeyIndex(d, key)) == -1) 
        return NULL; 
 

    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; 
    entry = zmalloc(sizeof(*entry));   // 為新增的節(jié)點(diǎn)分配內(nèi)存
    entry->next = ht->table[index];  //  將節(jié)點(diǎn)插入鏈表表頭
    ht->table[index] = entry;   // 更新節(jié)點(diǎn)和桶信息
    ht->used++;    //  更新ht
 
    /* 設(shè)置新節(jié)點(diǎn)的鍵 */ 
    dictSetKey(d, entry, key); 
    return entry; 
}

整個添加流程可以用下圖表示:

在接下來的三節(jié)中, 我們將分別看到细办,添加操作如何在以下三種情況中執(zhí)行:
1橙凳、字典為空;
2笑撞、添加新鍵值對時發(fā)生碰撞處理岛啸;
3、添加新鍵值對時觸發(fā)了 rehash 操作茴肥;

添加新元素時table為空

當(dāng)?shù)谝淮瓮兆值淅锾砑渔I值對時坚踩, 程序會根據(jù) dict.h/DICT_HT_INITIAL_SIZE 里指定的大小為 d->ht[0]->table 分配空間 (在目前的版本中, DICT_HT_INITIAL_SIZE 的值為 4 )瓤狐。

以下是字典空白時的樣子:

以下是往空白字典添加了第一個鍵值對之后的樣子:

添加新鍵值對時發(fā)生碰撞

在哈希表實(shí)現(xiàn)中瞬铸, 當(dāng)兩個不同的鍵擁有相同的哈希值時批幌, 稱這兩個鍵發(fā)生碰撞(collision), 而哈希表實(shí)現(xiàn)必須想辦法對碰撞進(jìn)行處理赴捞。

字典哈希表所使用的碰撞解決方法被稱之為鏈地址法: 這種方法使用鏈表將多個哈希值相同的節(jié)點(diǎn)串連在一起逼裆, 從而解決沖突問題。

假設(shè)現(xiàn)在有一個帶有三個節(jié)點(diǎn)的哈希表赦政,如下圖:

對于一個新的鍵值對 key4 和 value4 胜宇, 如果 key4 的哈希值和 key1 的哈希值相同, 那么它們將在哈希表的 0 號索引上發(fā)生碰撞恢着。

通過將 key4-value4 和 key1-value1 兩個鍵值對用鏈表連接起來桐愉, 就可以解決碰撞的問題:

添加新鍵值對時觸發(fā)了 rehash

對于使用鏈地址法來解決碰撞問題的哈希表 dictht 來說, 哈希表的性能取決于大嘘伞(size屬性)與保存節(jié)點(diǎn)數(shù)量(used屬性)之間的比率:

哈希表的大小與節(jié)點(diǎn)數(shù)量从诲,比率在 1:1 時,哈希表的性能最好靡羡;
如果節(jié)點(diǎn)數(shù)量比哈希表的大小要大很多的話系洛,那么哈希表就會退化成多個鏈表,哈希表本身的性能優(yōu)勢便不復(fù)存在略步;
舉個例子描扯, 下面這個哈希表, 平均每次失敗查找只需要訪問 1 個節(jié)點(diǎn)(非空節(jié)點(diǎn)訪問 2 次趟薄,空節(jié)點(diǎn)訪問 1 次):

而下面這個哈希表绽诚, 平均每次失敗查找需要訪問 5 個節(jié)點(diǎn):

為了在字典的鍵值對不斷增多的情況下保持良好的性能, 字典需要對所使用的哈希表(ht[0])進(jìn)行 rehash 操作: 在不修改任何鍵值對的情況下杭煎,對哈希表進(jìn)行擴(kuò)容恩够, 盡量將比率維持在 1:1 左右。

dictAdd 在每次向字典添加新鍵值對之前羡铲, 都會對哈希表 ht[0] 進(jìn)行檢查蜂桶, 對于 ht[0] 的 size 和 used 屬性, 如果它們之間的比率 ratio = used / size 滿足以下任何一個條件的話犀勒,rehash 過程就會被觸發(fā):

  • 自然 rehash : ratio >= 1 屎飘,且變量 dict_can_resize 為true。
  • 強(qiáng)制 rehash : ratio 大于變量 dict_force_resize_ratio (目前版本中贾费, dict_force_resize_ratio 的值為 5 )钦购。

什么時候 dict_can_resize 會為false?
在前面介紹字典的應(yīng)用時也說到過褂萧, 數(shù)據(jù)庫就是字典押桃, 數(shù)據(jù)庫里的哈希類型鍵也是字典, 當(dāng) Redis 使用子進(jìn)程對數(shù)據(jù)庫執(zhí)行后臺持久化任務(wù)時(比如執(zhí)行 BGSAVE 或 BGREWRITEAOF 時)导犹, 為了最大化地利用系統(tǒng)的 copy on write 機(jī)制唱凯, 程序會暫時將 dict_can_resize 設(shè)為false羡忘, 避免執(zhí)行自然 rehash , 從而減少程序?qū)?nèi)存的觸碰(touch)磕昼。

當(dāng)持久化任務(wù)完成之后卷雕, dict_can_resize 會重新被設(shè)為true。

另一方面票从, 當(dāng)字典滿足了強(qiáng)制 rehash 的條件時漫雕, 即使 dict_can_resize 不為true(有 BGSAVE 或 BGREWRITEAOF 正在執(zhí)行), 這個字典一樣會被 rehash 峰鄙。

Rehash 執(zhí)行過程

字典的 rehash 操作實(shí)際上就是執(zhí)行以下任務(wù):
1浸间、創(chuàng)建一個比 ht[0]->table 更大的 ht[1]->table ;
2吟榴、將 ht[0]->table 中的所有鍵值對遷移到 ht[1]->table 魁蒜;
3、將原有 ht[0] 的數(shù)據(jù)清空吩翻,并將 ht[1] 替換為新的 ht[0] 兜看;
經(jīng)過以上步驟之后, 程序就在不改變原有鍵值對數(shù)據(jù)的基礎(chǔ)上狭瞎, 增大了哈希表的大小铣减。

dict的rehash 本質(zhì)就是擴(kuò)容,就是將數(shù)組+鏈表結(jié)構(gòu)中的數(shù)組擴(kuò)容脚作;
這個過程,需要開辟一個更大空間的數(shù)組缔刹,將老數(shù)組中每個非空索引的bucket球涛,搬運(yùn)到新數(shù)組;搬運(yùn)完成后再釋放老數(shù)組的空間校镐。

作為例子亿扁, 以下四個小節(jié)展示了一次對哈希表進(jìn)行 rehash 的完整過程。

1. 開始 rehash
這個階段有兩個事情要做:

  • 設(shè)置字典的 rehashidx 為 0 鸟廓,標(biāo)識著 rehash 的開始从祝;
  • 為 ht[1]->table 分配空間,大小至少為 ht[0]->used 的兩倍引谜;

這時的字典是這個樣子:

2. Rehash 進(jìn)行中
在這個階段牍陌, ht[0]->table 的節(jié)點(diǎn)會被逐漸遷移到 ht[1]->table , 因?yàn)?rehash 是分多次進(jìn)行的(細(xì)節(jié)在下一節(jié)解釋)员咽, 字典的 rehashidx 變量會記錄 rehash 進(jìn)行到 ht[0] 的哪個索引位置上毒涧。
注意除了節(jié)點(diǎn)的移動外, 字典的 rehashidx 贝室、 ht[0]->used 和 ht[1]->used 三個屬性也產(chǎn)生了變化契讲。

3. 節(jié)點(diǎn)遷移完畢
到了這個階段仿吞,所有的節(jié)點(diǎn)都已經(jīng)從 ht[0] 遷移到 ht[1] 了:

4. Rehash 完畢
在 rehash 的最后階段,程序會執(zhí)行以下工作:

  • 釋放 ht[0] 的空間捡偏;
  • 用 ht[1] 來代替 ht[0] 唤冈,使原來的 ht[1] 成為新的 ht[0] ;
  • 創(chuàng)建一個新的空哈希表银伟,并將它設(shè)置為 ht[1] 你虹;
  • 將字典的 rehashidx 屬性設(shè)置為 -1 ,標(biāo)識 rehash 已停止枣申;
    以下是字典 rehash 完畢之后的樣子:

incremental rehashing 增量/漸進(jìn)式rehash

在上一節(jié)售葡,我們了解了字典的 rehash 過程, 需要特別指出的是忠藤, rehash 并不是在觸發(fā)之后挟伙,馬上就執(zhí)行直到完成; 而是分多次模孩、漸進(jìn)式地完成的尖阔。

rehash會產(chǎn)生的問題
1、rehash的過程榨咐,會使用兩個哈希表介却,創(chuàng)建了一個更大空間的ht[1],此時會造成內(nèi)存陡增块茁;
2齿坷、rehash的過程,可能涉及大量KV鍵值對dictEntry的搬運(yùn)数焊,耗時較長永淌;
如果這個 rehash 過程必須將所有鍵值對遷移完畢之后才將結(jié)果返回給用戶, 這樣的處理方式將不滿足Redis高效響應(yīng)的特性佩耳。
rehash會產(chǎn)生的問題遂蛀,主要層面就是內(nèi)存占用陡增、和處理耗時長的問題干厚,基于這兩點(diǎn)李滴,還會帶來其他影響。


為了解決這些問題蛮瞄, Redis 使用了incremental rehashing所坯,是一種 增量/漸進(jìn)式的 rehash 方式: 通過將 rehash 分散到多個步驟中進(jìn)行, 從而避免了集中式的計算/節(jié)點(diǎn)遷移挂捅。

dictAdd 添加鍵值對到dict包竹,檢查到需要進(jìn)行rehash時,會將dict.rehashidx 設(shè)置為 0 ,標(biāo)識著 rehash 的開始周瞎;
后續(xù)請求苗缩,在執(zhí)行add、delete声诸、find操作時酱讶,都會判斷dict是否正在rehash,如果是彼乌,就執(zhí)行_dictRehashStep()函數(shù)泻肯,進(jìn)行增量rehash。

每次執(zhí)行 _dictRehashStep 慰照, 會將ht[0]->table 哈希表第一個不為空的索引上的所有節(jié)點(diǎn)就會全部遷移到 ht[1]->table 灶挟。

也就是在某次dictAdd 添加鍵值對時,觸發(fā)了rehash;后續(xù)add、delete揩徊、find命令在執(zhí)行前都會檢查走芋,如果dict正在rehash齿梁,就先不急去執(zhí)行自己的命令,先去幫忙搬運(yùn)一個bucket;
搬運(yùn)完一個bucket,再執(zhí)行add抬伺、delete、find命令 原有處理邏輯灾梦。

ps:實(shí)際上incremental rehashing增量/漸進(jìn)式rehash峡钓,只解決了第二個:耗時長的問題,將集中式的節(jié)點(diǎn)遷移分?jǐn)偟蕉嗖竭M(jìn)行若河,ht[1]占用的雙倍多內(nèi)存椒楣,還一直占用。

下面我們通過dict的查找(dictFind)來看漸進(jìn)式rehash過程牡肉;

dict的查找(dictFind)

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    unsigned int h, idx, table;

    if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d);// 如果哈希表在rehashing,則執(zhí)行單步rehash
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        while(he) {
            if (key==he->key || dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

上述dictFind的源碼淆九,根據(jù)dict當(dāng)前是否正在rehash统锤,依次做了這么幾件事:

  • 如果當(dāng)前正在進(jìn)行rehash,那么將rehash過程向前推進(jìn)一步(即調(diào)用_dictRehashStep)炭庙。實(shí)際上饲窿,除了查找,插入和刪除也都會觸發(fā)這一動作焕蹄。這就將rehash過程分散到各個查找逾雄、插入和刪除操作中去了,而不是集中在某一個操作中一次性做完。
  • 計算key的哈希值(調(diào)用dictHashKey鸦泳,里面的實(shí)現(xiàn)會調(diào)用前面提到的hashFunction)银锻。
  • 先在第一個哈希表ht[0]上進(jìn)行查找。在table數(shù)組上定位到哈希值對應(yīng)的位置(如前所述做鹰,通過哈希值與sizemask進(jìn)行按位與)击纬,然后在對應(yīng)的dictEntry鏈表上進(jìn)行查找。查找的時候需要對key進(jìn)行比較钾麸,這時候調(diào)用dictCompareKeys更振,它里面的實(shí)現(xiàn)會調(diào)用到前面提到的keyCompare。如果找到就返回該項(xiàng)饭尝。否則肯腕,進(jìn)行下一步。
  • 判斷當(dāng)前是否在rehash钥平,如果沒有实撒,那么在ht[0]上的查找結(jié)果就是最終結(jié)果(沒找到,返回NULL)帖池。否則奈惑,在ht[1]上進(jìn)行查找(過程與上一步相同)。
    下面我們有必要看一下增量式rehash的_dictRehashStep的實(shí)現(xiàn)睡汹。
static void _dictRehashStep(dict *d) {
    if (d->iterators == 0) dictRehash(d,1);
}

/* 
 * Note that a rehashing step consists in moving a bucket (that may have more
 * than one key as we use chaining) from the old to the new hash table, however
 * since part of the hash table may be composed of empty spaces, it is not
 * guaranteed that this function will rehash even a single bucket, since it
 * will visit at max N*10 empty buckets in total, otherwise the amount of
 * work it does would be unbound and the function may block for a long time. 
 */
int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    if (!dictIsRehashing(d)) return 0;

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        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];
        /* Move all the keys in this bucket from the old to the new hash HT */
        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++;
    }

    /* Check if we already rehashed the whole table... */
    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;
    }

    /* More to rehash... */
    return 1;
}

根據(jù)dictRehash函數(shù)的注釋肴甸,rehash的單位是bucket,也就是從老哈希表dictht中找到第一個非空的下標(biāo)囚巴,要把該下標(biāo)整個鏈表搬運(yùn)到新數(shù)組原在。

如果遍歷老數(shù)組,訪問的 前N*10 個都是空bucket彤叉,則不再繼續(xù)往下尋找庶柿。

dictAdd、dictDelete秽浇、dictFind在rehash過程中的特殊性

在哈希表進(jìn)行 rehash 時浮庐, 字典還會采取一些特別的措施, 確保 rehash 順利柬焕、正確地進(jìn)行:

  • 因?yàn)樵?rehash 時审残,字典會同時使用兩個哈希表,所以在這期間的所有查找dictFind斑举、刪除dictDelete等操作搅轿,除了在 ht[0] 上進(jìn)行,還需要在 ht[1] 上進(jìn)行富玷。
  • 在執(zhí)行添加操作dictAdd時璧坟,新的節(jié)點(diǎn)會直接添加到 ht[1] 而不是 ht[0] 既穆,這樣保證 ht[0] 的節(jié)點(diǎn)數(shù)量在整個 rehash 過程中都只減不增。

dict的縮容

上面關(guān)于 rehash 的章節(jié)描述了通過 rehash 對字典進(jìn)行擴(kuò)展(expand)的情況雀鹃, 如果哈希表的可用節(jié)點(diǎn)數(shù)比已用節(jié)點(diǎn)數(shù)大很多的話幻工, 那么也可以通過對哈希表進(jìn)行 rehash 來收縮(shrink)字典。

收縮 rehash 和上面展示的擴(kuò)展 rehash 的操作幾乎一樣褐澎,執(zhí)行以下步驟:

  • 創(chuàng)建一個比 ht[0]->table 小的 ht[1]->table 会钝;
  • 將 ht[0]->table 中的所有鍵值對遷移到 ht[1]->table ;
  • 將原有 ht[0] 的數(shù)據(jù)清空工三,并將 ht[1] 替換為新的 ht[0] 迁酸;

小結(jié)

Redis的dict最顯著的一個特點(diǎn),就在于它的rehash俭正。它采用了一種稱為增量式(incremental rehashing)的rehash方法奸鬓,在需要擴(kuò)容時避免一次性對所有key進(jìn)行rehash,而是將rehash操作分散到對于dict的各個增刪改查的操作中去掸读。
這種方法能做到每次只對一小部分key進(jìn)行rehash串远,而每次rehash之間不影響dict的操作。dict之所以這樣設(shè)計儿惫,是為了避免rehash期間單個請求的響應(yīng)時間劇烈增加澡罚,這與前面提到的“快速響應(yīng)時間”的設(shè)計原則是相符的。

  • Redis的dict也是使用數(shù)組+鏈表實(shí)現(xiàn)肾请;
  • 當(dāng)沖突增加留搔、鏈表增長,也是采用rehash(數(shù)組擴(kuò)容)來將鏈表變短铛铁;
  • dict數(shù)組擴(kuò)容隔显,也是按2的指數(shù)次冪,使用位運(yùn)算饵逐,替代求余操作括眠,計算更快;
  • 漸進(jìn)式rehash倍权,其實(shí)是輔助式的掷豺;不是讓觸發(fā)rehash的一個人搬運(yùn)完所有dictEntry,而是讓后來者一起參與搬運(yùn)薄声。

福利:關(guān)注公眾號当船,回復(fù)“Redis源碼”,即可獲得 Redis 3.0源碼注釋版~

本文首發(fā)于公眾號 架構(gòu)道與術(shù)(ToBeArchitecturer)奸柬,歡迎關(guān)注、學(xué)習(xí)更多干貨~

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末婴程,一起剝皮案震驚了整個濱河市廓奕,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖桌粉,帶你破解...
    沈念sama閱讀 217,406評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蒸绩,死亡現(xiàn)場離奇詭異,居然都是意外死亡铃肯,警方通過查閱死者的電腦和手機(jī)患亿,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,732評論 3 393
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來押逼,“玉大人步藕,你說我怎么就攤上這事√舾瘢” “怎么了咙冗?”我有些...
    開封第一講書人閱讀 163,711評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長漂彤。 經(jīng)常有香客問我雾消,道長,這世上最難降的妖魔是什么挫望? 我笑而不...
    開封第一講書人閱讀 58,380評論 1 293
  • 正文 為了忘掉前任立润,我火速辦了婚禮,結(jié)果婚禮上媳板,老公的妹妹穿的比我還像新娘桑腮。我一直安慰自己,他們只是感情好拷肌,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,432評論 6 392
  • 文/花漫 我一把揭開白布到旦。 她就那樣靜靜地躺著,像睡著了一般巨缘。 火紅的嫁衣襯著肌膚如雪添忘。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,301評論 1 301
  • 那天若锁,我揣著相機(jī)與錄音搁骑,去河邊找鬼。 笑死又固,一個胖子當(dāng)著我的面吹牛仲器,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播仰冠,決...
    沈念sama閱讀 40,145評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼乏冀,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了洋只?” 一聲冷哼從身側(cè)響起辆沦,我...
    開封第一講書人閱讀 39,008評論 0 276
  • 序言:老撾萬榮一對情侶失蹤昼捍,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后肢扯,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體妒茬,經(jīng)...
    沈念sama閱讀 45,443評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,649評論 3 334
  • 正文 我和宋清朗相戀三年蔚晨,在試婚紗的時候發(fā)現(xiàn)自己被綠了乍钻。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,795評論 1 347
  • 序言:一個原本活蹦亂跳的男人離奇死亡铭腕,死狀恐怖银择,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情谨履,我是刑警寧澤欢摄,帶...
    沈念sama閱讀 35,501評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站笋粟,受9級特大地震影響怀挠,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜害捕,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,119評論 3 328
  • 文/蒙蒙 一绿淋、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧尝盼,春花似錦吞滞、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,731評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至赴精,卻和暖如春佩捞,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背蕾哟。 一陣腳步聲響...
    開封第一講書人閱讀 32,865評論 1 269
  • 我被黑心中介騙來泰國打工一忱, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人谭确。 一個月前我還...
    沈念sama閱讀 47,899評論 2 370
  • 正文 我出身青樓帘营,卻偏偏與公主長得像,于是被迫代替她去往敵國和親逐哈。 傳聞我的和親對象是個殘疾皇子芬迄,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,724評論 2 354

推薦閱讀更多精彩內(nèi)容

  • Redis 是一個鍵值對數(shù)據(jù)庫(key-value DB),數(shù)據(jù)庫的值可以是字符串昂秃、集合禀梳、列表等多種類型的對象择诈,而...
    吳昂_ff2d閱讀 3,190評論 0 5
  • 上一篇文章詳細(xì)介紹了Redis的五種常用數(shù)據(jù)類型,每種數(shù)據(jù)類型在內(nèi)存中的數(shù)據(jù)結(jié)構(gòu)都會因情況而異哗戈。接下來郊艘,我會用幾篇...
    Javar閱讀 1,898評論 0 2
  • Map 是一種很常見的數(shù)據(jù)結(jié)構(gòu)纱注,用于存儲一些無序的鍵值對。在主流的編程語言中胆胰,默認(rèn)就自帶它的實(shí)現(xiàn)狞贱。C、C++ 中的...
    一縷殤流化隱半邊冰霜閱讀 9,266評論 23 67
  • 字典本身就是很常見的數(shù)據(jù)結(jié)構(gòu)之一蜀涨,在Redis中瞎嬉,Redis數(shù)據(jù)庫就是使用字典來作為底層實(shí)現(xiàn)的,除了用來表示數(shù)據(jù)庫...
    wenmingxing閱讀 9,271評論 3 10
  • 今天我表現(xiàn)的非常好,媽媽就給我獎勵一個好玩的玩具别垮,他的名字叫玩具變形金剛車便监。 里面還有一個玩具拼裝模擬說明書。我照...
    2ce84d852ff4閱讀 165評論 0 0