Redis數據結構——字典


前言

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

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官方文檔》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM