Redis 原始碼簡潔剖析 03 - Dict Hash 基礎
Redis Hash 原始碼
- dict.h:定義 Hash 表的結構、雜湊項,和 Hash 表的各種函式操作
- dict.c:函式的具體實現
Redis Hash 資料結構
在 dict.h 檔案中,Hash 表是一個二維陣列(dictEntry **table)。
typedef struct dictht {
// 二維陣列
dictEntry **table;
// Hash 表大小
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
dictEntry **table 是個二維陣列,其中第一維是 bucket,每一行就是 bucket 指向的元素列表(因為鍵雜湊衝突,Redis 採用了鏈式雜湊)。
為了實現鏈式雜湊,Redis 的 dictEntry 結構中,除了包含鍵和值的指標,還包含了一個指向下一個雜湊項的指標 next。
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
整體的雜湊流程都是老生常談了,和 Java 幾乎是一樣的,這裡就不敘述了。
Redis rehash 原理
為什麼要 rehash?
為了效能。如果雜湊表 bucket 的數量是 1,但是裡面有了 1000 個元素,不管怎麼樣都變成了一個連結串列,查詢效率變得很低。同理,當雜湊表裡元素的個數比 bucket 數量多很多的時候,效率也會低很多。
Redis dict 資料結構
Redis 實際使用的是 dict 資料結構,內部用兩個 dictht(ht[0] 和 ht[1]),用於 rehash 使用。
typedef struct dict {
……
// 兩個 Hash 表,交替使用,用於 rehash 操作
dictht ht[2];
// Hash 表是否進行 rehash 的標識,-1 表示沒有進行 rehash
long rehashidx;
……
} dict;
Redis rehash 過程
- 正常請求階段,所有的鍵值對都寫入雜湊表 ht[0]
- 進行 rehash 時,鍵值對被遷移到 ht[1]
- 遷移完成後,是否 ht[0] 空間,把 ht[1] 的地址賦值給 ht[0],ht[1] 的表大小設定為 0
什麼時候觸發 rehash?
- ht[0] 大小=0
- ht[0] 裡的元素個數已經超過 ht[0] 大小 && Hash 表可以擴容
- ht[0] 裡的元素個數,是 ht[0] 大小的 5 倍(dict_force_resize_ratio)(類似於 Java 裡 HashMap 的負載因子)
static int _dictExpandIfNeeded(dict *d)
{
/* Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK;
// Hash 表為空,將 Hash 表擴充套件為初始大小 DICT_HT_INITIAL_SIZE(4)
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
// Hash 表當前的元素數量超過表的大小 && (可以擴容 || 當前數量是表大小的 5 倍以上)
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio) &&
dictTypeExpandAllowed(d))
{
return dictExpand(d, d->ht[0].used + 1);
}
return DICT_OK;
}
上面程式碼中有個引數 dict_can_resize,設定函式為:
void dictEnableResize(void) {
dict_can_resize = 1;
}
void dictDisableResize(void) {
dict_can_resize = 0;
}
這兩個函式被封裝在了 server.c 中的 updateDictResizePolicy:
void updateDictResizePolicy(void) {
if (!hasActiveChildProcess())
dictEnableResize();
else
dictDisableResize();
}
/* Return true if there are active children processes doing RDB saving,
* AOF rewriting, or some side process spawned by a loaded module. */
int hasActiveChildProcess() {
return server.child_pid != -1;
}
我們可以看到,hasActiveChildProcess 函式是判斷 Redis 存在 RDB 子程序、AOF 子程序是否存在。可以看到 dict_can_resize 只有在不存在 RDB 子程序、AOF 子程序時才為 TRUE。
那 _dictExpandIfNeeded 是在哪裡呼叫的呢?
rehash 擴容多大?
_dictExpandIfNeeded 裡呼叫了擴容函式 dictExpand。
/* return DICT_ERR if expand was not performed */
int dictExpand(dict *d, unsigned long size) {
return _dictExpand(d, size, NULL);
}
int _dictExpand(dict *d, unsigned long size, int* malloc_failed)
{
……
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size);
……
}
裡面有一個 _dictNextPower 函式,啥都不說了,都在註釋裡。
static unsigned long _dictNextPower(unsigned long size) {
unsigned long i = DICT_HT_INITIAL_SIZE;
// 要擴容的大小已經超過了最大值
if (size >= LONG_MAX) return LONG_MAX + 1LU;
// 要擴容的大小沒有超過最大值,找到第一個比 size 大的 2^i
while (1) {
if (i >= size)
return i;
i *= 2;
}
}
漸進式 rehash
為什麼需要漸進式 rehash?
Hash 表空間很大,全量 rehash 時間會很長,阻塞 Redis 主執行緒。為了降低 rehash 開銷,Redis 使用了「漸進式 rehash」。
具體一點
漸進式 rehash 並不是一次性把當前 Hash 表的所有鍵,都拷貝到新的位置,而是「分批拷貝」,每次只拷貝 Hash 表中一個 bucket 中的雜湊項。
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
// 迴圈 n 次後停止,或 ht[0] 遷移完成
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
assert(d->ht[0].size > (unsigned long) d->rehashidx);
// 如果要遷移的 bucket 中沒有元素
while (d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
// 獲取待遷移的 ht[0] 的 bucket
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while (de) {
uint64_t h;
// 獲取下一個遷移項
nextde = de->next;
// 計算 de 在 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;
}
// 當前 bucket 已經沒有雜湊項了,將該 bucket 設定為 null
d->ht[0].table[d->rehashidx] = NULL;
// 將 rehash+1,下次遷移下一個 bucket
d->rehashidx++;
}
// 判斷 ht[0] 是否已經全部遷移
if (d->ht[0].used == 0) {
// ht[0] 已經全部遷移到 ht[1] 了,釋放 ht[0]
zfree(d->ht[0].table);
// ht[0] 指向 ht[1]
d->ht[0] = d->ht[1];
// 重置 ht[1] 大小為 0
_dictReset(&d->ht[1]);
//設定全域性雜湊表的 rehashidx=-1,表示 rehash 結束
d->rehashidx = -1;
return 0;
}
// ht[0] 中仍然有元素沒有遷移完
return 1;
}
幾點說明:
- rehashidx 表示當前 rehash 在對哪個 bucket 做資料遷移,每次遷移完對應 bucket 時,會將 rehashidx+1。
- empty_visits 表示連續 bucket 為空的情況,此時漸進式 rehash 不會一直遞增檢查 rehashidx,因為一直檢測會阻塞主執行緒,Redis 主執行緒就無法處理其他請求了。
那麼 rehash 是在什麼哪些步驟進行操作的呢?檢視原始碼發現 dictRehash 是在 _dictRehashStep 函式中呼叫的,且傳入的 n=1。
static void _dictRehashStep(dict *d) {
if (d->pauserehash == 0) dictRehash(d,1);
}
而 _dictRehashStep 分別被 5 個方法呼叫了:
- dictAddRaw
- dictGenericDelete
- dictFind
- dictGetRandomKey
- dictGetSomeKeys
下面是 dictAddRaw 部分程式碼:
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
……
if (dictIsRehashing(d)) _dictRehashStep(d);
……
}
下面是 dictAdd 部分程式碼:
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;
}
Redis 原始碼簡潔剖析系列
Java 程式設計思想-最全思維導圖-GitHub 下載連結,需要的小夥伴可以自取~
原創不易,希望大家轉載時請先聯絡我,並標註原文連結。