1、說明
當我們使用 Redis 的 Hash 操作時,底層的實現就是字典。
在介紹字典之后,我們先回憶一下 Redis 中的 Hash 操作。最常用的就是 HSET 和 HGET 了
127.0.0.1:6379> HSET user name sherlock
(integer) 1
127.0.0.1:6379> HSET user age 20
(integer) 1
127.0.0.1:6379> HGET user name
"sherlock"
127.0.0.1:6379> HGET user age
"20"
127.0.0.1:6379>
除了 HSET 和 HGET 外的常見指令還有:HDEL、HEXISTS、HGETALL、HMGET 等等,這里就不一一列舉了,Redis 的 Hash 操作一般都是以 H 開頭的。
我們可以看到,Hash 操作可以保存很多組鍵值對,其底層的視線就是字典
2、dict
字典的定義在源碼目錄下 src/dict.h 文件中,為了便於理解,我們從最基本的結構往上介紹
2.1、dictEntry
首先是 dictEntry,它表示字典中的一組鍵值對,聲明如下:
typedef struct dictEntry {
void *key; //鍵
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; //值
struct dictEntry *next; //指向下個鍵值對
} dictEntry;
-
key 表示的鍵值對的鍵;
-
v 表示鍵值對的值,v 是一個共同體,表示這里的值類型可以是指針、uint64_t、int64_t 和 double 其中之一,用共同體可以節約內存;
-
dictEntrynext 指向下一組鍵值對,這里是鏈表,當需要存儲的鍵值對最后計算得到的存儲的位置索引出現重復的時候,就使用鏈表,將多個鍵值對存在一個數組元素中,而且,Redis 中,新數據會存儲在鏈表的最前面
2.2、dictht
dictht 即為 Redis 操作時的值結構,用於保存多組的鍵值對,聲明如下:
typedef struct dictht {
dictEntry **table; //鍵值對數組,數組的元素是個鏈表
unsigned long size; //鍵值對數組的大小
unsigned long sizemask; //掩碼,用於計算索引
unsigned long used; //鍵值對數量
} dictht;
- table 是一個 dictEntry 類型的數組指針,它的每個元素都是指針,都指向一個 dictEntry 類型;
- size 表示鍵值對數組的大小;
- sizemask 為掩碼,用於計算鍵值對插入時的數組索引,它總是 size - 1,后面會再次說到;
- used 表示當前哈希表存儲的鍵值對的數量;
下圖是一個 dictht 的存儲結構,k0和k1的鍵值計算的索引相同,所以放在一個數組元素中:
2.3、dict
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;
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int iterators; /* number of iterators currently running */
} dict;
-
dictType 是一組操作函數指針,用於操作特定類型的鍵值對,Redis 會為不同類型的鍵值對設置不同類型的函數;
-
privdata 表示需要傳遞給操作函數的特定私有數據;
-
ht 是一個 dictht 類型的數組,有兩個元素,之所以兩個,是因為需要rehash,后面會再次說到;
-
rehashidx 表示 rehash 進度,-1表示當前並沒有進行 rehash;
-
iterators 表示當前運行的迭代器數量,本次不做特別說明;
下圖表示一個沒有在rehash的字典
3、字典的存儲方法
Redis 中的哈希操作,顧名思義,存儲方法肯定和哈希算法有關,這里先簡單介紹一下哈希算法。
3.1、哈希算法
哈希算法又稱之為散列函數,它把任意長度的輸入,通過一系列的算法, 變換成固定長度的輸出。
Redis 底層使用的哈希算法是 MurmurHash 算法,最初由 Austin Appleby 在2008 年發明,其優勢在於,無論輸入是否有規律,輸出都是隨機分布的,而且速度很快。
其實了解哈希的同學都能夠明白,用有限的hash值來表示無限的數據,肯定會出現不同的數據得出重復的哈希值的問題,當然,專業的說法不叫重復,叫 碰撞。
3.2、存儲過程和鍵沖突
回到正題,現在當一個鍵值對添加到字典中,會先計算出當前鍵值對需要存儲在 table 中的 index,計算分兩步,先根據鍵計算hash值,再根據hash值和掩碼計算index
hash = dict->type->hashFunction(key); //根據鍵計算其hash值
//根據hash值和掩碼計算索引,ht[x]可能會是h[0],也可能是h[1],根據rehash來定
index = hash & dict->ht[x].sizemask;
首先,對於不同的key,這里計算的hash值可能相同,其次,不同的hash值經過和掩碼取&,會出現相同的index,這也是使用鏈表的原因,在 Redis 中稱之為鍵發生了沖突(collision)
我們前面說到,sizemask 總是 size - 1,即數組的最大索引,hash & sizemask 就保證得出的 index一定是一個小於等於 sizemask 的值,即一定在數組內
計算錯 index 之后,就把該鍵值對保存到 table 對應的位置,table 的元素都是鏈表,新插入的鍵值對,會保存在鏈表的最前端,這樣效率最高,時間復雜度為 O(1),如果保存在最后端,那么時間復雜度為O(N)。反正放在最前端和最后端都一樣,就取一個插入最快的吧。
這樣存儲完成之后,我們取數據也就是要在數組和鏈表中取,時間復雜度也就是 O(1) + O(N),這里的N越小越好,即鏈表越小越好,最好沒有鍵沖突,那么時間復雜度就是O(1)
3.3、rehash
rehash 即對hash表進行擴展和收縮。
這個操作是非常又必要的,可以想象一下,假設一開始創建的 dictEntry 數組的大小只有100個,結果隨着時間的推移,保存的鍵值對慢慢變多,變成五六百個,那么,鍵沖突的概率就會成倍地增加,最后就會導致個別數組內的鏈表元素有多個,這樣就大大地增加了讀取的效率,此時就很有必要對原先只有100個元素的數組進行拓展,比如擴展成五六百個,盡量保證,鏈表節點的數量最小。同理,當保存的鍵值對刪減之后,縮小數組可以節約內存,反正空着也是空着,不如釋放了。
提到hash,就不得不提負載因子(load factor)的概念,它是保存的鍵值對和數組大小的一個比值,使用擴展和收縮的手段,把它控制在一個合理的范圍之內,可以避免內存的浪費和讀取的低效
負載因子的計算公式如下:
load_factor = ht[0].used / ht[0].size
used 是保存的的鍵值對的數目,size 是數組的大小
可見,如果負載因子太大,表示數組中的元素的鏈表元素會多,即鍵沖突的概率會變大;
而如果負載因子太小,表示數組中可能有些元素沒有使用,即有些內存浪費了;
哈希表的收縮和擴展:
Redis 中,當滿足下面兩個條件之一時,會自動進行擴展操作:
- 當服務器沒有執行 BGSAVE 和 BGREWRITEAOF 命令,並且負載因子大於等於1的時候;
- 當服務器正在執行 BGSAVE 和 BGREWRITEAOF 命令,並且負載因子大於等於5的時候;
這是因為這兩個命令在執行過程中,Redis 需要創建子進程,而大多數的操作系統都采用寫時復制的技術來優化子進程的使用效率,所以在子進程存在期間,服務器會提高觸發擴展所需的負載因子,盡可能避免在子進程存在期間進行擴展操作,最大限度地節約內存。
當 Redis 進行 rehash 這種操作的時候,客戶端還在使用,要兼顧 rehash 和 客戶端,就要保證原數據和新數據同時存儲和查詢。這就是 dict 結構中 ht 大小為2的原因。
在沒有進行 rehash 的時候,只使用 ht[0],rehash 會將新舊數據都重新散列,存入 ht[1] 中。rehash 完成之后,將 ht[1] 變成 ht[0],原來的 ht[0] 釋放掉,再新建一個 ht[1]
3.4、漸進式rehash
當數據量很大的時候,rehash 操作如果一次性將h[0]數據轉到ht[1],會導致服務宕機,這是不能接受的。因此,Redis 的 rehash 操作並不是一次性、集中式地完成,而是分多次、漸進式地完成的。
大致步驟如下:
- 為 ht[1] 分配空間,,讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表;
- 將 rehashindex 值設置為0,表示開始 rehash;
- 在 rehash 期間,每次對字典執行增刪查改的操作時,程序還會順帶將 ht[0] 中哈希表在 rehashindex 上的所有鍵值對 rehash 到 ht[1] 上,完成只有,rehashindex 遞增;
- 隨着字典的操作的不斷執行,最終在某個時間點上,ht[0] 的所有鍵值對都會被rehash至 ht[1],這時候,rehash 全部完成,將 rehashindex 設置為-1;
漸進式 rehash 的好處在於,其采用分治的方式,將 rehash 鍵值對的工作量均攤到每次對字典的增刪查改上,避免了集中式 rehash 帶來的龐大的計算量
漸進式 rehash 執行期間的哈希表操作:
rehash 的時候,ht[0] 和 ht[1] 同時使用,字典的增刪查改等操作會先后在 ht[0] 和 ht[1] 上執行,比如查找時,先在 ht[0] 上查找,如果沒有找到,則在 ht[1] 上查找。
rehash 的時候,ht[0] 只刪改查,不會進行添加操作,添加會直接添加到 ht[1] 中,保證了 ht[0] 中的鍵值對數量只減不增,直到 rehash 完成之后,ht[0] 變成空表。
4、總結
- 字典廣泛用於實現 Redis 的各種功能,包括數據庫和哈希鍵;
- 字典底層使用哈希表實現,每個字典帶有兩個哈希表,一個平時使用,一個 rehash 的時候使用;
- 哈希表使用單項鏈表來解決鍵沖突;
- 哈希表擴展和收縮時,Redis 會將一個哈希表 rehash 到另一個哈希表上,這個操作是漸進式的,不是一次完成的;