Redis原始碼分析-底層資料結構盤點

不止是程式設計發表於2019-05-15

因為專案中經常使用到Redis,所以樓主一直以來對redis的原始碼很感興趣。前段時間忽然心血來潮,抽了點時間將Redis的原始碼過了一遍,主要包括多路複用和常用資料結構的底層實現部分,看的是C語言版本的Redis(雖然樓主是JAVA程式猿)。

應該說收益頗豐,尤其是redis對各種資料結構的實現,它的每個資料結構為各種不同的應用場景,做了特定的優化,譬如資料量大的時候結構怎麼定義、資料量小的時候結構怎麼定義、由於redis是單執行緒,在hash表rehash時還採用了漸進式的hash,諸如此類。

Redis為儲存做的這些優化,充分解答了redis為何如此高效,以下是我對Redis常見資料結構的一些總結。

 

一、SDS(Simple Dynamic String) 簡單動態字串

 

SDS是redis最簡單的資料結構

sds(簡單動態字串)特點,預先分配記憶體,記錄字串長度,在原字串陣列裡新增加一串字串。

新長度newlen為原len+addlen,若newlen小於1M,則為SDS分配新的記憶體大小為2*newlen;若newlen大於等於1M,則SDS分配新的記憶體大小為newlen + 1M

SDS是以len欄位來判斷是否到達字串末尾,而不是以'\0'判斷結尾。所以sds儲存的字串中間可以出現'\0',即sds字串是二進位制安全的。

當要清空一個SDS時,並不真正釋放其記憶體,而是設定len欄位為0即可,這樣當之後再次使用到該SDS時,可避免重新分配記憶體,從而提高效率。

SDS的好處就是通過預分配記憶體和維護字串長度,實現動態字串。

 

二、ADList(A generic doubly linked list) 雙向連結串列

 

ADList就是個具有頭尾指標的雙向連結串列,沒什麼可以多說的,看一下結構體的定義

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;

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

 

三、skipList 跳錶


Redis 使用跳躍表作為有序集合鍵的底層實現之一: 如果一個有序集合包含的元素數量比較多, 又或者有序集合中元素的成員(member)是比較長的字串時, Redis 就會使用跳躍表來作為有序集合鍵的底層實現。

 

1.跳錶描述

 

先看一下比較抽象的描述

1.跳錶是一種有序資料結構, 它通過在每個節點中維持多個指向其他節點的指標, 從而達到快速訪問節點的目的。
2.跳躍表支援平均 O(log N) 複雜度的節點查詢, 還可以通過順序性操作來批量處理節點。
3.在大部分情況下, 跳躍表的效率可以和平衡樹相媲美, 並且因為跳躍表的實現比平衡樹要來得更為簡單, 所以有不少程式都使用跳躍表來代替平衡樹。

這樣的描述對資料結構理解不夠深刻的同學或許難以理解,彆著急,下面看樓主對跳錶給出解釋。

1.跳錶本質是一個有序連結串列。(此刻你應想一下一個有序連結串列是怎樣的)

2.跳錶的查詢、插入、刪除的時間複雜度均為O(logn),跟平衡二叉樹的時間複雜度是一樣的。

3.跳錶是一種通過空間冗餘來換取時間效率的資料結構。(怎麼樣的空間冗餘的有序連結串列能換取更高的查詢效率呢?)

下來看一下跳錶的資料結構的示意圖

 

注意觀察示意圖中圈起來的連結串列,它是有序的原始連結串列,存入跳錶的資料,就存在這張連結串列中。那麼上面額外的兩條連結串列又是什麼呢,他們有什麼作用呢?

是的,上面的兩條連結串列就是所謂的冗餘空間,他們被稱為跳錶的索引,通過這樣的冗餘空間就可以在有序連結串列的基礎上實現更高的查詢效率。具體怎麼提高查詢效率的呢?

 

2.舉個例子

 

譬如查詢59這個元素,如果只有原始連結串列,你需要依次遍歷14-23-34-43-50-59,總共6個節點,而通過上面2條連結串列,你將依次遍歷14-50-59,總共3個節點。

 

這個例子已經說明了跳錶通過冗餘空間對查詢效率的優化,但是我們還需要理論證明它帶來的查詢效率的優化對於所有case存在而不是僅僅某些特定的case。

 

3.跳錶查詢優化證明

 

跳錶查詢優化實際上利用了二分查詢的思想,基於有序連結串列的二分查詢。

觀察跳錶結構,從最底層開始,每隔一個或者兩個節點向上抽取一個節點作為索引連結串列,當抽取到最頂層時,最終只剩兩個元素。

查詢時,從頂層連結串列開始將查詢關鍵字與連結串列節點進行對比,逐層向下進行查詢。

譬如查詢59這個元素的過程如下:

對比59、14,發現59>14。

對比59、50,發現59>50.

50在頂層是最後一個元素,從50節點下降一層。

對比59、72,發現59<72.

即59>50且59<72,從50節點下降一層。

對比59、59,查詢成功返回節點

通過這樣的查詢方式,優化了時間效率。

一般來說,跳錶儲存的關鍵字越多,跳錶的冗餘資料也會越多,跳錶的層數也越高,並且,

實際上,到底隔多少個節點向上抽取一個節點並不是固定的。

若抽取節點的間距越大,則使用冗餘空間越少,跳錶總層數越小,查詢效率越低 。

若抽取節點的間距越小(最小為1),則使用冗餘空間越多,跳錶總層數越大,查詢效率越高。

這便是以空間換取時間。

 

4.跳錶結構體c語言定義

 

跳錶的示意圖看起來很複雜,那麼怎麼用c語言實現跳錶呢。其實,跳錶的實現非常簡單,看一下跳錶結構體的基本定義。

struct skipList{

int lenth;

skipListNode* head;

}skiplist;

跳錶結構體記錄了儲存的元素個數和跳錶頭節點。

struct skipListNode{

skipListNode* levelnext[3];   

int currentlevel;

int totallevel;

int value;

}

跳錶節點結構體除了維護儲存的關鍵字外還儲存下一個連結串列節點指標levelnext和當前層數currentlevel。可以看見,下一個連結串列節點指標是一個大小為3的陣列.

陣列中的元素指向該節點在某一層的下一個節點。

譬如示意圖中的14節點,它的level[0]指向23,level[1]指向34,level[2]指向50。當需要降層查詢時,只需要將clevel-1即可。這樣便實現了跳錶的基本查詢邏輯。

而跳錶的插入刪除邏輯,在經過O(logn)複雜度查詢到待刪除節點或插入位置後,經過O(1)的時間複雜度即可完成。

 

5.跳錶索引更新

 

下面考慮這樣的問題

如果我往例子中的跳錶插入24、25、26、27,那麼在14-34之間元素就會新增加4個,那麼如果我在這之間繼續插入更多元素,但又不更新索引,那麼隨著插入元素的增加,跳錶的查詢效率將會退化成O(n)。

其實,這就像二叉排序樹的失衡問題,平衡二叉樹通過額外的翻轉操作來維護樹的左右平衡來確保它的效率,在跳錶中,這個額外的操作就是更新索引,那麼,跳錶是怎麼更新索引的呢?

在redis 中是通過一個隨機函式,來決定將這個結點插入到哪幾層索引中,比如隨機函式生成了值K,那麼我就將這個結點新增到第一級的到第K級的索引中,以此來避免複雜度的退化。

 

最後補充一點,redis中的 跳錶實際上是雙向的,並且儲存頭尾指標,支援雙向遍歷。

 

四、ziplist(壓縮連結串列)

 

1.ziplist介紹

 

ziplist是經過特殊編碼的方式壓縮的集合

redis中,當list和hash元素較少並且數值較小時,使用ziplist實現,因為在資料量小的時候ziplist的查詢效率接近於O(1),與hash效率相似,ziplist是一整塊連續記憶體,實質是個陣列,不利於插入刪除和查詢。刪除節點時,將節點之後的所有節點前移。由於節點儲存前一個節點的長度(可能一個位元組,可能4個位元組),如果刪除某節點後導致之後的節點長度發生變化,需要級聯更新之後的各個節點長度,直到不用更新長度的節點為止。

ziplist唯一的優勢:以位元組為單位,通過壓縮變長編碼的方式節省大量儲存空間,當需要使用時,資料可以從磁碟中快速匯入記憶體中處理,而資料在記憶體中的操作速度是極快的,通過節省儲存空間的方式節省了時間。

 

2.ziplist資料結構

 

我們先看一個普通陣列arr[100]的空間利用情況

struct Node{

 int value1;

    long value2;

}arr[100];

arr陣列記憶體2個變數value1、value2,分別是int及long型別,分別佔用4個位元組和8個位元組,當我們儲存小數值時,就會導致記憶體的浪費,如arr[0].value1=100,實際上100僅使用了1個位元組,但是卻佔用了4個位元組。

而ziplisl就是redis為充分利用儲存空間所設計的資料結構,實際上就是一個位元組陣列。

char ziplist[];

所有型別的資料,包括long、int、指標型別、字串型別,在存入ziplist時都會先被壓縮編碼。

 

3.關於ziplist的兩個問題

 

問:由於儲存進ziplist的元素都會被壓縮編碼,ziplist中每個節點所佔的位元組數並不是固定的,那麼ziplist能否用連結串列來儲存呢?

答:如果用連結串列的方式來儲存節點,會佔用離散空間,離散的空間容易產生記憶體碎片、並且不易匯入記憶體,而用陣列的方式,則可以使用連續記憶體空間。比起離散空間的連結串列,連續空間的陣列更有利於將ziplist匯入記憶體,這就是ziplist使用陣列實現的原因。

問:ziplist使用位元組陣列實現,但是由於每個節點的位元組數不固定,ziplist又該如何區分兩個節點呢?

答:為了區分兩個節點,ziplist中的節點需要儲存自身節點的長度,通過自身節點的長度,從而可以定位到該節點下一個節點的首位元組,相當於是下一個節點的指標。

另外,ziplist中的節點還儲存了前一個節點的長度,通過它,可以定位到該節點的前一個節點的首位元組,相當於是前一個節點的指標。從這個角度上來講,ziplist又是一個雙向連結串列。

 

4.zpilist格式

 

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

zlbytes:ziplist佔總位元組數

zltail:最後一個元素的偏移量,相當於ziplist的尾指標。

zllen:entry元素個數

zlend :ziplist結束標誌位

entry:ziplist的各個節點

 

ziplist的entry 的格式:

<prevlen> <encoding> <entry-data>

prevlen :前一個元素的長度,相當於節點儲存前一個元素的指標。

encoding: 記錄了當前節點儲存的資料的型別以及當前節點長度,相當於節點儲存後一個元素的指標。

entry-data :經過壓縮後的資料

 

 5.ziplist總結

 

通過觀察ziplist結構體的定義可知,ziplist就是用一個位元組陣列,儲存了雙向連結串列,既壓縮了資料,又保證了儲存空間的連續性,從而極大方便了將資料從硬碟匯入記憶體進行快速處理。

 

五、quicklist(快速連結串列)

 

1.quicklist介紹

 

Redis對外暴露的list資料結構,其底層實現所依賴的內部資料結構就是quicklist。quicklist就是一個塊狀的雙向壓縮連結串列。

考慮到雙向連結串列在儲存大量資料時需要更多額外記憶體儲存指標並容易產生大量記憶體碎片,以及ziplist的插入刪除的高時間複雜度,兩個資料結構的缺陷會導致在資料量很大或插入刪除操作頻繁的極端情況時,效能極其低下。

Redis為了避免資料結構在極端情況下的低效能,將雙向連結串列和ziplist綜合起來,成為了較雙向連結串列及ziplist效能更加穩定的quicklist

 

2.quicklist結構體定義

 

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* 列表中所有資料項的個數總和 total count of all entries in all ziplists */
    unsigned int len;           /* quicklist節點的個數,即ziplist的個數 number of quicklistNodes */
    int fill : 16;              /* / / ziplist大小限定,由list-max-ziplist-size給定 fill factor for individual nodes */
    unsigned int compress : 16; /* 節點壓縮深度設定,由list-compress-depth給定 depth of end nodes not to compress;0=off */
} quicklist;
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl; // 資料指標,如果沒有被壓縮,就指向ziplist結構,反之指向quicklistLZF結構
    unsigned int sz;             /* 表示指向ziplist結構的總長度(記憶體佔用長度)ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* 編碼方式,1--ziplist,2--quicklistLZF RAW==1 or LZF==2 */
    unsigned int container : 2;  /* 預留欄位,存放資料的方式,1--NONE,2--ziplist NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /*解壓標記,當檢視一個被壓縮的資料時,需要暫時解壓,標記此引數為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;

需要特別說明的一點是,REDIS使用quicklist的目的是使資料結構在最壞情況下也能有較穩定的效能,然而為了獲得穩定的效能,quicklist在最好情況下的操作的效能不如單純的adlist或者ziplist。

這一點在新人剛開始學習複雜資料結構的時候常常會被忽略,所以說,沒有最好的資料結構,只有最適用的場景

六、dict(字典)

 

 1.dict介紹

 

在redis中資料結構中,dict字典,就是hash表。

它的實現原理與jdk中的hashmap的實現原理非常類似,都是通過連結串列的方式(jdk1.8後引入紅黑樹)解決hash衝突。

 

2.dict rehash

 

dict與hashmap的不同主要體現在在擴容時的rehash操作。 

 

jdk中的hashmap的rehash操作是一次性rehash,被呼叫後就會將整表rehash完之後再允許操作;

redis中的dict的rehash操作是漸進式rehash,漸進式rehash是指,分多次將hash表中的元素進行rehash;

 

redis dict使用漸進式rehash的好處是,避免儲存大量資料的的dict在rehash時使redis一段時間內無法響應使用者指令。

 

3.漸進式rehash原理

 

redis dict結構體包含兩個hash表,ht[0]、ht[1],其中ht[0]指向優先被使用的hash表,ht[1]指向擴容用的hash表,rehash使用dict結構體中的rehashidx屬性輔助完成,rehashidx屬性指向哪個slot,每次就將ht[0]的那個slot的元素移動到ht[1]中,然後自增rehashidx,直到遍歷完整個hash表。由於不是一次性完成rehash,rehash進行時可能穿插著查詢等操作,查詢的過程是先從ht[0]中查詢,若查詢不到,則在ht[1]中查詢元素。

 

redis的 rehash包括了lazy rehashing和active rehashing兩種方式

lazy rehashing:在每次對dict進行操作的時候執行一個slot的rehash
active rehashing:每100ms裡面使用1ms時間進行rehash。這種方式在redis的事件迴圈,servercron中有相應體現。

 

 (歡迎加qq:1363890602,討論qq群:297572046,備註:程式設計藝術)

相關文章