1. 程式人生 > 實用技巧 >Redis設計與實現(一)——資料結構與物件

Redis設計與實現(一)——資料結構與物件

Redis中的資料結構

  • 簡單動態字串
  • 連結串列
  • 字典
  • 跳躍表
  • 整數集合
  • 壓縮列表
  • 物件

1.簡單動態字串

Redis底層是用C語言實現的,所以,在很多資料結構上可以直接使用C語言中已經存在的資料結構和庫函式。

Redis的字串資料結構並沒有直接使用C語言的字元陣列,而是用了一個結構體,名為簡單動態字串(SDS),這是Redis預設實現字串的資料結構。

SDS的定義

SDS由一個C語言結構體定義,裡面包含當前已使用長度、未使用長度和位元組陣列。

struct sdshdr 
{
	// 記錄buf中已經使用的位元組數量
	int len;
	// 記錄buf中未使用的位元組數量
	int free;
	// 位元組資料,用於儲存字串
	char buf[];
};

結構圖示如下:

因為Redis使用C語言實現,同時為了可以使用C中的很多庫函式,所以,需要遵照C陣列的慣例,在陣列末尾用一個'/0'表示陣列結尾。因此,給字串分配空間的時候都會多一個位元組。

SDS的功能

  • 儲存字串值。
  • 用作緩衝區,包括AOF模組的緩衝區,客戶端狀態中的輸入緩衝區。

SDS的特點

1.常數時間獲取字串的長度

在C語言環境下,如果需要獲取一個數組的長度,就需要遍歷這個陣列,並進行計數,直到遍歷到末尾的結尾字元,就能計算出長度。這樣做的問題就是每次想要獲取字串的長度時間複雜度都是O(n)的,對於需要經常獲取字串長度的應用,就會造成很大的開銷。

而SDS中,利用空間換時間,通過維護結構體中的一個整型,儲存當前結構體所表示的字串的長度,就可以在O(1)的時間獲取長度

。這樣做的代價就是需要增加一個整型的記憶體,同時也需要在變更字串的時候,需要對這個整型進行維護,也會帶來開銷。

2.防止緩衝區溢位

如果僅僅對字元陣列進行操作,給陣列添加了大於其分配了的記憶體的資料,就會導師溢位,將資料覆蓋到了後面的陣列上,造成錯誤。

而SDS中,對新增資料的操作都會進行一個擴容檢查,如果原有的空間無法裝下新增的資料,就會對陣列進行一次擴容操作。

3.減少字串修改帶來的記憶體重分配次數

首先,對於記憶體的重分配是一個比較耗時的工作。

如果每次新增資料和刪減資料,都對資料儲存陣列進行一個重分配修改,那麼造成的開銷就很大。SDS採用以下兩種策略進行一個優化:

  • 空間預分配
    對於陣列的擴容操作,每次都會擴得多一點,而不是剛剛好。如果陣列大小小於1MB,每次增加一倍;如果陣列大小大於30MB,那麼每次擴容增加1MB。這樣下一次資料增加的時候,直接新增就完了,不需要立馬又要擴容。
  • 惰性空間釋放
    對於資料的資料刪除,不需要馬上減少陣列的空間,而是動一動表示使用資料長度和空閒資料長度的兩個值就行了。但也需要避免空間浪費,SDS提供API實現真正的空間釋放。

4.二進位制安全

Redis中的字串圖片、音訊等很多資料,其資料基本單元是位元組而不是字元,所以,不能讓字元陣列中的'/0'表示陣列的結尾。

SDS的中的陣列不只是可以用來儲存字元,而是通過位元組形式儲存一系列二進位制資料,通過len和free,就可以避免使用'/0'結尾。

5.相容C字串函式庫

對於很多字串的操作,SDS的字元陣列和C的普通字元陣列是一樣的,也都有'/0',那麼就可以重用<string.h>中的部分庫函式,避免了程式碼的重複。

總結

比起C字元陣列,SDS有以下優點:

  1. 常數複雜度獲取字串的長度;
  2. 杜絕緩衝區溢位;
  3. 減少修改字串長度時需要記憶體重新分配的次數;
  4. 二進位制安全;
  5. 相容部分C字串函式。

2.連結串列

C語言中沒有實現連結串列的資料結構,所以Redis構建了自己的連結串列實現。這裡的連結串列就是很普通的雙向連結串列。

連結串列的定義

連結串列由連結串列節點和連結串列構成。其中連結串列節點的定義如下:

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

從這裡可以看出,redis中的連結串列是雙向連結串列

連結串列的結構定義如下:

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

其中,dump函式用於複製表系欸但所儲存的值;free用於釋放連結串列節點所儲存的值;match函式用於對比連結串列節點所儲存的值與另一個輸入值是否相等。

連結串列結構示例如下圖所示:

連結串列的功能

連結串列在Redis中使用很廣泛,提供瞭如下特性:

  • 高效的節點重排;
  • 順序性的節點訪問方式;
  • 通過增刪節點來靈活地調整連結串列的長度。

在Redis中,使用連結串列資料結構的地方主要有:

  • 連結串列鍵
  • 釋出與訂閱
  • 慢查詢
  • 監視器
  • 儲存多個客戶端的狀態資訊
  • 構建客戶端輸出緩衝區

連結串列的特點

  • 雙向:每個節點都有前置指標和後置指標,這樣獲取某個節點的前置和後置節點的時間複雜度都是O(1)的。
  • 無環:表頭節點前置指標和表尾的後置指標指向空,這樣對連結串列的訪問以null為終點。
  • 具有表頭節點和表尾節點:這樣可以分別從表頭或者表尾開始對標進行遍歷。
  • 存有表節點的個數:這樣獲取表長度的時間複雜度是O(1)的。
  • 多型:表節點中值的指標是用過void*來定義的,並且還可以通過dup、free、match三個屬性給節點值設定型別的特定函式,所以,可以用連結串列儲存不同型別的資料。

3.字典

字典就是對映,在python中也稱為字典,是用於儲存鍵值對的資料結構,鍵唯一,值可以重複。

字典的定義

Redis字典使用雜湊表作為底層的實現,其實大致的原理和JDK1.8之前的HashMap差不多,都採用基於連結串列的拉鍊法解決雜湊衝突。

字典中的結構定義主要分為雜湊表、雜湊表節點以及字典的實現。

1.雜湊表

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

其中table就是一個儲存雜湊連結串列頭節點的陣列。

2.雜湊表節點

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

這裡值的定義很奇怪,是一個C語言的聯合體,什麼是聯合體呢,就是裡面的變數是共享一塊空間的,彼此之間可能會相互覆蓋對方,所以,同時只能使用其中的一個型別。這裡為了實現值的多型,所以,採用這種聯合體的方式,就能實現不同型別資料的儲存。

next用於形成連結串列,用於解決雜湊衝突的問題。

3.字典

typedef struct dict 
{
    // 型別特定函式
    dictType *type;
    // 私有資料
    void *privdata;
    // 雜湊表
    dictht ht[2];
    // rehash 索引
    // 當 rehash 不在進行時,值為 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 目前正在執行的安全迭代器的數量
    int iterators; /* number of iterators currently running */
} dict;

其中type屬性和privdata是用於不同類的鍵值對,實現多型字典而設定的。

typedef 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, void *key);
    // 銷燬值的函式
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

dictType結構體中主要是對鍵值進行操作的函式指標,可以依據不同的鍵值型別,傳入對於的操作函式,實現了多型。

回到上面的字典,ht包含了兩個雜湊表,在一般的情況下,資料儲存在ht[0]中,ht[1]主要用於雜湊表的rehash。rehashidx記錄當前rehash的進度,如果沒有在rehash,該值就是-1。

字典的結構示例如下圖所示。

字典的功能

  • Redis的資料庫底層就是使用字典來實現的,對資料庫的增刪改查都是在對字典的操作。
  • 還可以用來實現雜湊鍵,當一個雜湊鍵包含的鍵值對比較多,或者鍵值對的元素較長,就會使用字典作為雜湊鍵的底層實現。
  • 還有其他的功能。

字典的演算法

1.解決雜湊衝突

Redis採用的鏈地址法來解決。鍵衝突的節點就用過連結串列,一個個連在一起,等待遍歷查詢。

和JDK1.7中的HashMap一樣,Redis採用的是頭插法,總是將新的節點新增到連結串列頭的位置,這樣可以提高速度,但是會導致在多執行緒下的死迴圈。在JDK1.8中採用了尾插法。

2.rehash

當節點變多的時候,會造成大量的哈系衝突,這樣雜湊表的效率就下降了,所以,需要對陣列進行擴容,減少雜湊衝突。同時節點變少的時候,需要減少陣列的大小,節約空間。

rehash就是用來在擴容或者收縮的時候,解決原有雜湊值對映到新表上的雜湊值的問題。

擴容的大小每次是2的n次冪。這些原理上和Java的實現幾乎一致。將當前表中的節點重新計算雜湊值,然後放到新表h1中的指定位置上。最後將h0指向h1,再將h1指向空。rehash的具體流程如下:

  1. 為ht[1]分配空間:如果執行擴容操作,則ht[1]的大小為第一個大於等於ht[0].used*2的2**n(2的n次方);如果執行收縮操作,則ht[1]的大小為第一個大於等於ht[0].used的2 **n。
  2. 將ht[0]上的元素rehash到ht[1]上,即重新計算雜湊值,將元素儲存到ht[1]中。
  3. 當元素全部遷移完成後,釋放ht[0],將ht[1]設定為ht[0],併為ht[1]新建立一個空白的雜湊表。

擴容的條件是大於其設定的負載因子。

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

不同於Java的HashMap,Redis的字典具有縮容功能,用於節省記憶體。
參考Java HashMap

3.漸進式rehash

在將h0的資料複製到h1時,可能不是一次性將資料複製過去的,而是分多次實現,這樣做的原因是當節點數量太多的時候,防止複製節點造成的系統停頓。

在複製的過程中,通過rehashidx來儲存複製的進度,每次完成一部分的時候,就更新rehashidx的值,直到完成所有的複製,就可以將rehash置為-1。如果是查詢修改操作,會先在h0中進行查詢,沒有找到再去h1;如果是新增操作,則一律新增到h1中。

總結

  • 字典被廣泛用於Redis的各種功能,其中包括資料庫和雜湊鍵。
  • Redis字典採用雜湊表作為底層實現,每個字典有兩個雜湊表,一個平時用,另一個僅僅在rehash的時候使用。
  • 通過拉鍊法解決雜湊衝突的問題。
  • 擴容或者縮容不是一次性完成,而是分批次,漸進式完成的。

4.跳躍表

跳躍表是一種有序的資料結構,通過在每個節點中位置多個指向其他節點的指標,實現快速訪問。

跳躍表的定義

Redis跳躍表由跳躍表和跳躍表節點兩個結構實現。

1.跳躍表節點

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

  • 層:一個節點有很多層,每一個層中包含指向其他節點的前進指標和該指標指向的節點的跨度。根據冪次定律隨機生成一個介於1和32的值作為層數。節點中儲存的數值越大,層數就會越少。
  • 前進指標:指向下面的節點。
  • 跨度:用於記錄兩個節點之間的距離。如果跨度為0,那麼表示該層指向的是null。
  • 後退指標:用於指向前一個節點,只能退一個位置。
  • 分值:用於給節點進行排序,分值可以相等,如果分值相等,那麼就根據成員物件的字典序進行排序。
  • 成員物件:它指向一個字串物件,而字串物件中儲存著一個SDS值。

2.跳躍表

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

跳躍表結構示例如下圖所示:

跳躍表的功能

Redis中只有兩個地方用到跳躍表。

  • 實現有序集合鍵。
  • 叢集節點中作為內部資料結構。

跳躍表的特點

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

5.整數集合

整數是用來儲存整數值的資料結構,並且集合內不會出現重複元素,且有序排列。

整數集合的定義

typedef struct intset
{
	// 編碼方式
	uint32_t encoding;
	// 集合包含的元素數量
	uint32_t length;
	// 儲存元素的陣列,一個位元組位單位儲存
	uint8_t contents[];
} intset;

其中,encoding表示陣列中資料的真正型別,可以是16位、32位或者64位的整型。

整數集合的功能

是集合鍵的底層實現之一,當一個集合只包含整數值的元素,而且元素數量不多,Redis就會採用整數集合作為集合鍵的底層實現。

整數集合的升級

當新新增的元素型別超出了現有的型別長度,就需要對集合進行升級,也就是提升儲存陣列的型別。再新增元素進去。

升級整數集合並新增新元素的步驟如下:

  1. 根據新元素型別,擴容底層的陣列空間大小,併為新元素分配空間;
  2. 將底層所有的元素都轉化為新元素的相同的型別,並放在正確的位置上,還要維持元素的有序性;
  3. 新增新元素到陣列中。

對於原有元素型別的升級和移動,其實就是再陣列中根據前面元素偏移的位置進行一個再偏移。其中可能需要空出新元素的位置。

升級的好處:

  • 提升靈活性,可以隨意新增不同型別的整型到集合中。
  • 節約記憶體,儘可能用最小的型別來儲存元素。

不支援降級的操作。


6.壓縮列表

壓縮列表的功能

是列表鍵和雜湊鍵的底層實現之一,當一個列表鍵只包含少量列表項,並且每個列表項中只有整數或者長度比較短的字串,那麼Redis就會採用壓縮列表來做列表鍵的底層實現。

壓縮列表的構成

一個壓縮列表中包含多個節點,每個節點中儲存一個位元組陣列或者整型。

列表中包含的屬性:

  • zlbytes:記錄整個列表佔用的記憶體數量,用於對列表的記憶體再分配。
  • zltail:記錄列表尾節點到起始地址之間有多少個位元組,通過這個偏移量,就不需要遍歷就能獲得尾節點的地址。
  • zllen:記錄列表包含的節點數量。
  • entryX:包含的各個節點。
  • zlend:用於標記列表的末端,0xFF。

節點中包含:

  • previous_entry_length:記錄前一個節點的長度。
  • encoding:表示當前節點儲存資料的型別,有不同長度的位元組陣列和不同整型等。
  • content:儲存系欸但的值,值得型別和長度由encoding決定。

物件

Redis並沒有直接使用上面講的資料結構來實現鍵值對資料庫,而是基於這些資料結構建立了一個物件系統,這個系統主要包含五類物件:

  • 字串
  • 列表
  • 雜湊
  • 集合
  • 有序集合

使用物件的好處:

  • 可以根據不同的使用場景,為物件設定不同的資料結構,從而優化不同場景下的效率。
  • Redis物件系統實現了基於引用計數法的垃圾回收機制,同時可以讓多個數據庫鍵共享一個物件來節約記憶體。
  • 物件具有訪問時間記錄,這樣可以實現類似LRU的記憶體換頁機制。

物件的型別和編碼

Redis中物件都由一個redisObject結構表示:

typedef struct redisObject 
{
	// 型別
	unsigned type:4;
	// 編碼
	unsigned encoding:4;
	// 指向底層資料結構的指標
	void *ptr;
}

其中,型別就是上面提到的五類物件。物件的ptr指向了底層的資料結構,而這些資料結構就是由編碼屬性來決定的。實現同一物件型別可以有很多不同的資料結構,一次適應實際的場景。

字串物件

字串物件的編碼有三種:

  • int:如果字串儲存的整數,那麼就用這種編碼,裡面用了一個long來儲存。
  • raw:如果儲存的是一個字串值,而且字串的長度大於32,那麼就用會這種編碼,裡面就是用了SDS。
  • embstr:如果儲存的字串值長度小於32,就會用這種編碼。

在對字串操作過程中,如果發生了修改使其不滿足原先編碼的條件,那就就會進行編碼的轉換。

列表物件

列表物件的編碼有兩種:

  • ziplist:利用壓縮列表作為底層實現。列表中儲存的所有的字串長度小於53位元組,列表中儲存的元素數量小於612。
  • linkedlist:利用雙端連結串列作為底層實現。如果壓縮列表的條件不滿足,才會使用連結串列。

雜湊物件

雜湊物件的編碼兩種:

  • ziplist:使用壓縮列表作為底層實現。儲存鍵值對的時候,分別生成鍵和值的兩個節點,鍵節點在前,值節點在後,一起從表尾壓縮列表。如果所有鍵和值的字串長度小於64位元組,同時,鍵值對數量小於512個,就會採用這種編碼。
  • hashtable:使用字典作為底層實現,每個鍵值對都使用字典鍵值來儲存,每個鍵和值都是字串物件。

集合物件

集合物件的編碼有兩種:

  • intset:使用整數集合作為底層實現,所有元素都儲存在整數集合中。儲存的元素都是整數,同時儲存元素的個數小於等於512個,就會採用這種編碼。
  • hashtable:使用字典作為底層實現。

有序集合物件

有序結合物件編碼有兩種:

  • ziplist:採用壓縮列表作為底層的實現,每個集合元素用兩個挨在一起的壓縮列表節點來儲存,第一個儲存元素的成員,第二個儲存元素的分值。壓縮列表內的元素從小到達進行排序。
  • skiplist:使用zset結構作為底層實現,一個zset結構包含一個字典和一個跳躍表。

為什麼需要同時使用字典和跳躍表的方式來實現?
如果只用字典,那麼無法保證集合有序,範圍型操作就需要排序,這樣時間複雜度就是nlongn的;如果只使用跳躍表,那麼查詢的時候就要遍歷查詢跳錶,時間複雜度是longn的。所以,同時使用這兩種資料結構,就能將查詢和範圍操作的時間複雜度都為O1,空間換時間。

記憶體回收

Redis採用引用計數法進行垃圾回收,在每個物件中添加了引用計數,如果計數值為0,說明物件不會再被使用,那麼物件就會被釋放。

怎麼解決迴圈引用的問題?

物件共享

Redis會共享值為0到9999的字串物件。有點像JVM的常量池。