Redis 字典
在 redis 中我們經常用到的 hash 結構,以及整個 redis 的 db 中 key-value 結構,都是以 dict 的形式存在,也就是字典。
原始碼結構
// 字典結構
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 結構,裡面存放著鍵值對的資料,以及指向下一個節點的指標。
Hash計算
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值相“與”,結果值一定不會比陣列的長度值大,因此也就不會發生陣列越界。
-
保證元素儘可能的均勻分佈 由上邊的分析可知,dict->ht.size 一定是一個偶數,dict->ht.sizemask 一定是一個奇數。假設現在陣列的長度(dict->ht.size)為16,減去1後(dict->ht.sizemask)就是15,15對應的二進位制是:1111。現在假設有兩個元素需要插入,一個雜湊值是8,二進位制是1000,一個雜湊值是9,二進位制是1001。和1111“與”運算後,結果分別是1000和1001,它們被分配在了陣列的不同位置,這樣,雜湊的分佈非常均勻。
那麼,如果陣列長度是奇數呢?減去1後(dict->ht.sizemask)就是偶數了,偶數對應的二進位制最低位一定是 0,例如14二進位制1110。對上面兩個數子分別“與”運算,得到1000和1000。結果都是一樣的值。那麼,雜湊值8和9的元素都被儲存在陣列同一個index位置的連結串列中。在操作的時候,連結串列中的元素越多,效率越低,因為要不停的對連結串列迴圈比較。
為什麼 ht 中陣列的長度一定是2的n次方?因為其實計算索引的過程其實就是取模(求餘數),但是取餘操作 % 的效率沒有位運算 & 來的高,而 hash%length==hash&(length-1)的條件就是 length 是 2的次方,這裡的原因上面也解釋過了。
漸進式rehash
隨著操作的不斷執行, 雜湊表儲存的鍵值對會逐漸地增多或者減少, 為了讓雜湊表的負載因子(load factor)維持在一個合理的範圍之內, 當雜湊表儲存的鍵值對數量太多或者太少時, 程式需要對雜湊表的大小進行相應的擴充套件或者收縮,也就是 rehash。
Redis 對字典的雜湊表執行 rehash 的步驟如下:
- 為字典的
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呢?
當以下條件中的任意一個被滿足時, 程式會自動開始對雜湊表執行擴充套件操作:
- 伺服器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且雜湊表的負載因子大於等於
1
; - 伺服器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且雜湊表的負載因子大於等於
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 工作正式開始。 - 在 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 操作的執行而最終變成空表。