在 redis 中咱們常常用到的 hash 結構,以及整個 redis 的 db 中 key-value 結構,都是以 dict 的形式存在,也就是字典。redis
// 字典結構
typedef struct dict {
// 類型特定函數
dictType *type;
// 保存類型特定函數須要使用的參數
void *privdata;
// 保存的兩個哈希表,ht[0]是真正使用的,ht[1]會在rehash時使用
dictht ht[2];
// rehash進度,若是不等於-1,說明還在進行rehash
long rehashidx;
// 正在運行中的遍歷器數量
unsigned long iterators;
} dict;
// hashtable結構
typedef struct dictht {
// 哈希表節點數組
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩碼,用於計算哈希表的索引值,大小老是dictht.size - 1
unsigned long sizemask;
// 哈希表已經使用的節點數量
unsigned long used;
} dictht;
// hashtable的鍵值對節點結構
typedef struct dictEntry {
// 鍵名
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一個節點, 將多個哈希值相同的鍵值對鏈接起來
struct dictEntry *next;
} dictEntry;
複製代碼
由上面的結構咱們能夠看到 dict 結構內部包含兩個 hashtable(如下簡稱ht),一般狀況下只有一個 ht 是有值的。ht 是一個 dictht 的的結構,dictht 的結構和 Java 的 HashMap 幾乎是同樣的,都是經過分桶的方式解決 hash 衝突。第一維是數組,第二維是鏈表。數組中存儲的是第二維鏈表的第一個元素的指針。這個指針在 ht 中就是指向一個 dictEntry 結構,裏面存放着鍵值對的數據,以及指向下一個節點的指針。數組
Redis 計算哈希值和索引值的方法以下:服務器
// 使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);
// 使用哈希表的 sizemask 屬性和哈希值,計算出索引值
// 根據狀況不一樣, ht 能夠是 ht[0] 或者 ht[1]
index = hash & dict->ht.sizemask;
複製代碼
hash函數咱們這裏就不說明了,計算出的 hash 值後將該值和 ht 的長度掩碼(長度 - 1 )作與運算得出數組的索引值,這裏我要解釋下這麼作的緣由:函數
保證不會發生數組越界 首先咱們要知道,ht 中數組的長度按規定必定是2的冪(2的n次方)。所以,數組的長度的二進制形式是:10000…000,1後面有一堆0。那麼,dict->ht.sizemask(dict->ht.size - 1) 的二進制形式就是01111…111,0後面有一堆1。最高位是0,和hash值相「與」,結果值必定不會比數組的長度值大,所以也就不會發生數組越界。ui
保證元素儘量的均勻分佈 由上邊的分析可知,dict->ht.size 必定是一個偶數,dict->ht.sizemask 必定是一個奇數。假設如今數組的長度(dict->ht.size)爲16,減去1後(dict->ht.sizemask)就是15,15對應的二進制是:1111。如今假設有兩個元素須要插入,一個哈希值是8,二進制是1000,一個哈希值是9,二進制是1001。和1111「與」運算後,結果分別是1000和1001,它們被分配在了數組的不一樣位置,這樣,哈希的分佈很是均勻。spa
那麼,若是數組長度是奇數呢?減去1後(dict->ht.sizemask)就是偶數了,偶數對應的二進制最低位必定是 0,例如14二進制1110。對上面兩個數子分別「與」運算,獲得1000和1000。結果都是同樣的值。那麼,哈希值8和9的元素都被存儲在數組同一個index位置的鏈表中。在操做的時候,鏈表中的元素越多,效率越低,由於要不停的對鏈表循環比較。3d
爲何 ht 中數組的長度必定是2的n次方?由於其實計算索引的過程其實就是取模(求餘數),可是取餘操做 % 的效率沒有位運算 & 來的高,而 hash%length==hash&(length-1)的條件就是 length 是 2的次方,這裏的緣由上面也解釋過了。指針
隨着操做的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減小, 爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍以內, 當哈希表保存的鍵值對數量太多或者太少時, 程序須要對哈希表的大小進行相應的擴展或者收縮,也就是 rehash。code
Redis 對字典的哈希表執行 rehash 的步驟以下:cdn
ht[1]
哈希表分配空間, 這個哈希表的空間大小取決於要執行的操做, 以及 ht[0]
當前包含的鍵值對數量 (也便是 ht[0].used
屬性的值):
ht[1]
的大小爲第一個大於等於 ht[0].used * 2
的 2^n (2
的 n
次方冪);ht[1]
的大小爲第一個大於等於 ht[0].used
的 2^n 。ht[0]
中的全部鍵值對 rehash 到 ht[1]
上面: rehash 指的是從新計算鍵的哈希值和索引值, 而後將鍵值對放置到 ht[1]
哈希表的指定位置上。ht[0]
包含的全部鍵值對都遷移到了 ht[1]
以後 (ht[0]
變爲空表), 釋放 ht[0]
, 將 ht[1]
設置爲 ht[0]
, 並在 ht[1]
新建立一個空白哈希表, 爲下一次 rehash 作準備。這就是爲何redis 的 dict 中要保存2個 ht 的緣由,方便2個 ht 的遷移替換。
爲何不直接複製 ht[0]
中的全部節點到 ht[1]
上而是 rehash 一遍?
咱們在看一遍計算索引的公式:index = hash & dict->ht.sizemask;
注意到了嗎,索引值的計算與字典數組的長度有關,而咱們rehash時數組的長度是已經變化了,因此須要從新計算。
那麼rehash的條件是什麼呢,ht 達到什麼樣的數量redis會去執行rehash呢?
當如下條件中的任意一個被知足時, 程序會自動開始對哈希表執行擴展操做:
1
;5
;其中哈希表的負載因子能夠經過公式:
// 負載因子 = 哈希表已保存節點數量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
複製代碼
負載因子其實就是一個哈希表的使用比例,用來衡量哈希表的容量狀態。
bgsave 或 bgrewriteaof 命令會形成內存頁的過多分離 (Copy On Write),Redis 儘可能不去擴容 ,可是若是 hash 表已經很是滿了,元素的個數已經達到了第一維數組長度的 5 倍,這個時候就會強制擴容。
另外一方面, 當哈希表的負載因子小於 0.1
時, 程序自動開始對哈希表執行收縮操做。
爲何稱爲漸進式?
擴展或收縮哈希表須要將 ht[0] 裏面的全部鍵值對 rehash 到 ht[1] 裏面, 可想而知大字典的 rehash過程是很耗時的,因此 redis 使用了一種漸進式的 rehash,也就是慢慢地將 ht[0] 裏面的鍵值對 rehash 到 ht[1]。
如下是哈希表漸進式 rehash 的詳細步驟:
ht[1]
分配空間, 讓字典同時持有 ht[0]
和 ht[1]
兩個哈希表。rehashidx
, 並將它的值設置爲 0
, 表示 rehash 工做正式開始。ht[0]
哈希表在 rehashidx
索引上的全部鍵值對 rehash 到 ht[1]
, 當 rehash 工做完成以後, 程序將 rehashidx
屬性的值增一。ht[0]
的全部鍵值對都會被 rehash 至 ht[1]
, 這時程序將 rehashidx
屬性的值設爲 -1
, 表示 rehash 操做已完成。因此你們能夠看到這整個過程是分步走的,每次rehash一點,直到執行完。那麼問題也來的,在漸進式rehash的過程當中,咱們的字典裏 ht[0] 和 ht[1] 會同時存在數據,那麼這時候操做字典會不會混亂呢,redis爲此提出瞭如下的邏輯判斷:
由於在進行漸進式 rehash 的過程當中, 字典會同時使用 ht[0]
和 ht[1]
兩個哈希表, 因此在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操做會在兩個哈希表上進行: 好比說, 要在字典裏面查找一個鍵的話, 程序會先在 ht[0]
裏面進行查找, 若是沒找到的話, 就會繼續到 ht[1]
裏面進行查找, 諸如此類。
另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一概會被保存到 ht[1]
裏面, 而 ht[0]
則再也不進行任何添加操做: 這一措施保證了 ht[0]
包含的鍵值對數量會只減不增, 並隨着 rehash 操做的執行而最終變成空表。