Redis
中另一個常用的數據結構就是list
,其底層有linkedList
、zipList
和quickList
這三種存儲方式。
鏈表linkedList
與Java
中的LinkedList
類似,Redis
中的linkedList
是一個雙向鏈表,也是由一個個節點組成的。Redis
中借助C
語言實現的鏈表節點結構如下所示:
//定義鏈表節點的結構體
typedf struct listNode{
//前一個節點
struct listNode *prev;
//后一個節點
struct listNode *next;
//當前節點的值的指針
void *value;
}listNode;
pre
指向前一個節點,next
指針指向后一個節點,value
保存着當前節點對應的數據對象。listNode
的示意圖如下所示:
鏈表的結構如下:
typedf struct list{
//頭指針
listNode *head;
//尾指針
listNode *tail;
//節點拷貝函數
void *(*dup)(void *ptr);
//釋放節點函數
void *(*free)(void *ptr);
//判斷兩個節點是否相等的函數
int (*match)(void *ptr,void *key);
//鏈表長度
unsigned long len;
}
head
指向鏈表的頭節點,tail
指向鏈表的尾節點,dup
函數用於鏈表轉移復制時對節點value
拷貝的一個實現,一般情況下使用等號足以,但在某些特殊情況下可能會用到節點轉移函數,默認可以給這個函數賦值NULL
即表示使用等號進行節點轉移。free
函數用於釋放一個節點所占用的內存空間,默認賦值NULL
的話,即使用Redis
自帶的zfree
函數進行內存空間釋放。match
函數是用來比較兩個鏈表節點的value
值是否相等,相等返回1,不等返回0。len
表示這個鏈表共有多少個節點,這樣就可以在O(1)
的時間復雜度內獲得鏈表的長度。
鏈表的結構如下所示:
zipList
Redis
的zipList
結構如下所示:
typedf struct ziplist<T>{
//壓縮列表占用字符數
int32 zlbytes;
//最后一個元素距離起始位置的偏移量,用於快速定位最后一個節點
int32 zltail_offset;
//元素個數
int16 zllength;
//元素內容
T[] entries;
//結束位 0xFF
int8 zlend;
}ziplist
zipList
的結構如下所示:
注意到zltail_offset
這個參數,有了這個參數就可以快速定位到最后一個entry
節點的位置,然后開始倒序遍歷,也就是說zipList
支持雙向遍歷。
下面是entry
的結構:
typede struct entry{
//前一個entry的長度
int<var> prelen;
//元素類型編碼
int<var> encoding;
//元素內容
optional byte[] content;
}entry
prelen
保存的是前一個entry
節點的長度,這樣在倒序遍歷時就可以通過這個參數定位到上一個entry
的位置。encoding
保存了content
的編碼類型。content
則是保存的元素內容,它是optional
類型的,表示這個字段是可選的。當content
是很小的整數時,它會內聯到content
字段的尾部。entry
結構的示意圖如下所示:
好了,那現在我們思考一個問題,為什么有了linkedList
還有設計一個zipList
呢?就像zipList
的名字一樣,它是一個壓縮列表,是為了節約內存而開發的。相比於linkedList
,其少了pre
和next
兩個指針。在Redis
中,pre
和next
指針就要占用16個字節(64位系統的一個指針就是8個字節)。另外,linkedList
的每個節點的內存都是單獨分配,加劇內存的碎片化,影響內存的管理效率。與之相對的是,zipList
是由連續的內存組成的,這樣一來,由於內存是連續的,就減少了許多內存碎片和指針的內存占用,進而節約了內存。
zipList
遍歷時,先根據zlbytes
和zltail_offset
定位到最后一個entry
的位置,然后再根據最后一個entry
里的prelen
時確定前一個entry
的位置。
連鎖更新
上面說到了,entry
中有一個prelen
字段,它的長度要么是1個字節,要么都是5個字節:
- 前一個節點的長度小於254個字節,則
prelen
長度為1字節; - 前一個節點的長度大於254字節,則
prelen
長度為5字節;
假設現在有一組壓縮列表,長度都在250~253字節之間,突然新增一個entry
節點,這個entry
節點長度大於等於254字節。由於新的entry
節點大於等於254字節,這個entry
節點的prelen
為5個字節,隨后會導致其余的所有entry
節點的prelen
增大為5字節。
同樣地,刪除操作也會導致出現連鎖更新這種情況,假設在某一時刻,插入一個長度大於等於254個字節的entry
節點,同時刪除其后面的一個長度小於254個字節的entry
節點,由於小於254的entry
節點的刪除,大於等於254個字節的entry
節點將會與后面小於254個字節的entry
節點相連,此時就與新增一個長度大於等於254個字節的entry
節點時的情況一樣,將會發生連續更新。發生連續更新時,Redis
需要不斷地對壓縮列表進行內存分配工作,直到結束。
linkedList與zipList的對比
- 當列表對象中元素的長度較小或者數量較少時,通常采用
zipList
來存儲;當列表中元素的長度較大或者數量比較多的時候,則會轉而使用雙向鏈表linkedList
來存儲。 - 雙向鏈表
linkedList
便於在表的兩端進行push
和pop
操作,在插入節點上復雜度很低,但是它的內存開銷比較大。首先,它在每個節點上除了要保存數據之外,還有額外保存兩個指針;其次,雙向鏈表的各個節點都是單獨的內存塊,地址不連續,容易形成內存碎片。 zipList
存儲在一塊連續的內存上,所以存儲效率很高。但是它不利於修改操作,插入和刪除操作需要頻繁地申請和釋放內存。特別是當zipList
長度很長時,一次realloc
可能會導致大量的數據拷貝。
quickList
在Redis
3.2版本之后,list
的底層實現方式又多了一種,quickList
。qucikList
是由zipList
和雙向鏈表linkedList
組成的混合體。它將linkedList
按段切分,每一段使用zipList
來緊湊存儲,多個zipList
之間使用雙向指針串接起來。示意圖如下所示:
節點quickListNode
的定義如下:
typedf struct quicklistNode{
//前一個節點
quicklistNode* prev;
//后一個節點
quicklistNode* next;
//壓縮列表
ziplist* zl;
//ziplist大小
int32 size;
//ziplist 中元素數量
int16 count;
//編碼形式 存儲 ziplist 還是進行 LZF 壓縮儲存的zipList
int2 encoding;
...
}quickListNode
quickList
的定義如下所示:
typedf struct quicklist{
//指向頭結點
quicklistNode* head;
//指向尾節點
quicklistNode* tail;
//元素總數
long count;
//quicklistNode節點的個數
int nodes;
//壓縮算法深度
int compressDepth;
...
}quickList
上述代碼簡單地表示了quickList
的大致結構,為了進一步節約空間,Redis
還會對zipList
進行壓縮存儲,使用LZF算法進行壓縮,可以選擇壓縮深度。
每個zipList可以存儲多少個元素
想要了解這個問題,就得打開redis.conf
文件了。在DVANCED CONFIG
下面有着清晰的記載。
# Lists are also encoded in a special way to save a lot of space.
# The number of entries allowed per internal list node can be specified
# as a fixed maximum size or a maximum number of elements.
# For a fixed maximum size, use -5 through -1, meaning:
# -5: max size: 64 Kb <-- not recommended for normal workloads
# -4: max size: 32 Kb <-- not recommended
# -3: max size: 16 Kb <-- probably not recommended
# -2: max size: 8 Kb <-- good
# -1: max size: 4 Kb <-- good
# Positive numbers mean store up to _exactly_ that number of elements
# per list node.
# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size),
# but if your use case is unique, adjust the settings as necessary.
list-max-ziplist-size -2
quickList
內部默認單個zipList
長度為8k字節,即list-max-ziplist-size
的值設置為-2,超出了這個閾值,就會重新生成一個zipList
來存儲數據。根據注釋可知,性能最好的時候就是就是list-max-ziplist-size
為-1和-2,即分別是4kb和8kb的時候,當然,這個值也可以被設置為正數,當list-max-ziplist-szie
為正數n時,表示每個quickList
節點上的zipList
最多包含n個數據項。
壓縮深度
上面提到過,quickList
中可以使用壓縮算法對zipList
進行進一步的壓縮,這個算法就是LZF算法,這是一種無損壓縮算法,具體可以參考上面的鏈接。使用壓縮算法對zipList
進行壓縮后,zipList
的結構如下所示:
typedf struct ziplist_compressed{
//元素個數
int32 size;
//元素內容
byte[] compressed_data
}
此時quickList
的示意圖如下所示:
當然,在redis.conf
文件中的DVANCED CONFIG
下面也可以對壓縮深度進行配置。
# Lists may also be compressed.
# Compress depth is the number of quicklist ziplist nodes from *each* side of
# the list to *exclude* from compression. The head and tail of the list
# are always uncompressed for fast push/pop operations. Settings are:
# 0: disable all list compression
# 1: depth 1 means "don't start compressing until after 1 node into the list,
# going from either the head or tail"
# So: [head]->node->node->...->node->[tail]
# [head], [tail] will always be uncompressed; inner nodes will compress.
# 2: [head]->[next]->node->node->...->node->[prev]->[tail]
# 2 here means: don't compress head or head->next or tail->prev or tail,
# but compress all nodes between them.
# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]
# etc.
list-compress-depth 0
list-compress-depth
這個參數表示一個quickList
兩端不被壓縮的節點個數。需要注意的是,這里的節點個數是指quicklist
雙向鏈表的節點個數,而不是指ziplist
里面的數據項個數。實際上,一個quicklist
節點上的ziplist
,如果被壓縮,就是整體被壓縮的。
quickList
默認的壓縮深度為0,也就是不開啟壓縮- 當
list-compress-depth
為1,表示quickList
的兩端各有1個節點不進行壓縮,中間結點進行壓縮; - 當
list-compress-depth
為2,表示quickList
的首尾2個節點不進行壓縮,中間結點進行壓縮; - 以此類推
從上面可以看出,對於quickList
來說,其首尾兩個節點永遠不會被壓縮。