大家都知道 ,redis是一個基於key-value 形式的 存儲系統。而字典就是一個元素類型爲key-value形式的一種數據結構, 那麼可以這麼認爲:redis本身就是一個巨大的字典。
在redis的源碼,中redis自己實現了字典。
首先是實現 key-value 鍵值對
typeof struct dictEntry{
void *key;
union{
void *val;
uint64_tu64;
int64_ts64;
}
struct dictEntry *next;
}
之後又基於 dictEntry 實現了 哈希表類型
typedef struct dictht {
dictEntry **table;//哈希表數組
unsigned long size;//哈希表大小
unsigned long sizemask;//哈希表大小掩碼,用於計算索引值
unsigned long used;//該哈希表已有節點的數量
}
這裏需要思考一下,字典和哈希表有什麼不同?
字典是將鍵映射到值(最終就是內存)上,爲什麼要這麼做呢?當然是爲了能夠快速獲得對應數值,不必像遍歷數組一樣挨個查詢。因此,字典的實現,除了使用散列函數/哈希,還可以有其他方法,比如C++ STL 中的 map,其實現利用的是紅黑樹。
既然字典的實現可以有多種方式,那麼該怎麼定義字典類呢?
學習過python的同學都知道,dict類有很多內置方法。redis 當然是不需要那麼多的
這裏可以看redis給出了其自己的方案,如下所示
typedef struct dictType {
uint64_t (*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;
這是redis中字典類的基類,其中只有幾個方法/屬性。
剛纔我們講到,有時候字典確實可以視作一個哈希表。因此如果只是爲了實現字典的功能,只要實現哈希表基本上可以了。
但是redis 還做了一層改進。先給出代碼如下:
typedef struct dict {
dictType *type;
void *privedata;
dictht ht[2]; // 定義兩個 哈希表,用來漸進式地哈希
int rehashidx; //flag位,表明當前在漸進式哈希過程中的狀態
}
以上是redis實際使用的字典的類型
這裏於我們設想的字典類型有哪些地方不同呢,除了一個dictType 類型的和私有的data,多出了兩個ht[2] 和rehashidx 。
這是用來幹嘛的呢?
回到上文,dictht 是哈希表類型的實現,按理說一個字典其內部有一套哈希表即可,但是這裏實現了兩個。
這是爲什麼呢?
這裏就要設計到redis字典中的漸進式哈希的設計理念了。
我們先從單個 哈希表開始講起。
哈希表擴容
對於哈希操作,我們知道,其實質是在有限的空間上按特定算法映射數據和內存。這意味着,在一開始,哈希表就必須設定大小。但是隨着哈希次數/新增數據的增多,可用空間會越來越小。雖然可以採用開放尋址法和鏈地址法(拉鍊法)解決衝突,但這也增加了以後查詢的成本,隨着數據增多,給哈希表擴容是無法避免的。
既然要給哈希表擴容,我們則需要設立一個標準/閾值,一旦哈希表的負載因子(元素 個數/哈希列表長度)過了閾值則啓動擴容。
java hashmap擴容
對於 擴容這一操作 ,java中的 hashmap 是通過其內置的resize()方法進行擴容的。當負載因子超過0.75,便會進行擴容。
這會在臨界點造成一個現象,即剛好插入新節點時,要進行擴容,之前的節點都需要重新計算哈希值,這會使得這個新節點的插入看起來比以往慢很多。
(詳情點擊--java hashmap擴容)
redis 字典擴容
redis 是一個高性能的數據庫,其目標只有兩個:快,更快!
由於其自身是基於key-value形式的,所以擴容在其內部是十分常見的。但是如果每次擴容都需要重新計算之前所有節點的哈希值到新哈希表,那無疑會使redis表現起來很怪異(性能會波動,也許並不會表現出來,畢竟redis十分快,這麼點計算消耗可能還遠不及網絡的一次波動,但是當數據量大的時候的確有可能引起短暫的阻塞)。
redis採用了漸進式哈希的方式,將一次集中擴容、rehash 操作的成本,分攤到各個節點的插入/更新操作。使得擴容十分平穩。
漸進式哈希
redis中的字典 內部有兩張哈希表,ht[0],ht[1]
我們稱負載因子爲 ht[0].used/ht[0].size 。
在最開始的時候,ht[1]是個空表。
1. ht[0] 負載因子 未到臨界點
所有操作都是在ht[0]上進行
直到當ht[0] 的負載因子 達到臨界點,就會觸發 擴容/縮容 操作。
這個時候會將字典的屬性 rehashidx 設爲0 , 表示啓動rehash過程。
2. rehash 過程
先給ht[1] 申請內存
-
擴容:ht[1]的大小爲大於等於ht[0].used * 2的且爲2^n的值。
-
收縮:ht[1]的大小爲大於等於ht[0].used 的且爲2^n的值。
在這之後,所有新增元素都會只在ht[1] 上進行分配。
而查找、更新操作,除了返回操作結果之外,還會將ht[0]表的該元素rehash到ht[1]
從而保證ht[0] 的元素一直是在減少,直到成爲空表。
這時會將 rehashidx 變量 置爲 -1 ,表示rehash 結束
3. rehash 結束
rehash結束之後,會有一個指針轉向的操作。
先將ht[0] 表的內存釋放,後將ht[0] 指向 ht[1] 。最後又重置ht[1]爲空表。
rehash 過程中的查找
在rehash過程中,兩個哈希表都有數據,查找順序是從ht[0]到ht[1]。
rehash 的不足
由於rehash 會引起使用內存的變化,因此在redis滿容或接近滿容狀態下 , rehash 時可能會使內存恰好突破了限制,觸發淘汰策略,刪除大量的鍵。
負載因子
Redis在執行BGSAVE和BGREWRITEAOF命令時,
哈希表的負載因子大於等於5,而未執行這兩個命令時大於等於1
當哈希表的負載因子小於0.1時,對哈希表執行收縮操作
在執行BGSAVE和BGREWRITEAOF命令時,Redis需要創建當前服務器進程的子進程,而大多數操作系統都採用寫時 複製技術來由於子進程的使用效率,所以在子進程存在期間,服務器會提高執行擴展操作所需的負載因子,從而儘可 能避免在子進程存在期間進行哈希表擴展操作,這可以避免不必要的內存寫入操作。