從redis源碼看數據結構(三)哈希表
卑微筆者大三,在準備明年的春招,下邊是自己的一些複習的筆記,可能有不對的地方,還請社區的大佬能及時指教
字典相對於數組,鏈表來說,是一種較高層次的數據結構,像我們的漢語字典一樣,可以通過拼音或偏旁唯一確定一個漢字,在程序裏我們管每一個映射關係叫做一個鍵值對,很多個鍵值對放在一起就構成了我們的字典結構。
有很多高級的字典結構實現,例如我們 Java 中的 HashMap 底層實現,根據鍵的 Hash 值均勻的將鍵值對分散到數組中,並在遇到哈希衝突時,衝突的鍵值對通過單向鏈表串聯,並在鏈表結構超過八個節點裂變成紅黑樹。
那麼 redis 中是怎麼實現的呢?我們一起來看一看。
一,redis底層hash字典定義
redis中的hash字典是使用拉鍊法的哈希表
/*
* 哈希表節點
*/
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 鏈往後繼節點(拉鍊法)
struct dictEntry *next;
} dictEntry;
其本質也是數組加上單鏈表的形式,數組用來存放key,當遇到哈希衝突不同key映射到數組同一位置時,採取拉鍊法放於數組同一位置來解決衝突,與hashmap很類似
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表節點指針數組(俗稱桶,bucket)
dictEntry **table;
// 指針數組的大小
unsigned long size;
// 指針數組的長度掩碼,用於計算索引值
unsigned long sizemask;
// 哈希表現有的節點數量
unsigned long used;
} dictht;
上面就是字典的底層實現----哈希表,其實就是兩張哈希表
/*
* 字典
*
* 每個字典使用兩個哈希表,用於實現漸進式 rehash
*/
typedef struct dict {
// 特定於類型的處理函數
dictType *type;
// 類型處理函數的私有數據
void *privdata;
// 哈希表(2個)
dictht ht[2];
// 記錄 rehash 進度的標誌,值爲-1 表示 rehash 未進行
int rehashidx;
// 當前正在運作的安全迭代器數量
int iterators;
} dict;
使用兩張哈希表作爲字典的實現是爲了後續字典的擴展rehash用的
- ht[0]:就是平時用來存放普通鍵值對的哈希表,經常使用字典中的這個
- ht[2]:是在進行字典擴張時rehash才啓用
二,字典的相關操作
1.初始化字典
在redis中字典中的hash表也是採用延遲初始化策略,在創建字典的時候並沒有爲哈希表分配內存,只有當第一次插入數據時,才真正分配內存。看看字典創建函數dictCreate。
/*
* 創建一個新字典
*
* T = O(1)
*/
dict *dictCreate(dictType *type,
void *privDataPtr)
{
// 分配空間
dict *d = zmalloc(sizeof(*d));
// 初始化字典
_dictInit(d,type,privDataPtr);
return d;
}
/*
* 初始化字典
*
* T = O(1)
*/
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
// 初始化 ht[0]
_dictReset(&d->ht[0]);
// 初始化 ht[1]
_dictReset(&d->ht[1]);
// 初始化字典屬性
d->type = type;
d->privdata = privDataPtr;
d->rehashidx = -1;
d->iterators = 0;
return DICT_OK;
}
2.rehash
剛剛在上邊提到字典中的ht[1]只有在rehash時才啓用,那麼什麼是rehash呢?
rehash是指當hash表擴容或者縮容後的各個key哈希值的重新調整
rehash過程:
1.漸進式rehash初始化
漸進式rehash初始化其實就只是創建了一個新的哈希表,並沒有把原來哈希表中的key進行rehash並且放到這個新表中
/* Expand or create the hash table */
/*
* 漸進式rehash初始化
* 創建一個新哈希表,並視情況,進行以下動作之一:
*
* 1) 如果字典裏的 ht[0] 爲空,將新哈希表賦值給它
* 2) 如果字典裏的 ht[0] 不爲空,那麼將新哈希表賦值給 ht[1] ,並打開 rehash 標識
*
* T = O(N)
*/
int dictExpand(dict *d, unsigned long size)
{
dictht n; /* the new hash table */
// 計算哈希表的真實大小
// O(N)
unsigned long realsize = _dictNextPower(size);
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
//如果當前正在進行rehash或者新哈希表的大小小於現已使用
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
// 創建並初始化新哈希表
// O(N)
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
// 如果 ht[0] 爲空,那麼這就是一次創建新哈希錶行爲
// 將新哈希表設置爲 ht[0] ,然後返回
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
// 如果 ht[0] 不爲空,那麼這就是一次擴展字典的行爲
// 將新哈希表設置爲 ht[1] ,並打開 rehash 標識
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
2.將rehash的key放入新的哈希表
在redis的實現中,沒有集中的將原有的key重新rehash到新的槽中,而是分解到各個命令的執行中,以及週期函數中 ,比如在redis的每一個增刪改查函數中都會判斷字典中哈希表是否正在進行漸進式rehash,即(rehashidx != -1),如果是,則幫忙執行一次將rehash後的key放在新的哈希表中這一過程
比如下邊的添加鍵值對的操作
// 判斷是否在進行漸進式的初始化rehash,如果是,則嘗試漸進式地 rehash 一個元素
if (dictIsRehashing(d)) _dictRehashStep(d);
詳細看下邊:
插入鍵值對
/*
* 添加給定 key-value 對到字典
*
* T = O(1)
*/
int dictAdd(dict *d, void *key, void *val)
{
// 添加 key 到哈希表,返回包含該 key 的節點
dictEntry *entry = dictAddRaw(d,key);
// 添加失敗?
if (!entry) return DICT_ERR;
// 設置節點的值
dictSetVal(d, entry, val);
//添加成功
return DICT_OK;
}
實際添加函數:
/*
* 添加 key 到字典的底層實現,完成之後返回新節點。
*
* 如果 key 已經存在,返回 NULL 。
*
* T = O(1)
*/
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
// 判斷是否在進行漸進式的初始化rehash,如果是,則嘗試漸進式地 rehash 一個元素
if (dictIsRehashing(d)) _dictRehashStep(d);
// 查找可容納新元素的索引位置
// 如果元素已存在, index 爲 -1
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;//key存在直接返回null
/* Allocate the memory and store the new entry */
// 決定該把新元素放在哪個哈希表,如果實在正在進行漸進式rehash初始化,則新元素放在ht[1],否則則放在ht[0]
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
// 爲新元素分配節點空間
entry = zmalloc(sizeof(*entry));
// 新節點的後繼指針指向舊的表頭節點
entry->next = ht->table[index];
// 設置新節點爲表頭
ht->table[index] = entry;
// 更新已有節點數量
ht->used++;
/* Set the hash entry fields. */
// 關聯起節點和 key
dictSetKey(d, entry, key);
// 返回新節點
return entry;
}
rehash過程
其實上邊添加鍵值對執行rehash是通過調用dictRehash方法執行的,這個函數式一個漸進式執行N步的rehash,n是rehash的次數
/*
* 執行 N 步漸進式 rehash 。
*
* 如果執行之後哈希表還有元素需要 rehash ,那麼返回 1 。
* 如果哈希表裏面所有元素已經遷移完畢,那麼返回 0 。
*
* 每步 rehash 都會移動哈希表數組內某個索引上的整個鏈表節點,
* 所以從 ht[0] 遷移到 ht[1] 的 key 可能不止一個。
*
* T = O(N)
*/
int dictRehash(dict *d, int n) {
//判斷當前有沒有在進行rehash初始化。沒有則返回0
if (!dictIsRehashing(d)) return 0;
while(n--) {
dictEntry *de, *nextde;
// 如果 ht[0] 已經爲空,那麼遷移完畢
// 用 ht[1] 代替原來的 ht[0]
if (d->ht[0].used == 0) {
// 釋放 ht[0] 的哈希表數組
zfree(d->ht[0].table);
// 將 ht[0] 指向 ht[1]
d->ht[0] = d->ht[1];
// 清空 ht[1] 的指針
_dictReset(&d->ht[1]);
// 關閉 rehash 標識
d->rehashidx = -1;
// 通知調用者, rehash 完畢
return 0;
}
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned)d->rehashidx);
// 從當前正在rehash初始化的索引開始,移動到數組中首個不爲 NULL 鏈表的索引上
while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
// 指向鏈表頭
de = d->ht[0].table[d->rehashidx];
// 將鏈表內的所有元素從 ht[0] 遷移到 ht[1]
// 因爲桶內的元素通常只有一個,或者不多於某個特定比率
// 所以可以將這個操作看作 O(1)
while(de) {
unsigned int h;
nextde = de->next;
/* Get the index in the new hash table */
// 計算元素在 ht[1] 的哈希值
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 添加節點到 ht[1] ,調整指針
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
// 更新計數器
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
// 設置指針爲 NULL ,方便下次 rehash 時跳過
d->ht[0].table[d->rehashidx] = NULL;
// 前進至下一索引
d->rehashidx++;
}
// 通知調用者,還有元素等待 rehash
return 1;
}
執行完後,原來ht[0].table[rehashidx]下的鏈表節點全部轉移到了ht[1]
rehash小結
在redis中,擴展或收縮哈希表需要將 ht[0] 裏面的所有鍵值對 rehash 到 ht[1] 裏面, 但是, 這個 rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。爲了避免 rehash 對服務器性能造成影響, 服務器不是一次性將 ht[0] 裏面的所有鍵值對全部 rehash 到 ht[1] , 而是分多次、漸進式地將 ht[0] 裏面的鍵值對慢慢地 rehash 到 ht[1] 。
以下是哈希表漸進式 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 而帶來的龐大計算量。