Redis(二)--- Redis的底層資料結構

MouseDong發表於2019-07-24

1、Redis的資料結構

Redis 的底層資料結構包含簡單的動態字串(SDS)、連結串列、字典、壓縮列表、整數集合等等;五大資料型別(資料物件)都是由一種或幾種數結構構成。

在命令列中可以使用 OBJECT ENCODING key 來檢視key的資料結構。

2、簡單動態字串SDS

 redis是使用C語言編寫的,但是string資料型別並沒有使用C語言的字串,而是重新編寫一個簡單的動態字串(simple dynamic string,SDS)。

 1 /*
 2   * 儲存字串物件的結構
 3   */
 4 struct sdshdr {
 5   
 6     // buf 中已佔用空間的長度
 7     int len;
 8   
 9     // buf 中剩餘可用空間的長度
10     int free;
11  
12     // 資料空間
13     char buf[]
14 };

 

使用SDS儲存字串Redis,具體表示如下:

                                              圖片來自《Redis設計與實現》 黃健巨集著

 

    • free 表示buf陣列中剩餘的空間數量
    • len 記錄了buf陣列中已儲存的位元組長度
    • buf 陣列是一個char型別的資料,記錄具體儲存的字串,並且以 ‘\0’(空字元) 作為結束識別符號

 SDS定義較C語言的字串幾乎相同,就是多出兩個屬性free,len;那為何不直接使用C語言的字串呢?

1、獲取字串長度複雜度為O(1)

        由於C語言沒有儲存字串長度,每次獲取字串長度多需要進行迴圈整個字串計算,時間複雜度為O(N);而SDS記錄了儲存的字串的長度,獲取字串長度時直接獲取len的屬性值即可,時間複雜度為O(1);而SDS中設定和更新長度是API中自動完成,無需手動進行操作。

2、杜絕緩衝區溢位

 C語言在進行兩個字串拼接時,一旦沒有分配足夠的記憶體空間,就會造成溢位;而SDS在修改字串時,會先根據len的值,檢查記憶體空間是否足夠,如果不足會先分配記憶體空間,再進行字串修改,這樣就杜絕了緩衝區溢位。

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

C語言不記錄字串長度,所以當修改時,會重新分配記憶體;如果是正常字串,記憶體空間不夠會產生溢位;如果是縮短字串,不重重分配會產生洩露。

SDS採用空間預分配和惰性釋放空間兩種優化策略

空間預分配:對字串進行增長操作,會分配出多餘的未使用空間,這樣如果以後的擴充套件,在一定程度上可以減少記憶體重新分配的次數。

惰性釋放空間:對字串經過縮短操作,並不會立即釋放這些空間,而是使用free來記錄這些空間的數量,當進行增長操作時,這些記錄的空間就可以被重新利用;SDS提供了響應的API進行手動釋放空間,所以不會造成記憶體浪費。

4、二進位制安全

C語言的字串中不能包含空字元(因為C語言是以空字元判斷字串結尾的),所以不能儲存一些二進位制檔案(有可能包含空字元,如圖片);SDS則是以len來判斷字串結尾,所以SDS結構可以儲存圖片等,並且都是以二進位制方式進行處理。

5、相容部分C字串函式

SDS結構中buf儲存字串同樣是以空字元結尾,所以可以相容C語言的部分字串操作API。

總結:

                                             表來源:《Redis設計與實現》

 

3、連結串列

Redis使用C語言編寫,但並沒有內建連結串列這種資料結構,而是自己構建了連結串列的實現;構成連結串列結構的節點為連結串列節點。

連結串列用的非常廣泛,如列表鍵、釋出與訂閱、慢查詢、監視器等。

1 typedef struct listNode {
2     // 前置節點
3     struct listNode * prev;
4     // 後置節點
5     struct listNode * next;
6     // 節點的值
7     void * value;
8 }listNode;

多個listNode可以通過prev和next指標構成雙端連結串列,使用list持有連結串列

 1 typedef struct list {
 2     // 表頭節點
 3     listNode * head;
 4     // 表尾節點
 5     listNode * tail;
 6     // 連結串列所包含的節點數量
 7     unsigned long len;
 8     // 節點值複製函式
 9     void *(*dup)(void *ptr);
10     // 節點值釋放函式
11     void (*free)(void *ptr);
12     // 節點值對比函式
13     int (*match)(void *ptr,void *key);
14 } list;
    • head 表頭指標
    • tail 表尾指標
    • len 連結串列長度計數器
    • dup、free、match 多型連結串列所需的型別特定的函式

Redis連結串列實現的特性如下:

1、雙端

連結串列節點帶有prev和next指標,可以快速獲取前置和後置節點,時間複雜度都是O(1)。

2、無環

 頭節點prev指標和尾節點next指標都指向null,對連結串列訪問以NULL為終點。

3、帶表頭指標和表尾指標

可以快速的獲取表頭節點和表尾節點。

4、有連結串列長度計數器

可以快速獲取連結串列長度。 

5、多型

連結串列可以儲存各種不同型別的值,通過list的dup,free,match三個屬性為節點設計值型別特定的函式。

 

4、字典

字典又稱為符號表(symbol table)、關聯陣列(associative array)或者對映(map);字典中儲存key-value鍵值對,並且key不重複;

字典在Redis中廣泛應用,如Redis資料庫就是使用字典作為底層實現的。

Redis使用的C語言沒有內建這種結構,所以Redis構建了自己的字典實現。

字典使用雜湊表作為底層試下,一個雜湊表包含多個雜湊節點,每個雜湊節點儲存一個鍵值對。

雜湊表

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

圖中是一個大小為4的空雜湊表

    • table是一個陣列,陣列元素是dictEntry結構的指標,每個dictEntry儲存一個鍵值對
    • size 記錄雜湊表的大小
    • sizemask 值總是等於size-1,這個屬性和雜湊值一起決定一個鍵應該被方法table陣列的哪個索引上
    • used 記錄雜湊表目前已有節點的數量

雜湊表節點 

 1 typedef struct dictEntry {
 2     //
 3     void *key;
 4     //
 5     union{
 6         void *val;
 7         uint64_tu64;
 8         int64_ts64;
 9     } v;
10     // 指向下個雜湊表節點,形成連結串列
11     struct dictEntry *next;
12 } dictEntry;
    • key屬性儲存著鍵值對中的鍵,v屬性儲存著鍵值對中的值
    • 鍵值對中的值可以使指標val、一個uint64_t整數,或是一個int64_t整數
    • next是指向另一個雜湊表節點的指標,用以解決多個雜湊值衝突問題

下圖為將兩個索引值相同的鍵連在一起

 

字典結構

 1 typedef struct dict {
 2     // 型別特定函式
 3     dictType *type;
 4     // 私有資料
 5     void *privdata;
 6     // 雜湊表
 7     dictht ht[2];
 8     // rehash索引
 9     //當rehash不在進行時,值為-1
10     in trehashidx; /* rehashing not in progress if rehashidx == -1 */
11 } dict;
12 
13 typedef struct dictType {
14     // 計算雜湊值的函式
15     unsigned int (*hashFunction)(const void *key);
16     // 複製鍵的函式
17     void *(*keyDup)(void *privdata, const void *key);
18     // 複製值的函式
19     void *(*valDup)(void *privdata, const void *obj);
20     // 對比鍵的函式
21     int (*keyCompare)(void *privdata, const void *key1, const void *key2);
22     // 銷燬鍵的函式
23     void (*keyDestructor)(void *privdata, void *key);
24     // 銷燬值的函式
25     void (*valDestructor)(void *privdata, void *obj);
26 } dictType;
    • type 屬性是一個指向dictType結構的指標,每個dictType機構儲存了一簇用於操作特定型別鍵值對的函式,Redis貨位用途不同的字典設定不同的型別特定函式。
    • privdata 屬性儲存了需要傳給那些型別特定函式的可選引數。
    • ht 屬性是一個長度為2的陣列,陣列中的每個元素都是一個雜湊表,一般情況下自字典只使用ht[0],ht[1]只會在進行rehash時使用.
    • trehashidx 屬性記錄了rehash目前的進度,如果沒有進行rehash則它的值為-1。

下圖為普通狀態下的字典結構

當一個新的鍵值對要新增到字典中去時,會涉及到一系列的操作,如計算索引、解決衝突、擴容等等,下面對這些操作進行描述。

1、雜湊演算法

新增鍵值對時,首先要根據鍵值對的鍵計算出雜湊值和索引值,然後再根據索引值進行放入

1 #使用字典設定的雜湊函式,計算鍵key的雜湊值
2 hash = dict->type->hashFunction(key);
3 #使用雜湊表的sizemask屬性和雜湊值,計算出索引值
4 #根據情況不同,ht[x]可以是ht[0]或者ht[1]
5 index = hash & dict->ht[x].sizemask;

2、結局鍵衝突

當有兩個或以上數量的鍵值被分配到了雜湊表陣列的同一個索引上時,就發生了鍵衝突。

Redis的雜湊表使用單向連結串列解決鍵衝突問題,每個新的鍵總是新增到單項鍊表的表頭。

3、rehash(擴充套件或收縮)

雜湊表具有負載因子(load factor),其始終需要保持在一個合理的範圍之內,當hashI表儲存的鍵值對過多或過少時,就需要對雜湊表進行rehash(重新雜湊)操作,步驟許下

(1) 為字典的ht[1]分配空間,空間大小:如果是擴充套件操作則為ht[0].used * 2 ,也就是擴充套件為當前雜湊表已使用空間的1倍;如果是收縮,則減小1倍。

(2) 將ht[0]內的資料重新計算雜湊值和索引,並放到新分配的ht[1]空間上。

(3) 全部遷移完成後,將ht[1]設定為ht[0],釋放ht[0]並建立一個空白的雜湊表為ht[1],為下次rehash做準備。

4、雜湊表的擴充套件與收縮觸發條件

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

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

以上條件中任意一條被滿足,程式自動開始對雜湊表進行擴充套件;

負載因子演算法:負載因子 = 雜湊表以儲存的節點數量 / 雜湊表大小

當負載因子小於0.1時,程式自動進行收縮操作。

5、漸進式rehash

漸進式rehash就是,當ht[1]的鍵值對向ht[1]遷移的過程中,如果資料量過大,則不能一次性遷移, 否則會對伺服器效能造成影響,而是分成多次,漸進式的進行遷移。

在rehash期間,會維持一個索引計數器rehashidx,並把每次的遷移工作分配到了新增、刪除、查詢、更新操作中,當rehash工作完成後rehashidx會增加1,這樣所有的ht[0]的值全部遷移完成後,程式會將rehashidx這是為-1,標識最終的rehash完成。

6、漸進式rehash之情期間的表操作

由於漸進式rehash期間,ht[0]和ht[1]中都有資料,當查詢時,會先在ht[0]中進行,沒找到繼續到ht[1]中找;而新增操作一律會新增到ht[1]中。

 

字典總結: 

Redis字典底層機構實現與java(1.6之前) 中的hashmap非常相像,都是使用單項鍊表解決鍵衝突問題。

個人疑問:jdk1.8以上已經是用紅黑樹解決多個鍵衝突問題,不知redis的鍵衝突是否也可以用紅黑樹?

 

5、跳躍表

跳躍表(skiplist)資料結構特點是每個節點中有多個指向其他節點的指標,從而快速訪問節點。

跳躍表結構由跳躍表節點(zskiplistNode)和zskiplist兩個結構組成

跳躍表節點

 1 typedef struct zskiplistNode {
 2     //
 3     struct zskiplistLevel {
 4         // 前進指標
 5         struct zskiplistNode *forward;
 6         // 跨度
 7         unsigned int span;
 8     } level[];
 9     // 後退指標
10     struct zskiplistNode *backward;
11     // 分值
12     double score;
13     // 成員物件
14     robj *obj;
15 } zskiplistNode;
    • 層:為一個陣列,陣列中的每個資料都包含前進指標和跨度。
    • 前進指標:指向表尾方向的其他節點的指標,用於從表頭方向到表尾方向快速訪問節點。
    • 跨度:記錄兩個節點之間的距離,跨度越大,兩個節點相聚越遠,所有指向NULL的前進指標的跨度都為0。
    • 後退指標:用於從表尾節點向表頭節點訪問,每個節點都有後退指標,並且每次只能後退一個節點。
    • 分值:節點的分值是一個double型別的浮點數,跳躍表中的說有分值按從小到大排列。
    • 成員物件:是一個指向字串的指標,字串則儲存著一個SDS值。

跳躍表

1 typedef struct zskiplist {
2     // 表頭節點和表尾節點
3     structz skiplistNode *header, *tail;
4     // 表中節點的數量
5     unsigned long length;
6     // 表中層數最大的節點的層數
7     int level;
8 } zskiplist;

    • header 指向跳躍表的表頭節點,tail指向跳躍表的表尾節點,level記錄節點中的最大層數(不含表頭節點),length跳躍表包含節點數量(不含表頭節點)。
    • 跳躍表由很多層構成(L1、L2 ...),每個層都帶有兩個屬性前進指標和跨度。
    • 每個節點都包含成員物件(obj)、分值(score)、後退指標(backward),頭結點也包含這些屬性但不會被用到

在此處只是介紹跳躍表的結構相關,關於跳躍表的層的形成,物件的插入、刪除、查詢等操作的原理在此處不做詳解,另外會有文章進行說明。

 

6、整數集合

整數集合(intset)是集合鍵的底層實現之一,當一個集合只包含整數元素,並且元素的個數不多時,Redis就會使用整數集合作為集合鍵的底層實現。

整數集合可以儲存int16_t、int32_t、int64_t的整數值,並且不會出現重複元素

1 typedef struct intset {
2     // 編碼方式
3     uint32_t encoding;
4     // 集合包含的元素數量
5     uint32_t length;
6     // 儲存元素的陣列
7     int8_t contents[];
8 } intset;
    • contents陣列儲存的是集合中的每個元素,他的型別是int8_t,但儲存資料的實際型別取決於編碼方式encoding
    • encoding編碼方式有三種INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64分別對應的是int16_t、int32_t、int64_t型別
    • length記錄整數集合的元素數量,即contents陣列的長度

整數集合的升級操作

整數集合中原來儲存的是小型別(如:int16_t)的整數,當插入比其型別大(如:int_64_t)的整數時,會把整合集合裡的元素的資料型別都轉換成大的型別,這個過程稱為升級

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

(1)根據新元素的型別,擴充套件整數集合的底層資料的空間大小,併為新元素分配空間。

(2)將現有的所有元素的型別轉換成與新元素相同的型別,保持原有資料有序性不變的情況下,把轉換後的元素放在正確的位置上。

(3)將新元素新增到陣列裡。

新元素引發升級,所以新元素要麼比所有元素都大,要麼比所有元素都小。

    • 當小於所有元素時,新元素放在底層陣列的最開頭
    • 當大於所有元素時,新元素放在底層資料的最末尾

升級操作的好處

    • 提升整數的靈活性,可以任意的向集合中放入3中不同型別的整數,而不用擔心型別錯誤。
    • 節約記憶體,整數集合中只有大型別出現的時候才會進行升級操作。

整數集合不支援降級操作

 

7、壓縮列表

壓縮列表(ziplist)是Redis為了節約記憶體而開發,是一系列特殊編碼的連續記憶體塊組成的順序型資料結構。

一個壓縮列表可以包含任意多個節點,每個節點可以儲存一個位元組陣列或者一個整數值。

下圖為壓縮列表的結構

每個壓縮列表含有若干個節點,而每個節點都由三部分構成,previous_entry_length、encoding、content,如圖:

 

    • previous_entry_length 儲存的是前一個節點的長度,由於壓縮列表記憶體塊連續,使用此屬性值可以計算前一個節點的地址,壓縮列表就是使用這一原理進行遍歷。
    • previous_entry_length 如果前一節點長度小於254位元組,那麼previous_entry_length屬性本身長度為1位元組,儲存的指就是前一節點的長度;如果大於254個位元組,那麼previous_entry_length屬性本身長度為5個位元組,前一個位元組為0xFE(十進位制254),之後四個位元組儲存前一節點的長度。
    • encoding 記錄本節點的content屬性所儲存資料的型別及長度,其本身長度為一位元組、兩位元組或五位元組,值得最高位為00、01或10的是位元組陣列的編碼,最高位以11開頭的是整數編碼。
    • content 儲存節點的值,可以是一個位元組陣列或者整數。

連鎖更新

當對壓縮列表進行新增節點或刪除節點時有可能會引發連鎖更新,由於每個節點的 previous_entry_length 存在兩種長度1位元組或5位元組,當所有節點previous_entry_length都為1個位元組時,有新節點的長度大於254個位元組,那麼新的節點的後一個節點的previous_entry_length原來為1個位元組,無法儲存新節點的長度,這是就需要進行空間擴充套件previous_entry_length屬性由原來的1個位元組增加4個位元組變為5個位元組,如果增加後原節點的長度超過了254個位元組則後續節點也要空間擴充套件,以此類推,最極端的情況是一直擴充套件到最後一個節點完成;這種現象稱為連鎖更新。在日常應用中全部連鎖更新的情況屬於非常極端的,不常出現。

 

8、總結

Redis的底層資料結構共有六種,簡單動態字串(SDS)、連結串列、字典、跳躍表、整數集合、壓縮列表。

Redis中的五大資料型別的底層就是由他們中的一種或幾種實現,資料的儲存結構最終也會落到他們上。

可是在redis命令下使用 OBJECT ENCODING 命令檢視鍵值物件的編碼方式,也就是是以哪種結構進行的底層編碼。

 

 參考:

《Redis設計與實現》黃健巨集著,網上對Redis的詳解等

 

此部落格為筆者使用redis很久之後,參考網路上各類文章總結性書寫,原創手打,如有錯誤歡迎指正。

相關文章