大綱:
- 簡單動態字符串SDS
- 鏈表
- 字典
- 跳躍表
- 整數集合
- 壓縮列表
閱讀本文你將收貨什麼:
- 瞭解Redis底層的六種數據結構。
- 瞭解每種數據結構的實現方式以及設計上的優點。
Redis爲什麼這麼快?
作爲高速KV數據庫,Redis的速度已經經過各大小公司的實戰考驗了,至於爲什麼這麼快,各個理由從google上一搜大同小異,今天我們來聊一聊其底層實現的六大數據結構。
Redis的高效與其基本的數據結構也是密不可分的,爲了滿足效率和安全這些需求,Redis根據自身需要量身定製了數據結構。注:Redis基於這些數據結構創建了字符串對象,列表對象,哈希對象,集合對象和有序集合對象的對象系統,以此實現鍵值對數據庫。
一.簡單動態字符串(simple dynamic string,SDS)
SDS:每個sdshdr結構表示一個SDS值
struct sdshdr {
// 記錄buf數組中已使用字節的數量
// 等於SDS所保存字符串的長度
int len;
// 記錄buf數組中未使用字節的數量
int free;
// 字節數組,用於保存字符串
char buf[ ];
};
- free屬性值爲0,表示這個SDS沒有分配任何未使用空間。
- len屬性值爲5,表示這個SDS保存了一個五字節長的字符串。
- buf屬性是一個char類型的數組,以’\0’結尾,不計算在len屬性中。
優點:
- 以’\0’結尾可以直接使用C字符串函數庫裏的函數。
- 常數複雜度獲取字符串長度O(1),只需要訪問len屬性即可。
- 杜絕緩衝區溢出,當SDS API需要對SDS進行修改時,會先檢查SDS的空間是否滿足需要,不滿足則自動擴容,避免溢出。
- 減少修改字符串時帶來的內存重分配次數。因爲內存重分配涉及複雜的算法,並且可能需要執行系統調用,所以它通常是一個比較耗時的操作。
- 空間預分配
如果對SDS修改後,其長度小於1M將分配和len屬性同樣大小的未使用空間,這時候len屬性與free屬性值相同。
如果對SDS修改後其長度大於1M,那麼程序會分配1M的未使用空間。 - 惰性空間釋放
惰性空間釋放用於優化SDS的字符串縮短操作,當SDS的API需要縮短其保存的字符串時,程序不立即使用內存重新分配來回收多出來的字節,而是使用free屬性將其記錄,留待以後使用。
- 空間預分配
- 二進制安全。所有SDS API都會以處理二進制的方式來處理SDS存放在buf數組裏的數據,數據在寫入時是什麼樣的,它被讀取時就是什麼樣。
二.鏈表
listNode:每個鏈表節點用一個listNode結構來表示
typedef struct listNode {
// 前置節點
struct listNode *prev;
// 後置節點
struct listNode *next;
// 節點的值
void *value
}listNode;
list:雖然多個listNode結構可以組成鏈表,但由list來持有鏈表操作方便許多
typedef struct list {
// 表頭結點
listNode *head;
// 表尾節點
listNode *tail;
// 鏈表所包含的節點數量
unsigned long len;
// 節點值複製函數
void *(*dup)(void *prt);
// 節點值釋放函數
void (*free)(void *ptr);
// 節點值對比函數
int (*match) (void *ptr, void *key);
}list;
圖2·由list結構和listNode結構組成的鏈表
特性總結:
- 雙端:鏈表節點帶有prev和next指針,獲取某個節點的前置節點和後置節點的複雜度爲O(1)
- 無環:表頭節點的pre和表尾節點的next都指向NULL,對鏈表的訪問以NULL爲終點。
- 帶表頭和表尾指針:通過list結構的head指針和tail指針,獲取鏈表頭尾節點的複雜度爲O(1).
- 帶鏈表長度技術器,使用list結構的len屬性來對list持有的鏈表節點計數,獲取鏈表中節點數量的複雜度爲O(1)。
- 多態:鏈表節點使用void* 指針來保存節點的值,並且可以通過list結構的dup、free、match三個屬性爲節點值設置類型特定的函數,用來保存各種不同類型的值。
###三.字典
三.字典
哈希表:Redis字典所使用的哈希表由dictht結構定義
typedef struct dictht {
// 哈希表數組
dictRntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩碼,用於計算索引值
// 總是等於size-1
unsigned long sizemask;
// 該哈希表已有節點的數量
unsigned long used;
}dictht;
圖3·一個空的哈希表
-
table屬性是一個數組,數組中的每個元素都是一個指向dictEntry結構的指針,每個dictEntry結構保存着一個鍵值對。
-
size屬性記錄了哈希表的大小,也即table數組的大小,而used屬性記錄了哈希表目前已有節點(鍵值對)的數量。
-
sizemask屬性的值總是等於size-1,這個屬性和哈希值一起決定一個鍵應該被放到table數組的哪個索引上面。
哈希表節點:哈希表節點使用dictEntry結構表示,每個結構保存着一個鍵值對
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
}v;
// 指向下個哈希表節點,形成鏈表
struct dictEntry *next;
}dictEntry;
圖4·連在一起的鍵K1和鍵K0
- key屬性保存着鍵值對中的鍵,而v屬性保存着鍵值對中的值,值可以是一個指針,或uint64_t整數,或int64_t整數。
- next屬性是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接在一起,一次解決鍵衝突的問題。
字典:Redis中的字典由dict結構表示
typedef struct dict {
// 類型特定函數
dictType *type;
// 私有數據
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引
// 當rehash不在進行時,值爲-1
int treashidx;
}dict;
- type屬性是一個指向dictType結構的指針,每個dictType結構保存了一簇用於操作特定類型鍵值對的函數,Redis會爲用途不同的字典設置不同類型的函數。
- privadata屬性保存了需要傳給那些類型特定函數的可選參數。
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 *key, const void *key2);
// 銷燬鍵的函數
void (*keyDestructor) (void *privdata, void *key);
// 銷燬值的函數
void (*valDestructor) (void *privdata, void *obj);
}dictType;
圖五·普通狀態下的字典
- 哈希算法:
當要將一個新的鍵值對添加到字典裏時,程序需要先根據鍵值對的鍵計算出哈希值和索引值,然後再根據索引值,將包含新鍵值對的哈希表節點放到哈希表數組指定索引上面。 - 解決鍵衝突:
Redis的哈希表使用鏈地址法來解決鍵衝突,每個哈希表節點都有一個next指針,多個哈希表節點用next指針構成一個單向鏈表。 - rehash:
1.爲字典的ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量。- 擴展:那麼ht[1]的大小爲第一個大於等於ht[0].used*2的2^n;
- 收縮:那麼ht[1]的的大小爲第一個大於等於ht[0].used的2^n.
- 將保存在ht[0]中的所有鍵值對rehash到ht[1]上面。
- 當ht[0]包含的所有鍵值對都遷移到ht[1]後,釋放ht[0],將ht[1]設置爲ht[0],並在ht[1]新創建一個空白哈希表,爲下一次rehash做準備。
- 漸進式rehsah:
1.爲ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表。
2.在字典中維持一個索引計數器變量rehashidx,並設置0,表示rehash工作開始。
3.在rehash期間,每次對字典進行增刪改查外,順帶將ht[0]哈希表在rehashidx索引上所有鍵值對rehash到ht[1],當rehash完成後,將rehashidx增一。
4. 當rehash完成時,將rehashidx設置爲-1,表示rehash操作完成。
!!!漸進式rehash的好處在於它採取分而治之的方式,將rehash鍵值對所需的計算工作均攤到每個增刪改查操作上,避免集中式rehash帶來的龐大計算量
漸進式rehash期間hash表操作:刪、查、改操作先ht[0]後ht[1],新增直接在ht[1]上。
四.跳躍表
跳躍表節點:由zskiplistNode結構定義
typedef struct zskiplistNode {
// 後退指針
struct zskiplistNode *backward;
// 分值
double score;
// 成員對象
robj *obj;
// 層
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 跨度
unsigned int span;
}level[];
}zskiplistNode;
圖六·帶不同層高的節點
- 層:
跳躍表節點的level數組可以包含多個元素,每個元素都包含一個指向其他節點的指針,程序可以通過這些層來加快訪問其他節點的速度,一般來說,層越多,訪問其他節點的速度就越快。
2.前進指針:
每個層都有一個指向表尾方向的前進指針(level[i].forward),用於從表頭向表尾方向訪問節點。 - 跨度:
層的跨度(level[i].span)用於記錄兩個節點之間的距離。跨度與操作無關,只是用於記算排位:在查找某個節點的過程中,將沿途訪問過的所有層的跨度累計起來,得到結果就是目標節點在跳躍表中的排位。 - 後退指針:
後退指針(backward)用於從表尾向表頭方向訪問節點,每次只能後退至前一個節點。 - 分值和成員:
- 節點的分值(score)是一個double類型的浮點數,跳躍表中所有節點都按分值從小到大排序。
- 節點的成員對象(obj)是一個指針,指向一個字符串對象,字符串對象則保存着一個SDS值。
- 在同一個跳躍表中,各個節點保存的成員對象必須是唯一的,但是多個節點保存的分值可以相同。
跳躍表
typedef struct zskiplist {
// 表頭結點和表尾節點
struct skiplistNode *header, *tail;
// 表中節點的數量
unsigned long length;
// 表中層數最大的節點的層數
int level;
}zskiplist;
圖七·帶有zskiplist結構的跳躍表
- header和tail指針分別指向跳躍表的表頭和表尾節點,通過這兩個指針,程序定位表頭節點和表尾節點的複雜度爲O(1)。
- 通過length屬性記錄節點的數量,獲取跳躍表長度的複雜度爲o(1)。
- level屬性用於在O(1)複雜度內獲取跳躍表中層高最大的那個節點的層數量。
五.整數集合
typedef struct intset {
// 編碼方式
uint32_t encoding;
// 集合包含的元素數量
uint32_t length;
// 保存元素的數組
int8_t contents[];
}intse;
圖八·一個包含五個int16_t類型整數值的整數集合
- contents數組是整數集合的底層實現:整數集合的每個元素都是contents數組的一個數組項(item),各個項在數組中按值從小到大排列,不重複。
- length屬性記錄了整數集合包含的元素數量。
- contents數組真正類型取決於encoding屬性的值。
升級
當添加新元素到整數集合中,新元素比整數集合現有元素都要長,則進行升級。
- 根據新元素的類型,擴展整數集合底層數組的空間大小,併爲新元素分配空間。
- 將底層數組現有所有元素都轉換成新元素的類型,並將類型轉換後的元素放置到正確的位置上。
- 將新元素添加到底層數組裏面。
升級的好處:
- 提升靈活性:C語言是靜態類型語言,避免類型錯誤,通常不會將兩種不同類型的值放在同一個數據結構中。
- 節約內存:讓一個數組可以同時保存int16_t、int32_t、int64_t三種類型最簡單的做法就是直接使用int64_t作爲整數集合的底層實現。整數集合技能保存不同類型的整數,又可以確保升級操作只會在必要的時候進行,這可以儘量節約內存。
降級
整數集合不支持降級。
六.壓縮列表
壓縮列表的構成
壓縮列表是Redis爲了節約內存而開發的,有一系列特殊編碼的連續內存塊組成的順序型數據結構。一個壓縮列表可以包含任意多個節點(entry),每個節點可以保存一個字節數組或者一個整數值。
圖九·包含三個節點的壓縮列表
- 列表zlbytes屬性值爲0x50(十進制80),表示壓縮列表總長爲80字節。
- 列表zltail屬性值爲0x3c(十進制60),這表示如果我們有一個指向壓縮列表起始指針的P,只要P加上偏移量60就能計算出表尾節點entry3的地址。
- 列表zllen屬性值爲0x3(十進制3),表示壓縮列表包含三個節點。
壓縮列表節點的構成
圖十·前一節點長度爲5字節
-
previous_entry_length
節點的previous_entry_length屬性以字節爲單位,記錄了壓縮列表中前一個字節的長度,其長度可爲1或5:- 如果前一節點長度小於254字節,那麼previous_entry_length長度爲一個字節,前一個節點的長度就保存在這個字節裏。
- 如果前一個節點的長度大於254字節,那麼previous_entry_length屬性的長度爲5字節,第一個字節被設爲0xFE,之後四個字節用於保存前一節點的長度。
因爲節點的previous_entry_length屬性記錄了前一個節點的長度,所以程序可以通過指針運算,根據當前節點的其實地址計算出前一個節點的起始地址。
-
encoding
節點encoding屬性記錄了節點的content屬性所保存數據的類型以及長度。
字節數組編碼:
編碼 | 編碼長度 | content屬性保存的值 |
---|---|---|
00bbbbbb | 1字節 | 長度小於等於63字節的字節數組 |
01bbbbbb xxxxxxxx | 2字節 | 長度小於等於16 383字節的字節數組 |
10_ _ _ _ _ _ aaaaaaa bbbbbbbb cccccccc | 5字節 | 長度小於等於4 294 967 295字節的字節數組 |
整數編碼:
編碼 | 編碼長度 | content屬性保存的值 |
---|---|---|
11000000 | 1字節 | int16_t 類型的數組 |
11010000 | 1字節 | int32_t 類型的數組 |
11100000 | 1字節 | int64_t 類型的數組 |
11110000 | 1字節 | 24位有符號整數 |
11111110 | 1字節 | 8位有符號整數 |
1111xxxx | 1字節 | 無意義 |
- content
節點的content屬性負責保存節點的值,節點值可以是一個字節數組或者整數,值的類型和長度有節點的encoding屬性決定。
連鎖更新
圖十一·添加新節點到壓縮列表
- 因爲每個節點的previous_entry_length屬性都記錄了前一個節點的長度
- 假設在一個壓縮列表中所有節點長度都小於254字節,當插入一長度大於254字節的新節點並設置爲表頭節點,那麼他下一個節點e1的previous_entry_length只有1個字節,沒法保存大於254字節的長度,需要擴展。
- e1更新後e2也需要擴展,擴展e2也會引發對e3的擴展,e4······直到每個節點previous_entry_length都符合壓縮列表對節點的要求。
因爲連鎖更新在最壞的情況下需要對壓縮列表執行N次空間重分配操作,每次空間分配的最壞複雜度爲O(N),所以連鎖更新的最壞複雜度爲O(N^2)。
儘管連鎖更新的複雜度較高,但真正造成性能問題的機率很低
總結:
以上就是Redis的六種底層數據的各種實現分析,總結於《Redis設計與實現》,用於自己速覽,也希望能幫助到對於redis感興趣的各位!