1. 程式人生 > >redis學習2:redis中的資料結構結構與物件

redis學習2:redis中的資料結構結構與物件

第1章 前言

redis這麼強大,那麼它底層是如何實現的呢?使用了哪些資料結構呢?本文就帶大家來剖析剖析

第2章 簡單動態字串(SDS)

  redis的字串不是直接用c語言的字串,而是用了一種稱為簡單動態字串(SDS)的抽象型別,並將其作為預設字串。

  redis中包含字串值的鍵值對在底層都是由SDS實現的。

2.1 SDS定義

/*
 * 儲存字串物件的結構
 */
struct sdshdr {

    // buf 中已佔用空間的長度
    int len;

    // buf 中剩餘可用空間的長度
    int free;

    // 資料空間
    char buf[];
};

 

  SDS遵循C字串以空字元結尾的慣例,但是那1個位元組不計算在len中。

  可以重用C字串庫函式裡的函式。

 

2.2 SDS與C語言字串的區別

1、常數複雜度獲取字串長度

  C語言如果要獲取字串的長度,需要從第一個字元開始,遍歷整個字串,直到遍歷到\0符號,時間複雜度是O(N),即字串的長度。

  而redis由於已經儲存了字串的長度,因此,時間複雜度是O(1)。

  這樣,避免了獲取大字串長度時時間的緩慢。

 

2、杜絕緩衝區溢位

  C語言給字串開闢一個儲存空間,如果對此儲存空間的使用超過開闢的空間,會導致記憶體溢位。

  例如使用字串拼接等方式時,就很容易出現此問題。而如果每次拼接之前都要計算每個字串的長度,時間上又要耗費很久。

  redis的SDS中內建一個sdscat函式,也是用於字串的拼接。但是在執行操作之前,其會先檢查空間是否足夠

  如果free的值不夠,會再申請記憶體空間,避免溢位。

 

3、減少記憶體分配次數

  C語言的字串長度和底層陣列之間存在關聯,因此字串長度增加時需要再分配儲存空間,避免溢位;字串長度減少時,需要釋放儲存空間,避免記憶體洩漏。

  redis的sds,主要是通過free欄位,來進行判斷。通過未使用空間大小,實現了空間預分配和惰性空間釋放

 

1)空間預分配

  當需要增長字串時,sds不僅會分配足夠的空間用於增長,還會預分配未使用空間。

  分配的規則是,如果增長字串後,新的字串比1MB小,則額外申請字串當前所佔空間的大小作為free值;如果增長後,字串長度超過1MB,則額外申請1MB大小。

  上述機制,避免了redis字串增長情況下頻繁申請空間的情況。每次字串增長之前,sds會先檢查空間是否足夠,如果足夠則直接使用預分配的空間,否則按照上述機制申請使用空間。

/*
 * 對 sds 中 buf 的長度進行擴充套件,確保在函式執行之後,
 * buf 至少會有 addlen + 1 長度的空餘空間
 * (額外的 1 位元組是為 \0 準備的)
 *
 * 返回值
 *  sds :擴充套件成功返回擴充套件後的 sds
 *        擴充套件失敗返回 NULL
 *
 * 複雜度
 *  T = O(N)
 */
sds sdsMakeRoomFor(sds s, size_t addlen) {

    struct sdshdr *sh, *newsh;

    // 獲取 s 目前的空餘空間長度
    size_t free = sdsavail(s);

    size_t len, newlen;

    // s 目前的空餘空間已經足夠,無須再進行擴充套件,直接返回
    if (free >= addlen) return s;

    // 獲取 s 目前已佔用空間的長度
    len = sdslen(s);
    sh = (void*) (s-(sizeof(struct sdshdr)));

    // s 最少需要的長度
    newlen = (len+addlen);

    // 根據新長度,為 s 分配新空間所需的大小
    if (newlen < SDS_MAX_PREALLOC)
        // 如果新長度小於 SDS_MAX_PREALLOC 預設1M
        // 那麼為它分配兩倍於所需長度的空間
        newlen *= 2;
    else
        // 否則,分配長度為目前長度加上 SDS_MAX_PREALLOC
        newlen += SDS_MAX_PREALLOC;
    // T = O(N)
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);

    // 記憶體不足,分配失敗,返回
    if (newsh == NULL) return NULL;

    // 更新 sds 的空餘長度
    newsh->free = newlen - len;

    // 返回 sds
    return newsh->buf;
}

 

2)懶惰空間釋放

  懶惰空間釋放用於優化sds字串縮短的操作

  當需要縮短sds的長度時,並不立即釋放空間,而是使用free來儲存剩餘可用長度,並等待將來使用。

  當有剩餘空間,而有有增長字串操作時,則又會呼叫空間預分配機制。

  當redis記憶體空間不足時,會自動釋放sds中未使用的空間,因此也不需要擔心記憶體洩漏問題。

 

4、二進位制安全

  SDS 的 API 都是二進位制安全的: 所有 SDS API 都會以處理二進位制的方式來處理 SDS 存放在 buf 數組裡的資料, 程式不會對其中的資料做任何限制、過濾、或者假設 —— 資料在寫入時是什麼樣的, 它被讀取時就是什麼樣。

  sds考慮字串長度,是通過len屬性,而不是通過\0來判斷。

 

5、相容部分C語言字串函式

  redis相容c語言對於字串末尾採用\0進行處理,這樣使得其可以複用部分c語言字串函式的程式碼,實現程式碼的精簡性。

 

 

第3章 連結串列

  列表鍵的底層之一是連結串列。(底層也有可能是壓縮列表)

  當列表鍵包含了許多元素,或者元素是比較長的字串的時候,就會用到連結串列作為列表鍵的底層實現。

3.1連結串列和表節點的實現

1、節點結構

/*
 * 雙端連結串列節點
 */
typedef struct listNode {

    // 前置節點
    struct listNode *prev;

    // 後置節點
    struct listNode *next;

    // 節點的值
    void *value;

} listNode;

其中prev指向前一個節點,next指向後一個節點,value儲存著節點本身的值。多個listNode組成雙向連結串列,如下圖所示:

 

2、連結串列結構

/*
 * 雙端連結串列結構
 */
typedef struct list {

    // 表頭節點
    listNode *head;

    // 表尾節點
    listNode *tail;

    // 節點值複製函式
    void *(*dup)(void *ptr);

    // 節點值釋放函式
    void (*free)(void *ptr);

    // 節點值對比函式
    int (*match)(void *ptr, void *key);

    // 連結串列所包含的節點數量
    unsigned long len;

} list;

 

 

連結串列如下圖所示:

 

 

redis的連結串列特性如下:

  1)雙向:每個listNode節點帶有prev和next指標,可以找到前一個節點和後一個節點,具有雙向性。

  2)無環:list連結串列的head節點的prev和tail節點的next指標都是指向null。

  3)帶表頭指標和尾指標:即上述的head和tail,獲取頭指標和尾指標的時間複雜度O(1)。

  4)帶連結串列長度計數器;即list的len屬性,記錄節點個數,因此獲取節點個數的時間複雜度O(1)。

  5)多型:連結串列使用void*指標來儲存節點的值,可以通過list的dup、free、match三個屬性為節點值設定型別特定函式,所以連結串列可以用於儲存不同型別的值。

 

第4章 字典

  字典,又稱符號表、關聯陣列、對映,是一種儲存鍵值對的抽象資料結構。

  每個鍵(key)和唯一的值(value)關聯,鍵是獨一無二的,通過對鍵的操作可以對值進行增刪改查。

  redis中字典應用廣泛,對redis資料庫的增刪改查就是通過字典實現的。即redis資料庫的儲存,和大部分關係型資料庫不同,不採用B+tree進行處理,而是採用hash的方式進行處理。

  字典還是hash鍵的底層實現之一。

  當hash鍵包含了許多元素,或者元素是比較長的字串的時候,就會用到字典作為hash鍵的底層實現。

 

4.1 字典的實現

redis的字典,底層是使用雜湊表實現,每個雜湊表有多個雜湊節點,每個雜湊節點儲存了一個鍵值對。

1、雜湊表

/*
 * 雜湊表
 *
 * 每個字典都使用兩個雜湊表,從而實現漸進式 rehash 。
 */
typedef struct dictht {
    
    // 雜湊表陣列
    dictEntry **table;

    // 雜湊表大小
    unsigned long size;
    
    // 雜湊表大小掩碼,用於計算索引值
    // 總是等於 size - 1
    unsigned long sizemask;

    // 該雜湊表已有節點的數量
    unsigned long used;

} dictht;

  其中,table是一個數組,裡面的每個元素指向dictEntry(雜湊表節點)結構的指標,dictEntry結構是鍵值對的結構;

  size表示雜湊表的大小,也是table陣列的大小;

  used表示table目前已有的鍵值對節點數量;

  sizemask一直等於size-1,該值與雜湊值一起決定一個屬性應該放到table的哪個位置。

  大小為4的空雜湊表結構如下圖(左邊一列的圖)所示:

2、雜湊表節點

/*
 * 雜湊表節點
 */
typedef struct dictEntry {
    
    // 鍵
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下個雜湊表節點,形成連結串列
    struct dictEntry *next;

} dictEntry;
 

  其中,key表示節點的鍵;union表示key對應的值,可以是指標、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用於存放用於處理特定型別的處理函式;

  privdata用於存放私有資料,儲存傳給type內的函式的資料;

  rehash是一個索引,當沒有在rehash進行時,值是-1;

  ht是包含兩個項的陣列,每個項是一個雜湊表,一般情況下只是用ht[0],只有在對ht[0]進行rehash時,才會使用ht[1]。

 

  完整的字典結構如下圖所示:

4.2 雜湊演算法

  要將新的鍵值對加到字典,程式要先對鍵進行雜湊演算法,算出雜湊值和索引值,再根據索引值,把包含新鍵值對的雜湊表節點放到雜湊表陣列指定的索引上。

  redis實現雜湊的程式碼是:

  hash =dict->type->hashFunction(key);
  index = hash& dict->ht[x].sizemask;

  算出來的結果中,index的值是多少,則key會落在table裡面的第index個位置(第一個位置index是0)。

  其中,redis的hashFunction,採用的是murmurhash2演算法,是一種非加密型hash演算法,其具有高速的特點。

 

4.3 鍵衝突解決

  當兩個或者以上的鍵被分配到雜湊表陣列的同一個索引上,則稱這些鍵發生了衝突。

  為了解決此問題,redis採用鏈地址法。被分配到同一個索引上的多個節點可以用單鏈表連線起來。

  因為沒有指向尾節點的指標,所以總是將新節點加在表頭的位置。(O(1)時間)

 

4.4 rehash(重新雜湊)

  隨著操作進行,雜湊表儲存的鍵值對會增加或減少,為了讓雜湊表的負載因子(load factor)維持在一個合理範圍,當一個雜湊表儲存的鍵太多或者太少,需要對雜湊表進行擴充套件或者收縮。擴充套件或收縮雜湊表的過程,就稱為rehash。

  rehash步驟如下:

  1、給字典的ht[1]申請儲存空間,大小取決於要進行的操作,以及ht[0]當前鍵值對的數量(ht[0].used)。假設當前ht[0].used=x。

    如果是擴充套件,則ht[1]的值是第一個大於等於x*2的2n的值。例如x是30,則ht[1]的大小是第一個大於等於30*2的2n的值,即64。

    如果是收縮,則ht[1]的值是第一個大於等於x的2n的值。例如x是30,則ht[1]的大小是第一個大於等於30的2n的值,即32。

  2、將儲存在ht[0]上面的所有鍵值對,rehash到ht[1],即對每個鍵重新採用雜湊演算法的方式計算雜湊值和索引值,再放到相應的ht[1]的表格指定位置。

  3、當ht[0]的所有鍵值對都rehash到ht[1]後,釋放ht[0],並將ht[1]設定為ht[0],再新建一個空的ht[1],用於下一次rehash。

 

  rehash條件:

  負載因子(load factor)計算:

  load_factor =ht[0].used / ht[0].size,即負載因子大小等於當前雜湊表的鍵值對數量,除以當前雜湊表的大小。

 

  擴充套件:

  當以下任一條件滿足,雜湊表會自動進行擴充套件操作:

  1)伺服器目前沒有在執行BGSAVE或者BGREWRITEAOF命令,且負載因子大於等於1。

  2)伺服器目前正在在執行BGSAVE或者BGREWRITEAOF命令,且負載因子大於等於5。

 

  收縮:

  當負載因子小於0.1時,redis自動開始雜湊表的收縮工作。

 

4.5 漸進式rehash

  redis對ht[0]擴充套件或收縮到ht[1]的過程,並不是一次性完成的,而是漸進式、分多次的完成,以避免如果雜湊表中存有大量鍵值對,一次性複製過程中,佔用資源較多,會導致redis服務停用的問題。

  漸進式rehash過程如下:

  1、為ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩張雜湊表。

  2、將字典中的rehashidx設定成0,表示正在rehash。rehashidx的值預設是-1,表示沒有在rehash。

  3、在rehash進行期間,程式處理正常對字典進行增刪改查以外,還會順帶將ht[0]雜湊表上,rehashidx索引上,所有的鍵值對資料rehash到ht[1],並且rehashidx的值加1。

  4、當某個時間節點,全部的ht[0]都遷移到ht[1]後,rehashidx的值重新設定為-1,表示rehash完成。

 

  漸進式rehash採用分而治之的工作方式,將雜湊表的遷移工作所耗費的時間,平攤到增刪改查中,避免集中rehash導致的龐大計算量。

  在rehash期間,對雜湊表的查詢、修改、刪除,會先在ht[0]進行。

  如果ht[0]中沒找到相應的內容,則會去ht[1]查詢,並進行相關的修改、刪除操作。而增加的操作,會直接增加到ht[1]中,目的是讓ht[0]只減不增,加快遷移的速度。

 

4.6 總結

  字典在redis中廣泛應用,包括資料庫和hash資料結構。

  每個字典有兩個雜湊表,一個是正常使用,一個用於rehash期間使用。

  當redis計算雜湊時,採用的是MurmurHash2雜湊演算法。

  雜湊表採用鏈地址法避免鍵的衝突,被分配到同一個地址的鍵會構成一個單向連結串列。

  在rehash對雜湊表進行擴充套件或者收縮過程中,會將所有鍵值對進行遷移,並且這個遷移是漸進式的遷移。

 

 

第5章 跳躍表

  跳躍表(skiplist)是一種有序的資料結構,它通過每個節點中維持多個指向其他節點的指標,從而實現快速訪問。

  跳躍表平均O(logN),最壞O(N),支援順序遍歷查詢。

  在redis中,有序集合(sortedset)的其中一種實現方式就是跳躍表。

  當有序集合的元素較多,或者集合中的元素是比較常的字串,則會使用跳躍表來實現。

 

5.1 跳躍表實現

跳躍表是由各個跳躍表節點組成。

/* ZSETs use a specialized version of Skiplists */
/*
 * 跳躍表節點
 */
typedef struct zskiplistNode {

    // 成員物件
    robj *obj;

    // 分值
    double score;

    // 後退指標
    struct zskiplistNode *backward;

    // 層
    struct zskiplistLevel {

        // 前進指標
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

 

/*
 * 跳躍表
 */
typedef struct zskiplist {

    // 表頭節點和表尾節點
    struct zskiplistNode *header, *tail;

    // 表中節點的數量
    unsigned long length;

    // 表中層數最大的節點的層數
    int level;

} zskiplist;

 

上圖最左邊就是跳躍表的結構:

  header和tail:是跳躍表節點的頭結點和尾節點,

  length:是跳躍表的長度(即跳躍表節點的數量,不含頭結點),

  level:表示層數中最大節點的層數(不計算表頭結點)。

  因此,獲取跳躍表的表頭、表尾、最大層數、長度的時間複雜度都是O(1)。

 

跳躍表節點:

  層:節點中用L1,L2表示各層,每個層都有兩個屬性,前進指標(forward)和跨度(span)。每個節點的層高是1到32的隨機數

  前進指標:用於訪問表尾方向的節點,便於跳躍表正向遍歷節點的時候,查詢下一個節點位置;

  跨度:記錄前進指標所指的節點和當前節點的距離,用於計算排位,訪問過程中,將沿途訪問的所有層的跨度累計起來,得到的結果就是跳躍表的排位。

  後退指標:節點中用BW來表示,其指向當前節點的前一個節點,用於反向遍歷時候使用。每次只能後退至前一個節點。

  分值:各節點中的數字就是分值,跳躍表中,節點按照分值從小到大排列

  成員物件:各個節點中,o1,o2是節點所儲存的成員物件。是一個指標,指向一個字串物件。

  表頭節點也有後退指標,分值,成員物件,因為不會被用到,所以圖中省略。

  分值可以相同,成員物件必須唯一。

  分值相同時,按照成員物件的字典序從小到大排。

 

跨度用來計算排位:

 

第6章 整數集合

  整數集合(intset)是集合鍵的底層實現之一,當一個集合只包含整數值元素,並且這個集合的元素數量不多時,Redis就會使用整數集合作為集合鍵的底層實現。

  它可以儲存型別為int16_t、int32_t或者int64_t的整數值,並且保證集合中不會出現重複元素。

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

 

  contents陣列是整數集合的底層實現:整數集合的每個元素都是contents陣列的一個數組項,各個項在陣列中按值的大小從小到大有序地排列,並且陣列中不包含任何重複項

 

升級:

每當我們要將一個新元素新增到整數集合裡面,並且新元素的型別比整數集合現有所有元素的的型別都要長時,整數集合需要先進行升級,然後才能將新元素新增到整數集合裡面。

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

  將底層陣列現有的所有元素都轉換成與新元素相同的型別,並將型別轉換後的元素放置到正確的位上(從後往前),而且在放置元素的過程中,需要繼續位置底層陣列的有序性質不變。

  將新元素新增到底層數組裡面。

 

  將encoding屬性更改。

  整數集合新增新元素的時間複雜度為O(N)。

  因為引發升級的元素要麼最大要麼最小,所有它的位置要麼是0要麼是length-1。

 

升級的好處:

  提升整數集合的靈活性,可以隨意將int16,int32,int64的值放入集合。

  儘可能地節約記憶體

 

降級:

  整數集合不支援降級操作

 

 

第7章 壓縮列表

  壓縮列表(ziplist)是列表鍵和雜湊鍵的底層實現之一。

  當一個列表鍵只包含少量列表項,並且每個列表項要麼就是小整數值,要麼就是長度比較短的字串,那麼Redis就會使用壓縮列表來做列表鍵的底層實現。

  壓縮列表是Redis為了節約記憶體而開發的,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構。

 

一個壓縮列表有一下幾個組成部分:

 

 

每個壓縮列表節點可以儲存一個位元組陣列或者一個整數值,而每個節點都由previous_entry_length、encoding、content三個部分組成。

 

 

previous_entry_length:

  節點的previous_entry_length屬性以位元組為單位,記錄了壓縮列表中前一個節點的長度

  因為有了這個長度,所以程式可以通過指標運算,根據當前節點的起始地址來計算出前一個節點的起始地址。

  壓縮列表的從表尾向表頭遍歷操作就是使用這一原理實現的。

 

encoding:

  節點的encoding屬性記錄了節點的content屬性所儲存資料的型別以及長度

 

content:

  節點的content屬性負責儲存節點的值,節點值可以是一個位元組陣列或者整數,值的型別和長度由節點的encoding屬性決定。

 

連鎖更新:

  由於previous_entry_length可能是一個或者五個位元組,所有插入和刪除操作帶來的連鎖更新在最壞情況下需要對壓縮列表執行N次空間重分配操作,而每次空間重分配的最壞複雜度為O(N),所有連鎖更新的最壞複雜度為O(N^2)。

  但連鎖更新的條件比較苛刻,而且壓縮列表中的資料量也不會太多,因此不需要注意效能問題,平均複雜度仍然是O(N)。

 

第8章 物件

  Redis物件系統中包含字串物件、列表物件、雜湊物件、集合物件、有序集合物件。

  實現了基於引用計數的記憶體回收機制。

 

8.1 物件的型別與編碼

  Redis使用物件來表示資料庫中的鍵和值。

/* 
 * Redis 物件 
 */  
typedef struct redisObject {  
  
    // 型別  
    unsigned type:4;          
  
    // 不使用(對齊位)  
    unsigned notused:2;  
  
    // 編碼方式  
    unsigned encoding:4;  
  
    // LRU 時間(相對於 server.lruclock)  
    unsigned lru:22;  
  
    // 引用計數  
    int refcount;  
  
    // 指向物件的值  
    void *ptr;  
  
} robj;

 

  type表示了該物件的物件型別:

    REDIS_STRING 字串物件

    REDIS_LIST 列表物件

    REDIS_HASH 雜湊物件

    REDIS_SET 集合物件

    REDIS_ZSET 有序集合物件

 

 

 

  SET msg “Hello World”

  TYPE msg

  輸出 string

 

  OBJECT ENCODING msg

  輸出 embstr

 

8.2 字串物件

  字串物件的編碼可以是int、raw、embstr

  如果值是字串物件,且長度大於32位元組,那麼編碼為raw

  如果值是字串物件,且長度小於等於32位元組,那麼編碼為embstr

  

  embstr的建立只需分配一次記憶體,而raw為兩次,分別建立redisObject結構和sdshdr結構。

  相對地,embstr釋放記憶體的次數也由兩次變為一次。

  embstr的objet和sds放在一起,更好地利用快取帶來的優勢。

  redis並未提供任何修改embstr的方式,即embstr是隻讀的形式。對embstr的修改實際上是先轉換為raw再進行修改。

 

 

8.3 列表物件

  列表物件的編碼可以是ziplist或者linkedlist。

  當列表物件同時滿足下面兩個條件時,則使用ziplist:

    所有字串元素的長度都小於64位元組

    元素數量小於512

  ziplist是一種壓縮列表,它的好處是更能節省記憶體空間,因為它所儲存的內容都是在連續的記憶體區域當中的。當列表物件元素不大,每個元素也不大的時候,就採用ziplist儲存。但當資料量過大時就ziplist就不是那麼好用了。因為為了保證他儲存內容在記憶體中的連續性,插入的複雜度是O(N),即每次插入都會重新進行realloc。如下圖所示,物件結構中ptr所指向的就是一個ziplist。整個ziplist只需要malloc一次,它們在記憶體中是一塊連續的區域。

  linkedlist是一種雙向連結串列。它的結構比較簡單,節點中存放pre和next兩個指標,還有節點相關的資訊。當每增加一個node的時候,就需要重新malloc一塊記憶體。

8.4 雜湊物件

  雜湊物件的底層實現可以是ziplist或者hashtable。

  當列表物件同時滿足下面兩個條件時,則使用ziplist:

    所有鍵值對的鍵和值的字串度都小於64位元組

    鍵值對數量小於512

 

8.5 集合物件

  集合物件的編碼可以是intset或者hashtable。

  滿足下面兩個條件,使用intset:

    所以有元素都是整數值

    元素數量不超過512個

 

8.6 有序集合物件

  有序集合的編碼可能兩種,一種是ziplist,另一種是skiplist與dict的結合。

  dict字典為有序集合建立了一個成員到分值的對映。給一用O(1)的時間查到分值。

  當有序集合物件同時滿足下面兩個條件時,則使用ziplist:

    所有元素的字串度都小於64位元組

    元素數量小於128

原文連結:https://www.cnblogs.com/mengchunchen/p/9025139.html

主要參考文章:《redis設計與實現》