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