1. 程式人生 > 其它 >一文看懂redis各種資料型別的底層實現

一文看懂redis各種資料型別的底層實現

redis資料結構

redis已經成為了現今構建網際網路應用最常用的中介軟體之一,它對使用者暴露的資料型別有string、list、hash、set、sorted set等,我們在使用這些資料型別的同時,肯定也會對其內部的設計和實現感興趣。這篇文章將探究這些資料型別底層的資料結構實現,比如sds、ziplist、quicklist、dict、skiplsit等

本文引用的redis原始碼版本為redis 6.2

一、SDS

redis string物件底層使用SDS來實現。

redis雖然使用C語言開發,卻沒有直接使用C語言的預設字串,而是自己構建了一種名叫SDS(simple dynamic string)的資料結構。

熟悉go語言的同學會發現,這個資料結構跟go語言中的slice切片的底層實現非常相似,下面來詳細解析

sds結構定義在sds.h中

typedef char *sds;

sds定義成char *型別,這是為了和傳統C語言的字串保持相容,但是sds並不等同char *,實際上sds還包含了一個header結構

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

一個完證的sds字串,由記憶體地址上前後相鄰的兩部分組成:一個header,和一個char [] 位元組陣列。下面來看header的構成:

  • len:sds字串的長度

  • alloc:sds字串底層位元組陣列的最大容量(不包括header),即buf陣列的長度-1(不包括結束符 ‘\0’)

  • buf:用於儲存字串的位元組陣列,沒有具體長度標識,是一個柔性陣列,柔性陣列並不佔用結構體的空間

  • flags:用來標記不同型別的sds,只使用了低3位來標記,高5位暫未使用(除了sdshdr5)

根據alloclen的型別不同,sds分為幾種型別

  • sdshdr5:使用高5位(5 msb)來表示len, alloc固定是5 msb的最大值 2^5,因而沒有辦法動態擴容
  • sdshdr8:長度為小於2^8的字串
  • sdshdr16:長度為小於2^16的字串
  • sdshdr32:長度為小於2^32的字串
  • sdshdr64:其他所有長度都使用此類

__attribute__ ((__packed__)) 關鍵字用來告訴編譯器這個結構體不在遵循記憶體對齊規則,而是欄位成員緊緻排列,方便通過偏移量來訪問結構體中的欄位,比如buf向前偏移一個位元組,就可以訪問到flags欄位,以此來判斷這個sds的型別

之所以要定義5種header,是為了能讓不同的sds字串可以使用不同的header,短的sds字串能夠使用小的header,儘可能的節省記憶體。

上圖展示了兩個有不同header sds字串的記憶體佈局,以及如何根據sds字串指標獲取對應header起始指標。先從sds字元指標向地址偏移一個位元組獲取到flag,從flag的低3bit得到header的型別,知道了header的型別,也就很容易的能夠計算出header的起始指標位置。

sds字串的header,其實隱藏在真正的字串資料的前面(低地址方向)。這樣定義有如下幾個好處:

  • header和資料相鄰,這有利於減少記憶體碎片,提高儲存效率
  • 雖然header有多個型別,但sds可以用統一的char *來表達。且它與傳統的C語言字串保持型別相容。我們可以直接把它傳給C函式,比如使用printf進行列印。

SDS特點(區別於C字串)

  • 記憶體預分配
    • redis作為資料庫,字串資料經常被頻繁修改。C語言的字串增長或者縮短,就必須對整個底層陣列進行一次重新分配,SDS可以在字串增長時,給SDS分配額外未使用空間(buf),以減少記憶體分配次數
    • 字串小於1M時,翻倍擴容,大於1M時,每次增加1M
  • 惰性釋放
    • SDS縮短字串時,只需要通過len欄位記錄新的字串長度,而不必立即重新分配記憶體
    • SDS也提供了相應的API,讓我們在需要的時候釋放SDS未使用的空間
  • 二進位制安全
    • SDS使用len欄位而不依懶於空字元\0來判斷字串的結尾,所以可以使用buf來儲存任何二進位制格式的資料
    • 比如我們可以使用redis來儲存ProtoBuf壓縮過的二進位制資料
  • 相容C字串
    • SDS被定義為char *,並且會在字串的末尾加上\0,所以能夠相容C字串,以複用C字串相關函式

二、連結串列List

redis3.2之前,list物件的底層實現之一是連結串列,但在3.2版本之後,list型別就改成用quicklist+ziplist來實現了。不過連結串列依然在釋出訂閱、監視器、儲存客戶端狀態資訊儲存、客戶端輸出緩衝區等場景被使用,所以做個簡單介紹

雙鏈表list結構定義在adlist.h中

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

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;

listNode 用兩個指標 prevnext儲存左右節點,用void *指標value儲存節點的值

list儲存了頭尾指標headtail,方便連結串列的操作。len來記錄連結串列的長度。dupfreematch這三個成員是三個函式的指標,指向用於實現多型連結串列所需的型別特定函式

  • dup用於複製節點值
  • free用於釋放節點儲存的值
  • match用於比較節點的值與輸入值是否相等

三、壓縮列表ziplist

zset和hash物件在元素個數比較少的時候,底層使用壓縮列表ziplist來進行儲存。

ziplist是redis為了節約記憶體而設計的,是由一系列特殊編碼的連續記憶體塊組成的序列型資料結構。它本質上就是一大塊連續的位元組陣列,但是會比常規的數字更節省記憶體,因為陣列要求每個元素的大小相同,這就導致很多記憶體的浪費。而壓縮列表的元素長度則是動態的、不固定的。

壓縮列表

屬性 型別 長度 用途
zlbytes uint32_t 4位元組 記錄整個壓縮列表佔用的記憶體位元組數:在對壓縮列表進行記憶體重分配, 或者計算 zlend 的位置時使用。
zltail uint32_t 4位元組 記錄表尾節點與起始地址的偏移量: 用來計算表位節點的地址
zllen uint16_t 2位元組 記錄了壓縮列表包含的節點數量: 當這個屬性的值小於 UINT16_MAX65535)時, 這個屬性的值就是壓縮列表包含節點的數量; 當這個值等於 UINT16_MAX 時, 節點的真實數量需要遍歷整個壓縮列表才能計算得出。
entryX 列表節點 不定 壓縮列表包含的各個節點,節點的長度由節點儲存的內容決定。
zlend uint8_t 1位元組 特殊值 0xFF (十進位制 255 ),用於標記壓縮列表的末端。

壓縮列表節點

每個壓縮列表節點有三部分構成

  • prevrawlen 表示前一個節點的長度
    • 當ziplist倒敘遍歷時,可以根據prevrawlen計算出前一個節點的起始地址
    • 前一個節點的長度小於254位元組,那麼prevlen使用一個位元組
    • 前一個節點的長度大於254位元組,那麼prevlen使用5個位元組,第一個位元組固定是0xFE(254),後四個位元組用來儲存前一個節點的長度(第一個位元組為什麼不是255呢?因為255被zlend使用了)
  • encoding 記錄了節點的長度 ,和content屬性所儲存的資料的型別
    • 長度有三種,一個位元組,兩個位元組,五個位元組
    • encoding告訴我們content表示的是字串(位元組陣列)還是整數型別(int16_t、int32_t...)
  • content 儲存節點的值,可以儲存任意二進位制序列(位元組陣列)

壓縮列表插入和查詢的平均複雜度為O(N),因為ziplist沒有指標,所以每次插入或者刪除節點,都要重新調整節點的位置,因而會發生記憶體拷貝,所以ziplist只適合用來儲存少量的資料。

四、快速列表quicklist

linkedlist 每個節點只能儲存一個元素,而且需要使用 prev 和 next 兩個指標,佔據了16個位元組,空間附加值太高,而且每個節點都是單獨分配,會加劇記憶體的碎片化。因此在 redis3.2 版本之後,使用了新的 quicklist+ziplist 結構代替了 linkedlist 來實現 list 列表物件。

quicklist還是一個雙向連結串列,只不過每個節點都是一個壓縮列表,先來看兩個相關配置引數:

  • 壓縮列表的長度由引數list-max-ziplist-size來控制,使用者可以自己設定,預設值是 -2代表8KB(正數代表個數,負數的意義參考redis.conf)
  • 當列表很長時,兩端的資料是最容易被訪問的,而中間的資料訪問頻次比較低,所以redis提供了一個選項,能夠把中間的節點的資料進行壓縮,進一步節約記憶體。引數list-compress-depth代表quicklist兩端不被壓縮的節點個數,預設是特殊值0,表示都不壓縮

quicklist相關結構在quicklist.h中定義

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

tip:quicklistNode結構體中count、encoding等欄位後面跟著一個冒號和一個數字,這是C語言結構體的位域的語法,它告訴編譯器,這個欄位所使用的位的寬度,可以把資料以位的形式緊湊儲存,並允許程式設計師對此結構的位進行操作

quicklist節點

quicklistNode欄位含義如下:

  • prev、next:是前後指標
  • zl:是一個char* 位元組陣列指標,如果節點沒有被壓縮,它指向一個ziplist;否則,它指向一個quicklistLZF結構
  • sz:size,ziplist的位元組大小
  • count: 節點的元素個數
  • encoding:標識節點的編碼方式,1代表ziplist,2代表壓縮過的節點(quicklistLZF)
  • container:1代表直接儲存資料(預留,實際未實現),2代表使用ziplist儲存資料
  • recompress:如果我們訪問了被壓縮的節點資料,需要把資料暫時解壓,這時設定recompress=1,表示後面需要把資料重新壓縮

壓縮過的節點

quicklistLZF表示一個被壓縮過的ziplist,redis使用LZF演算法壓縮ziplist

  • sz:壓縮後的大小
  • compressed:一個柔性陣列,儲存壓縮後的資料

quicklist

struct quicklist 是快速列表的的真正結構

  • head、tail 首位指標
  • count:元素個數(所有節點元素個數的總和)
  • len:節點數
  • fill:儲存 list-max-ziplist-sized的值,表示ziplist的容量
  • compress:儲存list-compress-depth的值,表示節點壓縮深度

上圖是一個 list-max-ziplist-size=3 list-compress-dept=2 的quicklist 示意圖

五、字典dict

redis的hash物件底層使用字典來實現,字典是一種儲存鍵值對的抽象資料結構,在redis中應用相當廣泛,redis的資料庫也是使用字典來作為底層實現的

dict相關結構在dict.h中定義

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

typedef struct dictType {
    uint64_t (*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, void *key);
    void (*valDestructor)(void *privdata, void *obj);
    int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;

雜湊表

dictht定義了雜湊表的結構, ht是hash table意思

  • table:是一個元素為dictEntry *的陣列的指標,每個dictEntry儲存一個鍵值對

  • size:是table陣列的長度

  • sizemask:總是等於size-1,一個鍵的雜湊值可以和sizemask做與運算(比取餘效率高),快速得到其在陣列中的索引位置

  • used:記錄雜湊表已有鍵值對個數

雜湊表節點

dictEntry定義了雜湊表節點

  • key:用來儲存鍵值對中的鍵,void *指標型別,可以指向任何值

  • v:用來儲存鍵值對中的值,是一個union結構,可以直接儲存uint64_t、int64_t或double型別,也可以是void*

  • next:指向另一個雜湊表節點,當多個鍵的索引相同時,組成連結串列來解決雜湊衝突問題,這種通過連結串列解決雜湊衝突的方法通常叫做鏈地址法(拉鍊法)。速度考慮,新節點總是被新增在連結串列頭的位置(複雜度O(1))

字典

dict就是redis的字典結構了

  • type:指向一個dictType結構,用來實現不同型別的多型字典

  • privdata:儲存了需要傳給特定型別函式的可選引數

  • ht:包含了兩個dctht,一般情況下,字典只會使用ht[0],ht[1]只會在對ht[0]進行rehash的時候使用。

  • rehashidx:記錄了rehash的進度,沒有在進行rehash的話,值為-1

  • pauserehash:大於0時表示rehash被暫停

dictType則儲存了一些用於特定型別鍵值對的函式

  • hashFunction:用來計算鍵的雜湊值

  • keyDupvalDupkeyDestructorvalDestructor:分別是複製鍵和值的函式,銷燬鍵和值的函式

  • keyCompare:比較鍵的函式

rehash

當雜湊表儲存過多的鍵值對時,雜湊衝突的可能性大大增減,為了保持O(1)的訪問複雜度,需要對雜湊表進行擴容;當雜湊表中的鍵被大量的刪除,縮容能釋放雜湊表的記憶體佔用。

負載因子(load factor)表示雜湊表已儲存的元素個數和雜湊表陣列的比值 load_factor = ht[0].uesd / ht[0].size

  • 當雜湊表的負載因子大於1的時候,redis會對雜湊表執行擴容操作(如果伺服器正在執行RDB備份,或者AOF檔案重寫,負載因子大於5才開始擴容)
  • 當負載因子小於0.1的時候,會執行縮容操作

rehash的過程

  1. 為ht[1]分配表空間,其大小總是2^n,即每次都是2的整數次冪的倍數擴縮容,這樣sizemask才能用來快速計算雜湊值的索引位置
  2. 將儲存在ht[0]上的鍵值對rehash到ht[1]上,即重新計算鍵的雜湊值和其在ht[1]索引值,然後將鍵值對放在ht[1]指定的位置上
  3. 當ht[0]包含的所有鍵值對都遷移到了ht[1]上後,釋放ht[0],將ht[1]設定為ht[0],然後在ht[1]新建一個空表雜湊表,為下次rehash做準備

漸進式rehash

rehash這個動作不是一次性集中完成的,而是分多次,漸進式的

  1. rehashidx記錄著rehash的進度,他的值是當前需要被rehash的索引位置,值設定為0,代表rehash開始(沒有rehash時是-1)
  2. rehash期間,每次對字典執行增刪改查的同時,也會將ht[0]上rehashidx索引位置的全部鍵值對rehash到ht[1]上,然後將rehashidx的值加一
  3. 除了在對字典操作時會執行rehash操作,伺服器的定時任務也會主動的進行rehash,可以通過activerehashing引數配置,預設值yes
  4. rehash結束後rehashidx會被設定為-1
  5. 漸進式rehash執行期間,新新增到字典的價值對會被儲存在ht[1]中,查詢、刪除、更新的操作會先在ht[0]中查詢對應的鍵,如果沒找到的話,就會繼續在ht[1]裡面進行查詢,然後執行對應的邏輯

六、跳躍表skiplist

跳躍表是一種有序的資料結構,有兩大特點

  • 支援平均O(logN)時間複雜度的節點查詢
  • 可以通過順序性操作來批量處理節點

大部分情況下跳躍表的效率可以和平衡樹媲美,而且跳躍表實現更簡單,範圍查詢也比平衡樹更方便,效率更好

跳躍表是zset的實現之一(集合包含元素較少多,或者字串較長時)

redis跳躍表由zskiplistNodezskiplist 實現,在server.h中定義

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplistNode {
    sds ele;                           //sds 字串
    double score;                      //分值 
    struct zskiplistNode *backward;    //後退指標,每次只能退一個節點
    struct zskiplistLevel {            
        struct zskiplistNode *forward; //每層都有一個前進指標,層數越高,跨度越大
        unsigned long span;            //跨度記錄兩個節點之間的距離
    } level[];                         //層高在1-32之間,根據冪次定律,越大的數出現的概率越小
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

先看兩個相關的常量

  • ZSKIPLIST_MAXLEVEL:跳躍表的最大層數為32層
  • ZSKIPLIST_P:如果一個節點有第i層(i>=1)指標,那麼它有第(i+1)層指標的概率為p=1/4。

跳躍表結構

zskiplist結構,跟普通的雙鏈表很像

  • headertail:分別指向跳躍表的頭節點、尾結點

  • length:跳躍表的長度,即節點個數(頭節點不計算在內)

  • level:跳躍表當前的最大層高,即層數最大的節點的層高(頭節點的層數不計算在內)

跳躍表節點

跳躍表的節點zskiplistNode

  • ele:儲存了一個sds,即zset中的成員,成員不可重複
  • score:ele對應的分數,跳躍表中,跳躍表節點按照分數從小到大排序
  • backward:節點的後退指標,指向前一個節點,用來從後向前遍歷跳躍表
  • level:level陣列也是柔性陣列,具體層數不確定,在新建節點的時候會根據ZSKIPLIST_P計算出當前節點的層數,越大的層數出現的概率越低
    • forward:每一層裡有一個前進指標,指向後面的某個節點
    • span:記錄前進指標的跨度,是當前節點和前進指標指向的節點的距離,用於計算元素排名(rank)

上圖展示了一個跳躍表示例,以及遍歷一個跳躍表的過程(虛線)

skiplist與平衡樹的比較

  • 在做範圍查詢的時候,平衡樹比skiplist操作要複雜。在平衡樹上,我們找到指定範圍的小值之後,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這裡的中序遍歷並不容易實現。而在skiplist上進行範圍查詢就非常簡單,只需要在找到小值之後,對第1層連結串列進行若干步的遍歷就可以實現。
  • 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯複雜,而skiplist的插入和刪除只需要修改相鄰節點的指標,操作簡單又快速。
  • 從記憶體佔用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指標(分別指向左右子樹),而skiplist每個節點包含的指標數目平均為1/(1-p),具體取決於引數p的大小。如果像Redis裡的實現一樣,取p=1/4,那麼平均每個節點包含1.33個指標,比平衡樹更有優勢。
  • 從演算法實現難度上來比較,skiplist比平衡樹要簡單得多。

七、整數集合

intset是set集合型別的底層實現之一,當set的元素數量小於set-max-intset-entries(預設值 512),並且只包含整數型別時,redis會使用intset作為set的底層實現

intsest可以儲存int16_t、int32_t、int64_t型別的整數,原始碼定義在intset.h和intset.c

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

#define INTSET_ENC_INT16 (sizeof(int16_t)) // 值為2
#define INTSET_ENC_INT32 (sizeof(int32_t)) // 值為4
#define INTSET_ENC_INT64 (sizeof(int64_t)) // 值為8
  • contents:contents柔性陣列是整數集合的底層實現,集合元素在陣列中從小到大排列,並且不重複。雖然其宣告為int8_t型別的陣列,但實際上contents陣列的真正型別取決於encoding的值
  • encoding:encoding的值決定了contents陣列的型別,有三種取值
    • INTSET_ENC_INT16,表示contents是一個int16_t型別的陣列
    • INTSET_ENC_INT32,表示contents是一個int32_t型別的陣列
    • INTSET_ENC_INT64,表示contents是一個int64_t型別的陣列
  • length:記錄了整數集合的元素個數

如果我們將一個新元素插入到整數集合中,並且新元素的型別比現有的所有元素型別都要大,整數集合就要先進行升級操作。升級的步驟大致如下:

  1. 根據新元素的型別為整數集合底層陣列重新分配空間大小(包含新元素的空間)
  2. 將底層陣列的型別都調整為新的型別,並從後向前依次將他們放到升級後的位置上
  3. 觸發升級的新元素要麼小於所有元素(負數),要麼大於所有元素,所以新元素要麼放在陣列開頭,要麼放在陣列末尾

intset通過自動升級底層陣列來適應新元素,所以我們可以隨意的將int16_t、int32_t、int64_t型別的整數新增到集合中,而不是像普通的陣列那樣只能儲存一種型別的元素;另外又可以確保升級只在需要的時候進行,以此來儘量的節省記憶體。

另外,intset不支援降級操作。

不像ziplist能夠儲存任何二進位制序列,每個元素採用變長編碼,intset只能用來儲存整數,而且每個元素的長度相同(編碼相同)


以上我們陸續介紹了redis中用到的主要資料結構,但redis並沒有直接使用這些資料結構來實現資料庫的各種型別,而是基於這些資料結構建立了一個物件系統。下一篇文章,將詳細的介紹redis的物件系統。

參考:

Redis設計與實現

Redis內部資料結構詳解