閒暇之餘,通讀了《Redis 設計與實現》,個人比較喜歡第一版,小記幾筆,以便查閱,如果單純爲了使用,請移步:《命令查詢手冊》,共勉~
簡單動態字符串
Redis中使用的並不是傳統的C字符串,還是使用其特有的數據結構Sds(Simple Dynamic String,簡單動態字符串)作爲char*
的替代品,因爲傳統字符串類型無法高效支持一些Redis常用操作,如:
- 計算字符串長度,傳統的字符串時間複雜度爲O(N)
- 對字符串進行N次追加,必定需要低字符串進行N次內存重分配(realloc)
所以,Redis中的Sds做了類似於下面的定義:
typedef char * sds;
struct sdshdr {
// buf 已佔用長度
int len;
// buf 剩餘可用長度
int free;
// 實際保存字符串數據的地方
char buf[];
};
通過額外的字段記錄,Sds的字符串長度的複雜度則變爲了O(1),而buf則採用的是內存預分配的策略,比如當前分配了1KB的空間,當追加後的大小小於1KB,則不會引起內存的重新分配,若是大於1KB,則Redis會爲他們額外分配1KB的空間,僞代碼實現如下:
def sdsMakeRoomFor(sdshdr, required_len):
# 預分配空間足夠,無須再進行空間分配
if (sdshdr.free >= required_len):
return sdshdr
# 計算新字符串的總長度
newlen = sdshdr.len + required_len
# 如果新字符串的總長度小於 SDS_MAX_PREALLOC
# 那麼爲字符串分配 2 倍於所需長度的空間
# 否則就分配所需長度加上 SDS_MAX_PREALLOC 數量的空間
if newlen < SDS_MAX_PREALLOC:
newlen *= 2
else:
newlen += SDS_MAX_PREALLOC
# 分配內存
newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)
# 更新 free 屬性
newsh.free = newlen - sdshdr.len
# 返回
return newsh
鏈表
鏈表作爲一種常用的數據結構,在很多高級編程語言中均有內置,但由於Redis所使用的C語言並沒有內置這種結構,所以Redis自己構建了鏈表的實現,鏈表在Redis中的應用非常廣泛,比如列表、發佈訂閱,慢查詢等等。
鏈表節點定義僞代碼:
typedef struct listNode {
// 前置節點
struct listNode *prev;
// 後置節點
struct listNode *next;
// 節點的值
void *value;
} listNode;
typedef struct list {
// 表頭節點
listNode *head;
// 表尾節點
listNode *tail;
// 鏈表所包含的節點數量
unsigned long len;
// 節點值複製函數
void *(*dup)(void *ptr);
// 節點值釋放函數
void (*free)(void *ptr);
// 節點值對比函數
int (*match)(void *ptr, void *key);
} list;
Redis列表中使用雙端鏈表和壓縮列表作爲底層實現,因爲雙端鏈表佔用的內存比壓縮列表要多,所以當創建新的列表時,Redis會優先考慮壓縮列表作爲底層實現,在有需要的時候,纔會從壓縮列表轉換到雙端鏈表實現。該結構特性可總結如下:
- 由於listNode帶有prev和next指針,所以獲取某個節點的前後節點的複雜度都是O(1)。
- list保存了head和tail兩個指針,所以對錶頭和表尾的複雜度都有O(1),所以list可以高效執行LPUSH、RPOP、RPOPLPUSH等命令。
- list使用len來對節點進行技術,所以程序獲取鏈表中節點數量的複雜度爲O(1)。
字典
字典的結構想必大家並不陌生,也是Redis中應用廣泛的結構之一,使用頻率和Sds及雙端鏈表不相上下,主要的用途有兩個:
- 作爲數據庫鍵空間。
- 作爲Hash類型鍵的底層實現之一。
與雙端鏈表一樣,雖然字典作爲一種常見的數據結構內置在很多高級編程語言裏,但Redis裏使用的C語言並沒有內置這種結構,因此Redis自己構建了字典的實現,實現的方案有多種:
- 最簡單就是使用鏈表或數組,但只適用於元素個數不多的情況下。
- 要兼顧高效和簡單性,可以使用哈希表。
- 如果追求更爲穩定的性能特徵,並希望高效的實現排序操作,則可使用更爲複雜的平衡樹。
Redis選擇高效和簡單薦股的哈希表,作爲字典的底層實現。
/*
* 字典
*
* 每個字典使用兩個哈希表,用於實現漸進式 rehash
*/
typedef struct dict {
// 特定於類型的處理函數
dictType *type;
// 類型處理函數的私有數據
void *privdata;
// 哈希表(2 個)
dictht ht[2];
// 記錄 rehash 進度的標誌,值爲 -1 表示 rehash 未進行
int rehashidx;
// 當前正在運作的安全迭代器數量
int iterators;
} dict;
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表節點指針數組(俗稱桶,bucket)
dictEntry **table;
// 指針數組的大小
unsigned long size;
// 指針數組的長度掩碼,用於計算索引值
unsigned long sizemask;
// 哈希表現有的節點數量
unsigned long used;
} dictht;
/*
* 哈希表節點
*/
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 鏈往後繼節點
struct dictEntry *next;
} dictEntry;
因爲壓縮列表比字典更節省內存,所以在創建Hash鍵時,默認使用壓縮列表作爲底層實現,當有需要是,程序纔會將底層實現從列表轉換到字典。值得關注的是dict類型中使用了兩個指針,分別指向兩個哈希表,其中,0號哈希表(ht[0])是字典主要使用的哈希表,而1號哈希表(ht[1])則只有在程序對0號號系表進行rehash時才使用。Redis目前使用的哈希算法有兩種:
- MurmurHash2 32bit算法:這種算法的分步率和速度都非常好:http://code.google.com/p/smhasher/ ;
- 基於djb算法實現的一個大小寫無關散列算法:http://www.cse.yorku.ca/~oz/hash.html
儘管使用了哈希算法,但不同的兩個鍵仍然可能擁有相同的哈希值,我們稱之爲碰撞,所以哈希表必須想辦法對碰撞進行處理,字典哈希表所使用碰撞解決方法被稱之爲鏈地址法:就是使用鏈表將多個哈希值相同的節點串聯在一起,從而解決衝突問題,如果哈希表的大小與節點數量保持在1:1時,哈希表性能最好,但是如果節點數量遠大於哈希表的大小的話,那麼哈希表就會退化成多個鏈表,那麼性能就會明顯下降。所以當字典的鍵值對不斷增多的情況下,爲了保持字典的性能,就需要對哈希表(ht[0])進行rehash操作,在不修改任何鍵值對的情況下,對哈希表進行擴容,儘量將比率維持在1:1左右。
通過查看dictht的定義我們可以發現其定義了size(指針數組大小)和used(哈希表現有節點數量)兩個屬性,當他們之間的比率被定義爲\(ratio=used/size\),當滿足下列條件,rehash操作就會被激活:
- 自然rehash:ratio>=1且變量dict_can_resize==true;
- 強制rehash:ratio>dict_force_resize_ratio(在2.6版本默認爲5)。
Rehash 的執行過程
- 設置字典的rehashidx爲0,標識rehash開始,創建一個比ht[0]->table更大的 ht[1]-->table,大小至少爲ht[0]-->used的兩倍;
- 將ht[0]->table中的所有鍵值遷移到ht[1]-->table;
- 將原有 ht[0]的數據清空,並將ht[1]替換爲新的ht[0];
也許你會有疑問,如果說在rehash的過程中,有新的值寫入怎麼辦?如果直接阻塞,等rehash過程完成,這樣是非常不友好的,所以Redis採用了漸進式(incremental)的rehash方式,主要由_dictRehashStep和dictRehashMilliseconds兩個函數進行:
- _dictRehashStep用於對數據庫字典以及哈希鍵的字典被動rehash,每次執行_dictRehashStep,哈希表ht[0]-->table第一個不爲空的索引上的所有節點就會全部遷移到ht[1]-->table,每一次執行添加、查找、刪除操作,_dictRehashStep都會被執行一次,因爲字典會保持哈希大小和節點的ratio在一個很小的範圍內,所以每個索引上的節點數量不會很多,在執行操作的同時,對單個索引上的節點進行遷移,幾乎不會對響應時間造成影響;
- dictRehashMilliseconds則由Redis服務器常規任務程序(service cron job)執行,可以在指定的毫秒數內對數據庫字典進行主動rehash,從而加速數據庫字典的rehash過程;
當然,爲了保證rehash的順利、正確執行,還需要採取一些特別的措施:
- 在rehash未完成時,字典會同時使用兩個哈希表,所以在這期間的查找、刪除操作,除了在ht[0]上進行,還需要在ht[1]上進行;
- 在執行添加操作時,新的節點會直接添加到ht[1]而不是ht[0],這樣保證ht[0]的節點數量在整個rehash的過程中都只減不增。
當然,如果因爲大量的刪除節點,導致了哈希表的可用節點數比已用節點數大很多的話,那麼也可以通過rehash來收縮(shrink)字典,操作過程和上述過程類似,不過不同於擴展的是,字典的收縮是需要手動執行的,一般來說當字典的填充率小於10%,我們就可以對這個字典進行收縮操作了。
跳躍表
什麼是跳躍表
首先我們先談談單鏈表,比如一個鏈表L:1->2->3->4->5->6->7->8->9,如果我們想查找某個數據,就只能從頭到尾遍歷,時間複雜度爲O(n),似乎有點難以接受,本着空間換時間的準則,大佬們爲鏈表建立了索引L1:1->3->5->7->9,這樣我們要查找6時,就現在L1中查找,當發現6在5到7之間時,在下降到L中進行查找,當加了一層索引後,我們就會發現,查找一個節點需要遍歷的節點個數減少了,爲了進一步提高效率,我們可以再加一級索引L2:1->5->7,這樣效率就又會進一步提升,當有大量數據時,我們就可以通過這種多級索引的方式,使查找效率大大提升,這種多級索引的結構就是跳躍表。跳躍表的效率和平衡樹媲美,在Redis主要用於實現有序數據類型,主要由以下幾個部分構成:
- 表頭:負責維護跳躍表的節點指針;
- 跳躍表節點:保存着元素值,以及多個層;
- 層:保存着指向其他元素的指針。高層的指針越過的元素數量>=低層的指針,爲了提高查找效率,程序總是從高層先開始訪問,然後隨着元素範圍的縮小,慢慢降低層次;
- 表尾:全部由NULL組成,標識跳躍表的末尾。
僅僅從文字上難以形象的說明跳躍表,還是直接上圖來的形象,不過本人又是個憊懶貨,就直接引用了原圖,各位大佬還是移步原文去看吧,比我抄的好多啦。
Redis中的跳躍表
爲了滿足自身需要,Redis對跳躍表進行了修改:
- 允許重複的score值:多個不同的member的score值可以相同;
- 進行對比操作時,不進要檢查score值,還要檢查member,因爲score重複時需要查member纔行;
- 每個加點都有一個高度爲1的後退指針,用於從表尾方向向表頭方向迭代。
//表示跳躍節點
typedef struct zskiplist {
// 頭節點,尾節點
struct zskiplistNode *header, *tail;
// 節點數量
unsigned long length;
// 目前表內節點的最大層數(表頭節點不計算在內)
int level;
} zskiplist;
//保存跳躍節點的相關信息
typedef struct zskiplistNode {
// 成員對象:在同一個跳躍表中,各個節點保存的成員對象必須是唯一的,但多個節點保存的分值可以是相同的
robj *obj;
// 分值:在跳躍表中,節點按各自所保存的分值從小到大排序
double score;
// 後退指針:它指向位於當前節點的前一個節點,用於程序從表尾向表頭遍歷時使用
struct zskiplistNode *backward;
// 層
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 這個層跨越的節點數量
unsigned int span;
} level[];
} zskiplistNode;