dict,又稱字典(dictionary)或映射(map),是集合的一種;這種集合中每個元素都是KV鍵值對。
字典dict在各編程語言中都有體現,面向對象的編程語言如C++、Java中都稱其爲Map。
Redis的KV存儲結構
Redis內存數據庫,最底層是一個redisDb;
redisDb 整體使用 dict字典 來存儲鍵值對KV;
字典中的每一項,使用dictEntry ,代表KV鍵值;類似於HashMap中的鍵值對Entry。
why dict/map?
dict是一種用於維護key和value映射關係的數據結構,與很多編程語言中的Map類似。
爲什麼dict/map 這麼受歡迎呢?
因爲dict/map實現了key和value的映射,通過key查詢value是效率非常高的操作,時間複雜度是O©,C是常數,在沒有衝突/碰撞的情況下,可以達到O(1)。
dict本質上是爲了解決算法中的查找問題(Searching),一般查找問題的解法分爲兩個大類:一個是基於各種平衡樹,一個是基於哈希表。
- 平衡樹,如二叉搜索樹、紅黑樹,使用的是“二分思想”;
如果需要實現排序,則可使用平衡樹,如:用大頂堆實現TreeMap; - 哈希表,如Java中的Map,Python中的字典dict,使用的是“映射思想”;
我們平常使用的各種Map或dict,大都是基於哈希表實現的。在不要求數據有序存儲,且能保持較低的哈希值衝突概率的前提下,基於哈希表的查找性能能做到非常高效,接近O(1),而且容易實現。
Redis dict的應用
字典dict 在 Redis 中的應用廣泛, 使用頻率可以說和 SDS 以及雙端鏈表不相上下, 基本上各個功能模塊都有用到字典的地方。
其中, 字典dict的主要用途有以下兩個:
- 實現數據庫鍵空間(key space);
- 用作 hash 鍵的底層實現之一;
以下兩個小節分別介紹這兩種用途。
Redis數據庫鍵空間(key space)
Redis 是一個鍵值對數據庫服務器,服務器中每個數據庫都由 redisDB 結構表示(默認16個庫)。其中,redisDB 結構的 dict 字典保存了數據庫中所有的鍵值對,這個字典被稱爲鍵空間(key space)。
可以認爲,Redis默認16個庫,這16個庫在各自的鍵空間(key space)中;其實就通過鍵空間(key space)實現了隔離。而鍵空間(key space)底層是dict實現的。
鍵空間(key space)除了實現了16個庫的隔離,還能基於鍵空間通知(Keyspace Notifications) 實現某些事件的訂閱通知,如某個key過期的時間,某個key的value變更事件。
鍵空間通知(Keyspace Notifications),是因爲鍵空間(key space)實現了16個庫的隔離,而我們執行Redis命令最終都是落在其中一個庫上,當有事件發生在某個庫上時,該庫對應的鍵空間(key space)就能基於pub/sub發佈訂閱,實現事件“廣播”。
鍵空間(key space),詳細分析,可參見:Redis鍵空間通知(Keyspace Notifications)
dict 用作 hash 鍵的底層實現
Redis 的 hash 鍵使用以下兩種數據結構作爲底層實現:
- 壓縮列表ziplist ;
- 字典dict;
因爲壓縮列表 比字典更節省內存,所以程序在創建新 Hash 鍵時,默認使用壓縮列表作爲底層實現, 當有需要時,纔會將底層實現從壓縮列表轉換到字典。
ziplist 是爲 Redis 節約內存而開發的、非常節省內存的雙向鏈表,深入學習可移步Redis的list
壓縮鏈表轉成字典(ziplist->dict)的條件
同時滿足以下兩個條件,hash 鍵纔會使用ziplist:
1、key和value 長度都小於64
2、鍵值對數小於512
該配置 在redis.conf
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
如何實現字典dict/映射
dict,又稱字典(dictionary)或映射(map),是集合的一種;這種集合中每個元素都是KV鍵值對。
它是根據關鍵字值(key)而直接進行訪問KV鍵值對的數據結構。也就是說,它通過把關鍵字值映射到一個位置來訪問記錄,以加快查找的速度。這個映射函數稱爲哈希函數(也稱爲散列函數)。
因此通常我們稱字典dict,也叫哈希表。
映射過程,通常使用hash算法實現,因此也稱映射過程爲哈希化,存放記錄的數組叫做散列表、或hash表。
哈希化之後難免會產生一個問題,那就是對不同的關鍵字,可能得到同一個散列地址,即不同的key散列到同一個數組下標,這種現象稱爲衝突
,那麼我們該如何去處理衝突呢?
最常用的就是鏈地址法,也常被稱爲拉鍊法,就是在衝突的下標處,維護一個鏈表,所有映射到該下標的記錄,都添加到該鏈表上。
Redis字典dict如何實現的?
Redis字典dict,也是採用哈希表,本質就是數組+鏈表。
也是衆多編程語言實現Map的首選方式,如Java中的HashMap。
Redis字典dict 的底層實現,其實和Java中的ConcurrentHashMap思想非常相似。
就是用數組+鏈表實現了分佈式哈希表。當不同的關鍵字、散列到數組相同的位置,就拉鍊,用鏈表維護衝突的記錄。當衝突記錄越來越多、鏈表越來越長,遍歷列表的效率就會降低,此時需要考慮將鏈表的長度變短
。
將鏈表的長度變短
,一個最直接有效的方式就是擴容數組。將數組+鏈表結構中的數組擴容,數組變長、對應數組下標就增多了;將原數組中所有非空的索引下標、搬運到擴容後的新數組,經過重新散列,自然就把衝突的鏈表變短了。
如果你對Java的HashMap或ConcurrentHashMap 底層實現原理比較瞭解,那麼對Redis字典dict的底層實現,也能很快上手。
dict.h 給出了這個字典dict的定義:
/*
* 字典
*
* 每個字典使用兩個哈希表,用於實現漸進式 rehash
*/
typedef struct dict {
// 特定於類型的處理函數
dictType *type;
// 類型處理函數的私有數據
void *privdata;
// 哈希表(2 個)
dictht ht[2];
// 記錄 rehash 進度的標誌,值爲 -1 表示 rehash 未進行
int rehashidx;
// 當前正在運作的安全迭代器數量
int iterators;
} dict;
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;
結合上面的代碼,可以很清楚地看出dict的結構。一個dict由如下若干項組成:
dictType *type;
一個指向dictType結構的指針(type)。它通過自定義的方式使得dict的key和value能夠存儲任何類型的數據。void *privdata;
一個私有數據指針(privdata)。由調用者在創建dict的時候傳進來。dictht ht[2];
兩個哈希表(ht[2])。只有在rehash的過程中,ht[0]和ht[1]才都有效。而在平常情況下,只有ht[0]有效,ht[1]裏面沒有任何數據。上圖表示的就是rehash進行到中間某一步時的情況。int rehashidx;
當前rehash索引(rehashidx)。如果rehashidx = -1,表示當前沒有在rehash過程中;否則,表示當前正在進行rehash,且它的值記錄了當前rehash進行到哪一步了。int iterators;
當前正在進行遍歷的iterator的個數。這不是我們現在討論的重點,暫時忽略。
dictType結構包含若干函數指針,用於dict的調用者對涉及key和value的各種操作進行自定義。這些操作包含:
- hashFunction,對key進行哈希值計算的哈希算法。
- keyDup和valDup,分別定義key和value的拷貝函數,用於在需要的時候對key和value進行深拷貝,而不僅僅是傳遞對象指針。
- keyCompare,定義兩個key的比較操作,在根據key進行查找時會用到。
- keyDestructor和valDestructor,分別定義對key和value的析構函數。
私有數據指針(privdata)就是在dictType的某些操作被調用時會傳回給調用者。
dictht(dict hash table)哈希表
dictht 是字典 dict 哈希表的縮寫,即dict hash table。
dict.h/dictht 類型定義:
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表節點指針數組(俗稱桶,bucket)
dictEntry **table;
// 指針數組的大小
unsigned long size;
// 指針數組的長度掩碼,用於計算索引值
unsigned long sizemask;
// 哈希表現有的節點數量
unsigned long used;
} dictht;
/*
* 哈希表節點
*/
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 鏈往後繼節點
struct dictEntry *next;
} dictEntry;
dictht 定義一個哈希表的結構,包括以下部分:
- 一個dictEntry指針數組(table)。key的哈希值最終映射到這個數組的某個位置上(對應一個bucket)。如果多個key映射到同一個位置,就發生了衝突,那麼就拉出一個dictEntry鏈表。
- size:標識dictEntry指針數組的長度。它總是2的指數次冪。
- sizemask:用於將哈希值映射到table的位置索引。它的值等於(size-1),比如7, 15, 31, 63,等等,也就是用二進制表示的各個bit全1的數字。每個key先經過hashFunction計算得到一個哈希值,然後計算(哈希值 & sizemask)得到在table上的位置。相當於計算取餘(哈希值 % size)。
- used:記錄dict中現有的數據個數。它與size的比值就是裝載因子。這個比值越大,哈希值衝突概率越高。
Redis dictht的負載因子
我們知道當HashMap中由於Hash衝突(負載因子)超過某個閾值時,出於鏈表性能的考慮、會進行擴容,Redis dict也是一樣。
一個dictht 哈希表裏,核心就是一個dictEntry數組,同時用size記錄了數組大小,用used記錄了所有記錄數。
dictht的負載因子,就是used與size的比值,也稱裝載因子(load factor)。這個比值越大,哈希值衝突概率越高。當比值[默認]超過5,會強制進行rehash。
dictEntry
結構中包含k, v和指向鏈表下一項的next指針。k是void指針,這意味着它可以指向任何類型。v是個union,當它的值是uint64_t、int64_t或double類型時,就不再需要額外的存儲,這有利於減少內存碎片。當然,v也可以是void指針,以便能存儲任何類型的數據。
next 指向另一個 dictEntry 結構, 多個 dictEntry 可以通過 next 指針串連成鏈表, 從這裏可以看出, dictht 使用鏈地址法來處理鍵碰撞: 當多個不同的鍵擁有相同的哈希值時,哈希表用一個鏈表將這些鍵連接起來。
下圖展示了一個由 dictht 和數個 dictEntry 組成的哈希表例子:
如果再加上之前列出的 dict 類型,那麼整個字典結構可以表示如下:
在上圖給出的示例中, 只使用了 0 號哈希表ht[0],且rehashidx=-1表明字典未進行 rehash。
什麼是rehash,下文會詳細展開。
Redis dict使用的哈希算法
前面提到,一個kv鍵值對,添加到哈希表時,需要用一個映射函數將key散列到一個具體的數組下標。
Redis 目前使用兩種不同的哈希算法:
1、MurmurHash2
是種32 bit 算法:這種算法的分佈率和速度都非常好;Murmur哈希算法最大的特點是碰撞率低,計算速度快。Google的Guava庫包含最新的Murmur3。
具體信息請參考 MurmurHash 的主頁: http://code.google.com/p/smhasher/ 。
2、基於 djb 算法實現的一個大小寫無關散列算法:具體信息請參考 http://www.cse.yorku.ca/~oz/hash.html 。
使用哪種算法取決於具體應用所處理的數據:
- 命令表以及 Lua 腳本緩存都用到了算法 2 。
- 算法 1 的應用則更加廣泛:數據庫、集羣、哈希鍵、阻塞操作等功能都用到了這個算法。
Redis dict各種操作
以下是用於處理 dict 的各種 API , 它們的作用及相應的算法複雜度:
操作 | 函數 | 算法複雜度 |
---|---|---|
創建一個新字典 | dictCreate | O(1) |
添加新鍵值對到字典 | dictAdd | O(1) |
添加或更新給定鍵的值 | dictReplace | O(1) |
在字典中查找給定鍵所在的節點 | dictFind | O(1) |
在字典中查找給定鍵的值 | dictFetchValue | O(1) |
從字典中隨機返回一個節點 | dictGetRandomKey | O(1) |
根據給定鍵,刪除字典中的鍵值對 | dictDelete | O(1) |
清空並釋放字典 | dictRelease | O(N) |
清空並重置(但不釋放)字典 | dictEmpty | O(N) |
縮小字典 | dictResize | O(N) |
擴大字典 | dictExpand | O(N) |
對字典進行給定步數的 rehash | dictRehash | O(N) |
在給定毫秒內,對字典進行rehash | dictRehashMilliseconds | O(N) |
下面,會對一些關鍵步驟進行詳細講解。
dict的創建(dictCreate)
創建dict
dict *d = dictCreate(&hash_type, NULL);
dict *dictCreate(dictType *type,
void *privDataPtr)
{
dict *d = zmalloc(sizeof(*d));
_dictInit(d,type,privDataPtr);
return d;
}
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
_dictReset(&d->ht[0]);
_dictReset(&d->ht[1]);
d->type = type;
d->privdata = privDataPtr;
d->rehashidx = -1;
d->iterators = 0;
return DICT_OK;
}
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
dictCreate爲dict的數據結構分配空間併爲各個變量賦初值。其中兩個哈希表ht[0]和ht[1]起始都沒有分配空間,table指針都賦爲NULL。這意味着要等第一個數據插入時纔會真正分配空間。
- ht[0]->table 的空間分配將在第一次往字典添加鍵值對時進行;
- ht[1]->table 的空間分配將在 rehash 開始時進行;
添加新鍵值對到字典(dictAdd)
根據字典所處的狀態, 將給定的鍵值對添加到字典可能會引起一系列複雜的操作:
- 如果字典爲未初始化(即字典的 0 號哈希表的 table 屬性爲空),則程序需要對 0 號哈希表進行初始化;
- 如果在插入時發生了鍵碰撞,則程序需要處理碰撞;
- 如果插入新元素,使得字典滿足了 rehash 條件,則需要啓動相應的 rehash 程序;
當程序處理完以上三種情況之後,新的鍵值對纔會被真正地添加到字典上。
dictAdd函數是調用 dictAddRaw實現的:
/* 將Key插入哈希表 */
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d); // 如果哈希表在rehashing,則執行單步rehash
/* 調用_dictKeyIndex() 檢查鍵是否存在,如果存在則返回NULL */
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry)); // 爲新增的節點分配內存
entry->next = ht->table[index]; // 將節點插入鏈表表頭
ht->table[index] = entry; // 更新節點和桶信息
ht->used++; // 更新ht
/* 設置新節點的鍵 */
dictSetKey(d, entry, key);
return entry;
}
整個添加流程可以用下圖表示:
在接下來的三節中, 我們將分別看到,添加操作如何在以下三種情況中執行:
1、字典爲空;
2、添加新鍵值對時發生碰撞處理;
3、添加新鍵值對時觸發了 rehash 操作;
添加新元素時table爲空
當第一次往空字典裏添加鍵值對時, 程序會根據 dict.h/DICT_HT_INITIAL_SIZE 裏指定的大小爲 d->ht[0]->table 分配空間 (在目前的版本中, DICT_HT_INITIAL_SIZE 的值爲 4 )。
以下是字典空白時的樣子:
以下是往空白字典添加了第一個鍵值對之後的樣子:
添加新鍵值對時發生碰撞
在哈希表實現中, 當兩個不同的鍵擁有相同的哈希值時, 稱這兩個鍵發生碰撞(collision), 而哈希表實現必須想辦法對碰撞進行處理。
字典哈希表所使用的碰撞解決方法被稱之爲鏈地址法: 這種方法使用鏈表將多個哈希值相同的節點串連在一起, 從而解決衝突問題。
假設現在有一個帶有三個節點的哈希表,如下圖:
對於一個新的鍵值對 key4 和 value4 , 如果 key4 的哈希值和 key1 的哈希值相同, 那麼它們將在哈希表的 0 號索引上發生碰撞。
通過將 key4-value4 和 key1-value1 兩個鍵值對用鏈表連接起來, 就可以解決碰撞的問題:
添加新鍵值對時觸發了 rehash
對於使用鏈地址法來解決碰撞問題的哈希表 dictht 來說, 哈希表的性能取決於大小(size屬性)與保存節點數量(used屬性)之間的比率:
哈希表的大小與節點數量,比率在 1:1 時,哈希表的性能最好;
如果節點數量比哈希表的大小要大很多的話,那麼哈希表就會退化成多個鏈表,哈希表本身的性能優勢便不復存在;
舉個例子, 下面這個哈希表, 平均每次失敗查找只需要訪問 1 個節點(非空節點訪問 2 次,空節點訪問 1 次):
而下面這個哈希表, 平均每次失敗查找需要訪問 5 個節點:
爲了在字典的鍵值對不斷增多的情況下保持良好的性能, 字典需要對所使用的哈希表(ht[0])進行 rehash 操作: 在不修改任何鍵值對的情況下,對哈希表進行擴容, 儘量將比率維持在 1:1 左右。
dictAdd 在每次向字典添加新鍵值對之前, 都會對哈希表 ht[0] 進行檢查, 對於 ht[0] 的 size 和 used 屬性, 如果它們之間的比率 ratio = used / size 滿足以下任何一個條件的話,rehash 過程就會被觸發:
- 自然 rehash : ratio >= 1 ,且變量 dict_can_resize 爲true。
- 強制 rehash : ratio 大於變量 dict_force_resize_ratio (目前版本中, dict_force_resize_ratio 的值爲 5 )。
什麼時候 dict_can_resize 會爲false?
在前面介紹字典的應用時也說到過, 數據庫就是字典, 數據庫裏的哈希類型鍵也是字典, 當 Redis 使用子進程對數據庫執行後臺持久化任務時(比如執行 BGSAVE 或 BGREWRITEAOF 時), 爲了最大化地利用系統的 copy on write 機制, 程序會暫時將 dict_can_resize 設爲false, 避免執行自然 rehash , 從而減少程序對內存的觸碰(touch)。當持久化任務完成之後, dict_can_resize 會重新被設爲true。
另一方面, 當字典滿足了強制 rehash 的條件時, 即使 dict_can_resize 不爲true(有 BGSAVE 或 BGREWRITEAOF 正在執行), 這個字典一樣會被 rehash 。
Rehash 執行過程
字典的 rehash 操作實際上就是執行以下任務:
1、創建一個比 ht[0]->table 更大的 ht[1]->table ;
2、將 ht[0]->table 中的所有鍵值對遷移到 ht[1]->table ;
3、將原有 ht[0] 的數據清空,並將 ht[1] 替換爲新的 ht[0] ;
經過以上步驟之後, 程序就在不改變原有鍵值對數據的基礎上, 增大了哈希表的大小。
dict的rehash 本質就是擴容,就是將數組+鏈表結構中的數組擴容;
這個過程,需要開闢一個更大空間的數組,將老數組中每個非空索引的bucket,搬運到新數組;搬運完成後再釋放老數組的空間。
作爲例子, 以下四個小節展示了一次對哈希表進行 rehash 的完整過程。
1. 開始 rehash
這個階段有兩個事情要做:
- 設置字典的 rehashidx 爲 0 ,標識着 rehash 的開始;
- 爲 ht[1]->table 分配空間,大小至少爲 ht[0]->used 的兩倍;
這時的字典是這個樣子:
2. Rehash 進行中
在這個階段, ht[0]->table 的節點會被逐漸遷移到 ht[1]->table , 因爲 rehash 是分多次進行的(細節在下一節解釋), 字典的 rehashidx 變量會記錄 rehash 進行到 ht[0] 的哪個索引位置上。
注意除了節點的移動外, 字典的 rehashidx 、 ht[0]->used 和 ht[1]->used 三個屬性也產生了變化。
3. 節點遷移完畢
到了這個階段,所有的節點都已經從 ht[0] 遷移到 ht[1] 了:
4. Rehash 完畢
在 rehash 的最後階段,程序會執行以下工作:
- 釋放 ht[0] 的空間;
- 用 ht[1] 來代替 ht[0] ,使原來的 ht[1] 成爲新的 ht[0] ;
- 創建一個新的空哈希表,並將它設置爲 ht[1] ;
- 將字典的 rehashidx 屬性設置爲 -1 ,標識 rehash 已停止;
以下是字典 rehash 完畢之後的樣子:
incremental rehashing 增量/漸進式rehash
在上一節,我們瞭解了字典的 rehash 過程, 需要特別指出的是, rehash 並不是在觸發之後,馬上就執行直到完成; 而是分多次、漸進式地完成的。
rehash會產生的問題
1、rehash的過程,會使用兩個哈希表,創建了一個更大空間的ht[1],此時會造成內存陡增;
2、rehash的過程,可能涉及大量KV鍵值對dictEntry的搬運,耗時較長;
如果這個 rehash 過程必須將所有鍵值對遷移完畢之後纔將結果返回給用戶, 這樣的處理方式將不滿足Redis高效響應的特性。
rehash會產生的問題,主要層面就是內存佔用陡增、和處理耗時長的問題,基於這兩點,還會帶來其他影響。
爲了解決這些問題, Redis 使用了incremental rehashing,是一種 增量/漸進式的 rehash 方式: 通過將 rehash 分散到多個步驟中進行, 從而避免了集中式的計算/節點遷移。
dictAdd 添加鍵值對到dict,檢查到需要進行rehash時,會將dict.rehashidx 設置爲 0 ,標識着 rehash 的開始;
後續請求,在執行add、delete、find操作時,都會判斷dict是否正在rehash,如果是,就執行_dictRehashStep()函數,進行增量rehash。
每次執行 _dictRehashStep , 會將ht[0]->table 哈希表第一個不爲空的索引上的所有節點就會全部遷移到 ht[1]->table 。
也就是在某次dictAdd 添加鍵值對時,觸發了rehash;後續add、delete、find命令在執行前都會檢查,如果dict正在rehash,就先不急去執行自己的命令,先去幫忙搬運一個bucket;
搬運完一個bucket,再執行add、delete、find命令 原有處理邏輯。
ps:實際上incremental rehashing
增量/漸進式rehash,只解決了第二個:耗時長的問題,將集中式的節點遷移分攤到多步進行,ht[1]佔用的雙倍多內存,還一直佔用。
下面我們通過dict的查找(dictFind)來看漸進式rehash過程;
dict的查找(dictFind)
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
unsigned int h, idx, table;
if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
if (dictIsRehashing(d)) _dictRehashStep(d);// 如果哈希表在rehashing,則執行單步rehash
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}
上述dictFind的源碼,根據dict當前是否正在rehash,依次做了這麼幾件事:
- 如果當前正在進行rehash,那麼將rehash過程向前推進一步(即調用_dictRehashStep)。實際上,除了查找,插入和刪除也都會觸發這一動作。這就將rehash過程分散到各個查找、插入和刪除操作中去了,而不是集中在某一個操作中一次性做完。
- 計算key的哈希值(調用dictHashKey,裏面的實現會調用前面提到的hashFunction)。
- 先在第一個哈希表ht[0]上進行查找。在table數組上定位到哈希值對應的位置(如前所述,通過哈希值與sizemask進行按位與),然後在對應的dictEntry鏈表上進行查找。查找的時候需要對key進行比較,這時候調用dictCompareKeys,它裏面的實現會調用到前面提到的keyCompare。如果找到就返回該項。否則,進行下一步。
- 判斷當前是否在rehash,如果沒有,那麼在ht[0]上的查找結果就是最終結果(沒找到,返回NULL)。否則,在ht[1]上進行查找(過程與上一步相同)。
下面我們有必要看一下增量式rehash的_dictRehashStep的實現。
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
/*
* Note that a rehashing step consists in moving a bucket (that may have more
* than one key as we use chaining) from the old to the new hash table, however
* since part of the hash table may be composed of empty spaces, it is not
* guaranteed that this function will rehash even a single bucket, since it
* will visit at max N*10 empty buckets in total, otherwise the amount of
* work it does would be unbound and the function may block for a long time.
*/
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* 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 long)d->rehashidx);
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while(de) {
unsigned int h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* Check if we already rehashed the whole table... */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}
根據dictRehash函數的註釋,rehash的單位是bucket,也就是從老哈希表dictht中找到第一個非空的下標,要把該下標整個鏈表搬運到新數組。
如果遍歷老數組,訪問的 前N*10 個都是空bucket,則不再繼續往下尋找。
dictAdd、dictDelete、dictFind在rehash過程中的特殊性
在哈希表進行 rehash 時, 字典還會採取一些特別的措施, 確保 rehash 順利、正確地進行:
- 因爲在 rehash 時,字典會同時使用兩個哈希表,所以在這期間的所有查找dictFind、刪除dictDelete等操作,除了在 ht[0] 上進行,還需要在 ht[1] 上進行。
- 在執行添加操作dictAdd時,新的節點會直接添加到 ht[1] 而不是 ht[0] ,這樣保證 ht[0] 的節點數量在整個 rehash 過程中都只減不增。
dict的縮容
上面關於 rehash 的章節描述了通過 rehash 對字典進行擴展(expand)的情況, 如果哈希表的可用節點數比已用節點數大很多的話, 那麼也可以通過對哈希表進行 rehash 來收縮(shrink)字典。
收縮 rehash 和上面展示的擴展 rehash 的操作幾乎一樣,執行以下步驟:
- 創建一個比 ht[0]->table 小的 ht[1]->table ;
- 將 ht[0]->table 中的所有鍵值對遷移到 ht[1]->table ;
- 將原有 ht[0] 的數據清空,並將 ht[1] 替換爲新的 ht[0] ;
小結
Redis的dict最顯著的一個特點,就在於它的rehash。它採用了一種稱爲增量式(incremental rehashing)的rehash方法,在需要擴容時避免一次性對所有key進行rehash,而是將rehash操作分散到對於dict的各個增刪改查的操作中去。
這種方法能做到每次只對一小部分key進行rehash,而每次rehash之間不影響dict的操作。dict之所以這樣設計,是爲了避免rehash期間單個請求的響應時間劇烈增加,這與前面提到的“快速響應時間”的設計原則是相符的。
- Redis的dict也是使用數組+鏈表實現;
- 當衝突增加、鏈表增長,也是採用rehash(數組擴容)來將鏈表變短;
- dict數組擴容,也是按2的指數次冪,使用位運算,替代求餘操作,計算更快;
- 漸進式rehash,其實是輔助式的;不是讓觸發rehash的一個人搬運完所有dictEntry,而是讓後來者一起參與搬運。
福利: 關注公衆號,回覆“Redis源碼”,即可獲得 Redis 3.0源碼註釋版~
本文首發於公衆號 架構道與術(ToBeArchitecturer),歡迎關注、學習更多幹貨~