《Redis設計與實現》第4章 字典
字典,又稱為符號表(symbol table)、關聯陣列(associative array)或對映(map),是一種用於儲存鍵值對(key-value pair)的抽象資料結構。
字典中的每個鍵都是獨一無二的,程式可以在字典中根據鍵查詢與之關聯的值,或者通過鍵更新值,又或者根據鍵刪除整個鍵值對,等等。
字典在Redis中的應用相當廣泛,比如Redis的資料庫就是使用字典來作為底層實現的,對資料庫的增、刪、查、改操作也是構建在對字典的操作之上的。
4.1 字典的實現
Redis的字典使用雜湊表作為底層實現。
4.1.1 雜湊表
Redis字典所使用的雜湊表由dict.h/dictht結構定義。
table屬性是一個數組,陣列中的每個元素都是一個指向dict.h/dictEntry結構的指標,每個dictEntry結構都儲存這一個鍵值對。size屬性記錄了雜湊表的大小,也即table陣列的大小,而used屬性則記錄了雜湊表目前已有節點(鍵值對)的數量。sizemask屬性的值總是等於size-1,這個屬性和雜湊值一起決定一個鍵應該被放到table陣列的哪個索引上。
4.1.2 雜湊表節點
key屬性儲存著鍵值對中的鍵,而v屬性則儲存著鍵值對中的值,其中鍵值對的值可以是一個指標,或者是一個uint64_t整數,又或者是一個int64_t整數。
next屬性是指向另一個雜湊表節點的指標,這個指標可以將多個雜湊值相同的鍵值對連線在一起,以此解決鍵衝突(collision)的問題。
4.1.3 字典
Redis中的字典由dict.h/dict結構表示:
type屬性和privdata屬性針對不同型別的鍵值對,為建立多型字典而設定的。
type屬性是一個指向dictType結構的指標,每個dictType結構儲存了一簇用於操作特定型別鍵值對的函式,Redis會為用途不同的字典設定不同的型別特定函式。
而privdata屬性則儲存了需要傳給那些型別特定函式的可選引數。
ht屬性是一個包含兩個項的陣列,陣列中的每個項都是一個dictht雜湊表,一般情況下,字典只使用ht[0]雜湊表,ht[1]雜湊表只會在對ht[0]雜湊表進行rehash時使用。
rehash屬性記錄rehash目前的進度,如果目前沒有在進行rehash,那麼它的值為-1。
普通狀態下的字典:
4.2 雜湊演算法
當要將一個新的鍵值對新增到字典裡面時,程式需要先根據鍵值對的鍵值計算出雜湊值和索引值,然後再根據索引值,將包含新鍵值對的雜湊表節點放到雜湊表陣列的指定索引上面。
Redis計算雜湊值和索引值方法如下:
// 使用字典設定的雜湊函式,計算key的雜湊值
hash = dict->type->hashFunction(key);
// 使用hash表的sizemask屬性和雜湊值,計算出索引值
// 根據情況不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;
&運算使得索引值範圍在[0,sizemask]。
當字典被用作資料庫的底層實現,或者雜湊鍵的底層實現時,Redis使用MurmurHash2演算法來計算鍵的雜湊值。
4.3 解決鍵衝突
Redis的雜湊表使用鏈地址法(separatechaining)來解決鍵衝突,每個雜湊表節點都有一個next指標,多個雜湊表節點可以用next指標構成一個單向連結串列,被分配到同一個索引上的多個節點可以用這個單向連結串列連線起來,這樣便解決了鍵衝突問題。
dictEntry節點組成的連結串列沒有指向連結串列表尾的指標,所以基於速度考慮,程式總是將新節點新增到連結串列的表頭位置(複雜度O(1)),排在其他已有節點的前面。
4.4 rehash
隨著操作的不斷進行,雜湊表儲存的鍵值對會逐漸地增多或減少,為了讓雜湊表的負載因子(loadfactor)維持在一個合理的範圍之內,當雜湊表儲存的鍵值對數量太多或者太少時,程式需要對雜湊表的大小進行相應的擴充套件或者收縮。
Redis對字典的雜湊表執行rehash的步驟如下:
1)為字典的ht[1]雜湊表分配空間,這個雜湊表的空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量(也即ht[0].used屬性的值):
- 如果執行的是擴充套件操作,那麼ht[1]的大小為第一個大於等於ht[0].used*2的2n
- 如果執行的是收縮操作,那麼ht[1]的大小為第一個大於等於ht[0].used的2n
2)將儲存在ht[0]中的所有鍵值對rehash到ht[1]上面:rehash指的是重新計算鍵的雜湊值和索引值,然後將鍵值對放置到ht[1]雜湊表的指定位置上。
3)當ht[0]包含的所有鍵值對都遷移到了ht[1]之後(ht[0]變為空表),釋放ht[0],將ht[1]設定為ht[0],並在ht[1]新建立一個空白雜湊表,為下一次rehash做準備。
雜湊表的擴充套件與收縮
1)伺服器目前沒有在執行BGSAVE命令或者BGREWRITEAOF命令,並且雜湊表的負載因子大於等於1;
2)伺服器目前正在執行BGSAVE命令或者BGREWRITEAOF命令,並且雜湊表的負載因子大於等於5;
其中,雜湊表的負載因子可以通過以下公式計算得出:
// 負載因子 = 雜湊表已儲存節點數量 / 雜湊表大小
load_factor = ht[0].used / ht[0].size
另一方面,當雜湊表的負載因子小於0.1時,程式自動開始對雜湊表執行收縮操作。
4.5 漸進式rehash
擴充套件或收縮雜湊表需要將ht[0]裡面所有鍵值對rehash到ht[1]裡面,但是,這個rehash動作並不是一次性、集中式地完成的,而是分多次、漸進式地完成的。
鍵值對的數量極其龐大(幾千萬甚至上億個)時,一次性rehash可能導致伺服器在一段時間內停止服務。
漸進式rehash步驟:
1)為ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個雜湊表;
2)在字典中維持一個索引計數器變數rehashidx,其值初始時為-1,rehash工作正式開始時,增加為0;
3)在rehash進行期間,每次對字典執行新增、刪除、查詢或者更新操作時,程式除了執行指定的操作外,還會順帶將ht[0]雜湊表在rehashidx索引上的所有鍵值對rehash到ht[1],當rehash工作完成後,程式將rehashidx屬性的值增加1;
4)隨著字典操作的不斷執行,最終在某個時間點上,ht[0]的所有鍵值對都會被rehash至ht[1],這時程式將rehashidx屬性的值設定為-1,表示rehash操作已完成;
漸進式rehash的好處是採取分而治之的方式,將rehash鍵值對所需的計算工作均攤到對字典的每個新增、刪除、查詢和更新操作上,從而避免了集中式rehash帶來的龐大計算量。
漸進式rehash期間的雜湊表操作
在漸進式rehash進行期間,字典會同時使用ht[0]和ht[1]兩個雜湊表,字典的刪除(delete)、查詢(find)、更新(update)等操作會在兩個雜湊表上進行。
另外,在漸進式rehash執行期間,新新增到字典的鍵值對一律會被儲存到ht[1]裡面,而ht[0]則不再進行任何新增操作。