Redis 一、資料結構與物件--五大資料型別的底層結構實現
阿新 • • 發佈:2019-01-25
原文地址:http://m.blog.csdn.net/u011531613/article/details/70193720
redis上手比較簡單,但是它的底層實現原理一直很讓人著迷。具體來說的話,它是怎麼做到如此高的效率的?閱讀R
edis設計與實現這本書,可以很好的理解Redis的五種基本型別:String,List,Hash,Set,ZSet是如何在底層實現
的。還可以瞭解Redis的其他機制的原理。我們現在來看看Redis中的基本的資料結構吧。
簡單動態字串
Redis的簡單動態字串,通常被用來儲存字串值,幾乎所有的字串型別都是SDS的。另外,SDS還常被用作緩衝區(buffer):AOF模組中的AOF緩衝區,以及客戶端狀態中的輸入緩衝區等,都是由SDS實現的。基本結構
redis裡面很多地方都用到了字串,我們知道redis是一個鍵值對儲存的非關係型資料庫,那麼所有的key都是用字串儲存的,還有字串型別,這些都是用字串儲存的。甚至包括序列化操作Dump和Restore,也是將物件序列化為字串之後好進行資料的傳輸。那麼redis的字串是怎麼實現的呢、 Redis的底層是C++實現的,我們知道C++的字串是一個以\0結尾的char陣列,字串的長度為陣列長度-1,但是redis並沒有直接使用C++的char陣列,而是自己實現了一個簡單的結構,這個結構的名字叫做簡單動態字串(simple dynamic string)。這個結構的定義如下:struct sdshdr{
int len;
int free;
int buf[];
}
sdshdr就是我們所說的簡單動態字串的結構了,這個結構有三個變數,第一個變數是字串的長度,是buf陣列已使用的位元組的數量,這個長度和C++中的長度不同,這個長度就是字串的長度,而C++中的因為還有一個字串結尾\0,所以長度比C++中的少1。 free是buf陣列中未使用的位元組的數量 buf陣列用來儲存字串,這個儲存的字串不包含最後的\0結尾。這個\0結尾在初始化redis字串時,由SDS函式自動新增,不計算在len裡面。這也意味著我們可以直接使用這個buf陣列 sdshdr s print(“%s”,s->buf),因為已經將結尾\0新增到buf數組裡面去了。buf陣列的長度等於len+free+1(1是\0)
和C++字串的比較
這樣的好處是,1)相比C++的字串,這個獲取字串長度的時間複雜度是常數,也就是O(1),不需要遍歷字串。2)而且,C++的陣列是有緩衝區溢位和記憶體洩露的風險的,而SDS巧妙的解決了這個風險,解決的措施是SDS可以自動的對陣列容量進行修改。比如進行字串拼接操作,這個時候buf陣列容量不夠的時候,若buf陣列小於1MB大小,會對buf陣列的容量擴容到原來的兩倍,如果大於1MB,那麼程式會分配1MB的free空間,這叫做空間預分配,這樣可以大大的減少因為多次空間不足導致的頻繁分配空間的情況發生。而對於空間回收,Redis的SDS採用的是惰性空間釋放,也就是說,當字串陣列buf儲存的字串內容變少時,並不立即回收空間,而是先將空間釋放,修改free值(加上釋放的空間),與此同時,你也可以呼叫redis真正釋放空間的api來釋放掉多於的空間。3)SDS是二進位制安全的。由於\0操作是SDS函式自動新增到buf陣列中的,所以buf陣列中的特殊字元(包括\0)都將被視為其本身的含義,不需要轉義符號的出現。4)相容部分C字串函式。連結串列
列表鍵的底層實現之一基本結構
Redis的連結串列也是自己實現的資料結構,因為C裡面沒有內建這種資料結構。 Redis的連結串列由連結串列和連結串列節點構成。連結串列封裝了連結串列節點,提供相關操作API,連結串列節點則封裝了每個節點的資料等相關資訊。我們來看下他們的結構typedef struct listNode{
struct listNode *prev;
struct listNode *next;
void *value;
}listNode;
這是一個連結串列節點,從中我們可以看到,連結串列節點持有三個變數,前兩個變數是兩個分別指向上一個和下一個節點的指標,value變數儲存了節點的值(多型儲存)。雖然使用ListNode可以實現一個簡單的連結串列,但是我們還是使用的list結構來封裝所有的操作,這樣操作起來會更方便。
typedef struct list{
listNode *head;
listNode *tail;
unsigned long len;
void *(*dup) (void *ptr)
void *(*free)(void *ptr)
int (*match)(void *ptr,void *key);
}list;
可以看到,這個list結構封裝了頭結點,尾節點和連結串列的長度以及若干操作,包括賦值操作,釋放操作以及節點對比的操作。而且,Redis是一個雙向無環連結串列,而且是多型的。
字典
Redis資料庫就是使用字典來實現的基本結構
字典,又稱為符號表,關聯陣列,或者對映,是一種用於儲存鍵值對的抽象資料結構。可以說Redis裡所有的結構都是用字典來儲存的。那麼字典是如何來使先的呢? 字典的結構從高層到底層實現分別是:字典(dict),字典雜湊表(dictht),雜湊表節點(dictEntry)。 我們先來看看字典雜湊表和雜湊表節點typedef struct dictht{
//雜湊表陣列
dictEntry **table;
//雜湊表大小
unsigned long size;
//雜湊表大小掩碼,
//總是等於size-1,
//用於計算索引值
unsigned long sizemask;
//該雜湊表已有的節點的數量
unsigned long used;
}dictht
註釋已經很好的解釋了每個變數的含義,下面我們來看看dictEntry的結構型別,其中key表示鍵的指標,v表示值,這個值可以是一個指標val,也可以是無符號整數或者有符號整數。
typedef struct dictEntry{
//鍵
void *key;
//值
union{
void *val;
unit64_t u64;
int64_t s64;
} v;
//指向下個雜湊表節點,形成連結串列
struct dictEntry *next;
}dictEntry;
可以看到,這個雜湊表的結構使用的是拉鍊法來實現的, 拉鍊法既可以實現基本的雜湊表結構,也能用來避免衝突。
我們知道了底層的字典是如何實現的之後,我們再來看看Redis中的字典dict結構是如何來實現的吧。
typedef struct dict{
//型別特定函式
dictType *type;
//私有資料
void *privdata;
//雜湊表
dictht ht[2]
//rehash索引
//當rehash不在進行時,值為-1
int trehashidx;
}dict;
type屬性和privdata是為了針對不同型別的鍵值對,為建立多型字典而設定的,其中type屬性指向的是一個dictType結構的指標,每個dictType結構都儲存了一系列用於操作特定型別鍵值對的函式,比如字典的增刪改查操作,redis會為用途不同的字典設定不同的型別特定函式。而privdata陣列則儲存了需要傳給那些特定函式的可選引數,瞭解即可。
ht陣列有兩項,每一項都是一個dictht雜湊表,dictht表我們已經在前面介紹過了,因此不再多說。為什麼會有兩項呢,這是因為方便再雜湊(rehash),接下來我們會詳細的說redis字典再雜湊的過程。字典會預設使用ht[0],另外一個ht[1]是用來再雜湊的。下圖是一個常規狀態下(沒有再雜湊)的字典結構圖
redis字典的雜湊演算法:
雜湊演算法比較簡單,採用的是按位與的操作,首先使用dict的type的一個函式hashFunction(key)對key計算一個hash值,然後將這個雜湊值和字典的某個雜湊表dictht的sizemask按位與計算出索引位置。見下圖redis的字典是使用拉鍊法解決衝突的。
再雜湊Rehash
redis的字典隨著不斷的增加(減少)元素,會導致裝載因子慢慢的變大(變小)(裝載因子=元素個數/ht[0].size,也就是雜湊表內實際元素個數和容量的比值,拉鍊法允許比值大於1),這個時候需要調整陣列ht陣列的大小,調整的基本方式是: 1)為字典的ht[1]分配空間(一開始空的嘛,對吧),然後分情況討論:- 如果是擴充套件操作,那麼ht[1]的大小為第一個大於等於ht[0].used*2的2的n次方
- 如果是縮小操作,那麼ht[1]的大小為第一個大於等於ht[0].used的2的n次方
漸進式Rehash
漸進式rehash在上面的rehash的基礎上,進行了改進,改進過後的步驟將rehash的過程變得不在那麼佔用cpu時間,詳細的步驟如下: 1)為ht[1]分配空間,讓字典同時擁有ht[0]和ht[1] 2)在字典中維持一個索引計數器變數rehashidx,將它的值設定為0,表示rehash工作正在進行。 3)在rehash執行期間,每次對字典的增刪改查,程式成了執行指定的操作之外,還會將ht[0]的雜湊表在rehashidx索引上的所有鍵值對rehash到ht[1],當rehash工作完成之後,程式將rehashidx的屬性值增一。(比如當前rehashidx為0,這個時候ht[0]的table陣列中索引為0的那些dictEntry會不斷的向ht[1]傳送,進行rehash分佈到ht['1]的不同位置) 4)這樣隨著操作不斷進行,rehasidx不斷的增加1,不斷的將ht[0]中的所以為0,1,2,3,上面的元素rehash到ht[1]中,總會在一定的時間之後,會rehash結束,這個時候就將rehashidx設定為-1,表示rehash操作完成。跳躍表
跳躍表這種資料結構可能接觸的不多,但是這是一種查詢操作的平均情況可以和平衡二叉樹差不多的結構。它基本的實現想法是你在儲存一個數據的同時,給這個資料賦予一個分值,這個分值代表了當前這個資料的大小,然後按照這個資料進行排序。當然實際情況要比這複雜的多。Redis只在兩個地方用到了跳躍表,一個是實現有序集合,另外一個是叢集節點中用作內部資料結構。 關於什麼是跳躍表,建議去參考這篇部落格的文章。這篇部落格的文章和書中的跳躍表最為相似:http://www.cnblogs.com/xuqiang/archive/2011/05/22/2053516.html;跳躍表的實現
跳錶是由節點和表結構兩個結構定義,名字分別為zskiplistNode 和 zskiplist,他們的 示例圖如下其中最座標的是zskiplist,它右邊的每一個都是一個zskiplistNode。每個元素的含義如下: header指向跳錶的表頭節點 tail指向跳錶的表尾節點 level表示目前跳躍表內,層數最大的那個節點的層數(表頭header不算) length表示跳錶的長度,當前跳錶含有多少個跳錶節點zskiplistNode(表頭節點不算) 層(level)在節點中用L1,L2等表示,分別表示第1層,第2層。每一層都帶有兩個屬性,指標和跨度,指標用於訪問下一個節點,跨度表示兩個節點之間的距離(距離表示他們之間相隔的節點個數+1) 後退指標(BW),指向前一個節點,當使用tail指標進行尾部遍歷的時候,就可以使用BW來進行。 分值(score):各個節點中的1.0,2.0,3.0是節點所儲存的分值,在跳躍表中,節點需要按照分值從小到大排列。 成員獨享(obj),在節點彙總用o1,o2表示,表示節點儲存的成員物件,可以是String型,可以是list型等
整數集合
整數集合時集合鍵的底層實現之一,當一個集合只包含整數值元素,並且集合元素不多時,就可以使用整數集合來作為集合鍵的底層實現。整數集合可以儲存型別Int16_t,int32_t或者int64_t的整數值,並且因為是集合,所以不會出現重複元素。typedef struct intset{
//編碼方式
uint32_t enconding;
//集合包含的元素數量
uint32_t length;
//儲存元素的陣列
int8_t contents[];
}intset;
可以看到,上面的幾行程式碼就是intset的基本結構了。其中不同屬性的含義是:
encoding:這是一個無符號32位整數,表示整數集合陣列contents儲存到的資料型別是Int16_t,int32_t或者int64_t
length:也是一個無符號32位整數,表示contents儲存的元素的個數。
contents:一個數組,並且裡面的資料按照從大到小的元素排列。並且儲存的型別由encoding決定,而不是它本身的型別int8_t
升級操作
當一個整數集合元素儲存的都是int16_t或者int32_t的時候,這個時候你如果放入一個位數比較大,超過16位或者32位,就會有引起整數陣列的升級操作,升級操作的基本過程如下: 1)先根據新元素的大小,拓展陣列contents的空間。 2)將contents陣列中的元素轉換成新元素相同的型別,並放到正確的位置上(維持有序性) 3)將新元素新增到新的contents數組裡,由於這個新元素會引起陣列升級,一般是比原來的數要大或者要小,所以要麼新增到尾部,要麼新增到頭部。 升級具有如下好處:提升整數集合的靈活性,避免一個集合內部儲存多種資料型別;節約記憶體。 整數集合不支援降級操作,一旦升級完成,就會一直保持升級後的狀態。壓縮列表
壓縮列表是列表和雜湊的底層實現之一。當一個列表只包含少量的列表項,且列表項要麼是小整數值,要麼是較短的字串時,Redis就會使用壓縮列表。另外,當一個雜湊只包含少量鍵值對,並且每個鍵值對的鍵和值要麼就是小整數值,要麼就是長度比較短的字串,那麼Redis機會使用壓縮列表來做雜湊鍵的底層實現。壓縮裡列表的結構
壓縮列表是一個總是從尾部開始遍歷的列表。因為zlbytes和zltail可以計算得到表尾,然後entryX的特殊結構又能使我們按照一定的順序從表尾遍歷我們的壓縮列表。
列表節點
可以看到,一個列表節點要麼儲存一個位元組陣列,要麼儲存一個整數值,而這些都是我們前面所說過的壓縮列表的基本含義中的內容。那麼是怎麼儲存的呢,這個就需要看一下他的結構了。如上圖,壓縮列表節點內部是由三個部分構成的,第一個節點表示上一個壓縮列表節點的長度。說到這裡,應該明白是做到從尾部遍歷的了吧。根據前面我們所說的zlbytes和zltail計算出尾部的entry,然後遍歷這個entry,然後遍歷完之後,往前便宜previous_entry_length,就可以找到上一個節點,然後依次遍歷下去。我們來看個例子。
當然,具體的實現不可能這麼簡單,下面我們來詳細的描述下,壓縮列表節點三個屬性的含義: 1)previous_entry_length:以位元組為單位,表示前一個列表節點的位元組長度。如果前一個列表節點的長度小於254位元組,那麼這個屬性只需要佔用8位(1位元組)的空間,如果前一個節點的長度大於254位元組,那麼這個屬性就會佔用5位元組,其中第一個位元組是固定值254,後面4個位元組用於表示前一節點的長度。 2)encoding記錄了content陣列所儲存的資料的型別以及長度。不同的編碼表示不同的型別以及長度。比如00、01、10開頭的表示位元組陣列,11開頭的表示整數編碼,分別表示content儲存的內容是位元組陣列還是整數。如下圖所示 3)content陣列記錄了儲存的內容
連鎖更新
前面說過,previous_entry_length屬性都記錄了前一個節點的長度,如果前一個節點長度小於254,那麼previous_entry_length長度為1個位元組,否則就是5個位元組。假設現在有這麼一種情況,在一個壓縮列表中,有多個連續的、長度介於250到253之間的列表節點,記做e1,e2.....en。這個時候,加入我們在e1前面插入一個新的節點e0,e0的長度大於254,我們會修改e1 的previous_entry_length為5,然後導致e1的長度大於254,這個時候又要修改e2的previous_entry_length,導致e2的長度大於254....這種影響會一直持續到en。這種狀況就叫做連鎖更新。除了新增節點,刪除節點也會導致連鎖更新,比如e1,e2,e3...en那個列表節點,其中e1的長度大於254,e2的長度小於254,然後e3到en的長度介於250-253之間,這個時候,刪除e2,將會導致e3到en的連鎖更新。 連鎖更新會導致n次空間重分配,每次空間分配的最壞複雜度為O(n),也就導致了O(n^2)的複雜度,這是很不好的。 雖然不好,但是這種情況並不常見,所以Redis並沒有對這種情況做特殊處理。物件
前面說了那麼多基本的資料結構,終於可以聊一下我們的物件了,也就是Redis的5大物件:字串(String),列表(List)、雜湊(Hash)、集合(Set)、有序集合(ZSet),每種物件都用到了至少一種前面介紹過的資料結構。物件型別和編碼
Redis使用物件來表示資料庫中的鍵和值,每次建立一個鍵值對時,我們都會至少建立兩個物件,一個物件用來儲存鍵值對的鍵,另一個物件用作鍵值對的值。 Redis的每個物件都有一個redisObject結構表示,如下所示:typedef struct redisObject{
//型別
unsigned type:4;
//編碼
unsigned encoding:4;
//指向底層實現的資料結構指標
void *ptr;//還有其他的資料,省略...
}robj
1)型別:表示這個物件是5鍾型別中的哪一種,我們可以使用Type命令來檢視我們所設定鍵值對的值的型別,他們通常使用型別常量來標記,比如:
2)編碼和底層實現:encoding表示底層實現的資料結構,也就是ptr指標指向的底層資料結構,也是用常量來表示,如下所示:
每種型別都至少可以使用兩種不同的編碼,也就是說,型別type表示當前是哪一種物件,encoding表示當前物件的底層實現,他們之間的對應關係如下圖所示(比如String物件型別可以是Int,EMBSTR或者RAW三種編碼實現):
字串物件
字串物件的編碼可以是int,raw或者embstr,這三種的意思我們分別來講解。 1)int:如果我們設定了一個字串物件,它儲存的是整數值,並且這個整數值可以用long型別來表示,那麼我們就可以使用int編碼,同時讓ptr指標指向一個long。我們使用一個Redis的命令來舉個例子: >SET number 10086 OK >OBJECT ENCODING number "int" 2)raw:如果一個字串物件儲存的是一個字串值,而且這個字串的長度大於32位元組,那麼就必須使用一個簡單動態字串來儲存這個字串值,編碼設定為raw。 3)embstr:既然有了raw,那麼embstr又是幹什麼的呢?非常簡單,embstr是用來儲存長度小於32位元組的字串的。embstr和raw的區別是,embstr在建立字串物件的時候,會分配一次空間,這個空間包含redisObject物件結構和sdshdr結構(儲存字串),而raw則會進行兩次分配,分表分配空間給redisObject和sdshdr。所以在字串較短時,使用embstr會有一些優勢:分配次數降低,釋放次數降低(因為只需要釋放一個空間,而raw需要釋放兩個空間),字串連續。 4)另外,浮點數也是以字串型別來儲存的。只不過使用的時候再轉化為浮點型別進行計算 5)特殊情況下,int和embstr會轉化成raw編碼的字串物件。比如對int編碼的字串物件執行了APPEND操作,使得數字不能再用long雷興表示或者添加了字元不在是數字等。int不會轉為embstr,只會變成raw。同時embstr是隻讀的,只要對embstr修改,就會程式設計raw 6)一些常用的字串命令列表物件
列表物件的編碼可以是雙端連結串列(linkedlist)或者壓縮列表(ziplist),前面我們都講過了。 如果列表使用壓縮列表來儲存元素,那麼結構就如下所示:同時,如果使用的是雙端連結串列,那麼結構圖如下所示,同時對於雙端連結串列中的每一個節點,都會巢狀一個obj,這個obj是String格式的。字串物件是五種型別中,唯一一種會被其他四種類型的物件巢狀的物件。如下圖所示,StringObject是一個redisObject,前面我們已經講過String型別的redisObject了,type為REDIS_String,encoding為int,embstr或者raw 編碼轉化 當列表物件同時滿足下面的兩個的兩個條件時,列表物件使用ziplist編碼: 當列表物件儲存的所有字串的元素長度都小於64位元組;列表物件儲存的元素數量小於512個。 不滿足這兩個條件的,需要使用linkedlist。 有一種情況,當一個列表物件的編碼是ziplist,隨著元素的增多或者修改某一個元素,導致不滿足上面的兩個條件的時候,就需要編碼轉換操作將ziplist轉化為linkedlist。 具體的轉化過程,沒有說。
雜湊物件
雜湊物件的編碼可以是ziplist或者hashtable。 對於ziplist,雜湊物件每次都是將鍵值對兩個物件加入到ziplist的尾部,先將鍵新增到ziplist的尾部,再將值新增到ziplist的尾部,這樣每次都會放入兩個值。如下圖所示如果是hashtable,由於這是一個字典,因此鍵值對直接就儲存到裡面了,所以實現起來理論上要好懂一些。如下圖所示:
編碼轉化 和列表一樣,ziplist和hashtable不能同時在hash物件中使用,因此,在儲存的鍵值對較為簡單的情況下,優先使用ziplist。使用ziplist必須滿足下列兩個條件: 雜湊物件儲存的鍵值對的鍵和值的長度都小於64位元組;雜湊物件儲存的鍵值對數量小於512個。 不滿足以上情況的,需要使用hashtable。 當一個底層編碼是ziplist的hash物件不滿足上述兩個條件時,就會引起向hashtable編碼的轉換,這就是編碼轉換。
集合物件
集合物件的編碼可以是intset或者hashtable。 對於intset,當一個集合裡面的物件都是整數,且元素數量不超過512時,底層的實現就是整數集合; 對於hashtable,底層是字典,這個時候這個字典沒有值,只是鍵。由於鍵是沒有重複的,所以,就能保證集合的無重複的性質。 當然,對於集合,也是存在編碼轉換的過程的,原理和前面的一致,不再細說。有序集合物件
有序集合的編碼可以是ziplist和skiplist 對於ziplist,底層使用壓縮列表作為實現,每個集合元素用兩個緊挨在一起的壓縮列表節點來儲存,第一個儲存集合元素的成員,迭戈元素儲存元素的分值。按照分值大小,分值較小的放到壓縮列表的表頭方向,分值較大的放到壓縮列表的表尾方向。 對於skiplist編碼,底層是用zset結構作為底層實現,而這個zset包含一個字典和一個跳躍表,如下所示:typedef struct zset{
zskiplist *zsl;
dict *dict;
}zset
zset結構中的zsl跳躍表按分值從小到大儲存了所有集合元素,每個跳躍表節點都儲存了一個集合元素:跳躍表節點的object儲存了元素的成員,而score儲存了元素的分值,利用這個跳躍表,可以對有序集合進行範圍型操作,比如ZRANK,ZRANGE等命令。除此之外,dickt字典為有序集合建立了一個從成員到分值的對映,字典中的每一個鍵值對都儲存了一個幾何元素:字典的鍵儲存了元素的成員,而字典的值則儲存了元素的分值,通過這個字典,程式可以用常數複雜度來查詢給定成員的分值,比如ZSCORE命令。
同時使用zskiplist和dict原因是為了效率,不同的操作可以使用不同的結構。而元素成員和分值可以通過指標在兩個結構之間連線起來,對空間的佔用也不會浪費很多,因此效率很高。編碼轉化 這裡的編碼轉化和前面原理一致,只不過數量不一樣了。當同時滿足下列兩個條件時,使用ziplist編碼,否則使用skiplist編碼: 有序集合儲存的元素數量小於128個,同時所有元素成員的長度小於64位元組。
型別檢查和命令多型
Redis命令可以分為兩種型別其中一種可以對任何型別的鍵執行,比如說DEL命令,EXPIRE命令,RENAME名理工,TYPE命令,OBJECT命令等。
另一種命令只能對待特定型別的鍵執行,比如:
SET、GET、APPEND、STRLEN等命令只能對字串鍵執行;
HDEL、HSET、HGET、HLE等命令只能對雜湊鍵執行;
RPUSH、LOPO、LINSERT、LLEN等命令只能對列表建執行;
SADD、SPOP、SINTER、SCARD等命令只能對集合鍵執行;
ZADD、ZCARD、ZRANK、ZSCORE等命令只能對有序集合鍵執行;
對於特定型別的命令,Redis會先檢查輸入鍵的型別是否正確,然後在確定是否執行給定的命令,這就是型別檢查。而型別檢查是通過redisObject的type屬性來實現的。 由於不同的物件有不同的實現type,而不同的type對應不同的底層實現,比如你對一個鍵執行LLEN命令,除了要進行前面的型別檢查意外,我們還知道列表可以由ziplist和linkedlist兩種不同的實現,於是根據不同的encoding,你要選擇不同的操作介面去獲得長度,這就是命令多型。記憶體回收
引用計數:c語言不具備自動記憶體回收計數,於是Redis在物件系統中構建了一個引用計數計數來實現記憶體回收。也就是說,redisObject除了我們前面講的三個變數外,還有另外的若干變數,其中一個就是叫做refcount的變數
當建立一個物件時,引用計數的值會被初始化為1;
當物件被一個新程式使用時,它的引用計數的值會增一;
當物件不再被一個程式使用時,它的引用計數的值會減一;
當物件的引用計數為0時,物件所佔用的記憶體會被釋放。