面試官:你看過Redis資料結構底層實現嗎?
面試中,redis也是很受面試官親睞的一部分。我向在這裡講的是redis的底層資料結構,而不是你理解的五大資料結構。你有沒有想過redis底層是怎樣的資料結構呢,他們和我們java中的HashMap、List、等使用的資料結構有什麼區別呢。
1. 字串處理(string)
我們都知道redis是用C語言寫,但是C語言處理字串和陣列的成本是很高的,下面我分別說幾個例子。
沒有資料結構支撐的幾個問題
- 及其容易造成緩衝區溢位問題,比如用
strcat()
,在用這個函式之前必須要先給目標變數分配足夠的空間,否則就會溢位。 - 如果要獲取字串的長度,沒有資料結構的支撐,可能就需要遍歷,它的複雜度是O(N)
- 記憶體重分配。C字串的每次變更(曾長或縮短)都會對陣列作記憶體重分配。同樣,如果是縮短,沒有處理好多餘的空間,也會造成記憶體洩漏。
好了,Redis自己構建了一種名叫Simple dynamic string(SDS)
的資料結構,他分別對這幾個問題作了處理。我們先來看看它的結構原始碼:
struct sdshdr{ //記錄buf陣列中已使用位元組的數量 //等於 SDS 儲存字串的長度 int len; //記錄 buf 陣列中未使用位元組的數量 int free; //位元組陣列,用於儲存字串 char buf[]; }
再來說說它的優點:
- 開發者不用擔心字串變更造成的記憶體溢位問題。
- 常數時間複雜度獲取字串長度
len欄位
。 - 空間預分配
free欄位
,會預設留夠一定的空間防止多次重分配記憶體。
更多瞭解:https://redis.io/topics/internals-sds
這就是string的底層實現,更是redis對所有字串資料的處理方式(SDS會被巢狀到別的資料結構裡使用)。
2. 連結串列
Redis的連結串列在雙向連結串列上擴充套件了頭、尾節點、元素數等屬性。
2.1 原始碼
ListNode節點資料結構:
typedef struct listNode{ //前置節點 struct listNode *prev; //後置節點 struct listNode *next; //節點的值 void *value; }listNode
連結串列資料結構:
typedef struct list{ //表頭節點 listNode *head; //表尾節點 listNode *tail; //連結串列所包含的節點數量 unsigned long len; //節點值複製函式 void (*free) (void *ptr); //節點值釋放函式 void (*free) (void *ptr); //節點值對比函式 int (*match) (void *ptr,void *key); }list;
從上面可以看到,Redis的連結串列有這幾個特點:
- 可以直接獲得頭、尾節點。
- 常數時間複雜度得到連結串列長度。
- 是雙向連結串列。
3. 字典(Hash)
Redis的Hash,就是在
陣列+連結串列
的基礎上,進行了一些rehash優化等。
3.1 資料結構原始碼
雜湊表:
typedef struct dictht { // 雜湊表陣列 dictEntry **table; // 雜湊表大小 unsigned long size; // 雜湊表大小掩碼,用於計算索引值 // 總是等於 size - 1 unsigned long sizemask; // 該雜湊表已有節點的數量 unsigned long used; } dictht;
Hash表節點:
typedef struct dictEntry { // 鍵 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下個雜湊表節點,形成連結串列 struct dictEntry *next; // 單鏈表結構 } dictEntry;
字典:
typedef struct dict { // 型別特定函式 dictType *type; // 私有資料 void *privdata; // 雜湊表 dictht ht[2]; // rehash 索引 // 當 rehash 不在進行時,值為 -1 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ } dict;
可以看出:
- Reids的Hash採用鏈地址法來處理衝突,然後它沒有使用紅黑樹優化。
- 雜湊表節點採用單鏈表結構。
- rehash優化。
下面我們講一下它的rehash優化。
3.2 rehash
當雜湊表的鍵對泰國或者太少,就需要對雜湊表的大小進行調整,redis是如何調整的呢?
- 我們仔細可以看到
dict
結構裡有個欄位dictht ht[2]
代表有兩個dictht陣列。第一步就是為ht[1]雜湊表分配空間,大小取決於ht[0]當前使用的情況。 - 將儲存在ht[0]中的資料rehash(重新計算雜湊值)到ht[1]上。
- 當ht[0]中所有鍵值對都遷移到ht[1]後,釋放ht[0],將ht[1]設定為ht[0],並ht[1]初始化,為下一次rehash做準備。
3.3 漸進式rehash
我們在3.2中看到,redis處理rehash的流程,但是更細一點的講,它如何進行資料遷的呢?
這就涉及到了漸進式rehash,redis考慮到大量資料遷移帶來的cpu繁忙(可能導致一段時間內停止服務),所以採用了漸進式rehash的方案。步驟如下:
- 為ht[1]分配空間,同時持有兩個雜湊表(一個空表、一個有資料)。
- 維持一個技術器rehashidx,初始值0。
- 每次對字典增刪改查,會順帶將ht[0]中的資料遷移到ht[1],
rehashidx++
(注意:ht[0]中的資料是隻減不增的)。 - 直到rehash操作完成,rehashidx值設為-1。
它的好處:採用分而治之的思想,將龐大的遷移工作量劃分到每一次CURD中,避免了服務繁忙。
4. 跳躍表
這個資料結構是我面試中見過最多的,它其實特別簡單。學過的人可能都知道,它和平衡樹效能很相似,但為什麼不用平衡樹而用skipList呢?
4.1 skipList & AVL 之間的選擇
- 從演算法實現難度上來比較,skiplist比平衡樹要簡單得多。
- 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯複雜,而skiplist的插入和刪除只需要修改相鄰節點的指標,操作簡單又快速。
- 查詢單個key,skiplist和平衡樹的時間複雜度都為O(log n),大體相當。
- 在做範圍查詢的時候,平衡樹比skiplist操作要複雜。
- skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的。
可以看到,skipList中的元素是有序的,所以跳躍表在redis中用在有序集合鍵、叢集節點內部資料結構
4.2 原始碼
跳躍表節點:
typedef struct zskiplistNode { // 後退指標 struct zskiplistNode *backward; // 分值 double score; // 成員物件 robj *obj; // 層 struct zskiplistLevel { // 前進指標 struct zskiplistNode *forward; // 跨度 unsigned int span; } level[]; } zskiplistNode;
跳躍表:
typedef struct zskiplist { // 表頭節點和表尾節點 struct zskiplistNode *header, *tail; // 表中節點的數量 unsigned long length; // 表中層數最大的節點的層數 int level; } zskiplist;
它有幾個概念:
4.2.1 層(level[])
層,也就是level[]
欄位,層的數量越多,訪問節點速度越快。(因為它相當於是索引,層數越多,它索引就越細,就能很快找到索引值)
4.2.2 前進指標(forward)
層中有一個forward
欄位,用於從表頭向表尾方向訪問。
4.2.3 跨度(span)
用於記錄兩個節點之間的距離
4.2.4 後退指標(backward)
用於從表尾向表頭方向訪問。
案例
level0 1---------->5 level1 1---->3---->5 level2 1->2->3->4->5->6->7->8
比如我要找鍵為6的元素,在level0中直接定位到5,然後再往後走一個元素就找到了。
5. 整數集合(intset)
Reids對整數儲存專門作了優化,intset就是redis用於儲存整數值的集合資料結構。當一個結合中只包含整數元素,redis就會用這個來儲存。
127.0.0.1:6379[2]> sadd number 1 2 3 4 5 6 (integer) 6 127.0.0.1:6379[2]> object encoding number "intset"
原始碼
intset資料結構:
typedef struct intset { // 編碼方式 uint32_t encoding; // 集合包含的元素數量 uint32_t length; // 儲存元素的陣列 int8_t contents[]; } intset;
你肯定很好奇編碼方式(encoding)欄位是幹嘛用的呢?
- 如果 encoding 屬性的值為 INTSET_ENC_INT16 , 那麼 contents 就是一個 int16_t 型別的陣列, 數組裡的每個項都是一個 int16_t 型別的整數值 (最小值為 -32,768 ,最大值為 32,767 )。
- 如果 encoding 屬性的值為 INTSET_ENC_INT32 , 那麼 contents 就是一個 int32_t 型別的陣列, 數組裡的每個項都是一個 int32_t 型別的整數值 (最小值為 -2,147,483,648 ,最大值為 2,147,483,647 )。
- 如果 encoding 屬性的值為 INTSET_ENC_INT64 , 那麼 contents 就是一個 int64_t 型別的陣列, 數組裡的每個項都是一個 int64_t 型別的整數值 (最小值為 -9,223,372,036,854,775,808 ,最大值為 9,223,372,036,854,775,807 )。
說白了就是根據contents欄位來判斷用哪個int型別更好,也就是對int儲存作了優化。
說到優化,那redis如何作的呢?就涉及到了升級。
5.1 encoding升級
如果我們有個Int16型別的整數集合,現在要將65535(int32)加進這個集合,int16是儲存不下的,所以就要對整數集合進行升級。
它是怎麼升級的呢(過程)?
假如現在有2個int16的元素:1和2,新加入1個int32位的元素65535。
- 記憶體重分配,新加入後應該是3個元素,所以分配3*32-1=95位。
- 選擇最大的數65535, 放到(95-32+1, 95)位這個記憶體段中,然後2放到(95-32-32+1+1, 95-32)位...依次類推。
升級的好處是什麼呢?
- 提高了整數集合的靈活性。
- 儘可能節約記憶體(能用小的就不用大的)。
5.2 不支援降級
按照上面的例子,如果我把65535又刪掉,encoding會不會又回到Int16呢,答案是不會的。官方沒有給出理由,我覺得應該是降低效能消耗吧,畢竟調整一次是O(N)的時間複雜度。
6. 壓縮列表(ziplist)
ziplist是redis為了節約記憶體而開發的順序型資料結構。它被用在列表鍵和雜湊鍵中。一般用於小資料儲存。
引用https://segmentfault.com/a/1190000016901154中的兩個圖:
6.1 原始碼
ziplist沒有明確定義結構體,這裡只作大概的演示。
typedef struct entry { /*前一個元素長度需要空間和前一個元素長度*/ unsigned int prevlengh; /*元素內容編碼*/ unsigned char encoding; /*元素實際內容*/ unsigned char *data; }zlentry;
typedef struct ziplist{ /*ziplist分配的記憶體大小*/ uint32_t zlbytes; /*達到尾部的偏移量*/ uint32_t zltail; /*儲存元素實體個數*/ uint16_t zllen; /*儲存內容實體元素*/ unsigned char* entry[]; /*尾部標識*/ unsigned char zlend; }ziplist;
第一次看可能會特別矇蔽,你細細的把我這段話看完就一定能懂。
Entry的分析
entry結構體裡面有三個重要的欄位:
- previous_entry_length: 這個欄位記錄了ziplist中前一個節點的長度,什麼意思?就是說通過該屬性可以進行指標運算達到表尾向表頭遍歷,這個欄位還有一個大問題下面會講。
- encoding:記錄了資料型別(int16? string?)和長度。
- data/content: 記錄資料。
連鎖更新
previous_entry_length欄位的分析
上面有說到,previous_entry_length這個欄位存放上個節點的長度,那預設長度給分配多少呢?redis是這樣分的,如果前節點長度小於254,就分配1位元組,大於的話分配5位元組,那問題就來了。
如果前一個節點的長度剛開始小於254位元組,後來大於254,那不就存放不下了嗎? 這就涉及到previous_entry_length的更新,但是改一個肯定不行阿,後面的節點記憶體資訊都需要改。所以就需要重新分配記憶體,然後連鎖更新包括該受影響節點後面的所有節點。
除了增加新節點會引發連鎖更新、刪除節點也會觸發。
7. 快速列表(quicklist)
一個由ziplist組成的雙向連結串列。但是一個quicklist可以有多個quicklist節點,它很像B樹的儲存方式。是在redis3.2版本中新加的資料結構,用在列表的底層實現。
結構體原始碼
表頭結構:
typedef struct quicklist { //指向頭部(最左邊)quicklist節點的指標 quicklistNode *head; //指向尾部(最右邊)quicklist節點的指標 quicklistNode *tail; //ziplist中的entry節點計數器 unsigned long count; /* total count of all entries in all ziplists */ //quicklist的quicklistNode節點計數器 unsigned int len; /* number of quicklistNodes */ //儲存ziplist的大小,配置檔案設定,佔16bits int fill : 16; /* fill factor for individual nodes */ //儲存壓縮程度值,配置檔案設定,佔16bits,0表示不壓縮 unsigned int compress : 16; /* depth of end nodes not to compress;0=off */ } quicklist;
quicklist節點結構:
typedef struct quicklistNode { struct quicklistNode *prev; //前驅節點指標 struct quicklistNode *next; //後繼節點指標 //不設定壓縮資料引數recompress時指向一個ziplist結構 //設定壓縮資料引數recompress指向quicklistLZF結構 unsigned char *zl; //壓縮列表ziplist的總長度 unsigned int sz; /* ziplist size in bytes */ //ziplist中包的節點數,佔16 bits長度 unsigned int count : 16; /* count of items in ziplist */ //表示是否採用了LZF壓縮演算法壓縮quicklist節點,1表示壓縮過,2表示沒壓縮,佔2 bits長度 unsigned int encoding : 2; /* RAW==1 or LZF==2 */ //表示一個quicklistNode節點是否採用ziplist結構儲存資料,2表示壓縮了,1表示沒壓縮,預設是2,佔2bits長度 unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */ //標記quicklist節點的ziplist之前是否被解壓縮過,佔1bit長度 //如果recompress為1,則等待被再次壓縮 unsigned int recompress : 1; /* was this node previous compressed? */ //測試時使用 unsigned int attempted_compress : 1; /* node can't compress; too small */ //額外擴充套件位,佔10bits長度 unsigned int extra : 10; /* more bits to steal for future usage */ } quicklistNode;
相關配置
在redis.conf中的ADVANCED CONFIG部分:
list-max-ziplist-size -2 list-compress-depth 0
list-max-ziplist-size引數
我們來詳細解釋一下list-max-ziplist-size
這個引數的含義。它可以取正值,也可以取負值。
當取正值的時候,表示按照資料項個數來限定每個quicklist節點上的ziplist長度。比如,當這個引數配置成5的時候,表示每個quicklist節點的ziplist最多包含5個數據項。
當取負值的時候,表示按照佔用位元組數來限定每個quicklist節點上的ziplist長度。這時,它只能取-1到-5這五個值,每個值含義如下:
-5: 每個quicklist節點上的ziplist大小不能超過64 Kb。(注:1kb => 1024 bytes)
-4: 每個quicklist節點上的ziplist大小不能超過32 Kb。
-3: 每個quicklist節點上的ziplist大小不能超過16 Kb。
-2: 每個quicklist節點上的ziplist大小不能超過8 Kb。(-2是Redis給出的預設值)
list-compress-depth引數
這個引數表示一個quicklist兩端不被壓縮的節點個數。注:這裡的節點個數是指quicklist雙向連結串列的節點個數,而不是指ziplist裡面的資料項個數。實際上,一個quicklist節點上的ziplist,如果被壓縮,就是整體被壓縮的。
引數list-compress-depth的取值含義如下:
0: 是個特殊值,表示都不壓縮。這是Redis的預設值。 1: 表示quicklist兩端各有1個節點不壓縮,中間的節點壓縮。 2: 表示quicklist兩端各有2個節點不壓縮,中間的節點壓縮。 3: 表示quicklist兩端各有3個節點不壓縮,中間的節點壓縮。 依此類推…
Redis對於quicklist內部節點的壓縮演算法,採用的LZF——一種無失真壓縮演算法。
&n