Redis詳解(四)redis底層數據結構詳解

1、前言

上一篇咱們詳細介紹了Redis的數據類型(Redis詳解(三)redis的數據類型詳解),本篇將帶你們重點詳細介紹Redis五種數據類型的底層數據結構。數據庫鍵老是一個字符串對象,值則能夠是字符串對象、列表對象、哈希對象、集合對象、有序集合對象這五種對象中的其中一種,Redis底層數據結構有如下數據類型:簡單動態字符串、鏈表、字典、跳躍表、整數集合、壓縮列表。redis

2、簡單動態字符串

構建了一種名爲簡單動態字符串(SDS)的抽象類型,並將SDS用做Redis的默認字符串表示。在Redis裏面,C字符串只會做爲字符串字面量用在一些無須對字符串值進行修改的地方(好比打印日誌)。當Redis須要一個能夠被修改的字符串值時,Redis就會使用SDS來表示字符串。SDS還被用做緩衝區:AOF模塊中的AOF緩衝區,以及客戶端狀態中的輸入緩衝區,都是由SDS實現的。算法

1.定義

1.1 簡單動態字符串結構

struct sdshdr {        
	// 記錄buf數組中已使用字節的數量
    // 等於SDS所保存字符串的長度
    int len;
  
    // 記錄buf數組中未使用字節的數量
    int free;
  
    // 字節數組,用於保存字符串
    char buf[];
}; 

複製代碼

1.2 示例

用SDS保存字符串 「Redis」具體圖示以下:數據庫

2. SDS與C字符串的區別

1. 常數複雜度獲取字符串長度

因爲 len 屬性的存在,咱們獲取 SDS 字符串的長度只須要讀取 len 屬性,時間複雜度爲 O(1)。而對於 C 語言,獲取字符串的長度一般是通過遍歷計數來實現的,時間複雜度爲 O(n)。經過 strlen key 命令能夠獲取 key 的字符串長度。數組

2. 杜絕緩衝區溢出

C字符串不記錄自身長度帶來的另外一個問題是容易形成緩衝區溢出。安全

假設程序中有兩個在內存中緊鄰着的字符串s1和s2,其中s1保存了字符串「redis」,而s2則保存了字符串「MongoDb」。服務器

此時將s1的內容修改成「Redis Cluster」,但忘記了爲s1分配足夠的空間,就會致使s1的數據將溢出到s2所在的空間中,致使s2保存的內容被意外地修改。微信

當SDS API須要對SDS進行修改時,API會先檢查SDS的空間是否知足修改所需的要求,若是不知足的話,API會自動將SDS的空間擴展至修改所需的大小,而後才執行實際的修改操做,因此使用SDS既不須要手動修改SDS的空間大小,也不會出現前面所說的緩衝區溢出問題。markdown

3. 減小修改字符串時帶來的內存重分配次數

由於C字符串並不記錄自身的長度,因此對於一個包含N個字符的C字符串來講,這個C字符串的底層實現老是一個N+1個字符長的數組(額外的一個字符串空間用於保存空字符)。由於C字符串的長度和底層數組的長度之間存在着這種關聯性,因此每次增加或者縮短一個C字符串,程序都總要對保存這個C字符串的數組進行一次內存重分配操做:數據結構

  1. 若是程序執行的是增加字符串的操做,程序須要先經過內存重分配來擴展底層數組組的空間大小,若是忘了這一步就會產生緩衝區溢出函數

  2. 若是程序執行的是縮短字符串的操做,程序須要經過內存重分配來釋放字符串再也不使用的那部分空間,若是忘了這一步就會產生內存泄露

SDS經過未使用空間解除了字符串長度和底層數組長度之間的關聯:在SDS中,buf數組的長度不必定就是字符數量加一,數組裏面能夠包含未使用的字節,而這些字節的數量就由SDS的free屬性記錄。經過未使用空間,SDS實現了空間預分配和惰性空間釋放兩種優化策略:

  1. 空間預分配:空間預分配用於優化SDS的字符串增加操做。對字符串進行空間擴展操做時,擴展的內存比實際須要的多(程序不只會爲SDS分配修改所必須的空間,還會爲SDS分配額外的未使用空間),這樣能夠減小連續執行字符串增加操做所需的內存重分配次數。

  2. 惰性空間釋放:惰性空間釋放用於優化SDS的字符串縮短操做。對字符串進行縮短操做時,程序不當即使用內存從新分配來回收縮短後多餘的字節,而是使用 free 屬性將這些字節的數量記錄下來,等待後續使用。(固然SDS也提供了相應的API,當咱們有須要時,也能夠手動釋放這些未使用的空間。)

4. 二進制安全

由於C字符串以空字符做爲字符串結束的標識,而對於一些二進制文件(如圖片等),內容可能包括空字符串,所以C字符串沒法正確存取;而全部 SDS 的API 都是以處理二進制的方式來處理 buf 裏面的元素,而且 SDS 不是以空字符串來判斷是否結束,而是以 len 屬性表示的長度來判斷字符串是否結束。

5. 兼容部分 C 字符串函數

雖然 SDS 是二進制安全的,可是同樣聽從每一個字符串都是以空字符串結尾的慣例,這樣能夠重用 C 語言庫<string.h> 中的一部分函數。

6. 總結

3、鏈表

鏈表是一種經常使用的數據結構,C 語言內部是沒有內置這種數據結構的實現,因此Redis本身構建了鏈表的實現。鏈表提供了高效的節點重排能力,以及順序性的節點訪問方式,而且能夠經過增刪節點來靈活地調整鏈表的長度。

鏈表鍵的底層實現之一就是鏈表。當一個列表鍵包含了數量比較多的元素,又或者列表中包含的元素都是比較長的字符串時,Redis就會使用鏈表做爲列表鍵的底層實現。

此外,發佈與訂閱、慢查詢、監視器等功能也用到了鏈表,Redis服務器自己還使用鏈表來保存多個客戶端的狀態信息,以及使用鏈表來構建客戶端輸出緩衝區

1.定義

1.1 鏈表節點結構

typedef struct listNode {
	// 前置節點
	struct listNode *prev;
    
	// 後置節點
	struct listNode *next;
    
	// 節點的值
	void *value;
}listNode;

複製代碼

1.2 示例

經過多個listNode能夠經過prev和next指針組成鏈表,這是一個雙向鏈表。

Redis還提供了操做鏈表的數據結構。

2.1 鏈表結構

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;
複製代碼

2.2 示例

2.特性

Redis的鏈表實現的特性能夠總結以下:

  1. 雙端:鏈表節點帶有prev和next指針,獲取某個節點的前置節點和後置節點的複雜度都是O(1)。

  2. 無環:表頭節點的prev指針和表尾節點的next指針都指向NULL,對鏈表的訪問以NULL爲終點。

  3. 帶表頭指針和表尾指針:經過list結構的head指針和tail指針,程序獲取鏈表的表頭節點和表尾節點的複雜度爲O(1)。

  4. 帶鏈表長度計數器:程序使用list結構的len屬性來對list持有的鏈表節點進行計數,程序獲取鏈表中節點數量的複雜度爲O(1)。

  5. 多態:鏈表節點使用void*指針來保存節點值,而且能夠經過list結構的dup、free、match三個屬性爲節點值設置類型特定函數,因此鏈表能夠用於保存各類不一樣類型的值。

4、字典

字典又稱爲符號表或者關聯數組、或映射(map),是一種用於保存鍵值對的抽象數據結構。字典中的每個鍵 key 都是惟一的,經過 key 能夠對值來進行查找或修改。

1.定義

1.1 哈希表結構

typedef struct dictht{
     //哈希表數組
     dictEntry **table;
     //哈希表大小
     unsigned long size;
     //哈希表大小掩碼,用於計算索引值
     //老是等於 size-1
     unsigned long sizemask;
     //該哈希表已有節點的數量
     unsigned long used;
 
}dictht
複製代碼
  • table屬性是一個數組,數組中的每一個元素都是一個指向哈希表節點(dictEntry)的指針,每一個節點都保存着一個鍵值對;

  • size屬性記錄了哈希表的大小,也就是table數組的大小;

  • sizemask屬性的值老是等於size-1,這個屬性和哈希值一塊兒決定一個鍵應該被放到table數組的那個索引上面;

  • used屬性記錄了哈希表目前已有節點的數量。

1.2 示例

一個大小爲4的空哈希表結構圖以下:

哈希表是由數組 table 組成,table 中每一個元素都是指向 dictEntry 結構。

2.1 哈希表節點結構

typedef struct dictEntry{
     //鍵
     void *key;
     //值
     union{
          void *val;
          uint64_tu64;
          int64_ts64;
     }v;
 
     //指向下一個哈希表節點,造成鏈表
     struct dictEntry *next;
}dictEntry
複製代碼
  • key屬性保存着鍵值對中的鍵;

  • v屬性保存着鍵值對中的值,其中值用union定義,支持三種數據類型;能夠是一個指針,還能夠是uint64_t整數,也能夠是int64_t整數。

  • next屬性是指向另外一個哈希表節點的指針,這個指針能夠將多個哈希值相同的鍵值對鏈接在一塊兒,以此來解決鍵衝突的問題。經過next指針將兩個索引值相同的鍵k1和k0鏈接在一塊兒。

2.2 示例

3.1 字典結構

typedef struct dict {
    // 類型特定函數
    dictType *type;
    
    // 私有數據
    void *privedata;
    
    // 哈希表
    dictht  ht[2];
    
    // rehash 索引
    // 在rehash不在進行時,值爲-1
    int trehashidx;
}dict;
複製代碼
  • type屬性是一個指向dictType結構的指針,每一個dictType結構保存了一組用於操做特定類型鍵值對的函數,Redis會爲用途不一樣的字典設置不一樣的類型特定函數

  • privedata屬性則保存了須要傳給那些類型特定函數的可選參數

  • ht屬性是一個包含兩個項的數組,數組中的每一個項都是一個dictht哈希表,通常狀況下,字典只使用ht[0]哈希表,ht[1]哈希表只會在對ht[0]哈希表進行rehash時使用

  • trehashidx屬性記錄了rehash目前的進度,若是目前沒有在進行rehash,那麼它的值爲-1

3.2 示例

一個普通狀態下(沒有進行rehash)的字典以下:

2.哈希算法

當要將一個新的鍵值對添加到字典裏面時,程序須要先根據鍵值對的鍵計算出哈希值和索引值,而後再根據索引值,將包含新建值對的哈希表節點放到哈希表數組的指定索引上面

Redis計算哈希值和索引值的方法以下:

# 使用字典設置的哈希函數,計算鍵key的哈希值
hash = dict -> type -> hashFunction(key);
# 使用哈希表的sizemask屬性和哈希值,計算出索引值
# 根據狀況不一樣,ht[x]能夠是ht[0]或者ht[1]
index = hash & dict -> ht[x].sizemask;
複製代碼

3.解決鍵衝突

當有兩個或以上數量的鍵被分配到了哈希表數組的同一個索引上面時,咱們稱這些鍵發生了衝突。

Redis的哈希表使用鏈地址法來解決鍵衝突,每一個哈希表節點都有一個next指針,多個哈希表節點能夠用next指針構成一個單向鏈表,被分配到同一個索引上的多個節點能夠用這個單向鏈表鏈接起來,這就解決了鍵衝突的問題。

由於dictEntry節點組成的鏈表沒有指向鏈表表尾的指針,因此爲了速度考慮,程序老是將新節點添加到鏈表的表頭位置(複雜度爲O(1)),排在其餘已有節點的前面。

使用鏈表解決k2和k1的衝突:

4.擴容和收縮

爲了讓哈希表的負載因子維持在一個合理的範圍以內,當哈希表保存的鍵值對數量太多或者太少時,程序須要對哈希表的大小進行相應的擴展或者收縮。

1.擴容和收縮步驟

擴展和收縮哈希表的工做能夠經過執行rehash(從新散列)操做來完成,Redis對字典的哈希表執行rehash的步驟以下:

  1. 爲字典的ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操做,以及ht[0]當前包含的鍵值對數量(也就是ht[0].used屬性的值)

    • 若是執行的是擴展操做,那麼ht[1]的大小爲第一個大於等於ht[0].used*2n(也就是每次擴展都是根據原哈希表已使用的空間擴大一倍建立另外一個哈希表)

    • 若是執行的是收縮操做,每次收縮是根據已使用空間縮小一倍建立一個新的哈希表。

  2. 從新利用上面的哈希算法,計算索引值,而後將鍵值對放到新的哈希表位置上。

  3. 當ht[0]包含的全部鍵值對都遷移到了ht[1]以後(ht[0]變爲空表),釋放ht[0],將ht[1]設置爲ht[0],並在ht[1]新建立一個空白哈希表,爲下一次rehash作準備)

2.擴容和收縮觸發條件

哈希表的負載因子計算公式:load_factor = ht[0].used / ht[0].size(負載因子=哈希表已保存節點數量/哈希表大小)

  1. 當如下條件中的任意一個被知足時,程序會自動開始對哈希表執行擴展操做:

    • 服務器目前沒有在執行BGSAVE命令或者BGREWRITEAOF命令,而且哈希表的負載因子大於等於1。

    • 服務器目前正在執行BGSAVE命令或者BGREWRITEAOF命令,而且哈希表的負載因子大於等於5。

  2. 當哈希表的負載因子小於0.1時,程序自動開始對哈希表執行收縮操做。

5.漸進式rehash

爲了不rehash對服務器性能形成影響,服務器不是一次性將ht[0]裏面的全部鍵值對所有rehash到ht[1],而是分屢次、漸進式地將ht[0]裏面的鍵值對慢慢地rehash到ht[1]。由於在進行漸進式rehash的過程當中,字典會同時使用ht[0]和ht[1]兩個哈希表,因此在漸進式rehash進行期間,字典的刪除、查找、更新等操做會在兩個哈希表上進行。而新添加到字典的鍵值對一概會被保存到ht[1]裏面,而ht[0]則再也不進行任何添加操做。

5、跳躍表

跳躍表(skiplist)是一種有序數據結構,它經過在每一個節點中維持多個指向其它節點的指針,從而達到快速訪問節點的目的。Redis使用跳躍表做爲有序集合鍵的底層實現之一,若是一個有序集合包含的元素數量比較多,又或者有序集合中元素的成員是比較長的字符串時,Redis就會使用跳躍表來做爲有序集合鍵的底層實現。Redis只在兩個地方用到了跳躍表,一個是實現有序集合鍵,另外一個是在集羣節點中用做內部數據結構

1.定義

1.1 跳躍表節點結構

typedef struct zskiplistNode {
     //層
     struct zskiplistLevel{
           //前進指針
           struct zskiplistNode *forward;
           //跨度
           unsigned int span;
     }level[];
 
     //後退指針
     struct zskiplistNode *backward;
     //分值
     double score;
     //成員對象
     robj *obj;
 
} zskiplistNode
複製代碼
  • level屬性能夠包含多個元素,每一個元素都包含一個指向其餘節點的指針,程序能夠經過這些層來加快訪問其餘節點的速度,通常來講,層的數量越多,訪問其餘節點的速度就越快。

    • forward 屬性(前進指針) 每一個層都有一個指向表尾方向的前進指針,用於從表頭向表尾方向訪問節點。

    • span屬性(跨度)用於記錄兩個節點之間的距離。

      • 兩個節點之間的跨度越大,它們相距得就越遠。
      • 指向NULL的全部前進指針的跨度都爲0,由於它們沒有連向任何節點。
  • backward屬性(後退指針)用於從表尾向表頭方向訪問節點:跟能夠一次跳過多個節點的前進指針不一樣,由於每一個節點只有一個後退指針,因此每次只能後退至前一個節點。

  • score屬性(分值)是一個double類型的浮點數,跳躍表中的全部節點都按分值從小到大來排序。

  • obj屬性(成員對象)是一個指針,它指向一個字符串對象,而字符串對象則保存着一個SDS值。

在同一個跳躍表中,各個節點保存的成員對象必須是惟一的,可是多個節點保存的分值卻能夠是相同的:分值相同的節點將按照成員對象在字典序中的大小來進行排序,成員對象較小的節點會排在前面(靠近表頭的方向),而成員對象較大的節點則會排在後面(靠近表尾的方向)。

1.2 示例

下圖中分別展現了三個高度爲1層、3層和5層的節點

2.1 跳躍表結構

typedef struct zskiplist {
	//表頭節點和表尾節點
	structz skiplistNode *header,*tail;
    
	//表中節點數量
	unsigned long length;
    
	//表中層數最大的節點的層數
	int level;
}zskiplist;
複製代碼
  • header 屬性指向跳躍表的表頭節點。

  • tail 屬性指向跳躍表的表尾節點。

  • length 屬性記錄跳躍表的長度,即跳躍表目前包含節點的數量。

  • level 屬性記錄目前跳躍表內,層數最大的那個節點的層數。

header和tail指針分別指向跳躍表的表頭和表尾節點,經過這兩個指針,程序定位表頭節點和表尾節點的複雜度爲O(1)經過使用length屬性來記錄節點的數量,程序能夠在O(1)複雜度內返回跳躍表的長度level屬性則用於在O(1)複雜度內獲取跳躍表中層高最大的那個節點的層數量,表頭節點的層高不計算在內。

2.2 示例

6、整數集合

整數集合是集合鍵的底層實現之一,當一個集合只包含整數值元素,而且這個集合的元素數量很少時,Redis就會使用整數集合做爲集合鍵的底層實現。整數集合(intset)是Redis用於保存整數值的集合抽象數據類型,它能夠保存類型爲int16_t、int32_t 或者int64_t 的整數值,而且保證集合中不會出現重複元素。

1.定義

1.1 整數結構

typedef struct intset{
	// 編碼方式
    uint32_t enconding;
    
	// 集合包含的元素數量
	uint32_t length;
    
	// 保存元素的數組    
	int8_t contents[];
}intset;

複製代碼
  • contents contents數組是整數集合的底層實現:整數集合的每一個元素都是contents數組的一個數組項,各個項在數組中按值的大小從小到大有序地排列,而且數組中不包含任何重複項。contents數組的真正類型取決於enconding屬性的值。

  • length 記錄了整數集合包含的元素數量,也便是contents數組的長度。

1.2 示例

2. 升級

當咱們新增的元素類型比原集合元素類型的長度要大時,須要對整數集合進行升級,才能將新元素放入整數集合中。整數集合的升級策略有兩個好處,一個是提高整數集合的靈活性,另外一個是儘量地節約內存。

  1. 根據新元素類型,擴展整數集合底層數組的大小,併爲新元素分配空間。

  2. 將底層數組現有的全部元素都轉成與新元素相同類型的元素,並將轉換後的元素放到正確的位置,放置過程當中,維持整個元素順序都是有序的。

  3. 將新元素添加到整數集合中(保證有序)。

3. 降級

整數集合不支持降級操做,一旦對數組進行了升級,編碼就會一直保持升級後的狀態。

7、壓縮列表

壓縮列表是列表建和哈希鍵的底層實現之一。當一個列表鍵只包含少許列表項,而且每一個列表項要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來作列表鍵的底層實現。當一個哈希鍵只包含少許鍵值對,並且每一個鍵值對的鍵和值要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來作哈希鍵的底層實現

1.定義

壓縮列表是Redis爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序型數據結構。一個壓縮列表能夠包含任意多個節點,每一個節點能夠保存一個字節數組或者一個整數值

壓縮列表各個組成部分的詳細說明:

壓縮列表節點的各個組成部分:

  • previous_entry_ength:節點的previous_entry_length屬性以字節爲單位,記錄了壓縮列表中前一個節點的長度。previous_entry_length屬性的長度能夠是1字節或者5字節。由於節點的previous_entry_length屬性記錄了前一個節點的長度,因此程序能夠經過指針運算,根據當前節點的起始位置來計算出前一個節點的起始位置。

    • 若是前一節點的長度小於254字節,那麼previous_entry_length屬性的長度爲1字節:前一節點的長度就保存在這一個字節裏面

    • 若是前一個節點的長度大於254字節,那麼previous_entry_length屬性的長度爲5字節:其中屬性的第一字節會被設置爲0xFE(十進制254),而以後的四個字節則用於保存前一節點的長度

  • encoding:節點的encoding保存的是節點的content的內容類型以及長度,encoding類型一共有兩種,一種字節數組一種是整數,encoding區域長度爲1字節、2字節或者5字節長。

  • content:節點的content屬性負責保存節點的值,節點值能夠是一個字節數組或者整數,值的類型和長度由節點的encoding屬性決定。


有一種境界叫自黑:聰明絕頂的我再沒用過梳子。

關注微信公衆號‘禿頂記’,聰明絕頂走到黑。

相關文章
相關標籤/搜索