Redis底層詳解(一) 雜湊表和字典
一、雜湊表概述
首先簡單介紹幾個概念:雜湊表(散列表)、對映、衝突、鏈地址、雜湊函式。
雜湊表(Hash table)的初衷是為了將資料對映到陣列中的某個位置,這樣就能夠通過陣列下標訪問該資料,提高資料的查詢速度,這樣的查詢的平均期望時間複雜度是O(1)的。
例如四個整數 6、7、9、12 需要對映到陣列中,我們可以開一個長度為13(C語言下標從0開始)的陣列,然後將對應值放到對應的下標,但是這樣做,就會浪費沒有被對映到的位置的空間。
採用雜湊表的話,我們可以只申請一個長度為4的陣列,如下圖所示:
將每個數的值對陣列長度4取模,然後放到對應的陣列槽位中,這樣就把離散的資料對映到了連續的空間,所以雜湊表又稱為散列表。這樣做,最大限度上提高空間了利用率,並且查詢效率還很高。
那麼問題來了,如果這四個資料是6、7、8、11呢?繼續看圖:
7 和 11 對4取模的值都是 3,所以佔據了同一個槽位,這種情況我們稱為衝突 (collision)。一般遇到衝突後,有很多方法解決衝突,包括但不限於 開放地址法、再雜湊法、鏈地址法 等等。 Redis採用的是鏈地址法,所以這裡只介紹鏈地址法,其它的方法如果想了解請自行百度。
鏈地址法就是將有衝突的資料用一個連結串列串聯起來,如圖所示:
這樣一來,就算有衝突,也可以將有衝突的資料儲存在一起了。儲存結構需要稍加變化,雜湊表的每個元素將變成一個指標,指向資料鏈表的連結串列頭,每次有新資料來時從連結串列頭插入,可以達到插入的時間複雜度保持O(1)。
再將問題進行變形,如果4個數據是 "are", "you", "OK", "?" 這樣的字串,如何進行對映呢?沒錯,我們需要通過一個雜湊函式將字串變成整數,雜湊函式的概念會在接下來詳細講述,這裡只需要知道它可以把一個值變成另一個值即可,比如雜湊函式f(x),呼叫 f("are") 就可以得到一個整數,f("you") 也可以得到一個整數。
一個簡易的大小寫不敏感的字串雜湊函式如下:
unsigned int hashFunction(const unsigned char *buf, int len) {
unsigned int hash = (unsigned int)5381; // hash初始種子,實驗值
while (len--)
hash = ((hash << 5) + hash) + (tolower(*buf++)); // hash * 33 + c
return hash;
}
我們看到,雜湊函式的作用就是把非數字的物件通過一系列的演算法轉化成數字(下標),得到的數字可能是雜湊表陣列無法承載的,所以還需要通過取模才能對映到連續的陣列空間中。對於這個取模,我們知道取模的效率相比位運算來說是很低的,那麼有沒有什麼辦法可以把取模用位運算來代替呢?
答案是有!我們只要把雜湊表的長度 L 設定為2的冪(L = 2^n),那麼 L-1 的二進位制表示就是n個1,任何值 x 對 L 取模等同於和 (L-1) 進行位與(C語言中的&)運算。
介紹完雜湊表的基礎概念,我們來看看 Redis 中是如何實現字典的。
二、Redis資料結構定義
1、雜湊表
雜湊表的結構定義在 dict.h/dictht :
typedef struct dictht {
dictEntry **table; // 雜湊表陣列
unsigned long size; // 雜湊表陣列的大小
unsigned long sizemask; // 用於對映位置的掩碼,值永遠等於(size-1)
unsigned long used; // 雜湊表已有節點的數量
} dictht;
table 是一個數組,陣列的每個元素都是一個指向 dict.h/dictEntry 結構的指標;
size 記錄雜湊表的大小,即 table 陣列的大小,且一定是2的冪;
used 記錄雜湊表中已有結點的數量;
sizemask 用於對雜湊過的鍵進行對映,索引到 table 的下標中,且值永遠等於 size-1。具體對映方法很簡單,就是對 雜湊值 和 sizemask 進行位與操作,由於 size 一定是2的冪,所以 sizemask=size-1,自然它的二進位制表示的每一個位(bit)都是1,等同於上文提到的取模;
如圖所示,為一個長度為8的空雜湊表。
2、雜湊表節點
雜湊表節點用 dict.h/dictEntry 結構表示,每個 dictEntry 結構儲存著一個鍵值對,且存有一個 next 指標來保持連結串列結構:
typedef struct dictEntry {
void *key; // 鍵
union { // 值
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 指向下一個雜湊表節點,形成單向連結串列
} dictEntry;
key 是鍵值對中的鍵;
v 是鍵值對中的值,它是一個聯合型別,方便儲存各種結構;
next 是連結串列指標,指向下一個雜湊表節點,他將多個雜湊值相同的鍵值對串聯在一起,用於解決鍵衝突;如圖所示,兩個dictEntry 的 key 分別是 k0 和 k1,通過某種雜湊演算法計算出來的雜湊值和 sizemask 進行位與運算後都等於 3,所以都被放在了 table 陣列的 3號槽中,並且用 next 指標串聯起來。
3、字典
Redis中字典結構由 dict.h/dict 表示:
typedef struct dict {
dictType *type; // 和型別相關的處理函式
void *privdata; // 上述型別函式對應的可選引數
dictht ht[2]; // 兩張雜湊表,ht[0]為原生雜湊表,ht[1]為 rehash 雜湊表
long rehashidx; // 當等於-1時表示沒有在 rehash,否則表示 rehash 的下標
int iterators; // 迭代器數量(暫且不談)
} dict;
type 是一個指向 dict.h/dictType 結構的指標,儲存了一系列用於操作特定型別鍵值對的函式;
privdata 儲存了需要傳給上述特定函式的可選引數;
ht 是兩個雜湊表,一般情況下,只使用ht[0],只有當雜湊表的鍵值對數量超過負載(元素過多)時,才會將鍵值對遷移到ht[1],這一步遷移被稱為 rehash (重雜湊),rehash 會在下文進行詳細介紹;
rehashidx 由於雜湊表鍵值對有可能很多很多,所以 rehash 不是瞬間完成的,需要按部就班,那麼 rehashidx 就記錄了當前 rehash 的進度,當 rehash 完畢後,將 rehashidx 置為-1;
4、型別處理函式
型別處理函式全部定義在 dict.h/dictType 中:
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;
以上的函式和特定型別相關,主要是為了實現多型,看到這個如果懵逼也沒關係,下面會一一對其進行介紹。
三、雜湊函式
型別處理函式中的第一個函式 hashFunction 就是計算某個鍵的雜湊值的函式,對於不同型別的 key,雜湊值的計算是不同的,所以在字典進行建立的時候,需要指定雜湊函式。
雜湊函式可以簡單的理解為就是小學課本上那個函式,即y = f(x),這裡的 f(x)就是雜湊函式,x是鍵,y就是雜湊值。好的雜湊函式應該具備以下兩個特質:
1、可逆性;
2、雪崩效應:輸入值(x)的1位(bit)的變化,能夠造成輸出值(y)1/2的位(bit)的變化;
可逆性很容易理解,來看兩個圖。圖(a)中已知雜湊值 y 時,鍵 x 可能有兩種情況,所以顯然是不可逆的;而圖(b)中已知雜湊值 y 時,鍵 x 一定是唯一確定的,所以它是可逆的。從圖中看出,函式可逆的好處是:減少衝突。由於 x 和 y 一一對應,所以在沒有取模之前,至少是沒有衝突的,這樣就從本原上減少了衝突。
雪崩效應是為了讓雜湊值更加符合隨機分佈的原則,雜湊表中的鍵分佈的越隨機,利用率越高,效率也越高。
Redis原始碼中提供了一些雜湊函式的實現:
1、整數雜湊
unsigned int dictIntHashFunction(unsigned int key)
{
key += ~(key << 15);
key ^= (key >> 10);
key += (key << 3);
key ^= (key >> 6);
key += ~(key << 11);
key ^= (key >> 16);
return key;
}
2、字串雜湊
unsigned int dictGenCaseHashFunction(const unsigned char *buf, int len) {
unsigned int hash = (unsigned int)dict_hash_function_seed;
while (len--)
hash = ((hash << 5) + hash) + (tolower(*buf++)); /* hash * 33 + c */
return hash;
}
這些雜湊函式是前人經過一系列的實驗,科學計算總結得出來的,我們只需要知道有這麼些函式就行了。當字典被用作資料庫的底層實現, 或者雜湊鍵的底層實現時, Redis 使用 MurmurHash2 演算法來計算鍵的雜湊值。MurmurHash 演算法最初由 Austin Appleby 於 2008 年發明, 這種演算法的優點在於, 即使輸入的鍵是有規律的, 演算法仍能給出一個很好的隨機分佈性, 並且演算法的計算速度也非常快。
四、雜湊演算法
1、索引
當要將一個新的鍵值對新增到字典裡面或者通過鍵查詢值的時候都需要執行雜湊演算法,主要是獲得一個需要插入或者查詢的dictEntry 所在下標的索引,具體演算法如下:
1、通過巨集 dictHashKey 計算得到該鍵對應的雜湊值
#define dictHashKey(d, key) (d)->type->hashFunction(key)
2、將雜湊值和雜湊表的 sizemask 屬性做位與,得到索引值 index,其中 ht[x] 可以是 ht[0] 或者 ht[1]
index = dictHashKey(d, key) & d->ht[x].sizemask;
2、衝突解決
雜湊的衝突一定發生在鍵值對插入時,插入的 API 是 dict.c/dictAddRaw:
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d); // 1、執行rehash
if ((index = _dictKeyIndex(d, key)) == -1) // 2、索引定位
return NULL;
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; // 3、根據是否 rehash ,選擇雜湊表
entry = zmalloc(sizeof(*entry)); // 4、分配記憶體空間,執行插入
entry->next = ht->table[index];
ht->table[index] = entry;
ht->used++;
dictSetKey(d, entry, key); // 5、設定鍵
return entry;
}
1、判斷當前的字典是否在進行 rehash,如果是,則執行一步 rehash,否則忽略。判斷 rehash 的依據就是 rehashidx 是否為1;
2、通過 _dictKeyIndex 找到一個索引,如果返回-1表明字典中已經存在相同的 key,具體參見接下來要講的 索引定位;
3、根據是否在 rehash 選擇對應的雜湊表;
4、分配雜湊表節點 dictEntry 的記憶體空間,執行插入,插入操作始終在連結串列頭插入,這樣可以保證每次的插入操作的時間複雜度一定是 O(1) 的,插入完畢,used屬性自增;
5、dictSetKey 是個巨集,呼叫字典處理函式中的 keyDup 函式進行鍵的複製;
3、索引定位
插入時還需要進行索引定位,以確定節點要插入到雜湊表的哪個位置,實現在靜態函式 dict.c/_dictKeyIndex 中:
static int _dictKeyIndex(dict *d, const void *key)
{
unsigned int h, idx, table;
dictEntry *he;
if (_dictExpandIfNeeded(d) == DICT_ERR) // 1、rehash 判斷
return -1;
h = dictHashKey(d, key); // 2、雜湊函式計算雜湊值
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask; // 3、雜湊演算法計算索引值
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key)) // 4、查詢鍵是否已經存在
return -1;
he = he->next;
}
if (!dictIsRehashing(d)) break; // 5、rehash 判斷
}
return idx;
}
1、判斷當前雜湊表是否需要進行擴充套件,具體參見接下來要講的 rehash;
2、利用給定的雜湊函式計算鍵的雜湊值;
3、通過位與計算索引,即插入到雜湊表的哪個槽位中;
4、查詢當前槽位中的連結串列裡是否已經存在該鍵,如果存在直接返回 -1;這裡的 dictCompareKeys 也是一個巨集,用到了keyCompare 這個比較鍵的函式;
5、這個判斷比較關鍵,如果當前沒有在做 rehash,那麼 ht[1] 必然是一個空表,所以不能遍歷 ht[1],需要及時跳出迴圈;
五、rehash
千呼萬喚始出來,提到了這麼多次的 rehash 終於要開講了。其實沒有想象中的那麼複雜,隨著字典操作的不斷執行,雜湊表儲存的鍵值對會不斷增多(或者減少),為了讓雜湊表的負載因子維持在一個合理的範圍之內,當雜湊表儲存的鍵值對數量太多或者太少時,需要對雜湊表大小進行擴充套件或者收縮。
1、負載因子
這裡提到了一個負載因子,其實就是當前已使用結點數量除上雜湊表的大小,即:
load_factor = ht[0].used / ht[0].size
2、雜湊表擴充套件
1、當雜湊表的負載因子大於5時,為 ht[1] 分配空間,大小為第一個大於等於 ht[0].used * 2 的 2 的冪;
2、將儲存在 ht[0] 上的鍵值對 rehash 到 ht[1] 上,rehash 就是重新計算雜湊值和索引,並且重新插入到 ht[1] 中,插入一個刪除一個;
3、當 ht[0] 包含的所有鍵值對全部 rehash 到 ht[1] 上後,釋放 ht[0] 的控制元件, 將 ht[1] 設定為 ht[0],並且在 ht[1] 上新創件一個空的雜湊表,為下一次 rehash 做準備;
Redis 中 實現雜湊表擴充套件呼叫的是 dict.c/_dictExpandIfNeeded 函式:
static int _dictExpandIfNeeded(dict *d)
{
if (dictIsRehashing(d)) return DICT_OK;
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); // 大小為0需要設定初始雜湊表大小為4
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) // 負載因子超過5,執行 dictExpand
{
return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;
}
3、雜湊表收縮
雜湊表的收縮,同樣是為 ht[1] 分配空間, 大小等於 max( ht[0].used, DICT_HT_INITIAL_SIZE ),然後和擴充套件做同樣的處理即可。
六、漸進式rehash
擴充套件或者收縮雜湊表的時候,需要將 ht[0] 裡面所有的鍵值對 rehash 到 ht[1] 裡,當鍵值對數量非常多的時候,這個操作如果在一幀內完成,大量的計算很可能導致伺服器宕機,所以不能一次性完成,需要漸進式的完成。
漸進式 rehash 的詳細步驟如下:
1、為 ht[1] 分配指定空間,讓字典同時持有 ht[0] 和 ht[1] 兩個雜湊表;
2、將 rehashidx 設定為0,表示正式開始 rehash,前兩步是在 dict.c/dictExpand 中實現的:
int dictExpand(dict *d, unsigned long size)
{
dictht n;
unsigned long realsize = _dictNextPower(size); // 找到比size大的最小的2的冪
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
if (realsize == d->ht[0].size) return DICT_ERR;
n.size = realsize; // 給ht[1]分配 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; // rehashidx 設定為0,開始漸進式 rehash
return DICT_OK;
}
3、在進行 rehash 期間,每次對字典執行 增、刪、改、查操作時,程式除了執行指定的操作外,還會將 雜湊表 ht[0].table中下標為 rehashidx 位置上的所有的鍵值對 全部遷移到 ht[1].table 上,完成後 rehashidx 自增。這一步就是 rehash 的關鍵一步。為了防止 ht[0] 是個稀疏表 (遍歷很久遇到的都是NULL),從而導致函式阻塞時間太長,這裡引入了一個 “最大空格訪問數”,也即程式碼中的 enmty_visits,初始值為 n*10。當遇到NULL的數量超過這個初始值直接返回。
這一步實現在 dict.c/dictRehash 中:
int dictRehash(dict *d, int n) {
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; // 設定一個空訪問數量 為 n*10
}
de = d->ht[0].table[d->rehashidx]; // dictEntry的遷移
while(de) {
unsigned int 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++; // 完成一次 rehash
}
if (d->ht[0].used == 0) { // 遷移完畢,rehashdix 置為 -1
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
return 1;
}
4、最後,當 ht[0].used 變為0時,代表所有的鍵值對都已經從 ht[0] 遷移到 ht[1] 了,釋放 ht[0].table, 並且將 ht[0] 設定為 ht[1],rehashidx 標記為 -1 代表 rehash 結束。
七、字典API
1、建立字典
內部分配字典空間,並作為返回值返回,並呼叫 _dictInit 進行字典的初始化,時間複雜度O(1)。
dict *dictCreate(dictType *type, void *privDataPtr)
2、增加鍵值對
呼叫 dictAddRaw 增加一個 dictEntry,然後呼叫 dictSetVal 設定值,時間複雜度O(1)。
int dictAdd(dict *d, void *key, void *val)
3、查詢鍵
利用雜湊演算法找到給定鍵的 dictEntry,時間複雜度O(1)。
dictEntry *dictFind(dict *d, const void *key)
4、查詢值
利用 dictFind 找到給定鍵的 dictEntry,然後獲得值,值的型別不確定,所以返回一個萬能指標,時間複雜度O(1)。
void *dictFetchValue(dict *d, const void *key)
5、刪除鍵
通過雜湊演算法找到對應的鍵,從對應連結串列移除,時間複雜度O(1)。
int dictDelete(dict *ht, const void *key)