前言
字典在Redis中的應用非常廣泛,數據庫與哈希對象的底層實現就是字典。
一、復習散列表
1.1 散列表
散列表(哈希表),其思想主要是基於數組支持按照下標隨機訪問數據時間復雜度為O(1)的特性。可是說是數組的一種擴展。假設,我們為了方便記錄某高校數學專業的所有學生的信息。要求可以按照學號(學號格式為:入學時間+年級+專業+專業內自增序號,如2011 1101 0001)能夠快速找到某個學生的信息。這個時候我們可以取學號的自增序號部分,即后四位作為數組的索引下標,把學生相應的信息存儲到對應的空間內即可。
如上圖所示,我們把學號作為key,通過截取學號后四位的函數后計算后得到索引下標,將數據存儲到數組中。當我們按照鍵值(學號)查找時,只需要再次計算出索引下標,然后取出相應數據即可。以上便是散列思想。
1.2 散列函數
上面的例子中,截取學號后四位的函數即是一個簡單的散列函數。
//散列函數 偽代碼
int Hash(string key) {
// 獲取后四位字符
string hashValue =int.parse(key.Substring(key.Length-4, 4));
// 將后兩位字符轉換為整數
return hashValue;
}
在這里散列函數的作用就是講key值映射成數組的索引下標。關於散列函數的設計方法有很多,如:直接尋址法、數字分析法、隨機數法等等。但即使是再優秀的設計方法也不能避免散列沖突。在散列表中散列函數不應設計太復雜。
1.3 散列沖突
散列函數具有確定性和不確定性。
- 確定性:哈希的散列值不同,那么哈希的原始輸入也就不同。即:key1=key2,那么hash(key1)=hash(key2)。
- 不確定性:同一個散列值很有可能對應多個不同的原始輸入。即:key1≠key2,hash(key1)=hash(key2)。
散列沖突,即key1≠key2,hash(key1)=hash(key2)的情況。散列沖突是不可避免的,如果我們key的長度為100,而數組的索引數量只有50,那么再優秀的算法也無法避免散列沖突。關於散列沖突也有很多解決辦法,這里簡單復習兩種:開放尋址法和鏈表法。
1.3.1 開放尋址法
開放尋址法的核心思想是,如果出現了散列沖突,我們就重新探測一一個空閑位置,將其插入。比如,我們可以使用線性探測法。當我們往散列表中插入數據時,如果某個數據經過散列函數散列之后,存儲位置已經被占用了,我們就從當前位置開始,依次往后查找,看是否有空閑位置,如果遍歷到尾部都沒有找到空閑的位置,那么我們就再從表頭開始找,直到找到為止。
散列表中查找元素的時候,我們通過散列函數求出要查找元素的鍵值對應的散列值,然后比較數組中下標為散列值的元素和要查找的元素。如果相等,則說明就是我們要找的元素;否則就順序往后依次查找。如果遍歷到數組中的空閑位置還沒有找到,就說明要查找的元素並沒有在散列表中。
對於刪除操作稍微有些特別,不能單純地把要刪除的元素設置為空。因為在查找的時候,一旦我們通過線性探測方法,找到一個空閑位置,我們就可以認定散列表中不存在這個數據。但是,如果這個空閑位置是我們后來刪除的,就會導致原來的查找算法失效。這里我們可以將刪除的元素,特殊標記為 deleted。當線性探測查找的時候,遇到標記為 deleted 的空間,並不是停下來,而是繼續往下探測。
線性探測法存在很大問題。當散列表中插入的數據越來越多時,其散列沖突的可能性就越大,極端情況下甚至要探測整個散列表,因此最壞時間復雜度為O(N)。在開放尋址法中,除了線性探測法,我們還可以二次探測和雙重散列等方式。
1.3.2 鏈表法
鏈表法是一種比較常用的散列沖突解決辦法,Redis使用的就是鏈表法來解決散列沖突。鏈表法的原理是:如果遇到沖突,他就會在原地址新建一個空間,然后以鏈表結點的形式插入到該空間。當插入的時候,我們只需要通過散列函數計算出對應的散列槽位,將其插入到對應鏈表中即可。
1.3.3 負載因子與rehash
我們可以使用裝載因子來衡量散列表的“健康狀況”。
散列表的負載因子 = 填入表中的元素個數/散列表的長度
散列表負載因子越大,代表空閑位置越少,沖突也就越多,散列表的性能會下降。
對於散列表來說,負載因子過大或過小都不好,負載因子過大,散列表的性能會下降。而負載因子過小,則會造成內存不能合理利用,從而形成內存浪費。因此我們為了保證負載因子維持在一個合理的范圍內,要對散列表的大小進行收縮或擴展,即rehash。散列表的rehash過程類似於數組的收縮與擴容。
1.3.4 開放尋址法與鏈表法比較
對於開放尋址法解決沖突的散列表,由於數據都存儲在數組中,因此可以有效地利用 CPU 緩存加快查詢速度(數組占用一塊連續的空間)。但是刪除數據的時候比較麻煩,需要特殊標記已經刪除掉的數據。而且,在開放尋址法中,所有的數據都存儲在一個數組中,比起鏈表法來說,沖突的代價更高。所以,使用開放尋址法解決沖突的散列表,負載因子的上限不能太大。這也導致這種方法比鏈表法更浪費內存空間。
對於鏈表法解決沖突的散列表,對內存的利用率比開放尋址法要高。因為鏈表結點可以在需要的時候再創建,並不需要像開放尋址法那樣事先申請好。鏈表法比起開放尋址法,對大裝載因子的容忍度更高。開放尋址法只能適用裝載因子小於1的情況。接近1時,就可能會有大量的散列沖突,性能會下降很多。但是對於鏈表法來說,只要散列函數的值隨機均勻,即便裝載因子變成10,也就是鏈表的長度變長了而已,雖然查找效率有所下降,但是比起順序查找還是快很多。但是,鏈表因為要存儲指針,所以對於比較小的對象的存儲,是比較消耗內存的,而且鏈表中的結點是零散分布在內存中的,不是連續的,所以對CPU緩存是不友好的,這對於執行效率有一定的影響。
二、Redis字典
2.1 Redis字典的實現
Redis字典使用散列表最為底層實現,一個散列表里面有多個散列表節點,每個散列表節點就保存了字典中的一個鍵值對。
2.1.1 字典
typedef struct dict{
//類型特定函數
void *type;
//私有數據
void *privdata;
//哈希表-見2.1.2
dictht ht[2];
//rehash 索引 當rehash不在進行時 值為-1
int trehashidx;
}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 *(*keyDup) (void *privdata,const void *obj);
//復制值的函數
void *(*keyCompare) (void *privdata,const void *key1, const void *key2);
//銷毀鍵的函數
void (*keyDestructor) (void *privdata, void *key);
//銷毀值的函數
void (*keyDestructor) (void *privdata, void *obj);
}dictType;
- ht屬性是一個包含兩個項的數組,數組中的每個項都是一個dictht哈希表, 一般情況下,字典只使用ht[0] 哈希表, ht[1]哈希表只會對ht[0]哈希表進行rehash時使用。
- rehashidx記錄了rehash目前的進度,如果目前沒有進行rehash,值為-1。
2.1.2 散列表
typedef struct dictht
{
//哈希表數組,C語言中,*號是為了表明該變量為指針,有幾個* 號就相當於是幾級指針,這里是二級指針,理解為指向指針的指針
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩碼,用於計算索引值
unsigned long sizemask;
//該哈希已有節點的數量
unsigned long used;
}dictht;
- table屬性是一個數組,數組中的每個元素都是一個指向dict.h/dictEntry結構的指針,每個dictEntry結構保存着一個鍵值對
- size屬性記錄了哈希表的大小,也是table數組的大小
- used屬性則記錄哈希表目前已有節點(鍵值對)的數量
- sizemask屬性的值總是等於 size-1(從0開始),這個屬性和哈希值一起決定一個鍵應該被放到table數組的哪個索引上面(索引下標值)。
2.1.3 散列表節點
//哈希表節點定義dictEntry結構表示,每個dictEntry結構都保存着一個鍵值對。
typedef struct dictEntry
{
//鍵
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
// 指向下個哈希表節點,形成鏈表
struct dictEntry *next;
}dictEntry;
key屬性保存着鍵值中的鍵,而v屬性則保存着鍵值對中的值,其中鍵值(v屬性)可以是一個指針,或uint64_t整數,或int64_t整數。 next屬性是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接在一起,解決鍵沖突問題。
2.2 Redis如何解決散列沖突
2.2.1 鏈表法
當有兩個或以上的鍵被分配到散列表數組同一個索引上時,就發生了鍵沖突。Redis使用鏈表法解決散列沖突。每個散列表節點都有一個next指針,多個散列表節點next可以用next指針構成一個單向鏈表,被分配到同一個索引上的多個節點可以使用這個單向鏈表連接起來。
如圖所示,當鍵k0和k1的經過散列函數得到索引值都為1時,就會使用next指針將兩個節點連接起來。而由於節點沒有指向鏈尾的指針,因此新的節點總是插入到鏈表的頭部,排在已有節點的前面。
2.2.2 Redis rehash
隨着操作的進行,散列表中保存的鍵值對會也會不斷地增加或減少,為了保證負載因子維持在一個合理的范圍,當散列表內的鍵值對過多或過少時,內需要定期進行rehash,以提升性能或節省內存。Redis的rehash的步驟如下:
-
為字典的ht[1]散列表分配空間,這個空間的大小取決於要執行的操作以及ht[0]當前包含的鍵值對數量(即:ht[0].used的屬性值)
- 擴展操作:ht[1]的大小為 第一個大於等於ht[0].used*2的2的n次方冪。如:ht[0].used=3則ht[1]的大小為8,ht[0].used=4則ht[1]的大小為8。
- 收縮操作: ht[1]的大小為 第一個大於等於ht[0].used的2的n次方冪。
-
將保存在ht[0]中的鍵值對重新計算鍵的散列值和索引值,然后放到ht[1]指定的位置上。
-
將ht[0]包含的所有鍵值對都遷移到了ht[1]之后,釋放ht[0],將ht[1]設置為ht[0],並創建一個新的ht[1]哈希表為下一次rehash做准備。
rehash操作需要滿足以下條件:
- 服務器目前沒有執行BGSAVE(rdb持久化)命令或者BGREWRITEAOF(AOF文件重寫)命令,並且散列表的負載因子大於等於1。
- 服務器目前正在執行BGSAVE命令或者BGREWRITEAOF命令,並且負載因子大於等於5。
- 當負載因子小於0.1時,程序自動開始執行收縮操作。
Redis這么做的目的是基於操作系統創建子進程后寫時復制技術,避免不必要的寫入操作。(有關BGSAVE、BGREWRITEAOF以及寫時復制會在后續持久化一文詳細介紹)。
2.2.3 漸進式 rehash
對於rehash我們思考一個問題如果散列表當前大小為 1GB,要想擴容為原來的兩倍大小,那就需要對 1GB 的數據重新計算哈希值,並且從原來的散列表搬移到新的散列表。這種情況聽着就很耗時,而生產環境中甚至會更大。為了解決一次性擴容耗時過多的情況,可以將擴容操作穿插在插入操作的過程中,分批完成。當負載因子觸達閾值之后,只申請新空間,但並不將老的數據搬移到新散列表中。當有新數據要插入時,將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,都重復上面的過程。經過多次插入操作之后,老的散列表中的數據就一點一點全部搬移到新散列表中了。這樣沒有了集中的一次一次性數據搬移,插入操作就都變得很快了。
Redis為了解決這個問題采用漸進式rehash方式。以下是Redis漸進式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 操作已完成。
**說明: **
1.因為在進行漸進式 rehash 的過程中,字典會同時使用 ht[0]
和 ht[1]
兩個哈希表,所以在漸進式 rehash 進行期間,字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行。
2. 在漸進式 rehash 執行期間,新添加到字典的鍵值對一律會被保存到 ht[1]
里面,而 ht[0]
則不再進行任何添加操作:這一措施保證了 ht[0]
包含的鍵值對數量會只減不增,並隨着 rehash 操作的執行而最終變成空表。
2.3 時間復雜度
下面給出幾個Redis字典常見操作的時間復雜度,可以結合上面的內容分析為什么。
操作 | 時間復雜度 |
---|---|
創建一個新字典 | O(1) |
將給定的鍵值對添加到字典內 | O(1) |
將給定的鍵值對添加到字典內,如果鍵存在則替換之 | O(1) |
返回給定鍵的值 | O(1) |
從字典中隨機返回一個鍵值對 | O(1) |
從字典中刪除給定鍵所對應的鍵值對 | O(1) |
釋放給定字典以及字典中包含的鍵值對 | O(N),N為字典包含的鍵值對的數量 |
本文重點
- 字典在redis中廣泛應用,包括數據庫和hash數據結構。
- 每個字典有兩個哈希表,一個是正常使用,一個用於rehash期間使用。
- 當redis計算哈希時,采用的是MurmurHash2哈希算法。
- 哈希表采用鏈表法解決散列沖突,被分配到同一個地址的鍵會構成一個單向鏈表。
- 在rehash對哈希表進行擴展或者收縮過程中,會將所有鍵值對進行遷移,並且這個遷移是漸進式的遷移。
小結
本篇文章主要回顧了散列表的概念,散列函數以及如何解決散列沖突。並分析了Redis中字典的實現。下篇文章將介紹跳躍表以及跳躍表在Redis中的實現。
參考
《Redis設計與實現》
《Redis開發與運維》
《Redis官方文檔》