前段時間翻看了Redis的源代碼(C語言版本,Git地址:https://github.com/antirez/redis),
過了一遍Redis數據結構,包括SDS、ADList、dict、intset、ziplist、quicklist、skiplist。
在此進行總結
一、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.跳表如何提高效率
舉個例子
譬如查詢72這個元素,如果只有原始鏈表,你需要依次遍歷14-23-34-43-50-59-66-72,總共8個節點,而通過上面2條鏈表,你將依次遍歷14-50-50-72,總共4個節點。
這個例子已經說明了跳表通過冗余空間對查詢效率的優化,但是我們還需要理論證明它帶來的查詢效率的優化對於所有case存在而不是僅僅某些特定的case。
3.跳表查詢優化證明
跳表查詢優化實際上利用了二分查找的思想,基於有序鏈表的二分查找。
觀察跳表結構,從最底層開始,每隔一個或者兩個節點向上抽取一個節點作為索引鏈表,當抽取到最頂層時,最終只剩兩個元素。
查詢時,從頂層鏈表開始將查詢關鍵字與鏈表節點進行對比,逐層向下進行查找。
譬如查詢72這個元素的過程如下:
對比72、14,發現59>14。
對比72、50,發現59>50.
50在頂層是最后一個元素,從50節點下降一層。
對比72、72,查找成功返回節點
通過這樣的查找方式,優化了時間效率。
一般來說,跳表存儲的關鍵字越多,跳表的冗余數據也會越多,跳表的層數也越高,並且,
實際上,到底隔多少個節點向上抽取一個節點並不是固定的。
若抽取節點的間距越大,則使用冗余空間越少,跳表總層數越小,查詢效率越低 。
若抽取節點的間距越小(最小為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)的時間復雜度(O(1)是指插入底層鏈表,更新索引需要O(logn)的復雜度)即可完成。
5.跳表索引更新
下面考慮這樣的問題
如果我往例子中的跳表插入24、25、26、27,那么在14-34之間元素就會新增加4個,那么如果我在這之間繼續插入更多元素,但又不更新索引,那么隨着插入元素的增加,跳表的查詢效率將會退化成O(n)。
其實,這就像二叉排序樹的失衡問題,平衡二叉樹通過額外的翻轉操作來維護樹的左右平衡來確保它的效率,在跳表中,這個額外的操作就是更新索引,那么,跳表是怎么更新索引的呢?
在redis 中是通過一個隨機函數,來決定將這個結點插入到哪幾層索引中,比如隨機函數生成了值K,那么我就將這個結點添加到第一級的到第K級的索引中,以此來避免復雜度的退化,而索引更新的復雜度是O(logn)。
最后補充一點,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,備注:編程藝術)