概述
上篇博客我簡單介紹了 redis 哈希表相關知識,本篇博客我打算在哈希表的基礎上,簡單整理一下 redis 字典的實現原理
字典
字典又稱爲符號表、關聯數組,映射等,它是一種保存鍵值對的抽象數據結構。
字典中的鍵是獨一無二的,程序可以在字典中根據鍵查找與之關聯的值,或通過鍵來更新值,刪除值等。它作爲一種數據結構內置在許多編程語言中,但 redis 的開發語言C語言並沒有實現這種數據結構,因此 redis 構建了自己的字典實現。
redis 字典就是使用上一節我們講過的哈希表作爲底層原理。關於哈希表的知識,可以點擊這裏參考上一篇博客內容。
redis 中的字典由頭文件 dict.h 中的 dict 表示:
typedef struct dict {
// 類型特定函數
dictType *type;
// 私有數據
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
//當rehash不在進行時,值爲-1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
- type 屬性是一個指向 dictType 類型結構的指針,每個 dictType 結構保存了一組用戶操作特定類型變量的函數,redis 會爲用途不同的字典設置不同的函數
- privdata 屬性保存了傳給 type 函數的可選參數
- ht 屬性是一個包含兩項的數組,數組中的每一項都是 dictht 類型的哈希表,一般情況下只需要使用 ht[0],ht[1] 是在數組 rehash 時使用的
- trehashidx 屬性也是在 rehash 時使用的,使用它來記錄 rehash 的進度,如果目前沒有進行 rehash,那麼它的值是 -1
dictht 類型已經在上一篇博客介紹過,這裏我們主要看看 dictType 類型的結構:
typedef struct dictType {
// 計算哈希值的函數
unsigned int (*hashFunction)(const void *key);
// 複製鍵的函數
void *(*keyDup)(void *privdata, const void *key);
// 複製值的函數
void *(*valDup)(void *privdata, const void *obj);
// 對比鍵的函數
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 銷燬鍵的函數
void (*keyDestructor)(void *privdata, void *key);
// 銷燬值的函數
void (*valDestructor)(void *privdata, void *obj);
} dictType;
從註釋可以看出,dictType 主要實現和字典操作相關的常用方法。下面我們看一張沒有 rehash 的字典示意圖:
哈希算法
當我們需要將一個新的鍵值對添加到字典時,程序首先需要根據鍵值對的鍵計算出 哈希值 和 索引值,其中索引值需要通過哈希值才能計算得出。然後再根據索引值,將包含鍵值對的哈希表節點,放到哈希表數組的指定索引上。
redis 計算哈希值以及索引值的方法如下:
#使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的 sizemask 屬性和哈希值,計算出索引值
#根據情況不同,ht[x]可以是ht[0]或者ht[1],
#也就是說:添加新元素時,可能添加到 ht[0] 數組,也可能添加到 ht[1] 數組,關於其中的原因本篇後面介紹
index = hash & dict->ht[x].sizemask;
當存在兩個或兩個以上數量的鍵被分配到哈希表的同一個索引上面時,我們稱這些鍵發生了 衝突。redis 使用 鏈地址法 來解決衝突:每個哈希表節點可以用 next 指針構成一個單向鏈表,被分配到同一索引上的哈希節點可以通過指針連接起來。
需要注意的一點是:dictht 結構中不存在指針指向鏈表尾部節點,爲了提高效率,每次使用頭插的方式保存新節點,這樣時間複雜度可以控制在 O(1),提高添加效率。
rehash
隨着操作的不斷執行,哈希表保存的鍵值對會逐漸增多或減少,爲了讓哈希表的 負載因子 維持在一個合理的範圍內,當哈希表的節點數過多或過少時,程序需要對哈希表的大小進行擴展或收縮。
# 負載因子= 哈希表已保存節點數量/ 哈希表大小
load_factor = ht[0].used / ht[0].size
擴展和收縮哈希表的工作可以通過執行 rehash 操作來完成,redis 對字典哈希表的 rehash 步驟如下:
- 爲字典的 ht[1] 哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及 ht[0] 當前包含的鍵值對數量(ht[0].used 屬性)
- 如果執行的是擴展操作,那麼 ht[1] 的大小爲第一個大於等於 2*ht[0].used 的 2的n次方冪
- 如果執行的是收縮操作,那麼 ht[1] 的大小爲第一個大於等於 ht[0].used 的 2的n次方冪
- 將保存在 ht[0] 中的所有鍵值對 rehash 到 ht[1] 上面
- 當 ht[0] 包含的所有鍵值對都遷移到了 ht[1] 之後(ht[0]變爲空表),釋放ht[0],將 ht[1] 設置爲 ht[0],並在 ht[1] 新創建一個空白哈希表,爲下一次 rehash 做準備。
rehash:是指重新計算鍵的哈希值和索引值,然後將鍵值對放置到新哈希表的相應位置上
從這裏我們可以看出,redis 哈希表數組的大小也總是2的n次冪,關於其中的原理可以參考我 hashmap 的博客內容,具體點擊這裏跳轉。
哈希表的擴容與縮容
當以下任一條件滿足時,程序會對哈希表執行擴容或縮容操作:
- 服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,並且哈希表的負載因子大於等於1
- 服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,並且哈希表的負載因子大於等於5
- 當哈希表的負載因子小於0.1時,程序自動開始對哈希表執行收縮操作
根據 BGSAVE 或 BGREWRITEAOF 命令是否正在執行,服務器執行擴展操作所需要的負載因子並不相同。這是因爲在執行這些命令期間,redis 服務器需要創建當前服務進程的子進程,而大多數操作系統採用 寫時複製 技術來優化子進程的使用效率,所以在子進程存在期間,服務器會提高執行拓展操作所需要的負載因子,從而儘可能地避免在子進程存在期間進行哈希表擴展操作,這可以避免不必要的內存寫入操作,最大限度地節約內存。
寫時複製:如果有多個調用者同時請求相同資源,他們會共同獲取相同的指針指向相同的資源。如果某個調用者試圖修改資源的內容時,系統會複製一份專用副本給該調用者,而其他調用者所見到的最初的資源仍然保持不變
也就是說,如果 BGSAVE 或 BGREWRITEAOF 命令執行期間擴容,則需要考慮主進程以及子進程之間的數據同步。爲了達成一致,需要額外進行同步操作,而同步操作往往需要耗費額外的資源。因此 redis 儘量避免在該命令執行期間擴容。
漸進式 rehash
前面我們提到,哈希表擴容過程中,需要將 ht[0] 上所有元素 rehash 到 ht[1] 上,但這個 rehash 的過程不是一次執行的,當哈希表上哈希節點太多是,一次 rehash 可能帶來性能問題。爲了解決該問題,redis 執行 rehash 操作不是集中式完成的,而是漸進式的,分多次逐步完成。
這裏我們主要看一下一次 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 操作已完成。
漸進式 rehash 的好處在於它採取分治的方式,將 rehash 鍵值對所需的計算工作均攤到對字典的每種操作上,從而避免了集中式 rehash 而帶來的龐大計算量。
需要注意的一點是:在執行 rehash 期間,查詢、修改,刪除等操作是同時在兩個數組上執行的。如果在 ht[0] 上沒有找到要操作的節點,就去 ht[1] 上查找,因爲此時被操作的節點可能在兩個數組中的任何一個。執行添加操作只需要在 ht[1] 上操作,因爲最終所有新節點都保存在 ht[1]上。
字典 API
下面我通過表格的形式列舉出字典常用方法:
函數 | 作用 | 時間複雜度 |
---|---|---|
dictCreate | 創建新字典 | O(1) |
dictAdd | 將指定鍵值對添加到字典表 | O(1) |
dictReplace | 將指定鍵值對添加到字典表,如果鍵已存在就替換 | O(1) |
dictFetchValue | 返回給定鍵的值 | O(1) |
dictGetRandomKey | 從字典中返回隨機鍵值對 | O(1) |
dictDelete | 從字典中刪除給定鍵所對應的鍵值對 | O(1) |
dictRelease | 釋放給定字典以及其中所有鍵值對 | O(1) |
用途
字典在 redis 的用途非常廣泛,下面我列舉出比較常見的幾種:
- redis 數據庫底層就是通過字典實現的,redis 數據庫的增、刪、改,查等操作也是建立在字典之上
- 當一個哈希鍵包含的鍵值對比較多,又或者鍵值對中的字符串比較長時,redis 使用字典作爲哈希鍵的實現原理
哈希鍵:如果一個鍵所對應的值是哈希類型,就稱它爲哈希鍵
需要注意的一點是:當字典被作爲數據庫底層或哈希鍵的實現原理時,redis 使用 MurmurHash2 算法計算該鍵所對應的哈希值。
MurmurHash2
MurmurHash2 是一種非加密型哈希函數,通過它可以高速計算出鍵值對 key 所對應的哈希值。即使對於一些規律性非常強的key,它也可以良好的做到隨機分佈。
MurmurHash2 算法的源碼如下,可以複製到項目中直接使用:
unsigned int murMurHash(const void *key, int len)
{
const unsigned int m = 0x5bd1e995;
const int r = 24;
const int seed = 97;
unsigned int h = seed ^ len;
// Mix 4 bytes at a time into the hash
const unsigned char *data = (const unsigned char *)key;
while(len >= 4)
{
unsigned int k = *(unsigned int *)data;
k *= m;
k ^= k >> r;
k *= m;
h *= m;
h ^= k;
data += 4;
len -= 4;
}
// Handle the last few bytes of the input array
switch(len)
{
case 3: h ^= data[2] << 16;
case 2: h ^= data[1] << 8;
case 1: h ^= data[0];
h *= m;
};
// Do a few final mixes of the hash to ensure the last few
// bytes are well-incorporated.
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return h;
}
經過其他博主的測試,MurmurHash2 算法在鍵特別長時效率較高,並且方法需要以字符串長度作爲參數。在C語言中,字符數組本身是不帶有長度的,因此該方法本身效率不算最高。但當哈希表中需要保存海量數據時,MurmurHash2 可以做到分佈均勻,這也正是 redis 使用它作爲 hash 算法的主要原因。