Redis的資料結構(三):字典
字典在redis的應用
字典在我們平時的程式設計中是一種非常常見的資料結構,它有著結構簡單,查詢快速的優點,而在redis中,字典的應用更是十分廣泛。redis本身是一個key-value
型的nosql資料庫,因此資料庫本身就是由字典而構成的,資料庫的curd都是在此基礎上進行的。除此之外,redis的map
型別也是由字典這個結構實現的。
字典的實現
c語言不像我們平時用習慣的高階程式語言一樣,它沒有內建字典結構,因此redis自身實現了一套字典結構,下面我們來簡單分析下實現字典的幾個結構體。
1. 雜湊表
typedef struct dictht { // 雜湊表陣列 dictEntry **table; // 雜湊表大小 unsigned long size; // 雜湊表大小掩碼,用於計算索引值 // 總是等於 size - 1 unsigned long sizemask; // 該雜湊表已有節點的數量 unsigned long used; } dictht;
這裡要注意的就是,table
欄位是一個可以儲存多個dictEntry
的陣列,也就是一個雜湊表裡面會有多個雜湊的實體類,也可以成為雜湊節點。
2. 雜湊節點
typedef struct dictEntry { // 鍵 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下個雜湊表節點,形成連結串列 struct dictEntry *next; } dictEntry;
v
欄位表示雜湊節點的值,使用union結構體表示這個值可以使void
指標型別,也可以是uint64_t
或者int64_t
整數,這裡我猜想是為了其他作業系統而做的一個相容吧。儘管大部分雜湊函式具有很強的防碰撞性,但是也會遇到雜湊值相同的鍵值對,這個時候next
欄位就發揮作用了,它會把相同雜湊值的鍵值對整理成一個單向連結串列結構來方便查詢。
3. 字典
typedef struct dict { // 型別特定函式 dictType *type; // 私有資料 void *privdata; // 雜湊表 dictht ht[2]; // rehash 索引 // 當 rehash 不在進行時,值為 -1 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ // 目前正在執行的安全迭代器的數量 int iterators; /* number of iterators currently running */ } dict;
type
欄位提供了一組型別的操作函式:
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;
這組函式可以讓不同型別的鍵值對能夠使用不同的方法進行復制、對比等操作,真正讓redis實現了多型字典。
我們還可以見到,字典結構體內部包含了兩個雜湊表的陣列,為什麼需要兩個雜湊表呢?這就引入了一個rehash
的概念了。
字典的rehash
當雜湊表中的雜湊節點,也就是鍵值對的數量越來越多或者越來越少的時候,原來分配的雜湊表將會進行伸縮,redis會維護一個雜湊表的負載因子
,其中計算方式是:負載因子=雜湊表的使用數量/雜湊表的長度
當符合以下兩個條件任何一個的時候就會進行reshash
操作:
-
伺服器沒有執行
BGSAVE
或者BGREWRITEAOF
命令,並且這個負載因子大於等於1的時候。 -
伺服器在執行
BGSAVE
或者BGREWRITEAOF
命令,並且負載因子超過5。
為什麼在執行備份命令的時候,負載因子要比沒有執行備份的時候大呢?原因就是redis在執行BGSAVE
之類的備份命令時候,會fork
一個子程序進行備份,而目前大部分作業系統都會採用copy on write
也就是寫時複製的技術,如果此時過多的去進行rehash
會導致記憶體佔用過多。
rehash
的步驟:
-
為雜湊表陣列
ht[1]
進行空間分配。分配的原則是:擴容則分配ht[0].used*2
,收縮則分配ht[0]*used/2
。 -
重新計算
ht[0]
中的所有鍵值對,然後逐步遷移到ht[1]
中。 -
當
ht[0]
所有鍵值對都遷移完畢之後,釋放ht[0]
所佔空間,把ht[1]
取代到ht[0]
的位置,並且新建立一個空白的雜湊表,也就是新的ht[1]
,整個rehash
步驟到此結束。
漸進式rehash
上面我們簡單介紹了rehash
的步驟,但是如果真是那麼簡單去實現的話,其實是有缺陷的。試想想,如果雜湊表中擁有大量的鍵值對,一次性去進行rehash
把大量的鍵值對進行遷移,勢必會導致效能低下,並且影響redis的讀寫效能甚至導致服務被停止。因此,rehash
是漸進的,並且是不能影響正常的redis讀寫服務的。以下就是漸進式rehash
的一個過程:
-
為
ht[1]
分配空間,分配的規則上面已經寫了,這裡就不再重複描述。 -
此時字典會擁有
ht[0]
和ht[1]
兩個雜湊表,欄位rehashidx
就能產生作用了。rehashidx
預設值是-1,當它變為0說明rehash
正式開始。 -
rehash
開始,redis會把ht[0]
中rehashidx
索引中的鍵值對進行rehash
到ht[1]
,每次rehash
完成都會讓rehashidx
遞增1,然後重複這個過程。 -
在這個過程中,我們難免會對redis進行增刪改查的操作,這個時候同時擁有兩個雜湊表的作用就發揮出來了。對於查詢、刪除、修改操作,redis會現在
ht[0]
中進行,如果找不到才會到ht[1]
進行對應操作,而增加這些操作則會直接在ht[1]
中進行,主要是讓ht[0]
只減不增,這樣到了某個時刻,ht[0]
的所有鍵值對一定會全部遷移到ht[1]
。
領悟
redis這個字典的結構實現清晰明瞭,特別是其中的rehash
機制很值得我去學習,這個對於一些資料遷移並且不能影響正常服務的程式設計實現非常有借鑑意義。