Redis的數據結構介紹

我們都知道Redis是用C語言編寫的內存數據庫。但是由於C幾乎沒有提供任何數據結構的封裝,所以Redis爲了實現更快,更安全的操作,自己在內部封裝了一系列的數據結構。
其中包括了簡單動態字符串、鏈表、字典、跳躍表、整數集合、壓縮列表,下面來一一介紹(畫的圖有點醜。。)。

簡單動態字符串(SDS)

SDS定義

在redis中,只有字符串字面量纔會用C字符串來表示(比如打印日誌),其它都使用SDS來表示(比如鍵值對的鍵都是用SDS表示的字符串)。

SDS的結構:

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

SDS爲了可以重用C字符串函數庫裏的函數,所以遵循了用空字符結尾,但這個空字符不計入len屬性中。

SDS的特點

  1. 常數複雜度獲取字符串長度。C字符串如果要獲取字符串長度,必須從頭到尾遍歷整個字符串,所以導致複雜度爲O(N)。但是SDS本身在屬性中記錄了長度,所以獲取SDS長度的複雜度爲O(1)。
  2. 杜絕緩衝區溢出。C字符串如果在拼接字符串操作時,已分配的內存空間不足以放下拼接後的字符串,那麼將會造成緩衝區溢出。但是SDS會根據所需空間和自身空間來動態擴展空間大小。
  3. 通過未使用空間減少了內存重分配次數。C字符串在每次拼接或截斷操作時,都要重新分配內存空間以防止緩衝區溢出或內存泄漏。而SDS通過未使用空間實現了空間預分配和惰性空間釋放兩種優化策略來減少內存重分配次數。
    1. 空間預分配:如果SDS修改之後,長度將小於1MB,那麼將會分配和SDS長度同樣大小的未使用空間。如果長度將大於1MB,那麼將直接分配1MB的未使用空間。
    2. 惰性空間釋放:如果SDS的長度縮短時,多餘的空間並不會被立即釋放,而是用未使用空間將他們留在SDS中,未以後可能的增加預留空間。當然,SDS也可以通過手動調用API來釋放未使用空間,以免造成內存泄漏。
  4. 二進制安全 。由於C字符串會將遇到的第一個空字符判斷爲字符串結尾,所以導致C字符串只能保存文本,而不能保存像圖片、視頻等二進制數據,所以C字符串被稱爲字符數組。而SDS會以處理二進制的方式來處理SDS存放再buf數組裏的數據,SDS不是以空字符判斷結尾的,而是通過len屬性的值來判斷字符串是否結束。所以SDS的API是二進制安全的,可以存放各種數據,所以SDS被稱爲字節數組。
  5. 兼容部分C字符串的函數。因爲SDS與C字符串一樣遵循以空字符結尾,所以可以讓那些保存文本數據的SDS重用一部分C字符串函數庫的函數。

鏈表

當一個列表鍵包含了數量比較多的元素,又或者列表中包含的元素都是比較長的字符串時,Redis就會使用鏈表作爲列表鍵的底層實現。同時,在發佈與訂閱、慢查詢、監視器等功能也用到了鏈表。

鏈表和鏈表節點的實現

鏈表結構

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

鏈表結構爲鏈表提供了表頭指針head、表尾指針tail,以及鏈表長度計數器len。而dup、free和match則是用於實現多臺所需的類型特定函數,從而實現可以保存各種不同類型的值。

鏈表節點結構

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

多個listNode可以通過prev和next指針組成雙端鏈表。但是無環,因爲表頭節點的prev指針和表尾節點的next指針都指向NULL,所以對鏈表的訪問以NULL爲終點。
在這裏插入圖片描述

字典

Redis的數據庫就是使用字典作爲底層來實現的。可以把數據庫中所有的對象都看作是鍵值對,而這個鍵值對就是保存在代表數據庫的字典裏的。另外,哈希鍵的底層也是通過字典實現的。

字典的實現

字典結構

typedef struct dict {
  // 類型特定函數(我覺得這個應該是相當於Java中的泛型)
  dictType *type;
  // 私有數據
  void *privdata;
  // 哈希表數組,字典存儲使用ht[0],ht[1]在rehash遷移字典數據時使用
  dictht ht[2];
  // rehash索引,當rehash不在進行時,值爲-1
  int trehashidx;
} dict;

type屬性和privdata屬性是針對不同類型的鍵值對,爲創建多態字典而設置的。

哈希表結構

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

sizemask屬性和哈希值一起決定一個鍵應該被放到table數組的哪個索引上面。

哈希表節點結構

typedef struct dictEntry {
  // 鍵
  void *key;
  // 值,用union結構存儲數據,用於壓縮空間
  union {
    void *val;
    uint64_t u64;
    int64_t s64;
  } v;
  // 指向下個哈希表節點,形成鏈表(拉鍊法解決哈希衝突)
  struct dictEntry *next;
} dictEntry;

在這裏插入圖片描述

哈希算法

當要將一個新的鍵值對添加到字典裏面時,程序需要先根據鍵值對的鍵計算出哈希值,再根據哈希表的sizemask和哈希值計算出索引值,然後再根據索引值,將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面。
Redis使用MurmurHash2算法來計算鍵的哈希值,這種算法的優點在於,即使輸入的鍵是有規律的,算法仍能給出一個很好的隨機分佈性,並且算法的計算速度也非常快。

rehash

擴展和收縮哈希表的工作可以通過執行rehash(重新散列)操作來完成,Redis對字典的哈希表執行rehash的步驟如下:

  1. 爲字典的ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量(也就是ht[0].used屬性的值)
    1. 如果要執行的是擴展操作,那麼ht[1] 的大小爲第一個大於等於ht[0].used乘以2的2的n次方冪。比如ht[0].used的值爲4,那麼4乘以2等於8又等於2的三次方冪,所以ht[1]的大小將被分配爲8.
    2. 如果要執行的是收縮操作,那麼ht[1] 的大小爲第一個大於等於ht[0].used的2的n次方冪。比如ht[0].used的值爲4,那麼4等於2的2次方冪,所以ht[1]的大小將被分配爲2.
  2. 將保存在ht[0]中的所有鍵值對rehash到ht[1]上面,rehash指的是重新計算鍵的哈希值和索引值,然後將鍵值對按照索引值放到ht[1]對應的位置上。
  3. 當ht[0]中的所有鍵值對都遷移到了ht[1]之後,ht[0]的空間將會被釋放,然後將ht[1]設置爲ht[0],並再創建一個ht[1]空表,爲下一次rehash做準備。

漸進式rehash

爲了避免rehash對服務器性能造成影響,服務器並不是一次性將ht[0]裏面的所有鍵值對全部rehash到ht[1],而是分多次、漸進式的將ht[0]裏面的鍵值對慢慢的rehash到ht[1]。這裏就用到了rehashidx屬性,當程序處理rehash期間時,rehashidx值被設置爲0,當rehash操作完成時,又將它設置爲-1.
漸進式rehash的好處在於它採取分而治之的方式,將rehash鍵值對所需的計算工作均攤到對字典的每個增刪改查操作上,從而避免了集中式rehash帶來的龐大計算量。
另外,在rehash期間,字典的刪除、查找、更新操作會在兩個哈希表上進行,如果在ht[0]沒有找到的話,就回去ht[1]找。而添加操作則全部在ht[1]進行,即所有新添加的鍵值對都會存到ht[1]裏面。

跳躍表

跳躍表是一種有序數據結構,它通過在每個節點中維持多個指向其它節點的指針,從而達到快速訪問節點的目的。
跳躍表支持平均O(logN),最壞O(N)複雜度的節點查找,還可以通過順序行操作來批量處理節點。在Redis中用跳躍表來作爲有序集合的底層實現之一。

跳躍表的實現

跳躍表結構(zskiplist)

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

level屬性用於在O(1)複雜度內獲取跳躍表中層數最高的那個節點的層數,注意,表頭節點的層高並不能算在裏面。

跳躍表節點

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-kdIgrhl3-1573733502227)(https://i.loli.net/2019/11/14/cfYdF2s7x8jLBIC.png)]

  1. 層:每次創建一個新跳躍表節點的時候,程序都根據冪次定律(越大的數出現的概率越小)隨機生成一個介於1和32之間的值作爲level數組的大小,這個大小就是層的高度。
  2. 前進指針:每個層都有一個指向表尾方向的前進指針,用於從表頭向表尾方向訪問節點,當程序遍歷跳躍表的時候,就是根據每個層的前進指針來移動的。
  3. 跨度:層的跨度用於記錄兩個節點之間的距離,這個是用於來計算節點的排位的。在查找某個節點的過程中,將沿途訪過的所有層的跨度累加起來,得到的就是當前節點在跳躍表中的排位。
  4. 後退指針:節點的後退指針用於從表尾向表頭方向訪問節點,但後退指針每次只能後退至前一個節點,而不能跳躍多個節點。
  5. 分值和成員:節點的分值是一個double類型的浮點數,也就是代表着節點的排位。跳躍表中的所有節點都按分值從小到大排序。節點的成員對象是一個指針,它指向一個字符串對象,而字符串對象則保存着一個SDS值。在一個跳躍表中,各個節點的成員對象必須是唯一的,但是分值可以相同。分值相同的節點按照成員對象在字典序中的大小來排序,成員對象較小的節點會排在前面(靠近表頭的方向)。

整數集合

整數集合是集合鍵的底層實現之一,當一個集合只包含整數值元素,且元素數量不多時,將會使用整數集合作爲集合的底層實現。

整數集合的實現

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

encoding的類型可以是int16_t,int32_t或者int64_t。其中雖然contents被聲明爲int8_t,但實際上contents數組中不會保存int8_t類型的值,真正的類型還是取決於encoding屬性的值。注意,如果contents數組中包含了不同整數類型的值,那麼encoding將被設置爲佔用空間最大的那個類型。同時,其他值也將被升級編碼爲該類型。

升級

當我們要將一個新元素添加到整數集合裏時,並且新元素的類型比整數集合現有元素的類型都要長時,我們將需要先將整數集合進行升級,才能將新元素添加進去。
升級整數集合並添加新元素分三步進行:

  1. 根據新元素的類型,擴展整數集合底層數組的空間大小,併爲新元素分配空間。
  2. 將底層數組的其他元素都轉換爲新類型,並保存與原來相同的順序放置。
  3. 最後再將新元素添加到數組中。

壓縮列表

壓縮列表是列表建和哈希鍵的底層實現之一。當列表鍵或哈希鍵中的元素較少時,將會使用壓縮列表來作爲他們的底層實現。

壓縮列表的實現

壓縮列表是Redis爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序性數據結構。一個壓縮列表可以包含任意多個節點,一個節點可以保存一個SDS或一個整數值。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-GKvIvNi0-1573733502233)(https://i.loli.net/2019/11/14/7hqXRxWIcN4QMwb.png)]

  1. zlbytes:記錄整個壓縮列表佔用的內存字節數,在對壓縮列表進行內存重分配或計算zlend的位置時使用。
  2. zltail:記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少個字節,通過zltail可以通過O(1)複雜度確定表尾節點的地址。
  3. zlen:記錄壓縮列表的節點數量,當這個值等於UINT16_MAX時,節點的真實數量需要遍歷整個壓縮列表才能得到。
  4. entryX:壓縮列表包含的各個節點。
  5. zlend:特殊值0xFF,用於標記壓縮列表的結尾。

壓縮列表節點的實現

每個壓縮列表節點可以保存一個字節數組或者一個整數值。壓縮列表節點由三部分組成。
在這裏插入圖片描述

previous_entry_length

記錄了壓縮列表中前一個節點的長度。previous_entry_length屬性自身的長度可以是1字節或5字節。

  • 如果前一個節點的長度小於254字節,那麼previous_entry_length屬性的長度爲1字節,前一節點的長度就保存在這1字節裏。
  • 如果前一個字節的長度大於等於254字節,那麼previous_entry_length屬性的長度爲5字節。其中1字節將被設置爲oxFE,而其它4字節用於保存前一節點的長度。
    程序可以通過指針運算,根據當前節點的起始地址來計算出前一個節點的起始地址。壓縮列表的從表尾向表頭的遍歷操作就是利用這一原理實現的。

encoding

  • 記錄了節點的content屬性所保存數據的類型以及長度。一字節、兩字節或五字節長、值的最高位爲00、01、10的表示節點的content屬性保存着字節數組,數組的長度爲去掉encoding的最高兩位之後的位記錄。
  • 一字節長,值的最高位以11開頭的是整數編碼:這種編碼表示節點的content屬性保存着的是整數值。

content

負責保存節點的值,節點值可以是字節數組或整數,具體由encoding決定。

連鎖更新

如果當前壓縮列表的節點長度都小於254字節,那麼用於記錄前一個字節長度的屬性previous_entry_length只需要用一個字節保存,但是現在要新加一個字節長度大於254字節的節點到壓縮列表中來,那麼將會造成連鎖更新,因爲新加節點的後一個節點保存了這個節點的長度,需要將previous_entry_length擴展爲5字節的,然後繼續類似的擴展直到最後一個節點。

參考

Redis的設計與實現 黃建宏 著

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章