Redis資料結構及物件(上)

王老魔發表於2019-05-06

Redis資料結構及物件(上)

Redis物件及底層資料結構


  redis一共有五大常用的物件,用type命令即可檢視當前鍵對應的物件型別,分別是string(字串)、hash(雜湊)、list(列表)、set(集合)、zset(有序集合),但是這些只是對外的資料結構,實際上每一個物件都有兩到三種不同底層資料結構實現,可以通過object encoding命令檢視鍵值對應的底層資料結構實現,

下表即為每種物件所對應的底層資料結構實現。

型別 編碼 底層資料結構
string int 整數值
string raw 簡單動態字串
string embstr 用embstr編碼的簡單動態字串
hash ziplist 壓縮列表
hash hashtable 字典
list ziplist 壓縮列表
list linkedlist 雙端列表
set intset 整數集合
set hashtable 字典
zset ziplist 壓縮列表
zset skiplist 跳錶和字典

簡單動態字串(SDS)


定義

  redis並沒有使用C字串,而是使用了名為簡單動態字串(SDS)的結構,SDS的定義如下:

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

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

    // 位元組陣列,用於儲存字串
    char buf[];
};
複製程式碼
  • len:記錄字串長度,大小為4個位元組
  • free: 記錄buf[]中未被使用位元組數量,大小為4個位元組
  • buf[]: 儲存字串,大小為字串大小+1,因為buf[]最後一個位元組儲存'\0' 所以sds的總大小為 = 4 + 4 + size(str) + 1
    sds圖示

SDS的作用

  那麼redis為什麼要使用看起來更佔空間的SDS結構呢?主要有以下幾個原因:

  1. O(1)複雜度獲得string的長度  相比於C字串需要遍歷string才能獲得長度(複雜度O(N)),SDS直接查詢len的數值即可。
  2. 防止緩衝區溢位  當修改C字串時,如果沒有分配夠足夠的記憶體,很容易造成緩衝區溢位。而使用SDS結構,當修改字串時,會自動檢測當前記憶體是否足夠,如果記憶體不夠,則會擴充套件SDS的空間,從而避免了緩衝區溢位。
  3. 減少修改字串帶來的頻繁的記憶體分配  每次增長或縮短C字串,都需要重新分配記憶體,而redis經常被用在資料修改頻繁的場合,所以SDS採用了兩種策略從而避免了頻繁的記憶體分配。  ①空間預分配   如上文所述,SDS會自動分配記憶體,如果修改後字串記憶體佔用小於1MB,則會分配同樣大小的未使用記憶體空間。(eg len: 20kb free: 10kb→ len: 40kb free 40kb),如果大於1MB,則分配1MB未使用記憶體空間。如此一來就可以避免因為字串增長帶來的頻繁空間分配。  ②惰性刪除   當縮短字串時,SDS並沒有釋放掉相應的記憶體,而是保留下來,用free記錄未使用的空間,為以後的增長字串做準備。
  4. 二進位制安全  SDS會以處理二進位制資料的形式存取buf中的內容,從而讓SDS不僅可以儲存任意編碼的文字資訊,還可以儲存諸如圖片、視訊、壓縮檔案等二進位制資料。

雙端列表


定義

  雙端列表作為一種常用的資料結構,當一個list的長度超過512時,那麼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;
複製程式碼

  可以看到雙端列表是一個無環雙端帶表頭表尾節點的連結串列。

字典


定義

雜湊表Hash table,也叫雜湊表),是根據鍵而直接訪問在記憶體儲存位置的資料結構。也就是說,它通過計算一個關於鍵值的函式,將所需查詢的資料對映到表中一個位置來訪問記錄,這加快了查詢速度。這個對映函式稱做雜湊函式,存放記錄的陣列稱做雜湊表

  當hashtable的型別無法滿足ziplist的條件時(元素型別小於512且所有值都小於64位元組時),redis會使用字典作為hashtable的底層資料結構實現。redis的字典(dict)中維護了兩個雜湊表(table),而每個雜湊表包含了多個雜湊表節點(entry)。下面分別來介紹這三個物件。

雜湊表節點

typedef struct dictEntry {

    // 鍵
    void *key;

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

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

} dictEntry;
複製程式碼
  • key:鍵值對中的鍵。
  • v: 鍵值對中的值,可以看到值可以為一個指標,或者是一個uint64整數或者int64整數。
  • next:是為了用鏈地址法解決hash衝突。

雜湊表

typedef struct dictht {

   // 雜湊表陣列
   dictEntry **table;

   // 雜湊表大小
   unsigned long size;

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

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

} dictht;
複製程式碼
  • table:是一個儲存著指向所有節點指標的陣列。
  • size: 記錄了table陣列的大小。
  • sizemask: 用於和hash值一起計算索引值(index = hash & sizemask )

字典

typedef struct dict {

   // 型別特定函式
   dictType *type;

   // 私有資料
   void *privdata;

   // 雜湊表
   dictht ht[2];

   // rehash 索引
   // 當 rehash 不在進行時,值為 -1
   int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;
複製程式碼
  • type 屬性和 privdata 屬性是針對不同型別的鍵值對, 為建立多型字典而設定的。
  • 字典內部有兩個雜湊表,這樣做的目的是為rehash做準備。
    字典圖示

hash演算法

  當在雜湊表中存取資料時,首先需要用hash演算法算出鍵值對中的鍵所對應的hash值,然後再根據根據table陣列的大小取模,計算出對應的索引值,再繼續接下來的操作。redis使用了MurmurHash2 演算法來計算鍵的雜湊值,又使用了快速冪取模演算法降低了取模的複雜度。整個過程如下:

hash = dict->type->hashFunction(k0);
index = hash & dict->ht[0].sizemask;
複製程式碼

  當hash衝突發生時則採用鏈地址法解決hash衝突。

rehash

  當雜湊表儲存的鍵值對越來越多時,雜湊表的負載因子(load factor = used / size)越來越大, 原本O(1)複雜度的查詢也會漸漸趨向於O(N),為了保證雜湊表的負載因子在一定的範圍之內。redis需要動態的調整table陣列的大小,其中最重要的便是rehash過程。rehash分以下的幾個步驟:

  1. 為字典的 ht[1] 雜湊表分配空間,需要注意的是新的size必須是2^n,這主要是為了配合快速冪取模演算法。
  2. 將ht[0]上的鍵值對rehash到ht[1]上,即重新計算ht[0]上所有鍵值對的hash值和索引值,然後分配到ht[1]上,當原來的雜湊表資料量很大時可能會引起執行緒的阻塞,所以redis採用漸進式的rehash方式。
  3. ht[0]表釋放,原子性的替換ht[1]至ht[0],並建立一個空的雜湊表分配至ht[1]

漸進式rehash

  redis的rehash過程並不是一次性集中rehash,而是分批間隔式的,在dict中的rehashidx便是為此服務。   相較於一次性的rehash,漸進式的rehash多了下面這些步驟:

  1. 開始rehash時,將rehashidx置為0。
  2. 當完成了一次rehash後,將rehashidx自增1,直到遍歷完所有的table陣列。
  3. 在rehash過程中,如果有對字典進行增加,則只增加ht[1],如果是查詢,則先查詢ht[0],如果找不到則去查詢ht[1],而如果是刪除和更新,則ht[0]和ht[1]同步操作。
  4. 完成所有rehash後,將rehashidx置為-1。

  這是比較典型的分而治之的思想,將一次性集中作業分散,降低了系統的風險。

跳躍表


定義

  跳錶的的查詢複雜度為平均O(logN)/最壞O(N)。在很多場合下作為替代平衡樹的資料結構,在redis中,如果有序集合的屬性不滿足ziplist的要求,則將跳錶作為有序集合的底層實現。

跳錶圖示
  上圖即為一個完整的跳錶,其中有幾點比較重要,這個跳錶一共有三個節點再加上一個頭節點,最高有五層。一個跳躍表包含了兩種物件,一個是跳躍表節點,一個是跳躍表。

跳躍表節點

typedef struct zskiplistNode {
    // 後退指標
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成員物件
    robj *obj;

    // 層
    struct zskiplistLevel {

        // 前進指標
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;
複製程式碼
  • backward:後退指標,和雙端列表一樣,指向上一個節點。
  • score:分值,有序列表的排序依據。
  • obj:成員物件,實際上為一個SDS,在有序集合中分值可以重複,但成員物件不能重複。
  • level:層,跳錶的關鍵所在,在條表中每一層包含了1到n個節點,在有序的情況下,可以快速遍歷陣列。
  • forward:下一個節點的物件,這裡的下一個代表是第一個或者是第n個。
  • span: 下一個節點和現在節點的距離。

跳躍表

typedef struct zskiplist {

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

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

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

} zskiplist;
複製程式碼

跳躍表中儲存了頭尾節點,方便遍歷,還儲存了節點的數量,可以在O(1) 複雜度內返回跳躍表的長度。

整數集合


定義

  當集合的值全為整數且集合的長度不超過512時,redis採用整數集合作為集合的底層資料結構。

typedef struct intset {

    // 編碼方式
    uint32_t encoding;

    // 集合包含的元素數量
    uint32_t length;

    // 儲存元素的陣列
    int8_t contents[];

} intset;
複製程式碼
  • encoding:整數集合中元素的編碼方式

INTSET_ENC_INT16 , contents 就是一個 int16_t 型別的陣列(最小值為 -32,768 ,最大值為 32,767 )。 INTSET_ENC_INT32 , contents 就是一個 int32_t 型別的陣列(最小值為 -2,147,483,648 ,最大值為 2,147,483,647 )。 INTSET_ENC_INT64 , contents 就是一個 int64_t 型別的陣列(最小值為 -9,223,372,036,854,775,808 ,最大值為 9,223,372,036,854,775,807 )。

  • length:數量
  • contents:集合元素 雖然contents看起來是int8_t,但是它的具體內容的存取還是按encoding的方式完成。

升級

  redis採用多種編碼的方式,主要還是為了省記憶體。當集合中加入了不符合當前集合編碼的數字時,陣列集合會自動更新至能匹配到的編碼,值得注意的是,這種升級是不可逆的,只能由小往大,不能降級。如此一來,就能夠在存放小資料時,剩下很大的空間,而且也不必為編碼不匹配的事情而煩惱了。

壓縮列表


  壓縮列表是redis又一個為了節省記憶體所做的優化,是list/hash/zset的底層資料結構之一,當資料值不大且數量較低時,redis都會使用壓縮列表。

壓縮列表圖示

  • zlbytes:記錄整個壓縮列表佔用的記憶體位元組數:在對壓縮列表進行記憶體重分配, 或者計算 zlend 的位置時使用。
  • zltail:記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少位元組: 通過這個偏移量,程式無須遍歷整個壓縮列表就可以確定表尾節點的地址。
  • zllen:記錄了壓縮列表包含的節點數量: 當這個屬性的值小於 UINT16_MAX (65535)時, 這個屬性的值就是壓縮列表包含節點的數量; 當這個值等於 UINT16_MAX 時, 節點的真實數量需要遍歷整個壓縮列表才能計算得出。
  • entryX:壓縮列表包含的各個節點,節點的長度由節點儲存的內容決定。
  • zlend:特殊值 0xFF (十進位制 255 ),用於標記。壓縮列表的末端。

  壓縮列表和雙端列表有些類似,不過一個用指標銜接起來,一個則是用陣列和長度銜接起來。下面來看一看壓縮列表節點的定義:

節點圖示

  • prevrawlen:前置節點的長度,相當於雙端列表中的前置指標,通過它可以計算出前置節點的地址。
  • coding: 和正數集合類似,是為了表明content中是何種資料
  • content: 資料

總結


  本文對於redis常見的資料結構及其底層實現進行了分析和梳理,希望能夠理清這些底層資料結構對於redis高效能的作用和影響。

相關文章