Redis設計與實現學習筆記(一)

埃克斯誒爾發表於2019-05-14

一. 引言

  《Redis設計與實現》一書主要分為四個部分,其中第一個部分主要講的是Redis的底層資料結構與物件的相關知識。

  Redis是一種基於C語言編寫的非關係型資料庫,它的五種基本物件型別分別為:STRING,LIST,SET,HASH,ZSET。然而,對於每一種基本物件資料型別,底層都至少有2種不同的實現方式。

 

二. 簡單動態字串(Simple Dynamic String, SDS)

  SDS是Redis的預設字串表示,包含字串值的鍵值對底層都是由SDS實現的。除了儲存資料庫中的字串值之外,SDS還被用作緩衝區。

示例:

redis>SET msg "hello world"
OK

   當執行上述程式碼之後,Redis會建立一個STRING型別的鍵值對,其中鍵和值均是一個字串物件,鍵物件的底層是一個儲存著字串"msg"的SDS,而值物件的底層是一個儲存著字串"hello world"的SDS。

  每個SDS都結構如下所示

struct sdshdr{
    //記錄buf陣列中已使用的位元組數量(也是SDS所儲存的字串長度) 
    int len;

    //buf陣列中未使用的位元組數量
    int free;

    //位元組陣列,用於儲存字串
    char buf[];       
};

  如上圖所示,SDS遵循C字串以空字元結尾的慣例,但是儲存空字元的一位元組空間不計算在SDS的len屬性中。即對於SDS的結構滿足:buf的長度 = len + free + 1。即當SDS的len=5,free=0位元組時,則buf的長度為 5+0+1=6位元組。

  C字串本身的兩個問題有:1.獲取字串長度的複雜度高  2.由於C字串不記錄自身長度容易造成緩衝區溢位等問題。C字串修改字串時會有大量的記憶體重分配操作,如拼接字串時,如果不進行記憶體重分配,可能會造成緩衝區溢位;進行縮短字串操作時,不進行記憶體重分配釋放不再使用的那部分空間,則會產生記憶體洩露。

  為了解決上述兩個問題,SDS做了一系列的改進操作。

  (1)由於SDS將字串的長度儲存在len屬性中,所以SDS獲取字串長度的時間複雜度為O(1)。

  (2)SDS通過設計兩種空間分配策略來減少字串修改時帶來的記憶體重分配次數,同時杜絕了緩衝區溢位的可能性

SDS的兩種空間分配優化策略:

  SDS的優化策略是通過未使用空間(即free標記的空間)實現的

  (1)空間預分配:用於優化SDS字串增長操作。當SDS的API對SDS進行修改並且需要進行空間擴充套件時,程式不僅會為SDS分配修改所必要的空間,還會為SDS分配額外的未使用空間。其中主要分為兩點:當len<1MB時,程式分配和len同樣大小的未使用空間,即free=len;當len>=1MB時,free=1MB。

  (2)惰性空間釋放:用於優化SDS的字串縮短操作。當SDS的API需要縮短SDS儲存的字串時,程式不會馬上使用記憶體重分配來回收縮短後多出來的空間,而是使用 free 屬性將這些位元組的數量記錄起來,以供將來使用。(縮短重分配操作,並未將來可能有的增長操作進行了優化)。

  

三. 連結串列

  Redis中連結串列可以用來實現列表鍵、釋出與訂閱、慢查詢、監視器等功能。

  連結串列由兩種結構組成,分別是list結構和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;
typedef struct listNode{
    struct listNode *prev;//前置節點
    struct listNode *next;//後置節點
    void *value;//節點值
}listNode;

 

  根據程式碼可以知道,list結構擁有一個指向連結串列表頭和一個指向連結串列表尾的指標,而listNode中有一個前置指標和後置指標,因此,連結串列獲得頭、尾節點的時間複雜度為O(1),且可以從任意一端開始遍歷。此外,list中還存有len屬性儲存連結串列長度,因此獲得連結串列長度的時間複雜度僅為O(1)

 

四. 字典

  Redis中字典可用於實現資料庫和雜湊鍵等。

  字典使用雜湊表作為底層實現,雜湊表dictht和雜湊表節點dictEntry結構如下所示:

typedef struct dictht{
    disctEntry **table;//雜湊表陣列
    unsigned long size;//雜湊表大小
    unsigned long sizemask;//雜湊表大小掩碼,為size-1,用於計算索引值
    unsigned long used;//已有節點數
} dictht;
typedef struct dictEntry{
    void *key;//
    union{//
        void *val;
        unint64_t u64;
        int64_t s64;
    } v;
    struct dicEntry *next;//指向下個雜湊表節點
} dictEntry;    

  由結構程式碼和圖可知,dictht結構中size屬性為雜湊表的總大小,used為雜湊表節點個數;dictEntry節點中儲存了鍵值對和指向下一個節點的指標。而dictht中sizemask屬性總等於size-1,該屬性值用於雜湊演算法。

  字典結構則如下所示:

typedef struct dict{
    dictType *type;//型別特定函式
    void *privdata;//私有資料
    dictht ht[2];//兩個雜湊表
    int rehashidx;//用於標記是否處於rehash狀態
} dict; 

  字典由dict結構表示,其屬性type是指向dictType結構的指標,該結構中儲存了一簇用於操作特定型別鍵值對的函式privdata屬性則儲存了需要傳給這些函式的可選引數rehashIdx則用於標記當前字典是否處於rehash(重新雜湊)狀態,rehashidx=-1時未進行rehash(圖示中略有錯誤,解決衝突時,鏈地址法是將新節點插入頭部,即頭插法,所以應當k2在前,k1在後)

  字典的雜湊演算法:每當一個新鍵值對新增到字典中時,程式需要先根據鍵計算出雜湊值和索引值,再根據索引值將包含鍵值對的雜湊節點放到雜湊表陣列的指定位置。雜湊值使用字典的type中儲存的雜湊函式(hashFunction)計算(當字典被用作資料庫或雜湊鍵(HASH-key)的底層實現時,Redis使用MurmurHash2演算法),而索引值則根據雜湊表的sizemask和雜湊值計算,index = 雜湊值 & sizemask。例,新增鍵的雜湊值為8,則上圖新增鍵在ht[0]索引值為 8 & 3 = 0。

  處理鍵衝突:Redis的雜湊表採用鏈地址法解決鍵衝突的問題,且為了速度考慮,每次都是將新節點新增到連結串列的表頭位置(複雜度為O(1))。

  雜湊表的擴充套件與收縮:負載因子 load_factor = ht[0].used / ht[0].size

    (1)當伺服器未執行BGSAVE命令或者BGREWRITEAOF命令時,且雜湊表的負載因子大於等於1時,自動擴充套件。

    (2)當伺服器正在執行BGSAVE命令或者BGREWRITEAOF命令時,且雜湊表的負載因子大於等於5時,自動擴充套件。

    (3)當雜湊表的負載因子小於0.1時,程式對雜湊表自動收縮。

    之所以有(1)、(2)的區別,是因為在執行這些命令的過程中,Reis需要建立當前伺服器程式的子程式,而大多數作業系統都採用寫時複製技術來優化紫禁城的使用效率;因此,子程式存在期間,伺服器會提高進行擴充套件操作所需的負載因子,儘可能避免子程式存在期間進行雜湊表擴張操作,避免不必要的記憶體寫入,最大限度的節約記憶體。

  漸進式rehash:當程式需要對雜湊表的大小進行擴充套件或者收縮時,需要通過rehash操作來完成。

    (1)字典會為ht[1]的雜湊表分配空間(擴充套件操作,ht[1]大小為第一個大於等於ht[0].used*2的2n;收縮操作,則ht[1]大小為第一個大於等於ht[0].used的2n)。

    (2)將儲存在ht[0]上的鍵值對rehash到ht[1]上(即重新計算鍵的雜湊值和索引值)。

    (3)當ht[0]上的鍵值對全部遷移完畢後,釋放ht[0],並將ht[1]設定ht[0],再建立一個空白雜湊表作為ht[1],為下次rehash準備。

    值得注意的是,rehash操作並不是一次性集中完成的,而是分多次、漸進式的完成。為了避免rehas對伺服器效能造成影響,rehash採取了分而治之的方式,將rehash鍵值對所需的計算工作平攤到對字典的新增、刪除、查詢和更新操作上,從而避免集中式rehash帶來了龐大計算量。

  

五. 跳躍表

  跳躍表是有序集合鍵的底層實現之一,它的結構由zskiplist和zskiplistNode組成,其結構和程式碼如下圖所示

typedef struct zskiplistNode{
    struct zskiplistNode *backward;//後退指標
    double score;//分值
    robj *obj;//成員物件
    struct zskiplistLevel {
        struct zskiplistNode *forward;//前進指標
        unsigned int span;//跨度
    }
} zskiplistNode;

  zskiplist儲存跳躍表資訊,header指向表頭節點,tail指向表尾節點,level為跳躍表中的最大層數(表頭節點層數不算在內),length為跳躍表長度(不包含表頭節點)。

  zskiplistNode為跳躍表節點,level陣列中可以包含多個元素分為多個層(每個跳躍表層高都是1~32之間的整數),每個層都有一個forward前進指標(用於表頭向表尾方向訪問)和一個span跨度(用於記錄兩個節點之間的距離以及記錄排位的,所有指向NULL的前進指標跨度都為0);backward指標用於從表尾向表頭方向遍歷時使用(每次只能後退一個節點);score分值是一個double型別的浮點數,跳躍表中節點都按分值從小到大排序;obj屬性是一個指向字串物件的指標,而字串物件儲存著一個SDS值。

  同一個跳躍表中,多個節點可以包含相同的分值,但每個節點的成員物件必須是唯一的。

  跳躍表中的節點按照分值大小順序排列,當分值相同時,按照成員物件的大小排列。

 

六. 整數集合

  整數集合時Redis中用於儲存整數值的集合抽象資料結構,其結構程式碼和圖示如下所示:

typedef struct intset{
    uint32_t encoding;//編碼方式
    uint32_t length;//集合包含的元素數量
    int8_t contents;//儲存元素的陣列
} intset;

  其中,encoding為intset的編碼方式,length儲存元素的數量,contents陣列是整數集合的底層實現,其內的元素按從小到大的方式儲存。contents陣列的真正型別取決於encoding的值。

  整數集合的升級操作:每當一個型別比整數集合現有所有元素的型別都要長的新元素新增到整數集合中時,整數集合都需要先進行升級操作。

    (1)根據新元素的型別,擴充套件整數集合底層陣列的空間大小,並未新元素分配空間。

    (2)將原來元素轉換為新元素相同的型別,並從後往前依次放置原來的元素(放置過程中需位置底層陣列的有序性質不變)

    (3)將新元素新增到底層陣列中

    從上可知,向整數集合新增新元素的時間複雜度為O(n)。

  升級的好處

    (1)通過自動升級來使用不同型別元素的陣列,提升了整數集合的靈活性

    (2)儘可能節省記憶體。(如組織有在將int32_t型別存入時,原來的int16_t型別陣列才會轉換,不需要預先設定好)

 

七. 壓縮列表

  壓縮列表式列表建和雜湊鍵的底層實現之一,是Redis為了節約記憶體而開發的,是一系列特殊編碼的連續記憶體塊組成的順序型資料結構。其結構如下所示:

  

  zlbytes表示壓縮列表總長度,zltail表示偏移量(用於記錄氣質地址到表尾節點的距離有多少位元組),zllen為壓縮列表節點個數,entry等都是壓縮列表的節點,zlend用於標記壓縮連結串列末端。而壓縮列表節點中,previous_entry_length表示前一個節點長度(該屬性長度可以是1位元組或者5位元組),encoding表示content屬性儲存的資料型別與長度,content負責儲存節點值。

  如果前一個節點長度小於254位元組,previous_entry_length長度為1位元組;如果前一個節點長度大於等於254位元組,previous_entry_length長度為5位元組,後面4個位元組儲存前一個節點長度,第一個位元組的值被設定為0x05。

  壓縮列表從表尾向表頭的遍歷就是基於 previous_entry_length屬性實現的(先要獲得起始地址,再根據zltail獲得指向表尾節點的指標,然後previous_entry_length屬性計算出前一個節點的地址,便可依次從後往前遍歷)。

  由於previous_entry_length屬性記錄前一個節點的長度,且該屬性的長度由前一個節點的長度決定,因此在某些特殊情況下,刪除或者增加節點可能會造成連鎖更新(即特殊情況下產生的連續多次空間擴充套件操作)。例如,原來壓縮列表節點長度都小於254(確切的說是250~253之間),此時將一個長度大於254的節點放到他們之前,便會引起後一個節點previous_entry_length的長度變化,從而使後一個節點長度大於等於254,依次類推,就想多米諾骨牌一樣造成連鎖反應。刪除節點時的特殊情況則剛好相反。

  連鎖更新在最壞情況下複雜度為O(N2),但真正造成這種情況出現的操作並不多見。

相關文章