Redis源碼閱讀【1-簡單動態字符串】
Redis源碼閱讀【2-跳躍表】
Redis源碼閱讀【3-Redis編譯與GDB調試】
Redis源碼閱讀【4-壓縮列表】
Redis源碼閱讀【5-字典】
Redis源碼閱讀【6-整數集合】
Redis源碼閱讀【7-quicklist】
Redis源碼閱讀【8-命令處理生命週期-1】
Redis源碼閱讀【8-命令處理生命週期-2】
Redis源碼閱讀【8-命令處理生命週期-3】
Redis源碼閱讀【8-命令處理生命週期-4】
Redis源碼閱讀【番外篇-Redis的多線程】
建議搭配源碼閱讀:源碼地址
在前面介紹完Redis的【動態字符串sds】,以空間換時間的【跳躍表】,和極限壓縮內存使用的【壓縮列表】後,現在介紹Redis下一個重量級數據結構——【字典】,這一章對於許多讀過Java-JDK中HashMap或者HashTable源碼的同學來說應該並不陌生,甚至相似度很高。同時沒讀過HashMap或者HashTable的同學,希望看完這章後續對你理解HashMap或者HashTable有所幫助
文章目錄
1、什麼是字典
字典又叫做散列表,是用來存儲健值對(key-value)的一種數據結構,基本上在許多高級語言中都有這樣一種結構,而Redis作爲一個幾乎是使用key-value來存儲的內存形數據庫,必然是非常重要的,對Redis數據庫進行任何的增,刪,改,查,實際就是對字典的增,刪,改,查。但是C語言本身沒有散列這一種數據結構,所以Redis的實現了自己的字典。
在開始瞭解字典之前,我們先了解一下,Redis具有的特點:
1、可以存儲海量數據,健值是映射關係,可以在O(1)的時間複雜度取出或者插入關聯值
2、健值對中健的類型可以是字符串,整型,浮點型等,且健是唯一的。
3、健值對中值的類型可以爲String,Hash,List,Set,SortedSet
根據上面三個特徵,我們想一下在C語言中,【字典】這個數據結構我們要怎麼實現呢?都需要哪些字段呢?Redis的底層是C語言寫的,要實現第一個特徵:可以存儲海量數據,所以此時我們需要一個根節點,或者一個數據節點,我們稱爲data,用於存儲海量數據的內存地址。
但是現在又引出另一個難題了,海量數據如何保證以相近O(1)的時間複雜度去操作數據呢?我們前面使用的【跳躍表】也只是幾乎接近O(logN),而且跳錶過於浪費空間不適合作爲數據庫的主要數據結構,那麼我們想,曾經有什麼樣的數據結構能達到O(1)的時間複雜度呢?這個時候我們想起了一個數據結構:【數組】。
1.1、數組
有限個類型相同的對象的集合稱爲數組。組成數據的各個對象稱爲數組的元素,用於區分數據的各個元素的數字編號稱爲數組下標。元素的類型可爲:數值,字符,指針,結構體等等。其中指針和結構體類型就保證了,我們的字典的value可以是多種類型的數據結構,同時數組是連續的一段內存空間,通過偏移量就能在O(1)時間複雜度內找到相應的元素,假設一個int類型並且長度爲10的數組,內存結構如下圖:
當需要對數組a中的元素進行操作時,C語言需通過下標找到其對應的內存地址,然後才能對這塊內存進行相應的操作。例如,讀取 a[9] 的值,在C語言中是會轉換成 *(a+9) 的形式,a[9] 和 *(a+9)是等價的,也就是說,要得到a[9]的地址,可以通過對數組a的首地址偏移9個元素就行。
當一個數組中的數據非常海量的時候,通過頭指針 + 偏移量的方式也能以O(1)的時間複雜度定位到數據所在的內存地址,然後進行操作,這也滿足了我們前面提到的第一個條件,以O(1)的時間複雜度操作數據
2、字典的結構
通過前面對字典特徵的定義和數組的介紹,我們大概能先得到一個大概的Redis數組結構的示意圖如下:
這個結構看上去很完美,沒毛病,但是有一個嚴重的問題就是:我們存儲的key是字符串,而數組的下標卻是有序的數字索引,我們怎麼樣才能將不規則的key匹配到這個數組中去呢?
2.1、Hash函數
Hash稱之位散列,做用是把任意長度的輸入通過Hash散列算法轉換成固定類型,固定長度的散列值,換句話說,Hash函數可以把不同健轉換成唯一的整型數據。散列函數一般有以下特徵:
1、相同的輸入經Hash計算後得出相同的輸出
2、不同的輸入經Hash計算後一般得出不同的值,但也可能相同
所以,好的Hash算法是經過Hash計算後得出的值具有強隨機分佈性。這裏Redis使用的是times33散列算法 ,其核心算法是:hash(i)=hash(i-1)*33+str[i]
注:與Java中JDKHash算法不同,HashMap是使用HashCode的高16位和低16位異或的出來的
hash的實現代碼如下(dict.c:53):
static unsigned int dictGenHashFunction(const unsigned char *buf, int len) {
unsigned int hash = 5381;
while (len--)
hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */
return hash;
}
dictGenHashFunction 函數的主要做用是,入參是任意長度的字符串,通過Hash計算後返回無符號整數。因此,我們可以通過Hash函數,將任意輸入的鍵轉換爲整型數據,使其可以當作數組的下標使用。但是,如果直接使用Hash值作爲數組下標也不合理,hash本身可以是一個long類型的整數,未免太大了,或者我們無法出類似於如此大的數組,去存儲。於是,redis這裏使用了一種方式:下標取餘,通過使用hash % 數組
長度來得出數組的下標。最終結構如下圖:
2.2、Hash衝突
由前面得知,Hash函數是根據Hash算法算出來的,Hash本身是可能產生衝突的,即:多個不同的輸入具有相同的輸出,也有可能是取餘操作的時候導致的hash衝突。hash衝突的本質就是,不同的key,具有相同的數組下標。
爲了解決Hash衝突,所以數組中的元素除了應把健值對中的值存儲外,還應該存儲一個鍵和next
指針,next
指針可以把衝突的鍵值對串成一個單鏈表,鍵信息用於判斷是否爲當前要查找的鍵。此時數組中元素的字段也明確了,字典數據結構示意圖如下圖:
當根據鍵去找值時,分如下幾步:
第1步:鍵通過Hash,取餘等操作得到索引值,根據索引值找到對應元素。
第2步:判斷元素中鍵與查找的值是否相等,相等則讀取元素中的值返回,否則判斷next指針是否有值,如有值則繼續取next值,直到找到或者沒找到對應的鍵,沒找到返回null
3、Redis字典
Redis的字典也是通過Hash函數來實現的,由於Redis需要考慮的因素更多,所以字典的實現會比上面的更加複雜,但是原理是一樣的。Redis的字典實現主要分爲三個部分:字典
,Hash表
,Hash表節點
,字典嵌入了兩個Hash表,Hash表中的table字段存放着Hash表節點,Hash表節點對應存儲健值對。
3.1、Hash表
Hash表結構,與前面介紹的結構類似,在Redis源碼中取名爲Hash表,其數據結構如下:
//Redis 字典 Hash表的定義
typedef struct dictht {
dictEntry **table; //指針數組,用於存儲鍵值對
unsigned long size; //table數組大小
unsigned long sizemask;//掩碼 = size - 1
unsigned long used; //table數組已存元素個數,包含next單鏈表的數據
} dictht;
Hash表的結構整體佔用32個字節,其中table字段是數組,做用是存儲健值對,該數組中的元素指向的是dictEntry的結構體,每個dictEntry裏面存有健值對。size表示table數組的總大小。used字段記錄着table數組以存健值對個數
sizemask字段用來計算鍵的索引值,sizemask的值恆等於size -1。我們知道,索引值是鍵Hash值與數組總容量取餘之後的值,而Redis爲提高性能對這個計算進行了優化,具體計算步驟如下:
第1步:人爲設定Hash表的數組容量初始值爲4,隨着健值對存儲量的增加,就需對Hash表擴容,新擴容的容量大小設定爲當前容量大小的一倍,也就是說,Hash表的容量大小隻能爲4,8,16,32…。而sizemask掩碼值就只能爲3,7,15,31…,對應的二進制爲11,111,1111,11111…,因此掩碼值的二進制肯定是每一位都爲1。(這裏其實有點像HashMap的位移)
第2步:索引值 = Hash值 & 掩碼值,對應Redis源碼爲:idx = hash & d->ht[table].sizemask
,其計算結果等同Hash值與Hash表容量取餘,而計算機的位運算要比取餘運算快的多。
結構如下圖:
3.2、Hash表節點
Hash表中的元素是用dictEntry結構體來封裝的,主要作用是存儲健值對,具體結構如下:
//Hash表元素結構體
typedef struct dictEntry {
void *key; //存儲鍵
union {
void *val; //db.dict 中的val
uint64_t u64;
int64_t s64; //db.expires 中存儲過期時間
double d;
} v; // 值,是個聯合體
struct dictEntry *next; //用於解決衝突的next指針
} dictEntry;
Hash表中元素結構體和我們前面定義的元素結構體類似,整體佔24字節,key字端存儲的是鍵值對中的鍵。v聯合體存儲的是健值對中的值,在不同場景下使用不同字段。例如,用字典存儲整個Redis數據庫所有的健值對時,用的是*val字段,可以指向不同類型的值,再比如,字典被用記錄鍵的過期時間時,用的是s64字段存儲,當出現Hash衝突時,next字段用來指向衝突的元素,通過頭插法,形成單鏈表。如下圖:
3.3、字典結構
Redis字典實現除了包含前面介紹的兩個結構體Hash表以及Hash表節點外,還在最外面層封裝了一個叫字典的結構體。其主要作用是對散列表再進行一層封裝,當字典需要進行一些特殊操作時要用到裏面的輔助字段。具體結構如下:
//字典結構體的定義
typedef struct dict {
dictType *type; //該字典對應的特定操作函數
void *privdata; //該字典依賴的數據
dictht ht[2]; //Hash表,健值對存儲在這裏,(爲什麼是兩個呢?這個和rehash有關)
long rehashidx; //用來標記字典當前是否在rehash,存儲的值代表當前rehash的位置,-1表示當前沒有進行rehash
unsigned long iterators; //當前運行的迭代器數量
} dict;
字典整體結構佔用96字節,其中type指向dictType結構體,dictType定義如下:
//該字典對應的特定操作函數
typedef struct dictType {
uint64_t (*hashFunction)(const void *key); //該字典對應的Hash函數
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字典這個數據結構,除了主數據庫的K-V數據存儲外,還有很多其他地方會用到。例如,Redis的哨兵模式,就使用字典管理所有master節點以及slave節點,再如數據庫中健值對的值爲Hash類型時,存儲這個Hash類型的值也是用字典。在不同的應用中,字典中的健值形態可能不同,而dictType結構體,則是爲了實現各種形態的字典而抽象出來的一組操作函數。
1、[privdata 字段]: 私有數據,配合type字段指向的函數一起使用。
2、[ht 字段]:是個大小爲2的數組,該數組存儲的元素類型爲dictht,雖然有兩個元素,但一般情況下只會使用ht[0],只有當字典擴容,縮容需要進行rehash的時候,纔會使用ht[1]。
3、[rehashidx字段]:用來標記字典是否正在rehash,沒進行rehash的時,值爲-1,否則該值用來表示Hash表ht[0]執行rehash到了哪個元素,並記錄該元素的數組下標值
4、[iterators字段]:用來記錄當前運行的安全迭代器數,當有安全迭代器綁定到該字典時,會暫停rehash操作。Redis很多場景下都會用到迭代器,例如:執行keys命令會創建一個安全的迭代器,此時iterators會加1,命令執行完時候數量會-1,而執行sort命令時會創建普通迭代器,該字段不會改變
完整的字典結構如下圖:
4、Redis字典操作
前面介紹了字典原理,Hash表以及字典的結構,那麼單純瞭解結構是不夠的,我們也知道Redis是一個支持 增,刪,改,查
的數據庫,那麼下面我們將介紹Redis的字典生成銷燬以及操作。
4.1、字典初始化
在redis-serve啓動中,整個數據庫會先初始化一個空的字典用於存儲整個數據庫的健值對。初始化一個空字典,調用的是dict.h中的dictCreate
函數,實現如下:
dict *dictCreate(dictType *type,void *privDataPtr){
dict *d = zmalloc(sizeof(*d)); //分配96字節
_dictInit(d,type,privDataPtr); //初始化結構體
return d;
}
int _dictInit(dict *d, dictType *type,
void *privDataPtr)
{
_dictReset(&d->ht[0]); //初始化 ht[0]
_dictReset(&d->ht[1]); //初始化 ht[1]
d->type = type; //該字典對應的特定操作函數
d->privdata = privDataPtr; //初始化該字典依賴的數據
d->rehashidx = -1; //設置rehash值
d->iterators = 0; //設置迭代器數量
return DICT_OK;
}
static void _dictReset(dictht *ht)
{
ht->table = NULL;
ht->size = 0;
ht->sizemask = 0;
ht->used = 0;
}
dictCreate
函數初始化一個空字典的主要步驟爲:申請空間
,調用_dictInit
,給字典的各個字段賦值
。初始化後字段的如下圖所示:
4.2、添加元素
redis-server啓動完畢後,再啓動redis-cli連上Server,執行命令 set k1 v1
,最終會執行到setKey(redisDb *db,robj *key,robj *val)
函數,前文介紹字典的特性時提到過,每個鍵必須是唯一的,所以元素添加需要經過以下幾步來完成:先判斷該鍵是否存在,存在則執行修改,否則添加健值對。而setKey函數的主要邏輯也是如此,其主要流程如下。
第一步:調用dictFind函數查找鍵是否存在,是則調用dbOverwrite函數修改鍵值對,否則調用dbAdd函數添加元素
第二步:dbAdd 最終調用dict.h文件中的dictAdd函數插入鍵值對。
dictAdd
函數的插入代碼如下:
//字典添加元素(在這個函數調用之前已經調用了dictFind)
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key,NULL); //創建待添加的元素
if (!entry) return DICT_ERR;
dictSetVal(d, entry, val); //設置值
return DICT_OK;
}
完整代碼:
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
long index;
dictEntry *entry;
dictht *ht;
//判斷當前是否正在rehash,如果是調用一次rehash
if (dictIsRehashing(d)) _dictRehashStep(d);
//定位對應的table index
if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
//查找鍵,找到則直接返回-1,並把老節點存入existing字段,否則把新節點的索引值返回。如果遇到Hash表容量不足,則進行擴容
return NULL;
//判斷是否rehash 如果是插入 ht[1] 否則插入 ht[0] ,因爲上面結束後可能rehash已經結束
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry)); //分配空間
entry->next = ht->table[index];
ht->table[index] = entry;
ht->used++;
dictSetKey(d, entry, key); //設置鍵
return entry;
}
//查找鍵,找到則直接返回-1,並把老節點存入existing字段,否則把新節點的索引值返回。如果遇到Hash表容量不足,則進行擴容
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
unsigned long idx, table;
dictEntry *he;
if (existing) *existing = NULL;
//判斷是否需要拓展Hash表
if (_dictExpandIfNeeded(d) == DICT_ERR)
return -1;
for (table = 0; table <= 1; table++) {
//定位到相應hash表的index
idx = hash & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
//判斷是否找到已經存在的鍵
if (key==he->key || dictCompareKeys(d, key, he->key)) {
//將已經存在的鍵存儲在existing中
if (existing) *existing = he;
return -1;
}
he = he->next;
}
//如果正在rehash 還要取 ht[1]裏面去找
if (!dictIsRehashing(d)) break;
}
return idx;
}
//拓展Hash表
static int _dictExpandIfNeeded(dict *d)
{
if (dictIsRehashing(d)) return DICT_OK;
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;
}
//拓展或者創建hash表
int dictExpand(dict *d, unsigned long size){
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
dictht n;
//重新計算擴容後的值,必須爲2的冪
unsigned long realsize = _dictNextPower(size);
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;
d->rehashidx = 0; //剛剛開始擴容設置爲0
return DICT_OK;
}
//調用rehash入口
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
//字典rehash
int dictRehash(dict *d, int n) {
//當前rehash的最大數量 buckets 爲單位 每個buckets rehash 10個
int empty_visits = n*10;
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
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];
while(de) {
uint64_t h;
nextde = de->next;
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++;
}
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;
}
return 1;
}
dictAddRaw
函數主要作用是添加或查找鍵,添加成功返回新節點,查找成功返回NULL並把老節點存入existing 字段。該函數中比較核心的是調用 _dictKeyIndex
函數,作用是得到鍵的索引值,索引值獲取與前文介紹的函數類似,主要有這麼兩步:
//第一步:調用該字典的Hash函數得到鍵的Hash值
dictHashKey(d,key)
//第二步:用健的Hash值與字典掩碼取與,得到索引值
idx = hash & d->ht[table].sizemask;
dictAddRaw
函數拿到鍵的索引值後則可直接定位健值對要存入的位置,新創建一個節點存入即可。
4.2.1、字典擴容
上面的代碼也列出來,當添加元素的時候,當元素達到容量的上限時或者容量不足的時候,字典本身是有可能會進行擴容的,擴容的代碼就是上面的 dictExpand
:
//拓展或者創建hash表
int dictExpand(dict *d, unsigned long size){
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
dictht n;
//重新計算擴容後的值,必須爲2的冪
unsigned long realsize = _dictNextPower(size);
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; //擴容後新內存放入到 ht[1]中
d->rehashidx = 0; //剛剛開始擴容設置爲0
return DICT_OK;
}
擴容的主要流程爲:
1、申請一塊新內存,默認大小爲4,每次擴展一倍
2、把新申請的內存地址賦值給ht[1],並把rehashidx 設置爲 0
擴容後,字典容量及掩碼都會發生改變,同一個鍵與掩碼經過位運算後得到的索引值就會發生改變,從而導致根據健查找不到值的情況。解決這個問題的辦法是,新擴容的Hash表爲ht[1],原本ht[0]的數據不受到影響,直到ht[0]的數據都被遷移一份到ht[1]中後,才切換爲擴容後的表。
4.2.2、漸進式rehash
rehash除了擴容時會觸發,縮容時也會觸發。Redis整個rehash的實現,主要分爲以下幾步完成。
1、給Hash表ht[1]申請足夠的空間,擴容時空間大小爲當前容量*2,當使用空間不足10%時,進行縮容,縮容的大小爲,剛好爲能容納used*2的整數
2、進行rehash操作調用的是dictRehash函數,重新計算ht[0]中每個健的Hash值與索引值,並且依次添加到ht[1]中,並把舊值刪除,把字典rehashidx修改爲ht[0]正在rehash操作節點的索引值
3、rehash操作後,情況ht[0],然後對調ht[1]和ht[0]的索引,並把rehashidx值設置爲*-1
我們知道,Redis爲了提供高性能的線上服務,而且是單進程模式,當數據庫中的數據達到百萬,千萬,億級別的時候,整個rehash的過程會非常緩慢,這裏redis用了一個巧妙的方式,利用分治的思想進行rehash操作,大致步驟如下:
執行
插入
刪除
查找
修改
等操作前,都判斷一下當前字典是否正在進行 rehash ,如果是則調用dictRehashStep
函數進行 rehash 操作(每次只對一個節點進行 rehash 操作,共執行1次)。除了這種情況外,當服務器空閒的時候,也會進行 rehash 操作,則會調用incrementallyRehash函數進行批量rehash操作(每次對100個節點進行rehash,共耗時1毫秒)。再經歷N次rehash後,整個ht[0]的數據都會遷移到ht[1]中。
dictRehashStep
代碼如下:
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
//字典rehash
int dictRehash(dict *d, int n) {
//當前rehash的最大數量 buckets 爲單位 每個buckets rehash 10個
int empty_visits = n*10;
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
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];
while(de) {
uint64_t h;
nextde = de->next;
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++;
}
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;
}
return 1;
}
incrementallyRehash
代碼如下:
//批量rehash
int incrementallyRehash(int dbid) {
if (dictIsRehashing(server.db[dbid].dict)) {
dictRehashMilliseconds(server.db[dbid].dict,1);
return 1;
}
if (dictIsRehashing(server.db[dbid].expires)) {
dictRehashMilliseconds(server.db[dbid].expires,1);
return 1;
}
return 0;
}
// d 是字典 ms 是期望耗時
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds(); //獲取當前時間
int rehashes = 0;
while(dictRehash(d,100)) {
rehashes += 100;
//超時就結束
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
4.3 、查找元素
查找元素例如使用 get
指令的時候,Server最終回去調用dict.h文件中的dictFind
函數,源碼如下:
//查找元素
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
uint64_t h, idx, table;
if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
if (dictIsRehashing(d)) _dictRehashStep(d); //判斷是否正在rehash 如果是調用rehash
h = dictHashKey(d, key); // 獲取鍵對應的hash值
for (table = 0; table <= 1; table++) { //在 ht[0] 和 ht[1] 中查找
idx = h & d->ht[table].sizemask; //找到對應的hash 表的index
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key))
//查找到鍵直接返回
return he;
he = he->next;
}
//如果沒有在rehash直接結束
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}
查找鍵的過程比較簡單,過程基本如下:
1、根據鍵調用Hash函數取得相應的hash值
2、根據Hash值取到索引值
3、遍歷字典的兩個Hash表,讀取索引對應的元素
4、遍歷該元素單鏈表,如找到了與自身鍵匹配的鍵,則返回該元素
5、找不到返回NULL
4.4、修改元素
Server收到set
命令後,會查詢鍵是否存在,如果已經存在會調用db.c文件中的dbOverwrite
函數,其源碼如下:
//更新數據
void dbOverwrite(redisDb *db, robj *key, robj *val) {
dictEntry *de = dictFind(db->dict,key->ptr); //查找該元素
serverAssertWithInfo(NULL,key,de != NULL); //不存在則中斷執行
dictEntry auxentry = *de;
robj *old = dictGetVal(de); //獲取老節點的val字段值
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { //過期策略(後面會講)
val->lru = old->lru;
}
dictSetVal(db->dict, de, val); //給節點設置新的值
if (server.lazyfree_lazy_server_del) { //緩慢刪除(後面會講)
freeObjAsync(old);
dictSetVal(db->dict, &auxentry, NULL);
}
dictFreeVal(db->dict, &auxentry);//釋放舊的val內存
}
修改鍵的源碼,雖然沒有調用dict.h中的方法去修改字典中的元素,但修改過程基本類似,Redis修改鍵值對,整個過程主要分如下幾個步驟:
1、調用dictFind查找健值對是否存在
2、不存在則中斷執行
3、修改節點鍵值對中的值爲新值
4、釋放舊值內存
4.5、刪除元素
繼續跟進刪除字段中的健值對,例如執行 del
指令,Server收到 del命令後,最終刪除鍵值對會調用dict.h文件中的dictDelete
函數,其主要執行過程爲:
1、查找該健是否存在當前字典中
2、存在則把該節點從單鏈表中刪除
3、釋放該節點對應鍵佔用的內存,值佔用的內存,以及本身dictEntry佔用的內存
4、給對應的Hash表的used字段值減1
當字典操作後,使用量不到總空間的10%的時候,就會進行縮容操作,縮容核心代碼又兩個分別爲tryResizeHashTables
和 dictResize
代碼如下:
//字典元素刪除
static int dictDelete(dict *ht, const void *key) {
unsigned int h;
dictEntry *de, *prevde;
if (ht->size == 0)
return DICT_ERR;
h = dictHashKey(ht, key) & ht->sizemask;//查找hash表對應的index
de = ht->table[h];
prevde = NULL;
while(de) {
//查找到鍵
if (dictCompareHashKeys(ht,key,de->key)) {
//由於是單鏈表刪除節點的時候需要記錄下上一個節點
if (prevde)
prevde->next = de->next;
else
ht->table[h] = de->next;
dictFreeEntryKey(ht,de); //釋放鍵空間
dictFreeEntryVal(ht,de); //釋放值空間
free(de); //釋放本身dictEntry空間
ht->used--;
return DICT_OK;
}
prevde = de;
de = de->next;
}
return DICT_ERR;
}
//縮容入口
void tryResizeHashTables(int dbid) {
//redisDb字典縮容(後面介紹redisDb對象的時候會講)
if (htNeedsResize(server.db[dbid].dict))
dictResize(server.db[dbid].dict);
//redisDb字典過期時間縮容(後面介紹redisDb對象的時候會講)
if (htNeedsResize(server.db[dbid].expires))
dictResize(server.db[dbid].expires);
}
//hash表縮容
int dictResize(dict *d){
int minimal;
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
minimal = d->ht[0].used;
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
return dictExpand(d, minimal); //和前面擴容的 dictExpand 是一樣的
}
5、字典遍歷
前面講解了字典的基本概念,基本操作,本節講解字典的遍歷操作,遍歷的原則有以下幾點。
1、不重複出現數據
2、不遺漏任何數據
目前遍歷Redis整個數據庫主要有兩種方式:【全遍歷】【間斷遍歷】
【全遍歷】:一次命令執行就遍歷整個數據庫
【間斷遍歷】:每次執行命令,只取部分數據,多次遍歷
5.1、迭代器遍歷
迭代器-可在容器上遍訪的接口,設計人員無須關心容器的內容,調用迭代器固定的接口就可遍歷數據,在很多高級語音中都有實現。
字典迭代器的主要用於迭代字典這個數據結構中的數據,既然是迭代字典中的數據,必然會出現一個問題,迭代過程中,如果發生了數據增刪,則可能導致字典觸發rehash操作,或迭代開始時字典正在rehash操作,從而導致一條數據可能被多次遍歷的情況。那Redis如何解決這個問題呢?我們首先來看看字典迭代器結構體的實現吧:
//redis 字典迭代器結構體
typedef struct dictIterator {
dict *d; //迭代的目標字典
long index; // 當前迭代到Hash表中哪個索引
int table, safe; // table用於表示當前正在迭代的Hash表,即ht[0]與ht[1],safe用於表示當前創建的是否爲安全迭代器
dictEntry *entry, *nextEntry; //當前節點,下一個節點
long long fingerprint; // 字典的指紋,當字典未發生改變的時候,該值不變,發生改變的時候則值也隨之改變
} dictIterator;
整結構體佔了48字節,其中d字段指向需要迭代的字典,index字段代表當前讀取到Hash表中的哪個值,table字段表示當前正在迭代的Hash表(即ht[0]和ht[1]中的0和1),safe字段表示當前創建的迭代器是否爲安全模式,entry字段表示正在讀取的節點數據,nextEntry字段表示entry節點中的next字段所指向的數據。
fingerprint字段是一個64位的整數,表示在給定時間內字段的狀態。在這裏稱其爲字典的指紋,因爲該字段的值爲字典(dict結構體)中所有字段值組合在一起生成的Hash值,所以當字典中數據發生任何變化時,其值都會不同,生成算法不做過多解讀,讀者可參見源碼dict.c文件中的dictFingerPrint
函數
爲了讓迭代過程變的簡單,Redis也提供了迭代相關的API函數,主要爲:
dictIterator *dictGetIterator(dict *d); //初始化迭代器
dictIterator *dictGetSafeIterator(dict *d); //初始化安全的迭代器
dictEntry *dictNext(dictIterator *iter); //通過迭代器獲取下一個元素
void dictReleaseIterator(dictIterator *iter); //釋放迭代器
簡單介紹完迭代器的基本結構,字段含義以及API,我們來看一下Redis如何解決增刪
數據的同時不出現讀取數據重複的問題。Redis爲單進程單線程模式。不存在兩個命令同時指向的情況,因此只有當指向執行的命令在遍歷的同時刪除了數據,纔會觸發前面的問題。我們把迭代器遍歷數據分爲兩類
:
1、普通迭代器,只遍歷數據
2、安全迭代器,遍歷的同時刪除數據
5.1.1、普通迭代器
普通迭代器迭代字典中數據時,會對迭代器中fingerprint字段的值作嚴格的校驗,來保證迭代過程中字典結構不發生任何變化,確保讀取出的數據不出現重複。
當Redis執行部分命令時會使用普通迭代器迭代字典數據,例如sort
命令。sort
命令主要作用是對給定列表,集合,有序集合的元素進行排序,如果給定的是有序集合、其成員名存儲用的是字典,分值存儲用的是跳躍表,則執行sort命令讀取數據的時候會去用到迭代器來遍歷整個字典。
普通迭代器迭代數據的過程比較簡單,主要分爲如下幾個步驟。
1、調用
dictGetIterator
函數初始化一個普通迭代器,此時會把iter->safe
值置爲0,表示初始化的迭代器爲普通迭代器,初始化後的結構示意圖如下:
2、循環調用
dictNext
函數依次遍歷字典中Hash表的節點,首次遍歷時會通過dictFingerprint
函數拿到當前字典的指紋值,此時結構示意圖如圖所示:
3、當調用
dictNext
函數遍歷完字典Hash表中節點數據後,釋放迭代器時會繼續調用dictFingerprint
函數計算字典的指紋值,並與首次拿到的指紋對比,不相等則輸出異常"===ASSERTION FAILED ==="
,且退出程序執行。
普通迭代器通過步驟1,步驟3的指紋對比,來限制整個迭代過程中只能進行迭代操作,即迭代過程中字典數據的修改、添加、刪除、查找
等操作都不能進行,只能調用dictNext
函數迭代整個字典,否則就報異常,由此來保證這次迭代器取出數據的準確性。
5.1.2、安全迭代器
安全迭代器和普通迭代器迭代數據原理類似,也是通過循環調用dictNext
函數依次遍歷字典中Hash表的節點。安全迭代器確保數據的準確性,不是通過限制字典的部分操作來實現的而是通過限制rehash的進行來確保數據的準確性,因此迭代過程中可以對字典進行增、刪、改、查
等操作。
我們知道,對字典的增、刪、改、查
操作會調用dictRehashStep
函數進行漸進式rehash操作,那如何對rehash操作進行限制呢,我們一起看下dictRehashStep
函數源碼實現:
//漸進式rehash入口
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1); //字典正在運行迭代操作的安全迭代器個數
}
原理很簡單,如果當前有安全迭代器正在運行,則不進行漸進式rehash操作,rehash操作暫停,字典中的數據不會被重複遍歷,由此確保讀取數據的準確性。
當Redis執行部分命令時會使用安全迭代器迭代字典數據,例如keys命令。keys命令主要作用是通過模式匹配,返回給定模式的所有key列表,遇到過期鍵則會進行刪除操作。Redis數據健值對存儲在字典中,因此keys命令會通過安全迭代器來遍歷整個字典。安全迭代器整個迭代過程也比較簡單,主要分如下幾個步驟:
第一步
、調用dictGetSafeIterator
函數初始化一個安全迭代器,此時會把iter->safe
值置爲1,表示初始化的迭代器爲安全迭代器。(結構參考普通迭代器結構)
第二步
、循環調用dictNext
函數依次遍歷字典中Hash表的節點,首次遍歷時會把字典中iterators
字段進行加1操作,確保暫停rehash
第三步
、當調用dictNext
函數遍歷完字段Hash表中節點數據後,釋放迭代器時會把字典中iterators
字段進行減1,步驟3中對字典的iterators
字段進行修改,使得迭代過程中漸進式rehash操作被中斷,由此來保證迭代器讀取數據的準確性。
5.2、間斷遍歷
前文講解了全遍歷字典的實現,但有一個問題凸顯出來,當數據庫中有海量數據的時候,執行keys命令進行一次數據庫全遍歷,耗時肯定不短,會造成短暫的Redis不可用,所以在Redis在2.8.0版本後增加了scan
操作,也就是間斷遍歷。而dictScan
是間斷遍歷中的一種實現,主要在迭代字典中數據時使用,例如hscan
命令迭代整個數據庫中的key,以及zscan
命令迭代有序集合所有成員與值時,都是通過dictScan
函數來實現的字典遍歷。dictScan
遍歷字典過程中是可以進行rehash操作的,通過算法來保證所有的數據能被遍歷到。
下面我們來看一下dictScan函數定義:
//字典間斷遍歷
unsigned long dictScan(dict *d, //需要遍歷的字典
unsigned long v, //變量標識迭代開始的遊標(整個遍歷圍繞遊標值的改動進行)
dictScanFunction *fn, // 函數指針,遍歷一個節點則調用該函數處理
dictScanBucketFunction* bucketfn, //用來整理碎片時調用
void *privdata) // 調用函數fn所需要的參數
變量d
是當前迭代的字典,變量v
標識迭代開始的遊標(即Hash表中數組索引),每次遍歷後回返回新的遊標值,整個遍歷過程都是圍繞這個遊標值的改動進行的,來保證所有的數據都能被遍歷到,fn
是函數指針,每遍歷一個節點則調用該函數處理,bucketfn
函數在整理碎片時調用privdata
是回調函數fn所需參數。
執行hscan命令時外層調用dictScan
函數示例:
//count爲hscan命令傳入的count值,代表獲取數據的個數
long maxiterations = count*10;
do {
//調用dictScan函數迭代字典數據,cursor字段初始值爲hscan傳入值,代表迭代Hash數組的遊標起點值
cursor = dictScan(ht, cursor, scanCallback, NULL, privdata);
} while (cursor &&
maxiterations-- &&
listLength(keys) < (unsigned long)count);
dictScan函數間斷遍歷字典過程中會遇到如下3種情況:
1、從迭代開始到結束,散列表沒有進行rehash
2、從迭代開始到結束,散列表進行了擴容或縮容操作,且恰好爲兩次迭代間隔期間完成了rehash操作
3、從迭代開始到結束,某次或者某幾次迭代時散列表正在進行rehash操作
5.2.1、遍歷中始終未rehash
每次迭代都沒有遇到rehash操作,也就是遍歷字典只遇到第一種或,第二種情況。其實第一種情況,只要依次按照順序遍歷Hash表ht[0]
中節的點即可,第2種情況因爲在遍歷的整個過程中,期間字典可能發生了擴容或縮容操作,間斷遍歷期間 (並且已經完成),如果依次按照順序遍歷,則可能會出現數據重複讀取的現象,於是redis技巧性的使用了一種方式實現避免重複讀取,如下所示:
dictScan
函數中:
t0 = &(d->ht[0]);
m0 = t0->sizemask; //當前掩碼
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]); //整理碎片使用的函數
de = t0->table[v & m0]; //避免遍歷後遊標超出最大值
while (de) {
next = de->next;
fn(privdata, de); // 依次將節點中健值對存入privdata字段中的單鏈表
de = next;
}
整個迭代過程強依賴遊標值v變量,根據v找到當前需要讀取的Hash表元素,然後遍歷該元素單鏈表上所有的健值對,依次執行fn函數指針執行的函數,對健值對進行讀取操作。
爲了兼容迭代期間可能發生的縮容與擴容操作,每次迭代時都會對v變量(遊標值)進行修改,以確保迭代出的數據無遺漏,遊標具體變更算法爲:
// 遊變變更算法
v |= ~m0;
v = rev(v); //二進制逆轉
v++;
v = rev(v); //二進制逆轉
爲了更好的立即這個算法下面列出了兩個表描述整個過程:
假設1:Hash表大小爲4,迭代從始至終未進行擴容縮容操作
掩碼m0=0x11
~m0=0x100
,遊標值的變化如下所示:
輸入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最終結果 |
---|---|---|---|---|---|---|
第1次遊標爲0 | 0x000 | 0x100 | 0x001 | 0x010 | 0x010 | 2 |
第2次遊標爲2 | 0x010 | 0x110 | 0x011 | 0x100 | 0x001 | 1 |
第3次遊標爲1 | 0x001 | 0x101 | 0x101 | 0x110 | 0x011 | 3 |
第4次遊標爲3 | 0x011 | 0x111 | 0x111 | 0x000 | 0x000 | 0 |
剛好把整個Hash表遍歷完成順序是:2 1 3 0
假設2:Hash表大小爲4,進行3次迭代時,Hash表擴容爲8
掩碼m0=0x11
~m0=0x100
輸入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最終結果 |
---|---|---|---|---|---|---|
第1次遊標爲0 | 0x000 | 0x100 | 0x001 | 0x010 | 0x010 | 2 |
第2次遊標爲2 | 0x010 | 0x110 | 0x011 | 0x100 | 0x001 | 1 |
進行第三次迭代的時候,表擴容到了8,數組的掩碼m0=0x111
~m0=0x1000
,接下來的遊標如下所示:
輸入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最終結果 |
---|---|---|---|---|---|---|
第3次遊標爲1 | 0x0001 | 0x1001 | 0x1001 | 0x1010 | 0x0101 | 5 |
第4次遊標爲5 | 0x0101 | 0x1101 | 0x1011 | 0x1100 | 0x0011 | 3 |
第5次遊標爲3 | 0x0011 | 0x1011 | 0x1101 | 0x1110 | 0x0111 | 7 |
第6次遊標爲1 | 0x0111 | 0x1111 | 0x1111 | 0x0000 | 0x000 | 0 |
此時我們發現,迭代只進行6次就完成了,順序爲0 2 1 5 3 7
少遍歷 4 6
,因爲遊標爲0 2
的數據已經遍歷過
0|4 和 2|6是一樣的數據,所以無需再遍歷
假設3:Hash表大小爲8 迭代到第5次的時候縮容,Hash表大小變爲4
掩碼:m0=0x111
~m0=0x1000
遊標值的變化順序如下
輸入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最終結果 |
---|---|---|---|---|---|---|
第1次遊標爲0 | 0x0000 | 0x1000 | 0x0001 | 0x0010 | 0x0100 | 4 |
第2次遊標爲4 | 0x0100 | 0x1100 | 0x0011 | 0x0100 | 0x0010 | 2 |
第3次遊標爲2 | 0x0010 | 0x1010 | 0x0101 | 0x0110 | 0x0110 | 6 |
第4次遊標爲6 | 0x0110 | 0x1110 | 0x0111 | 0x1000 | 0x0001 | 1 |
進行第5次迭代的時候Hash表大小縮容到4 掩碼::m0=0x11
~m0=0x100
輸入 | 初始值 | v |= 0x100 | v=rev(v) | v++ | v=rev(v) | 最終結果 |
---|---|---|---|---|---|---|
第5次遊標爲0 | 0x001 | 0x101 | 0x101 | 0x110 | 0x011 | 3 |
第6次遊標爲4 | 0x011 | 0x111 | 0x111 | 0x000 | 0x000 | 0 |
同樣,迭代只進行6次結束,順序爲 0 4 2 6 1 3
少遍歷了0 2
總結:只要遍歷過程中沒有遇到rehash恰好在執行,通過遊標變更算法
可以保證無論擴容還是縮容都不會遍歷重複數據。
5.2.2、遍歷中遇到rehash
從迭代開始到結束,某次或某幾次迭代時散列表正在進行rehash操作,rehash操作中會同時並存兩個Hash表,一張爲擴容或縮容後的表ht[1]
,一張爲老表ht[0]
,ht[0]
的數據會通過漸進式rehash會逐步遷移到ht[1]
中,最終完成整個遷移過程。
因爲兩張表並存,所以需要從ht[0]
和ht[1]
中都取出數據,整個遍歷過程爲:先找到兩個散列表中的小表,先對小表遍歷,然後對大的Hash表遍歷,代碼如下dictScan
:
t0 = &(d->ht[0]);
m0 = t0->sizemask;
//判斷哪個Hash表小
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask;
de = t0->table[v & m0];
//迭代第一張小表
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
//迭代第二張大表
do {
if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
de = t1->table[v & m1];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
// 遊標算法
v |= ~m1;
v = rev(v);
v++;
v = rev(v);
} while (v & (m0 ^ m1));
結合rehash中的遊標算法,這樣能保證不會重複遍歷相同的節點
其實大家可以這樣理解,遊標算法
的主要作用就是:保證無論擴容還是縮容,都不會遍歷到已經遍歷過的bucket index,例如 遍歷過2 就不會遍歷 6 ,遍歷過 0 就不會遍歷4
6 、API列表
這裏順便介紹一下剩餘的API 主要在文件 src/dict.h
中
dict *dictCreate(dictType *type, void *privDataPtr); //初始化字典
int dictExpand(dict *d, unsigned long size); //字典擴容
int dictAdd(dict *d, void *key, void *val); //添加鍵值對,已存在則不添加
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing);//添加key,並返回新添加key對應的節點指針。若存在,則存入 existing 並返回-1
dictEntry *dictAddOrFind(dict *d, void *key); //添加或查找key
int dictReplace(dict *d, void *key, void *val); //添加健值對,若存在則修改,否則添加
int dictDelete(dict *d, const void *key); //刪除節點
dictEntry *dictUnlink(dict *ht, const void *key); //刪除key但不釋放內存
void dictFreeUnlinkedEntry(dict *d, dictEntry *he);//釋放dictUnlink函數刪除key的內存
void dictRelease(dict *d); //釋放字典
dictEntry * dictFind(dict *d, const void *key); //根據鍵查找元素
void *dictFetchValue(dict *d, const void *key); //根據鍵查找出值
int dictResize(dict *d); //將字典表的大小調整爲包含所有元素的最小值,即收縮字典
dictIterator *dictGetIterator(dict *d); //初始化迭代器
dictIterator *dictGetSafeIterator(dict *d); //初始化安全迭代器
dictEntry *dictNext(dictIterator *iter); //通過迭代器獲取下一個節點
void dictReleaseIterator(dictIterator *iter); //釋放迭代器
dictEntry *dictGetRandomKey(dict *d); //隨機得到一個鍵
dictEntry *dictGetFairRandomKey(dict *d); //隨機得到一個公平鍵
unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count);//隨機得到幾個鍵
void dictGetStats(char *buf, size_t bufsize, dict *d); //讀取字典的狀態,使用情況等
uint64_t dictGenHashFunction(const void *key, int len); //hash函數(字母大小寫敏感)
uint64_t dictGenCaseHashFunction(const unsigned char *buf, int len);//hash函數(字母大小寫不敏感)
void dictEmpty(dict *d, void(callback)(void*)); //清空字典
void dictEnableResize(void); //開啓Resize(調整)
void dictDisableResize(void); //關閉Resize(調整)
int dictRehash(dict *d, int n); //漸進式rehash n 爲幾步進行
int dictRehashMilliseconds(dict *d, int ms); //持續式rehash ms 爲持續時間
void dictSetHashFunctionSeed(uint8_t *seed); //設置新的散列種子
uint8_t *dictGetHashFunctionSeed(void); //獲取當前散列種子
unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, dictScanBucketFunction *bucketfn, void *privdata); //間斷迭代數據
uint64_t dictGetHash(dict *d, const void *key); //得到鍵的hash值
dictEntry **dictFindEntryRefByPtrAndHash(dict *d, const void *oldptr, uint64_t hash);//使用指針 + hash值取查找元素
文章內容參考來源:《Redis5涉及與源碼分析》