Redis - 底層資料結構

程式設計師翔仔發表於2023-04-12

簡介

Redis 的底層資料結構主要以下幾種:

  • SDS(Simple Dynamic String, 簡單動態字串)
  • ZipList(壓縮列表)
  • QuickList(快表)
  • Dict(字典)
  • IntSet(整數集合)
  • ZSkipList(跳躍表)

簡單動態字串

在 Redis 中,並不會直接使用 C 語言自帶的字串結構作為實際的儲存結構,而只是將字串作為字面量使用,大多數情況使用自定義的 SDS 來表示字串。

SDS 主要用於儲存 Redis 的預設字串表示、AOF 模組中的 AOF 緩衝區、客戶端狀態輸入緩衝區。它的定義如下:

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

優點

相對於 C 語言的字串實現,Redis 實現的 SDS 有以下優點:

  • 透過記錄 len 屬性,實現常數級時間複雜度獲取字串長度
  • 透過檢查 len 屬性,避免字串在修改時出現緩衝區溢位的情況
  • 透過記錄 len 屬性和 alloc 屬性,對於修改字串實現了空間預分配和惰性空間釋放
  • 實際儲存的 buf 是一個位元組陣列,可以實現 SDS 安全操作二進位制資料
  • SDS 仍然以 \0 作為字串結尾的標識,這樣可以重用 C 語言字串的部分函式

空間預分配

當 SDS 修改時需要擴充套件空間大小,程式不僅會為 SDS 擴充套件修改所需的空間,還會為 SDS 分配額外的未使用空間。這額外空間一般是 len 的大小,最大不超過 1MB。

這樣可以減少連續執行字串增長操作所需的記憶體重分配次數。

惰性空間釋放

當 SDS 修改時需要縮短空間大小,程式並不會立即將多出來的空間進行空間重分配,而是使用 alloc 屬性將這些空間大小記錄下來,以待後續使用。

而且 SDS 也提供手動釋放未使用空間的方法,這樣可以避免浪費記憶體。

壓縮列表

ZipList 實際是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構,是 Hash 型別底層實現的一種方式。

結構

一個 ZipList 結構由 zlbyteszltailzllenentrieszlend 這些屬性組成,這些屬性順序連線在一起組成了 ZipList:

ZipList 結構

zlbytes 用於記錄 ZipList 佔用的記憶體位元組數,在對 ZipList 進行記憶體重分配或者計算 zlend 的位置時使用。

zltail 記錄了 ZipList 表尾結點距離 ZipList 的起始地址有多少個位元組,Redis 可以透過這個屬性快速確定表尾結點的地址。

zllen 記錄了 ZipList 包含的結點數量,當這個屬性小於 UINT16_MAX(65535) 時,這個值就是 ZipList 包含的結點數量;這個屬性大於 UINT16_MAX 時,則需要遍歷整個 ZipList 才能計算得出結點數量。一個 ZipList 可以包含任意多個結點,每個結點可以儲存一個位元組陣列或者一個整數值。

zlend 定義了特殊值 OxFF 用於標記 ZipList 的末端。

優點

ZipList 是一種節省記憶體的列表結構,對於普通的陣列來說,其中每個元素佔用的空間大小取決於最大的元素,而 ZipList 解決了這個問題。

因此,ZipList 在設計的時候,儘量讓每個元素按照實際的內容大小儲存,所以增加了 encoding 屬性,使得程式可以根據不同的 encoding 屬性來細化儲存大小。

由於陣列每個元素都佔用相同的記憶體空間,在遍歷陣列時非常方便。

而 ZipList 每個元素儲存的記憶體空間不一樣,為了解決倒序遍歷的問題,增加了 prevlen 屬性來定位上一個元素的起始位置。

缺點

ZipList 內部的資料儲存是一段連續的空間,並且沒有預留記憶體空間,在移除結點時也是立即縮容,這表示每次寫操作都會進行記憶體分配操作。

第二個缺點就是,在某種情況下,ZipList 會出現連鎖更新的問題。

連鎖更新

ZipList 儲存了 prevlen 屬性表示前一個元素的長度,如果前一個元素長度小於 254 個位元組,則 prevlen 使用 1 個位元組儲存這個長度值,如果前一個結點大於 254 個位元組,則 prevlen 使用 5 個位元組儲存這個長度值。

如果 ZipList 中正好存在連續多個長度介於 250~253 個位元組的結點,這時需要在 ZipList 前面插入一個大於等於 254 個位元組的新結點,後一個結點的 prevlen 需要從 1 個位元組轉換成 5 個位元組,則後一個結點也會大於等於 254 個位元組,後續的結點以此類推,將會造成這部分結點出現連續更新。

快表

Redis 在 3.2 版本之後新增了快表資料結構,它是一種以 ZipList 為結點的雙端連結串列結構,可以理解成分段的 ZipList 連結在一起。

快表結構

在 3.2 版本之前,Redis 使用 ZipList 或 LinkedList 來實現 List 型別,並且有一個選擇的標準:

  • 儲存的所有字串元素的長度都小於 64 位元組,且儲存的元素數量小於 512 個,選擇使用 ZipList
  • 否則使用 LinkedList 資料結構替代 ZipList

ZipList 要求有一段連續的記憶體空間,而使用 LinkedList 分配記憶體又會出現大量的記憶體碎片,因此 QuickList 對此做了最佳化,既避免出現大量的記憶體碎片,又避免一次性佔用記憶體過大。

字典

字典在 Redis 中的應用非常廣泛,比如 Redis 的資料庫就是使用字典來作為底層實現的,對資料庫的增、刪、改、查操作都是構建在對字典的操作之上的。

雜湊表結點

字典儲存資料的最小結構就是雜湊表結點,Redis 中的雜湊表結點使用 dictEntry 結構表示,每個 dictEntry 都儲存著一個鍵值對:

typedef struct dictEntry {
    void *key;                  // 鍵值對的鍵
    union {                     // 鍵值對的值
        void *val;              // 可以是一個指標
        uint64_t u64;           // 可以是一個 uint64_t 整數
        int64_t s64;            // 可以是一個 int64_t 整數
    } v;

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

這裡值得注意的就是,next 指標會指向下一個雜湊表結點,而它的功能就是用於解決雜湊衝突,由此可見 Redis 的雜湊表解決雜湊衝突的方法是鏈地址法。

雜湊表

雜湊表是由多個雜湊表結點組成的,Redis 中自定義的雜湊表結構如下:

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

一般的,雜湊表的物理儲存結構都是陣列,Redis 的雜湊表結構也是如此,而這個結點陣列中的每個元素都是一個指向 dictEntry 結構的指標。

字典結構

Redis 為了使雜湊表結構更加具有通用性,最後是在自定義的 dictht 雜湊表結構外層再包一層字典結構,即是 dict 結構:

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

這裡展示了另一個 dictType 的結構,其實這個結構儲存了一簇用於操作特定型別鍵值對的函式,Redis 會為用途不同的字典設定不同的型別特定函式。以下是 dictType 的結構定義:

typedof struct dictType {
    unsigned int (*hashFunction)(const void *key);                          // 計算雜湊值的函式
    void *(*keyDup)(void *privData, const void *key);                       // 複製鍵的函式
    void *(*valDup)(void *privData, const void *obj);                       // 複製值的函式
    int (*keyCompare)(void *privData, const void *key1, const void *key2);  // 對比鍵的函式
    void *(*keyDestructor)(void *privData, const void *key);                // 銷燬鍵的函式
    void *(*keyDestructor)(void *privData, const void *obj);                // 銷燬值的函式
} dictType;

其實 dict 結構的 type 屬性和 privdata 屬性是針對不同型別的鍵值對,為建立多型字典而設定的。其中 privData 屬性儲存了需要傳給那些型別特定函式的可選引數。

需要注意一下,字典結構的 ht 屬性是一個長度為 2 的陣列,也就是說,這個字典結構儲存了兩個 dictType 結構,其中一個用於儲存實際使用的雜湊表,另一個用於儲存重新雜湊的臨時雜湊表。

這個重新雜湊還涉及到了 rehashidx 屬性,表示的是重新雜湊當前的進度。

雜湊演算法

當要將一個新的鍵值對新增到字典裡面時,程式會先根據鍵值對的鍵計算出雜湊值和索引值,然後再根據索引值,將包含新鍵值對的雜湊表結點放到雜湊表陣列的指定索引上。

Redis 計算雜湊值和索引值的流程是:透過 dict 中的 type 屬性找到計算雜湊值的函式,然後透過函式計算出對應的雜湊值;確定對應的 dictht 結構之後,再根據 sizemask 和雜湊值計算出索引值。

Redis 使用 MurmurHash2 演算法計算鍵的雜湊值,其優點就是對於有規律的輸入值也能給出很好的隨機分佈性,並且演算法的計算速度也非常快。

雜湊衝突

相同的雜湊值會計算出相同的索引值,這就會導致雜湊衝突。

Redis 使用了鏈地址法解決雜湊衝突,每一個雜湊表結點都有一個 next 指標,多個衝突的雜湊表結點就會使用這個 next 指標構成一個單向連結串列,這就解決了鍵衝突的問題。

這裡需要注意一點,由於雜湊表結點不儲存連結串列的尾結點,為了速度考慮,雜湊衝突時構建的單向連結串列使用頭插法插入一個連結串列結點。

重新雜湊

隨著操作不斷執行,雜湊表儲存的資料會逐漸增多或減少,為了讓雜湊表的負載因子維持在一個合理的範圍內,Redis 會在必要的時候進行重新雜湊的操作。

重新雜湊指的是重新計算雜湊表結點的雜湊值和索引值,然後將鍵值對放到 ht 陣列的另一個雜湊表中。

Redis 對雜湊表進行擴充套件操作的兩個條件如下:

  • 伺服器目前沒有正在執行 BGSAVE 命令或 BGREWRITEAOF 命令,並且雜湊表的負載因子大於等於 1。
  • 伺服器目前正在執行 BGSAVE 命令或 BGREWRITEAOF 命令,並且雜湊表的負載因子大於等於 5。

其中負載因子 = 雜湊表已儲存結點數量 / 雜湊表大小。

另一方面,當雜湊表的負載因子小於 0.1 時,Redis 會自動開始對雜湊表進行收縮操作。

Redis 做自動擴充套件的條件包含兩種情況的原因是,執行 BGSAVEBGREWRITEAOF 命令的是伺服器的子程式,而大多數作業系統都採用寫時複製技術以最佳化子程式的使用效率,所以在子程式存在期間,伺服器會提高執行擴充套件操作所需的負載因子。

漸進式重新雜湊

為了避免因為重新雜湊導致停止服務的情況,Redis 做重新雜湊不是一次性完成的,而是分多次、漸進式地完成的。這也是 dict 結構中存在 ht 陣列的原因。

漸進式重新雜湊的好處在於它採取了分而治之的方式,將重新雜湊所需的計算工作均攤到對字典的每個新增、刪除、查詢和更新操作上,從而避免集中式重新雜湊而帶來的龐大計算量。

整數集合

整數集合被 Redis 用於儲存整數值的不重複集合,以下是整數集合的實現:

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

其中 contents 陣列中儲存的是整數集合中的元素,各個項按照從小到大進行排列,且陣列中不包含任何重複值。

length 屬性記錄了整數集合包含的元素個數,也相當於 contents 的陣列長度。

encoding 記錄著整數集合的編碼方式,雖然 contents 的定義是 int8_t 型別,但實際上 contents 陣列儲存元素的真正型別取決於 encoding 的值。

升級

整數集合的 contents 屬性可以儲存 int16int32int64 三種型別之一的數值,如果原本只儲存了 int16 型別的 contents 陣列插入一個 int32 型別的數值,這時就涉及到整數集合的升級操作。

每當要將一個整數插入到整機集合中時,Redis 都會先判斷新元素的型別是否會比已存在的元素型別長,如果存在這種情況,整數集合需要先進行升級,才能將新元素新增到整數集合裡面。具體的步驟如下:

  1. 根據新元素的型別,擴充套件整數集合底層陣列的空間大小,併為新元素分配空間;
  2. 將現有元素都轉換成與新元素的型別相同,並將轉換型別後的數值放置到正確的位上,並保持原陣列的順序不變;
  3. 最後改變 encoding 的值,並將 length1

整數集合的升級操作是不可逆的,一旦對陣列進行了升級,編碼就會一直保持升級後的狀態。

升級的好處

整數集合的升級策略有兩個好處,一個是提升整數集合的靈活性,另一個是儘可能地節約記憶體。

因為 C 語言是靜態型別語言,不同型別的整數值需要用不同的陣列儲存,而整數集合透過升級策略將有原本不同型別的整數新增到同一個陣列中,減少了型別錯誤的情況。

同樣的,整數集合透過使用一個陣列儲存了三種不同型別的整數,又確保升級操作只會在有需要的時候進行,這可以儘量節省記憶體。

跳錶

跳錶是一種有序的資料結構,它透過在每個結點中維持多個指向其他結點的指標,從而達到快速訪問結點的目的。

跳錶結構

Redis 的跳錶包括了兩個結構,一個是跳錶結點的結構,另一個是儲存跳錶結點的外部結構。

跳錶結點

以下是跳錶結點的結構定義:

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

這裡只是一個跳錶結點的結構,概念比較多。

跳錶的 zskiplistLevel 是一個陣列,陣列的長度表示結點有多少層索引,其中每個元素都包含一個指向其他結點的指標,程式可以透過這些指標加快訪問其他結點的速度。每次建立一個新的跳錶結點的時候,程式都會根據冪次定律隨機生成一個介於 1 和 32 之間的值作為陣列的大小。

forward 是指每個索引層都包含指向下一個具有相同高度索引層的結點。也可以將前進指標理解成連結串列的 next 指標,從相同層級的角度上看,每一個相同層級的結點都組成了類似於連結串列的結構。

span 記錄了兩個結點之間的距離,實際上是用來計算排位的:在查詢某個結點的過程中,將沿途訪問過的所有層的跨度累積起來,得到的結果就是目標結點在跳躍表的排位。

backward 用於從表尾向表頭訪問結點,對於最底層的連結串列來說,前進指標和後退指標使得這個連結串列成為一個雙向連結串列。

結點的 score 即是 Redis 的有序集合中的分值。結點的成員是一個指向 SDS 物件的指標,這個 SDS 物件儲存當前結點的值。對於相同分值的成員,Redis 會按照成員物件在字典序中的大小來進行排序,成員物件較小的結點會排在前面,而成員物件較大的結點會排在後面。

跳錶

僅使用多個跳錶結點就可以實現跳錶,但是新增外部跳錶結構可以使得程式更方便處理跳錶。Redis 的跳錶結構如下:

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

其中 head 指標和 tail 指標分別指向跳錶的表頭和表尾,透過這兩個指標,Redis 定位跳錶表頭結點和表尾結點的時間複雜度為 \(O(1)\)

透過記錄 length 屬性,Redis 可以在 \(O(1)\) 的時間複雜度內返回跳錶的長度。

跳錶使用 level 屬性記錄了表內結點的最大層數,但這個是不包含表頭結點的層高的。

相關文章