圖解Redis之數據結構篇——字典

文章導航-readme

前言

    字典在Redis中的應用很是普遍,數據庫與哈希對象的底層實現就是字典。html

系列文章

圖解Redis之數據結構篇——簡單動態字符串SDSredis

圖解Redis之數據結構篇——鏈表算法

圖解Redis之數據結構篇——字典數據庫

1、複習散列表

1.1 散列表

    散列表(哈希表),其思想主要是基於數組支持按照下標隨機訪問數據時間複雜度爲O(1)的特性。但是說是數組的一種擴展。假設,咱們爲了方便記錄某高校數學專業的全部學生的信息。要求能夠按照學號(學號格式爲:入學時間+年級+專業+專業內自增序號,如2011 1101 0001)可以快速找到某個學生的信息。這個時候咱們能夠取學號的自增序號部分,即後四位做爲數組的索引下標,把學生相應的信息存儲到對應的空間內便可。c#

散列思想

    如上圖所示,咱們把學號做爲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緩存是不友好的,這對於執行效率有必定的影響。

2、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指針構成一個單向鏈表,被分配到同一個索引上的多個節點可使用這個單向鏈表鏈接起來。

Redis 鏈表法

如圖所示,當鍵k0和k1的通過散列函數獲得索引值都爲1時,就會使用next指針將兩個節點鏈接起來。而因爲節點沒有指向鏈尾的指針,所以新的節點老是插入到鏈表的頭部,排在已有節點的前面。

2.2.2 Redis rehash

    隨着操做的進行,散列表中保存的鍵值對會也會不斷地增長或減小,爲了保證負載因子維持在一個合理的範圍,當散列表內的鍵值對過多或過少時,內須要按期進行rehash,以提高性能或節省內存。Redis的rehash的步驟以下:

  1. 爲字典的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次方冪。

  2. 將保存在ht[0]中的鍵值對從新計算鍵的散列值和索引值,而後放到ht[1]指定的位置上。

  3. 將ht[0]包含的全部鍵值對都遷移到了ht[1]以後,釋放ht[0],將ht[1]設置爲ht[0],並建立一個新的ht[1]哈希表爲下一次rehash作準備。

rehash操做須要知足如下條件:

  1. 服務器目前沒有執行BGSAVE(rdb持久化)命令或者BGREWRITEAOF(AOF文件重寫)命令,而且散列表的負載因子大於等於1。
  2. 服務器目前正在執行BGSAVE命令或者BGREWRITEAOF命令,而且負載因子大於等於5。
  3. 當負載因子小於0.1時,程序自動開始執行收縮操做。

Redis這麼作的目的是基於操做系統建立子進程後寫時複製技術,避免沒必要要的寫入操做。(有關BGSAVE、BGREWRITEAOF以及寫時複製會在後續持久化一文詳細介紹)。

2.2.3 漸進式 rehash

    對於rehash咱們思考一個問題若是散列表當前大小爲 1GB,要想擴容爲原來的兩倍大小,那就須要對 1GB 的數據從新計算哈希值,而且從原來的散列表搬移到新的散列表。這種狀況聽着就很耗時,而生產環境中甚至會更大。爲了解決一次性擴容耗時過多的狀況,能夠將擴容操做穿插在插入操做的過程當中,分批完成。當負載因子觸達閾值以後,只申請新空間,但並不將老的數據搬移到新散列表中。當有新數據要插入時,將新數據插入新散列表中,而且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,都重複上面的過程。通過屢次插入操做以後,老的散列表中的數據就一點一點所有搬移到新散列表中了。這樣沒有了集中的一次一次性數據搬移,插入操做就都變得很快了。

    Redis爲了解決這個問題採用漸進式rehash方式。如下是Redis漸進式rehash的詳細步驟:

  1. ht[1] 分配空間, 讓字典同時持有 ht[0]ht[1] 兩個哈希表。
  2. 在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 ,表示 rehash 工做正式開始。
  3. 在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操做時, 程序除了執行指定的操做之外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的全部鍵值對 rehash 到 ht[1] , 當 rehash 工做完成以後, 程序將 rehashidx 屬性的值增一。
  4. 隨着字典操做的不斷執行, 最終在某個時間點上, 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爲字典包含的鍵值對的數量

本文重點

  1. 字典在redis中普遍應用,包括數據庫和hash數據結構。
  2. 每一個字典有兩個哈希表,一個是正常使用,一個用於rehash期間使用。
  3. 當redis計算哈希時,採用的是MurmurHash2哈希算法。
  4. 哈希表採用鏈表法解決散列衝突,被分配到同一個地址的鍵會構成一個單向鏈表。
  5. 在rehash對哈希表進行擴展或者收縮過程當中,會將全部鍵值對進行遷移,而且這個遷移是漸進式的遷移。

小結

    本篇文章主要回顧了散列表的概念,散列函數以及如何解決散列衝突。並分析了Redis中字典的實現。下篇文章將介紹跳躍表以及跳躍表在Redis中的實現。

參考

《Redis設計與實現》

《Redis開發與運維》

《Redis官方文檔》

相關文章
相關標籤/搜索