1. 程式人生 > >面試官:你看過Redis資料結構底層實現嗎?

面試官:你看過Redis資料結構底層實現嗎?

面試中,redis也是很受面試官親睞的一部分。我向在這裡講的是redis的底層資料結構,而不是你理解的五大資料結構。你有沒有想過redis底層是怎樣的資料結構呢,他們和我們java中的HashMap、List、等使用的資料結構有什麼區別呢。

1. 字串處理(string)

我們都知道redis是用C語言寫,但是C語言處理字串和陣列的成本是很高的,下面我分別說幾個例子。

沒有資料結構支撐的幾個問題

  1. 及其容易造成緩衝區溢位問題,比如用strcat(),在用這個函式之前必須要先給目標變數分配足夠的空間,否則就會溢位。
  2. 如果要獲取字串的長度,沒有資料結構的支撐,可能就需要遍歷,它的複雜度是O(N)
  3. 記憶體重分配。C字串的每次變更(曾長或縮短)都會對陣列作記憶體重分配。同樣,如果是縮短,沒有處理好多餘的空間,也會造成記憶體洩漏。

好了,Redis自己構建了一種名叫Simple dynamic string(SDS)的資料結構,他分別對這幾個問題作了處理。我們先來看看它的結構原始碼:

struct sdshdr{
     //記錄buf陣列中已使用位元組的數量
     //等於 SDS 儲存字串的長度
     int len;
     //記錄 buf 陣列中未使用位元組的數量
     int free;
     //位元組陣列,用於儲存字串
     char buf[];
}

再來說說它的優點:

  1. 開發者不用擔心字串變更造成的記憶體溢位問題。
  2. 常數時間複雜度獲取字串長度len欄位
  3. 空間預分配free欄位,會預設留夠一定的空間防止多次重分配記憶體。

更多瞭解:https://redis.io/topics/internals-sds

這就是string的底層實現,更是redis對所有字串資料的處理方式(SDS會被巢狀到別的資料結構裡使用)。

2. 連結串列

Redis的連結串列在雙向連結串列上擴充套件了頭、尾節點、元素數等屬性。

2.1 原始碼

ListNode節點資料結構:

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

連結串列資料結構:

typedef struct list{
     //表頭節點
     listNode *head;
     //表尾節點
     listNode *tail;
     //連結串列所包含的節點數量
     unsigned long len;
     //節點值複製函式
     void (*free) (void *ptr);
     //節點值釋放函式
     void (*free) (void *ptr);
     //節點值對比函式
     int (*match) (void *ptr,void *key);
}list;

從上面可以看到,Redis的連結串列有這幾個特點:

  1. 可以直接獲得頭、尾節點。
  2. 常數時間複雜度得到連結串列長度。
  3. 是雙向連結串列。

3. 字典(Hash)

Redis的Hash,就是在陣列+連結串列的基礎上,進行了一些rehash優化等。

3.1 資料結構原始碼

雜湊表:

typedef struct dictht {
    // 雜湊表陣列
    dictEntry **table;
    // 雜湊表大小
    unsigned long size;
    // 雜湊表大小掩碼,用於計算索引值
    // 總是等於 size - 1
    unsigned long sizemask;
    // 該雜湊表已有節點的數量
    unsigned long used;
} dictht;

Hash表節點:

typedef struct dictEntry {
    // 鍵
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下個雜湊表節點,形成連結串列
    struct dictEntry *next;  // 單鏈表結構
} dictEntry;

字典:

typedef struct dict {
    // 型別特定函式
    dictType *type;
    // 私有資料
    void *privdata;
    // 雜湊表
    dictht ht[2];
    // rehash 索引
    // 當 rehash 不在進行時,值為 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

可以看出:

  1. Reids的Hash採用鏈地址法來處理衝突,然後它沒有使用紅黑樹優化。
  2. 雜湊表節點採用單鏈表結構。
  3. rehash優化。

下面我們講一下它的rehash優化。

3.2 rehash

當雜湊表的鍵對泰國或者太少,就需要對雜湊表的大小進行調整,redis是如何調整的呢?

  1. 我們仔細可以看到dict結構裡有個欄位dictht ht[2]代表有兩個dictht陣列。第一步就是為ht[1]雜湊表分配空間,大小取決於ht[0]當前使用的情況。
  2. 將儲存在ht[0]中的資料rehash(重新計算雜湊值)到ht[1]上。
  3. 當ht[0]中所有鍵值對都遷移到ht[1]後,釋放ht[0],將ht[1]設定為ht[0],並ht[1]初始化,為下一次rehash做準備。

3.3 漸進式rehash

我們在3.2中看到,redis處理rehash的流程,但是更細一點的講,它如何進行資料遷的呢?

這就涉及到了漸進式rehash,redis考慮到大量資料遷移帶來的cpu繁忙(可能導致一段時間內停止服務),所以採用了漸進式rehash的方案。步驟如下:

  1. 為ht[1]分配空間,同時持有兩個雜湊表(一個空表、一個有資料)。
  2. 維持一個技術器rehashidx,初始值0。
  3. 每次對字典增刪改查,會順帶將ht[0]中的資料遷移到ht[1],rehashidx++(注意:ht[0]中的資料是隻減不增的)。
  4. 直到rehash操作完成,rehashidx值設為-1。

它的好處:採用分而治之的思想,將龐大的遷移工作量劃分到每一次CURD中,避免了服務繁忙。

4. 跳躍表

這個資料結構是我面試中見過最多的,它其實特別簡單。學過的人可能都知道,它和平衡樹效能很相似,但為什麼不用平衡樹而用skipList呢?

4.1 skipList & AVL 之間的選擇

  1. 從演算法實現難度上來比較,skiplist比平衡樹要簡單得多。
  2. 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯複雜,而skiplist的插入和刪除只需要修改相鄰節點的指標,操作簡單又快速。
  3. 查詢單個key,skiplist和平衡樹的時間複雜度都為O(log n),大體相當。
  4. 在做範圍查詢的時候,平衡樹比skiplist操作要複雜。
  5. skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的。

可以看到,skipList中的元素是有序的,所以跳躍表在redis中用在有序集合鍵、叢集節點內部資料結構

4.2 原始碼

跳躍表節點:

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

跳躍表:

typedef struct zskiplist {
 
    // 表頭節點和表尾節點
    struct zskiplistNode *header, *tail;
 
    // 表中節點的數量
    unsigned long length;
 
    // 表中層數最大的節點的層數
    int level;
 
} zskiplist;

它有幾個概念:

4.2.1 層(level[])

層,也就是level[]欄位,層的數量越多,訪問節點速度越快。(因為它相當於是索引,層數越多,它索引就越細,就能很快找到索引值)

4.2.2 前進指標(forward)

層中有一個forward欄位,用於從表頭向表尾方向訪問。

4.2.3 跨度(span)

用於記錄兩個節點之間的距離

4.2.4 後退指標(backward)

用於從表尾向表頭方向訪問。

案例

level0    1---------->5
level1    1---->3---->5
level2    1->2->3->4->5->6->7->8

比如我要找鍵為6的元素,在level0中直接定位到5,然後再往後走一個元素就找到了。

5. 整數集合(intset)

Reids對整數儲存專門作了優化,intset就是redis用於儲存整數值的集合資料結構。當一個結合中只包含整數元素,redis就會用這個來儲存。

127.0.0.1:6379[2]> sadd number 1 2 3 4 5 6
(integer) 6
127.0.0.1:6379[2]> object encoding number
"intset"

原始碼

intset資料結構:

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

你肯定很好奇編碼方式(encoding)欄位是幹嘛用的呢?

  • 如果 encoding 屬性的值為 INTSET_ENC_INT16 , 那麼 contents 就是一個 int16_t 型別的陣列, 數組裡的每個項都是一個 int16_t 型別的整數值 (最小值為 -32,768 ,最大值為 32,767 )。
  • 如果 encoding 屬性的值為 INTSET_ENC_INT32 , 那麼 contents 就是一個 int32_t 型別的陣列, 數組裡的每個項都是一個 int32_t 型別的整數值 (最小值為 -2,147,483,648 ,最大值為 2,147,483,647 )。
  • 如果 encoding 屬性的值為 INTSET_ENC_INT64 , 那麼 contents 就是一個 int64_t 型別的陣列, 數組裡的每個項都是一個 int64_t 型別的整數值 (最小值為 -9,223,372,036,854,775,808 ,最大值為 9,223,372,036,854,775,807 )。

說白了就是根據contents欄位來判斷用哪個int型別更好,也就是對int儲存作了優化。

說到優化,那redis如何作的呢?就涉及到了升級。

5.1 encoding升級

如果我們有個Int16型別的整數集合,現在要將65535(int32)加進這個集合,int16是儲存不下的,所以就要對整數集合進行升級。

它是怎麼升級的呢(過程)?

假如現在有2個int16的元素:1和2,新加入1個int32位的元素65535。

  1. 記憶體重分配,新加入後應該是3個元素,所以分配3*32-1=95位。
  2. 選擇最大的數65535, 放到(95-32+1, 95)位這個記憶體段中,然後2放到(95-32-32+1+1, 95-32)位...依次類推。
升級的好處是什麼呢?
  1. 提高了整數集合的靈活性。
  2. 儘可能節約記憶體(能用小的就不用大的)。
5.2 不支援降級

按照上面的例子,如果我把65535又刪掉,encoding會不會又回到Int16呢,答案是不會的。官方沒有給出理由,我覺得應該是降低效能消耗吧,畢竟調整一次是O(N)的時間複雜度。

6. 壓縮列表(ziplist)

ziplist是redis為了節約記憶體而開發的順序型資料結構。它被用在列表鍵和雜湊鍵中。一般用於小資料儲存。

引用https://segmentfault.com/a/1190000016901154中的兩個圖:

6.1 原始碼

ziplist沒有明確定義結構體,這裡只作大概的演示。

typedef struct entry {
     /*前一個元素長度需要空間和前一個元素長度*/
    unsigned int prevlengh;
     /*元素內容編碼*/
    unsigned char encoding;
     /*元素實際內容*/
    unsigned char *data;
}zlentry;
typedef struct ziplist{
     /*ziplist分配的記憶體大小*/
     uint32_t zlbytes;
     /*達到尾部的偏移量*/
     uint32_t zltail;
     /*儲存元素實體個數*/
     uint16_t zllen;
     /*儲存內容實體元素*/
     unsigned char* entry[];
     /*尾部標識*/
     unsigned char zlend;
}ziplist;

第一次看可能會特別矇蔽,你細細的把我這段話看完就一定能懂。

Entry的分析

entry結構體裡面有三個重要的欄位:

  1. previous_entry_length: 這個欄位記錄了ziplist中前一個節點的長度,什麼意思?就是說通過該屬性可以進行指標運算達到表尾向表頭遍歷,這個欄位還有一個大問題下面會講。
  2. encoding:記錄了資料型別(int16? string?)和長度。
  3. data/content: 記錄資料。
連鎖更新
previous_entry_length欄位的分析

上面有說到,previous_entry_length這個欄位存放上個節點的長度,那預設長度給分配多少呢?redis是這樣分的,如果前節點長度小於254,就分配1位元組,大於的話分配5位元組,那問題就來了。

如果前一個節點的長度剛開始小於254位元組,後來大於254,那不就存放不下了嗎? 這就涉及到previous_entry_length的更新,但是改一個肯定不行阿,後面的節點記憶體資訊都需要改。所以就需要重新分配記憶體,然後連鎖更新包括該受影響節點後面的所有節點。

除了增加新節點會引發連鎖更新、刪除節點也會觸發。

7. 快速列表(quicklist)

一個由ziplist組成的雙向連結串列。但是一個quicklist可以有多個quicklist節點,它很像B樹的儲存方式。是在redis3.2版本中新加的資料結構,用在列表的底層實現。

結構體原始碼

表頭結構:

typedef struct quicklist {
    //指向頭部(最左邊)quicklist節點的指標
    quicklistNode *head;
 
    //指向尾部(最右邊)quicklist節點的指標
    quicklistNode *tail;
 
    //ziplist中的entry節點計數器
    unsigned long count;        /* total count of all entries in all ziplists */
 
    //quicklist的quicklistNode節點計數器
    unsigned int len;           /* number of quicklistNodes */
 
    //儲存ziplist的大小,配置檔案設定,佔16bits
    int fill : 16;              /* fill factor for individual nodes */
 
    //儲存壓縮程度值,配置檔案設定,佔16bits,0表示不壓縮
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

quicklist節點結構:

typedef struct quicklistNode {
    struct quicklistNode *prev;     //前驅節點指標
    struct quicklistNode *next;     //後繼節點指標
 
    //不設定壓縮資料引數recompress時指向一個ziplist結構
    //設定壓縮資料引數recompress指向quicklistLZF結構
    unsigned char *zl;
 
    //壓縮列表ziplist的總長度
    unsigned int sz;                  /* ziplist size in bytes */
 
    //ziplist中包的節點數,佔16 bits長度
    unsigned int count : 16;          /* count of items in ziplist */
 
    //表示是否採用了LZF壓縮演算法壓縮quicklist節點,1表示壓縮過,2表示沒壓縮,佔2 bits長度
    unsigned int encoding : 2;        /* RAW==1 or LZF==2 */
 
    //表示一個quicklistNode節點是否採用ziplist結構儲存資料,2表示壓縮了,1表示沒壓縮,預設是2,佔2bits長度
    unsigned int container : 2;       /* NONE==1 or ZIPLIST==2 */
 
    //標記quicklist節點的ziplist之前是否被解壓縮過,佔1bit長度
    //如果recompress為1,則等待被再次壓縮
    unsigned int recompress : 1; /* was this node previous compressed? */
 
    //測試時使用
    unsigned int attempted_compress : 1; /* node can't compress; too small */
 
    //額外擴充套件位,佔10bits長度
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
相關配置

在redis.conf中的ADVANCED CONFIG部分:

list-max-ziplist-size -2
list-compress-depth 0
list-max-ziplist-size引數

我們來詳細解釋一下list-max-ziplist-size這個引數的含義。它可以取正值,也可以取負值。

當取正值的時候,表示按照資料項個數來限定每個quicklist節點上的ziplist長度。比如,當這個引數配置成5的時候,表示每個quicklist節點的ziplist最多包含5個數據項。

當取負值的時候,表示按照佔用位元組數來限定每個quicklist節點上的ziplist長度。這時,它只能取-1到-5這五個值,每個值含義如下:

-5: 每個quicklist節點上的ziplist大小不能超過64 Kb。(注:1kb => 1024 bytes)

-4: 每個quicklist節點上的ziplist大小不能超過32 Kb。

-3: 每個quicklist節點上的ziplist大小不能超過16 Kb。

-2: 每個quicklist節點上的ziplist大小不能超過8 Kb。(-2是Redis給出的預設值)

list-compress-depth引數

這個引數表示一個quicklist兩端不被壓縮的節點個數。注:這裡的節點個數是指quicklist雙向連結串列的節點個數,而不是指ziplist裡面的資料項個數。實際上,一個quicklist節點上的ziplist,如果被壓縮,就是整體被壓縮的。

引數list-compress-depth的取值含義如下:

0: 是個特殊值,表示都不壓縮。這是Redis的預設值。 1: 表示quicklist兩端各有1個節點不壓縮,中間的節點壓縮。 2: 表示quicklist兩端各有2個節點不壓縮,中間的節點壓縮。 3: 表示quicklist兩端各有3個節點不壓縮,中間的節點壓縮。 依此類推…

Redis對於quicklist內部節點的壓縮演算法,採用的LZF——一種無失真壓縮演算法。

&n