Redis源码剖析(三)字典结构的设计与实现
Redis是K-V類型的數據庫,所謂K-V類型,就是底層存儲的數據結構是key-value,即鍵key,值value。鍵key在Redis中以字符串的形式存在,而值value可以是多種類型
Redis內部的鍵值對采用字典存儲,而字典底層又采用哈希表實現。哈希表是常用的鍵值對存儲結構,根據鍵key計算哈希值,然后計算索引下標,在哈希表中對應下標處存儲鍵key對應的值。因為不同key被映射到同一個下標是很常見的事情,所以哈希表一般都需要解決沖突,Redis使用開鏈法解決沖突,即哈希表每個下標處都是一個鏈表,用來保存被映射到相同下標的鍵值對
數據庫結構
Redis的數據庫結構定義在server.h中,內部包含多個字典,分別適用于不同的場景。因為還沒有涉及到Redis其他功能的實現,所以這里只關心保存鍵值對的字典
//server.h typedef struct redisDb {dict *dict; /* 保存鍵值對的字典 */ int id; /* 數據庫唯一id */ ... } redisDb;dict變量是是存儲鍵值對數據的字典,在進行數據庫的曾,刪,改,查時都需要使用這個字典。下面就來看一下字典在底層是如何實現的
字典的設計與實現
想要實現字典,首先需要實現哈希表,由于采用開鏈法解決沖突問題,就需要先設計哈希節點,所以在dict.h中可以清楚的看到對于這些結構的定義
哈希節點
其中,哈希節點的定義如下,由于采用開鏈法,所以哈希表每個元素都應該是個鏈表。對于哈希節點的值,采用聯合結構是為了可以保存不同類型的值,對于非數值類型的數據,可以通過void*指針保存。后面可以看到,Redis的對象系統將所有數據類型進行了統一
//dict.h /* 哈希表節點,使用開鏈法解決沖突 */ typedef struct dictEntry {void *key; //鍵union {void *val;uint64_t u64;int64_t s64;double d;} v; //值,可以保存不同數值類型,而其它的類型可以通過void*指針保存struct dictEntry *next; /* 下一個哈希節點 */ } dictEntry;哈希表
接下來是哈希表的定義,哈希表主要保存的就是一個大數組,數組每個元素都是dictEntry*,指針表示的實際是哈希節點,所以可以看到每個數組元素都是一個鏈表
//dict.h /* 哈希表 */ typedef struct dictht {dictEntry **table; //哈希表數組,數組元素是鏈表(開鏈法解決沖突)unsigned long size; //哈希表大小unsigned long sizemask; //掩碼,用于計算索引,總是等于size-1unsigned long used; //已有節點數量 } dictht;這里需要區分一下size和used的區別,size是指哈希表的大小,即數組大小,used是指哈希表中哈希節點的個數,一個下標下可能會有多個哈希節點。后面會看到,size和used的大小關系決定了是否需要對哈希表進行擴充或收縮(rehash操作)
sizemask的掩碼,用來計算鍵key對應的下標。通常通過哈希算法計算的哈希值會大于哈希表大小,通過sizemask,可以將哈希值的大小映射到[0:size-1]范圍內,采用的方法是將哈希值與sizemask做與運算,實際上就是對size取模
字典
Redis的字典主要保存兩個哈希表(以下簡稱為ht[0]和ht[1]),ht[0]是實際保存鍵值對的哈希表,ht[1]只有當進行rehash時才會用到
rehashidx是rehash的進度,后面會看到用處
//dict.h /* 字典 */ typedef struct dict {dictType *type; /* 字典處理函數 */void *privdata; //用于傳給函數的參數dictht ht[2]; //兩個哈希表,多出的一個用于rehash時用long rehashidx; /* rehash時使用,記錄rehash的進度 */int iterators; } dict;dictType類型定義如下,目前可以簡單理解成是字典處理函數,根據不同類型的鍵值對提供不同的處理函數
根據函數名可以很好理解函數用途,需要注意第一個函數hashFunction是計算哈希值的函數
//dict.h /* 哈希表處理函數 */ 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;字典的Rehash操作
本來是想在最后介紹rehash的,結果發現在字典的基本操作中處處涉及到rehash,所以想想還是將rehash挪到前面好了
Redis會在兩種情況對字典進行rehash操作
- 字典中哈希節點數量過多,導致在同一個下標下查找某個鍵的時間邊長,需要擴大字典容量
- 字典中哈希節點數量過少,導致很多位置處于空缺,占用大量空缺內存,需要縮小字典容量
哈希表的擴充
當為鍵計算哈希值時,會判斷是否需要對字典進行擴充,判斷函數是_dictExpandIfNeeded,判斷的依據是
- 字典中哈希節點個數大于哈希表大小
- 哈希節點個數 / 哈希表大小 > 負載因子
函數定義如下
//dict.c /* 判斷是否需要對字典執行擴充操作,如果需要,擴充 */ static int _dictExpandIfNeeded(dict *d) {/* 如果此時正在進行rehash操作,則不進行擴充 */if (dictIsRehashing(d)) return DICT_OK;/* 如果哈希表容量為0,代表此時是初始化,創建默認大小的哈希表 */if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);/* 負載因子,如果字典中的節點數量 / 字典大小 > 負載因子,就需要對字典進行擴充 */if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize ||d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)){return dictExpand(d, d->ht[0].used*2);}return DICT_OK; }rehash的意思是進行重新映射,此時就需要使用ht[1],即第二個哈希表(字典中包含兩個哈希表,第二個用于rehash操作,上面提到過)
rehash的方法是先確定擴充或縮小的哈希表大小,然后為ht[1]申請內存空間,最后將rehashidx設置成0,標志著開始執行rehash操作
這些操作在dictExpand函數中執行
//dict.c /* 擴充字典的哈希表,擴充的主要目的是用于rehash */ int dictExpand(dict *d, unsigned long size) {/* 新的哈希表 */dictht n; /* the new hash table *//* _dictNextPower函數返回第一個大于size的2的冪次方,Redis保證哈希表的大小永遠是2的冪次方 */unsigned long realsize = _dictNextPower(size);/* rehash的過程中不能進行擴充操作 *//* 擴充后的大小小于原有哈希節點時報錯,說明size太小 */if (dictIsRehashing(d) || d->ht[0].used > size)return DICT_ERR;/* 大小沒有改變,返回 */if (realsize == d->ht[0].size) return DICT_ERR;/* 設置新的哈希表屬性,分配空間 */n.size = realsize;n.sizemask = realsize-1;n.table = zcalloc(realsize*sizeof(dictEntry*));n.used = 0;/* 如果以前的哈希表為空,那么就直接使用新哈希表即可,不需要進行數據移動 */if (d->ht[0].table == NULL) {d->ht[0] = n;return DICT_OK;}/* 令第二個哈希表使用新的哈希表 */d->ht[1] = n;/* rehashidx設置為0,標志開始rehash操作 */d->rehashidx = 0;return DICT_OK; }這個函數并沒有直接調用rehash函數,而是僅僅將rehashidx賦值為0,標志著當前處于rehash狀態。每當對數據庫進行增刪改查時,都會先判斷當前是否處于rehash狀態,如果是,那么就執行一次rehash,這樣的好處是將rehash帶來的時間空間消耗分攤給了每一個對數據庫的操作。而如果采用直接將全部的數據rehash到ht[1],那么當數據量很大時,rehash執行時間會很長,服務器很可能被阻塞。
需要注意的是為ht[1]申請的新大小而不是ht[0],這是Redis進行rehash的策略,即將ht[0]中的每個鍵值對重新計算其哈希值,然后添加到ht[1]中,當ht[0]中所有數據都已經添加到ht[1]時,將ht[1]作為ht[0]使用
Rehash操作
什么時候進行rehash操作呢,當對數據庫進行增刪改查時,會首先判斷當前是否處在rehash狀態
/* 判斷是否正在進行rehash操作(rehashidx != -1) */ /* 如果正在進行rehash,那么每次對數據庫的增刪改查都會進行一遍rehash操作 * 這樣可以將rehash的執行時間分攤到每一個對數據庫的操作上 */ if (dictIsRehashing(d)) _dictRehashStep(d);dictIsRehashing是個宏定義,僅僅是判斷rehashidx是否為-1,這就是上面為什么將rehashidx設置為0就代表開啟rehash操作
//dict.h #define dictIsRehashing(d) ((d)->rehashidx != -1)當Redis發現此時正處于rehash操作時,會執行rehash函數。不過Redis不是一次將ht[0]中所有的數據全部移動到ht[1],而是僅僅移動一小部分。Redis采用N步漸進式執行rehash操作,即每次移動ht[0]N個下標的數據到ht[1]中
rehash函數由dictRehash完成,這個函數雖然長了點,但是還是蠻好理解的
其中需要注意的是
- rehashidx記錄著當前rehash的進度,即從哈希表ht[0]的rehashidx位置開始移動數據到ht[1]中(每次移動完成后都會更改rehashidx)
- rehash的步驟是先獲取ht[0]中rehashidx位置的哈希節點鏈表,將這個鏈表中所有哈希節點代表的鍵值對重新計算哈希值,添加到ht[1]中,然后從ht[0]中刪除
- 每步移動一個下標下的整個鏈表,總共執行n步
- 如果已經將ht[0]的所有數據移動到ht[1]中,就釋放ht[0],將ht[1]作為ht[0],然后重置ht[1],將rehashidx設置為-1標志不再執行rehash操作
每次對數據庫進行增刪改查時,如果當前處于rehash狀態,就執行一步rehash函數(n=1)
//dict.c /* 執行一步rehash */ static void _dictRehashStep(dict *d) {/* 執行一次rehash */if (d->iterators == 0) dictRehash(d,1); }另外,當處于rehash狀態時,數據庫的操作會有些不同
對數據庫的添加操作會添加到ht[1]中而不再添加到ht[0],因為ht[0]中的鍵值對遲早要添加到ht[1]中,還不如直接添加到ht[1]中。這樣帶來的另一個好處是ht[0]的數量不會增加,可以保證rehash早晚可以完成
對數據庫的查找操作會從ht[0]和ht[1]兩個哈希表中查找,因為ht[0]中的鍵值對已經有一部分放到ht[1]中了
此外,Redis還提供了另一種rehash策略,即每次rehash一定時間,也比較好理解
//dict.c /* 執行ms毫秒的rehash操作 */ int dictRehashMilliseconds(dict *d, int ms) {/* 保存開始時的時間 */long long start = timeInMilliseconds();int rehashes = 0;/* 每次rehash執行100步 */while(dictRehash(d,100)) {rehashes += 100;/* 判斷時間是否到達 */if (timeInMilliseconds()-start > ms) break;}/* 返回rehash了多少步 */return rehashes; }字典的基本操作
字典的操作比較多,以增刪改查為例
添加鍵值對
添加鍵值對操作是由dictAdd函數完成的,當在終端輸入各種SET命令添加鍵值對時,最終也會調用這個函數
函數會先創建一個只有鍵沒有值的哈希節點,然后為哈希節點設置值
/* 向字典中添加鍵值對 */ int dictAdd(dict *d, void *key, void *val) {/* 創建一個只有鍵沒有值的哈希節點 */ dictEntry *entry = dictAddRaw(d,key);if (!entry) return DICT_ERR;/* 將值添加到創建的哈希節點中 */dictSetVal(d, entry, val);return DICT_OK; }創建只有鍵沒有值的操作由dictAddRaw函數完成,函數首先判斷是否處于rehash狀態,如果是,那么會執行一步rehash。同時如果處于rehash狀態,那么會將鍵值對添加到ht[1]中而不是ht[0]
//dict.c /* 向字典中添加一個只有key的鍵值對,如果正在進行rehash操作,則需要先執行rehash再添加 */ dictEntry *dictAddRaw(dict *d, void *key) {int index;dictEntry *entry;dictht *ht;/* 判斷是否正在進行rehash操作(rehashidx != -1) *//* 如果正在進行rehash,那么每次對數據庫的增刪改查都會進行一遍rehash操作* 這樣可以將rehash的執行時間分攤到每一個對數據庫的操作上 */if (dictIsRehashing(d)) _dictRehashStep(d);/* 計算鍵key在哈希表中的索引,如果哈希表中已存在相同的key,則返回 */if ((index = _dictKeyIndex(d, key)) == -1)return NULL;/* 如果正在執行rehash,那么直接添加到第二個哈希表中 *//* 因為rehash操作是將第一個哈希表重新映射到第二個哈希表中* 那么就沒必要再往第一個哈希表中添加,可以直接添加到第二個哈希表中* 這樣做的目的是保證在rehash的過程中第一個哈希表的節點數量是一直減少的 */ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];/* 申請節點 */entry = zmalloc(sizeof(*entry));/* 將為key創建的哈希節點添加到哈希表的相應索引下 */entry->next = ht->table[index];ht->table[index] = entry;/* 節點數加一 */ht->used++;/* 設置節點的鍵為key */dictSetKey(d, entry, key);return entry; }刪除鍵值對
刪除鍵值對主要由dictGenericDelete函數完成,函數首先會判斷是否處于rehash狀態,如果是則執行一步rehash。然后在ht[0]和ht[1]中查找匹配的鍵值對,執行刪除操作
//dict.c /* 從字典中刪除鍵key對應的鍵值對,nofree代表是否釋放鍵值對的內存空間 */ static int dictGenericDelete(dict *d, const void *key, int nofree) {unsigned int h, idx;dictEntry *he, *prevHe;int table;/* 字典為空,沒有數據,直接返回 */if (d->ht[0].size == 0) return DICT_ERR; /* d->ht[0].table is NULL *//* 如果正處于rehash狀態,那么執行一步rehash */if (dictIsRehashing(d)) _dictRehashStep(d);/* 計算鍵key的哈希值 */h = dictHashKey(d, key);/* 在ht[0]和ht[1]中尋找 */for (table = 0; table <= 1; table++) {/* 計算鍵key的索引下標(與掩碼sizemask與運算) */idx = h & d->ht[table].sizemask;he = d->ht[table].table[idx];prevHe = NULL;while(he) {/* 依次比較索引下標下的哈希節點,找到鍵相同的那個哈希節點 */if (key==he->key || dictCompareKeys(d, key, he->key)) {/* Unlink the element from the list *//* 將哈希節點移除 */if (prevHe)prevHe->next = he->next;elsed->ht[table].table[idx] = he->next;/* 如果需要釋放內存,則將鍵值對的內存空間釋放 */if (!nofree) {dictFreeKey(d, he);dictFreeVal(d, he);}/* 釋放哈希節點的內存 */zfree(he);/* 節點個數減一 */d->ht[table].used--;return DICT_OK;}prevHe = he;he = he->next;}/* 如果沒有進行rehash,那么ht[1]中沒有數據,不需要在ht[1]中尋找 */if (!dictIsRehashing(d)) break;}return DICT_ERR; /* not found */ }修改鍵值對
修改鍵值對由dictReplace函數完成,如果鍵不存在,則執行常規的添加操作,如果存在,則修改值。實際調用的還是添加操作,因為添加操作當鍵存在是會返回錯誤
//dict.c /* 如果添加時對應的key已經存在,就替換 */ int dictReplace(dict *d, void *key, void *val) {dictEntry *entry, auxentry;if (dictAdd(d, key, val) == DICT_OK)return 1;/* 到達這里表示鍵key在哈希表中已存在,從哈希表中找到該節點 */entry = dictFind(d, key);/* 保存以前的哈希節點,實際上是為了保存哈希節點值,因為需要釋放值的內存 */auxentry = *entry;/* 將鍵key對應的節點值設置為val */dictSetVal(d, entry, val);/* 釋放舊的哈希節點值 */dictFreeVal(d, &auxentry);return 0; }查找鍵值對
查找鍵值對由dictFind函數完成,首先根據鍵計算哈希值和下標,然后在對應位置查找是否有匹配的鍵,如果有則將哈希節點返回
//dict.c /* 查找鍵為key的哈希節點 */ 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 *//* 如果處于rehash狀態,執行一步rehash */if (dictIsRehashing(d)) _dictRehashStep(d);/* 計算哈希值 */h = dictHashKey(d, key);/* 在ht[0]和ht[1]中搜索 */for (table = 0; table <= 1; table++) {/* 計算下標 */idx = h & d->ht[table].sizemask;/* 獲得下標處的哈希節點鏈表頭 */he = d->ht[table].table[idx];while(he) {/* 尋找鍵與key相同的哈希節點 */if (key==he->key || dictCompareKeys(d, key, he->key))return he;he = he->next;}/* 如果沒有進行rehash,則沒有必要在ht[1]中查找 */if (!dictIsRehashing(d)) return NULL;}return NULL; }小結
字典是Redis存儲數據的數據結構,保存有兩個哈希表。當字典中數據過多時會執行rehash操作,rehash操作實際是將第一個哈希表的鍵值對重新計算哈希值添加到第二個哈希表中。Redis沒有一下子將所有鍵值對都進行rehash,而是采用漸進式的方法,每次對數據庫進行操作時,都會執行一次rehash操作。這樣就將rehash的消耗分攤到對數據庫的操作上。
另外,沒有找到當字典數量過少時進行的縮小操作…
總結
以上是生活随笔為你收集整理的Redis源码剖析(三)字典结构的设计与实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Redis源码剖析(二)io多路复用函数
- 下一篇: Redis源码剖析(四)过期键的删除策略