字典
一個鍵(key)可以和一個值(value)進行關聯(或者說將鍵映射爲值), 這些關聯的鍵和值就被稱爲鍵值對。
字典在 Redis 中的應用相當廣泛, 比如 Redis 的數據庫就是使用字典來作爲底層實現的, 對數據庫的增、刪、查、改操作也是構建在對字典的操作之上的。
除了用來表示數據庫之外, 字典還是哈希鍵的底層實現之一: 當一個哈希鍵包含的鍵值對比較多, 又或者鍵值對中的元素都是比較長的字符串時, Redis 就會使用字典作爲哈希鍵的底層實現。
Redis 的字典使用哈希表作爲底層實現, 一個哈希表裏面可以有多個哈希表節點, 而每個哈希表節點就保存了字典中的一個鍵值對。
哈希表
Redis 字典所使用的哈希表由 dict.h/dictht
結構定義:
typedef struct dictht {
// 哈希表數組
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩碼,用於計算索引值
// 總是等於 size - 1
unsigned long sizemask;
// 該哈希表已有節點的數量
unsigned long used;
} dictht;
table
屬性是一個數組, 數組中的每個元素都是一個指向 dict.h/dictEntry
結構的指針, 每個 dictEntry
結構保存着一個鍵值對。
size
屬性記錄了哈希表的大小, 也即是 table
數組的大小, 而 used
屬性則記錄了哈希表目前已有節點(鍵值對)的數量。
sizemask
屬性的值總是等於 size - 1
, 這個屬性和哈希值一起決定一個鍵應該被放到 table
數組的哪個索引上面。
哈希表節點
哈希表節點使用 dictEntry
結構表示, 每個 dictEntry
結構都保存着一個鍵值對:
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下個哈希表節點,形成鏈表
struct dictEntry *next;
} dictEntry;
key
屬性保存着鍵值對中的鍵, 而 v
屬性則保存着鍵值對中的值, 其中鍵值對的值可以是一個指針, 或者是一個 uint64_t
整數, 又或者是一個 int64_t
整數。
next
屬性是指向另一個哈希表節點的指針, 這個指針可以將多個哈希值相同的鍵值對連接在一次, 以此來解決鍵衝突(collision)的問題。
字典
Redis 中的字典由 dict.h/dict
結構表示:
typedef struct dict {
// 類型特定函數
dictType *type;
// 私有數據
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 當 rehash 不在進行時,值爲 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
type
屬性和 privdata
屬性是針對不同類型的鍵值對, 爲創建多態字典而設置的:
-
type
屬性是一個指向dictType
結構的指針, 每個dictType
結構保存了一簇用於操作特定類型鍵值對的函數, Redis 會爲用途不同的字典設置不同的類型特定函數。 -
而
privdata
屬性則保存了需要傳給那些類型特定函數的可選參數。
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;
ht
屬性是一個包含兩個項的數組, 數組中的每個項都是一個 dictht
哈希表, 一般情況下, 字典只使用 ht[0]
哈希表, ht[1]
哈希表只會在對 ht[0]
哈希表進行 rehash 時使用。
除了 ht[1]
之外, 另一個和 rehash 有關的屬性就是 rehashidx
: 它記錄了 rehash 目前的進度, 如果目前沒有在進行 rehash , 那麼它的值爲 -1
。
哈希算法
當要將一個新的鍵值對添加到字典裏面時, 程序需要先根據鍵值對的鍵計算出哈希值和索引值, 然後再根據索引值, 將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面。
Redis 計算哈希值和索引值的方法如下:
# 使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 屬性和哈希值,計算出索引值
# 根據情況不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。
解決鍵衝突
Redis 的哈希表使用鏈地址法(separate chaining)來解決鍵衝突: 每個哈希表節點都有一個 next
指針, 多個哈希表節點可以用 next
指針構成一個單向鏈表, 被分配到同一個索引上的多個節點可以用這個單向鏈表連接起來, 這就解決了鍵衝突的問題。
因爲 dictEntry
節點組成的鏈表沒有指向鏈表表尾的指針, 所以爲了速度考慮, 程序總是將新節點添加到鏈表的表頭位置O(1)
, 排在其他已有節點的前面。
rehash
隨着操作的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減少, 爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍之內, 當哈希表保存的鍵值對數量太多或者太少時, 程序需要對哈希表的大小進行相應的擴展或者收縮。
擴展和收縮哈希表的工作可以通過執行 rehash (重新散列)操作來完成, Redis 對字典的哈希表執行 rehash 的步驟如下:
-
爲字典的
ht[1]
哈希表分配空間, 這個哈希表的空間大小取決於要執行的操作, 以及ht[0]
當前包含的鍵值對數量 (也即是ht[0].used
屬性的值):-
如果執行的是擴展操作, 那麼
ht[1]
的大小爲第一個大於等於ht[0].used * 2
的 2N(2
的n
次方冪); -
如果執行的是收縮操作, 那麼
ht[1]
的大小爲第一個大於等於ht[0].used
的 2N 。
-
-
將保存在
ht[0]
中的所有鍵值對 rehash 到ht[1]
上面: rehash 指的是重新計算鍵的哈希值和索引值, 然後將鍵值對放置到ht[1]
哈希表的指定位置上。 -
當
ht[0]
包含的所有鍵值對都遷移到了ht[1]
之後 (ht[0]
變爲空表), 釋放ht[0]
, 將ht[1]
設置爲ht[0]
, 並在ht[1]
新創建一個空白哈希表, 爲下一次 rehash 做準備。
哈希表的擴展與收縮
當以下條件中的任意一個被滿足時, 程序會自動開始對哈希表執行擴展操作:
-
服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於
1
; -
服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於
5
;
其中哈希表的負載因子可以通過公式:
# 負載因子 = 哈希表已保存節點數量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
計算得出。
根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操作所需的負載因子並不相同, 這是因爲在執行 BGSAVE 命令或BGREWRITEAOF 命令的過程中, Redis 需要創建當前服務器進程的子進程, 而大多數操作系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率, 所以在子進程存在期間, 服務器會提高執行擴展操作所需的負載因子, 從而儘可能地避免在子進程存在期間進行哈希表擴展操作, 這可以避免不必要的內存寫入操作, 最大限度地節約內存。
另一方面, 當哈希表的負載因子小於 0.1
時, 程序自動開始對哈希表執行收縮操作。
漸進式rehash
擴展或收縮哈希表需要將 ht[0]
裏面的所有鍵值對 rehash 到 ht[1]
裏面, 但是, 這個 rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。
這樣做的原因在於, 如果 ht[0]
裏只保存着四個鍵值對, 那麼服務器可以在瞬間就將這些鍵值對全部 rehash 到 ht[1]
; 但是, 如果哈希表裏保存的鍵值對數量不是四個, 而是四百萬、四千萬甚至四億個鍵值對, 那麼要一次性將這些鍵值對全部 rehash 到 ht[1]
的話, 龐大的計算量可能會導致服務器在一段時間內停止服務。
因此, 爲了避免 rehash 對服務器性能造成影響, 服務器不是一次性將 ht[0]
裏面的所有鍵值對全部 rehash 到 ht[1]
, 而是分多次、漸進式地將 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 鍵值對所需的計算工作均灘到對字典的每個添加、刪除、查找和更新操作上, 從而避免了集中式 rehash 而帶來的龐大計算量。
漸進式 rehash 執行期間的哈希表操作
因爲在進行漸進式 rehash 的過程中, 字典會同時使用 ht[0]
和 ht[1]
兩個哈希表, 所以在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行: 比如說, 要在字典裏面查找一個鍵的話, 程序會先在 ht[0]
裏面進行查找, 如果沒找到的話, 就會繼續到 ht[1]
裏面進行查找, 諸如此類。
另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一律會被保存到 ht[1]
裏面, 而 ht[0]
則不再進行任何添加操作: 這一措施保證了 ht[0]
包含的鍵值對數量會只減不增, 並隨着 rehash 操作的執行而最終變成空表。
字典API
函數 | 作用 | 時間複雜度 |
---|---|---|
dictCreate |
創建一個新的字典。 | |
dictAdd |
將給定的鍵值對添加到字典裏面。 | |
dictReplace |
將給定的鍵值對添加到字典裏面, 如果鍵已經存在於字典,那麼用新值取代原有的值。 | |
dictFetchValue |
返回給定鍵的值。 | |
dictGetRandomKey |
從字典中隨機返回一個鍵值對。 | |
dictDelete |
從字典中刪除給定鍵所對應的鍵值對。 | |
dictRelease |
釋放給定字典,以及字典中包含的所有鍵值對。 | , N 爲字典包含的鍵值對數量。 |
總結
-
字典被廣泛用於實現 Redis 的各種功能, 其中包括數據庫和哈希鍵。
-
Redis 中的字典使用哈希表作爲底層實現, 每個字典帶有兩個哈希表, 一個用於平時使用, 另一個僅在進行 rehash 時使用。
-
當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。
-
哈希表使用鏈地址法來解決鍵衝突, 被分配到同一個索引上的多個鍵值對會連接成一個單向鏈表。
-
在對哈希表進行擴展或者收縮操作時, 程序需要將現有哈希表包含的所有鍵值對 rehash 到新哈希表裏面, 並且這個 rehash 過程並不是一次性地完成的, 而是漸進式地完成的。