本文是《Redis內部資料結構詳解》系列的第四篇。在本文中,我們首先介紹一個新的Redis內部資料結構——ziplist,然後在文章後半部分我們會討論一下在robj, dict和ziplist的基礎上,Redis對外暴露的hash結構是怎樣構建起來的。
我們在討論中還會涉及到兩個Redis配置(在redis.conf中的ADVANCED CONFIG部分):
hash-max-ziplist-entries 512
hash-max-ziplist-value 64複製程式碼
本文的後半部分會對這兩個配置做詳細的解釋。
什麼是ziplist
Redis官方對於ziplist的定義是(出自ziplist.c的檔案頭部註釋):
The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time.
翻譯一下就是說:ziplist是一個經過特殊編碼的雙向連結串列,它的設計目標就是為了提高儲存效率。ziplist可以用於儲存字串或整數,其中整數是按真正的二進位制表示進行編碼的,而不是編碼成字串序列。它能以O(1)的時間複雜度在表的兩端提供push
和pop
操作。
實際上,ziplist充分體現了Redis對於儲存效率的追求。一個普通的雙向連結串列,連結串列中每一項都佔用獨立的一塊記憶體,各項之間用地址指標(或引用)連線起來。這種方式會帶來大量的記憶體碎片,而且地址指標也會佔用額外的記憶體。而ziplist卻是將表中每一項存放在前後連續的地址空間內,一個ziplist整體佔用一大塊記憶體。它是一個表(list),但其實不是一個連結串列(linked list)。
另外,ziplist為了在細節上節省記憶體,對於值的儲存採用了變長的編碼方式,大概意思是說,對於大的整數,就多用一些位元組來儲存,而對於小的整數,就少用一些位元組來儲存。我們接下來很快就會討論到這些實現細節。
ziplist的資料結構定義
ziplist的資料結構組成是本文要討論的重點。實際上,ziplist還是稍微有點複雜的,它複雜的地方就在於它的資料結構定義。一旦理解了資料結構,它的一些操作也就比較容易理解了。
我們接下來先從總體上介紹一下ziplist的資料結構定義,然後舉一個實際的例子,通過例子來解釋ziplist的構成。如果你看懂了這一部分,本文的任務就算完成了一大半了。
從巨集觀上看,ziplist的記憶體結構如下:
<zlbytes><zltail><zllen><entry>...<entry><zlend>
各個部分在記憶體上是前後相鄰的,它們分別的含義如下:
<zlbytes>
: 32bit,表示ziplist佔用的位元組總數(也包括<zlbytes>
本身佔用的4個位元組)。<zltail>
: 32bit,表示ziplist表中最後一項(entry)在ziplist中的偏移位元組數。<zltail>
的存在,使得我們可以很方便地找到最後一項(不用遍歷整個ziplist),從而可以在ziplist尾端快速地執行push或pop操作。<zllen>
: 16bit, 表示ziplist中資料項(entry)的個數。zllen欄位因為只有16bit,所以可以表達的最大值為2^16-1。這裡需要特別注意的是,如果ziplist中資料項個數超過了16bit能表達的最大值,ziplist仍然可以來表示。那怎麼表示呢?這裡做了這樣的規定:如果<zllen>
小於等於2^16-2(也就是不等於2^16-1),那麼<zllen>
就表示ziplist中資料項的個數;否則,也就是<zllen>
等於16bit全為1的情況,那麼<zllen>
就不表示資料項個數了,這時候要想知道ziplist中資料項總數,那麼必須對ziplist從頭到尾遍歷各個資料項,才能計數出來。<entry>
: 表示真正存放資料的資料項,長度不定。一個資料項(entry)也有它自己的內部結構,這個稍後再解釋。<zlend>
: ziplist最後1個位元組,是一個結束標記,值固定等於255。
上面的定義中還值得注意的一點是:<zlbytes>
, <zltail>
, <zllen>
既然佔據多個位元組,那麼在儲存的時候就有大端(big endian)和小端(little endian)的區別。ziplist採取的是小端模式來儲存,這在下面我們介紹具體例子的時候還會再詳細解釋。
我們再來看一下每一個資料項<entry>
的構成:
<prevrawlen><len><data>
我們看到在真正的資料(<data>
)前面,還有兩個欄位:
<prevrawlen>
: 表示前一個資料項佔用的總位元組數。這個欄位的用處是為了讓ziplist能夠從後向前遍歷(從後一項的位置,只需向前偏移prevrawlen個位元組,就找到了前一項)。這個欄位採用變長編碼。<len>
: 表示當前資料項的資料長度(即<data>
部分的長度)。也採用變長編碼。
那麼<prevrawlen>
和<len>
是怎麼進行變長編碼的呢?各位讀者打起精神了,我們終於講到了ziplist的定義中最繁瑣的地方了。
先說<prevrawlen>
。它有兩種可能,或者是1個位元組,或者是5個位元組:
- 如果前一個資料項佔用位元組數小於254,那麼
<prevrawlen>
就只用一個位元組來表示,這個位元組的值就是前一個資料項的佔用位元組數。 - 如果前一個資料項佔用位元組數大於等於254,那麼
<prevrawlen>
就用5個位元組來表示,其中第1個位元組的值是254(作為這種情況的一個標記),而後面4個位元組組成一個整型值,來真正儲存前一個資料項的佔用位元組數。
有人會問了,為什麼沒有255的情況呢?
這是因為:255已經定義為ziplist結束標記<zlend>
的值了。在ziplist的很多操作的實現中,都會根據資料項的第1個位元組是不是255來判斷當前是不是到達ziplist的結尾了,因此一個正常的資料的第1個位元組(也就是<prevrawlen>
的第1個位元組)是不能夠取255這個值的,否則就衝突了。
而<len>
欄位就更加複雜了,它根據第1個位元組的不同,總共分為9種情況(下面的表示法是按二進位制表示):
- |00pppppp| - 1 byte。第1個位元組最高兩個bit是00,那麼
<len>
欄位只有1個位元組,剩餘的6個bit用來表示長度值,最高可以表示63 (2^6-1)。 - |01pppppp|qqqqqqqq| - 2 bytes。第1個位元組最高兩個bit是01,那麼
<len>
欄位佔2個位元組,總共有14個bit用來表示長度值,最高可以表示16383 (2^14-1)。 - |10__|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes。第1個位元組最高兩個bit是10,那麼len欄位佔5個位元組,總共使用32個bit來表示長度值(6個bit捨棄不用),最高可以表示2^32-1。需要注意的是:在前三種情況下,
<data>
都是按字串來儲存的;從下面第4種情況開始,<data>
開始變為按整數來儲存了。 - |11000000| - 1 byte。
<len>
欄位佔用1個位元組,值為0xC0,後面的資料<data>
儲存為2個位元組的int16_t型別。 - |11010000| - 1 byte。
<len>
欄位佔用1個位元組,值為0xD0,後面的資料<data>
儲存為4個位元組的int32_t型別。 - |11100000| - 1 byte。
<len>
欄位佔用1個位元組,值為0xE0,後面的資料<data>
儲存為8個位元組的int64_t型別。 - |11110000| - 1 byte。
<len>
欄位佔用1個位元組,值為0xF0,後面的資料<data>
儲存為3個位元組長的整數。 - |11111110| - 1 byte。
<len>
欄位佔用1個位元組,值為0xFE,後面的資料<data>
儲存為1個位元組的整數。 - |1111xxxx| - - (xxxx的值在0001和1101之間)。這是一種特殊情況,xxxx從1到13一共13個值,這時就用這13個值來表示真正的資料。注意,這裡是表示真正的資料,而不是資料長度了。也就是說,在這種情況下,後面不再需要一個單獨的
<data>
欄位來表示真正的資料了,而是<len>
和<data>
合二為一了。另外,由於xxxx只能取0001和1101這13個值了(其它可能的值和其它情況衝突了,比如0000和1110分別同前面第7種第8種情況衝突,1111跟結束標記衝突),而小數值應該從0開始,因此這13個值分別表示0到12,即xxxx的值減去1才是它所要表示的那個整數資料的值。
好了,ziplist的資料結構定義,我們介紹了完了,現在我們看一個具體的例子。
上圖是一份真實的ziplist資料。我們逐項解讀一下:
- 這個ziplist一共包含33個位元組。位元組編號從byte[0]到byte[32]。圖中每個位元組的值使用16進製表示。
- 頭4個位元組(0x21000000)是按小端(little endian)模式儲存的
<zlbytes>
欄位。什麼是小端呢?就是指資料的低位元組儲存在記憶體的低地址中(參見維基百科詞條Endianness)。因此,這裡<zlbytes>
的值應該解析成0x00000021,用十進位制表示正好就是33。 - 接下來4個位元組(byte[4..7])是
<zltail>
,用小端儲存模式來解釋,它的值是0x0000001D(值為29),表示最後一個資料項在byte[29]的位置(那個資料項為0x05FE14)。 - 再接下來2個位元組(byte[8..9]),值為0x0004,表示這個ziplist裡一共存有4項資料。
- 接下來6個位元組(byte[10..15])是第1個資料項。其中,prevrawlen=0,因為它前面沒有資料項;len=4,相當於前面定義的9種情況中的第1種,表示後面4個位元組按字串儲存資料,資料的值為"name"。
- 接下來8個位元組(byte[16..23])是第2個資料項,與前面資料項儲存格式類似,儲存1個字串"tielei"。
- 接下來5個位元組(byte[24..28])是第3個資料項,與前面資料項儲存格式類似,儲存1個字串"age"。
- 接下來3個位元組(byte[29..31])是最後一個資料項,它的格式與前面的資料項儲存格式不太一樣。其中,第1個位元組prevrawlen=5,表示前一個資料項佔用5個位元組;第2個位元組=FE,相當於前面定義的9種情況中的第8種,所以後面還有1個位元組用來表示真正的資料,並且以整數表示。它的值是20(0x14)。
- 最後1個位元組(byte[32])表示
<zlend>
,是固定的值255(0xFF)。
總結一下,這個ziplist裡存了4個資料項,分別為:
- 字串: "name"
- 字串: "tielei"
- 字串: "age"
- 整數: 20
(好吧,被你發現了~~tielei實際上當然不是20歲,他哪有那麼年輕啊......)
實際上,這個ziplist是通過兩個hset
命令建立出來的。這個我們後半部分會再提到。
好了,既然你已經閱讀到這裡了,說明你還是很有耐心的(其實我寫到這裡也已經累得不行了)。可以先把本文收藏,休息一下,回頭再看後半部分。
接下來我要貼一些程式碼了。
ziplist的介面
我們先不著急看實現,先來挑幾個ziplist的重要的介面,看看它們長什麼樣子:
unsigned char *ziplistNew(void);
unsigned char *ziplistMerge(unsigned char **first, unsigned char **second);
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where);
unsigned char *ziplistIndex(unsigned char *zl, int index);
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen);
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p);
unsigned char *ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip);
unsigned int ziplistLen(unsigned char *zl);複製程式碼
我們從這些介面的名字就可以粗略猜出它們的功能,下面簡單解釋一下:
- ziplist的資料型別,沒有用自定義的struct之類的來表達,而就是簡單的unsigned char *。這是因為ziplist本質上就是一塊連續記憶體,內部組成結構又是一個高度動態的設計(變長編碼),也沒法用一個固定的資料結構來表達。
- ziplistNew: 建立一個空的ziplist(只包含
<zlbytes><zltail><zllen><zlend>
)。 - ziplistMerge: 將兩個ziplist合併成一個新的ziplist。
- ziplistPush: 在ziplist的頭部或尾端插入一段資料(產生一個新的資料項)。注意一下這個介面的返回值,是一個新的ziplist。呼叫方必須用這裡返回的新的ziplist,替換之前傳進來的舊的ziplist變數,而經過這個函式處理之後,原來舊的ziplist變數就失效了。為什麼一個簡單的插入操作會導致產生一個新的ziplist呢?這是因為ziplist是一塊連續空間,對它的追加操作,會引發記憶體的realloc,因此ziplist的記憶體位置可能會發生變化。實際上,我們在之前介紹sds的文章中提到過類似這種介面使用模式(參見sdscatlen函式的說明)。
- ziplistIndex: 返回index引數指定的資料項的記憶體位置。index可以是負數,表示從尾端向前進行索引。
- ziplistNext和ziplistPrev分別返回一個ziplist中指定資料項p的後一項和前一項。
- ziplistInsert: 在ziplist的任意資料項前面插入一個新的資料項。
- ziplistDelete: 刪除指定的資料項。
- ziplistFind: 查詢給定的資料(由vstr和vlen指定)。注意它有一個skip引數,表示查詢的時候每次比較之間要跳過幾個資料項。為什麼會有這麼一個引數呢?其實這個引數的主要用途是當用ziplist表示hash結構的時候,是按照一個field,一個value來依次存入ziplist的。也就是說,偶數索引的資料項存field,奇數索引的資料項存value。當按照field的值進行查詢的時候,就需要把奇數項跳過去。
- ziplistLen: 計算ziplist的長度(即包含資料項的個數)。
ziplist的插入邏輯解析
ziplist的相關介面的具體實現,還是有些複雜的,限於篇幅的原因,我們這裡只結合程式碼來講解插入的邏輯。插入是很有代表性的操作,通過這部分來一窺ziplist內部的實現,其它部分的實現我們也就會很容易理解了。
ziplistPush和ziplistInsert都是插入,只是對於插入位置的限定不同。它們在內部實現都依賴一個名為__ziplistInsert的內部函式,其程式碼如下(出自ziplist.c):
static unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen;
unsigned int prevlensize, prevlen = 0;
size_t offset;
int nextdiff = 0;
unsigned char encoding = 0;
long long value = 123456789; /* initialized to avoid warning. Using a value
that is easy to see if for some reason
we use it uninitialized. */
zlentry tail;
/* Find out prevlen for the entry that is inserted. */
if (p[0] != ZIP_END) {
ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
} else {
unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
if (ptail[0] != ZIP_END) {
prevlen = zipRawEntryLength(ptail);
}
}
/* See if the entry can be encoded */
if (zipTryEncoding(s,slen,&value,&encoding)) {
/* 'encoding' is set to the appropriate integer encoding */
reqlen = zipIntSize(encoding);
} else {
/* 'encoding' is untouched, however zipEncodeLength will use the
* string length to figure out how to encode it. */
reqlen = slen;
}
/* We need space for both the length of the previous entry and
* the length of the payload. */
reqlen += zipPrevEncodeLength(NULL,prevlen);
reqlen += zipEncodeLength(NULL,encoding,slen);
/* When the insert position is not equal to the tail, we need to
* make sure that the next entry can hold this entry's length in
* its prevlen field. */
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
/* Store offset because a realloc may change the address of zl. */
offset = p-zl;
zl = ziplistResize(zl,curlen+reqlen+nextdiff);
p = zl+offset;
/* Apply memory move when necessary and update tail offset. */
if (p[0] != ZIP_END) {
/* Subtract one because of the ZIP_END bytes */
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
/* Encode this entry's raw length in the next entry. */
zipPrevEncodeLength(p+reqlen,reqlen);
/* Update offset for tail */
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
/* When the tail contains more than one entry, we need to take
* "nextdiff" in account as well. Otherwise, a change in the
* size of prevlen doesn't have an effect on the *tail* offset. */
zipEntry(p+reqlen, &tail);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
} else {
/* This element will be the new tail. */
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
}
/* When nextdiff != 0, the raw length of the next entry has changed, so
* we need to cascade the update throughout the ziplist */
if (nextdiff != 0) {
offset = p-zl;
zl = __ziplistCascadeUpdate(zl,p+reqlen);
p = zl+offset;
}
/* Write the entry */
p += zipPrevEncodeLength(p,prevlen);
p += zipEncodeLength(p,encoding,slen);
if (ZIP_IS_STR(encoding)) {
memcpy(p,s,slen);
} else {
zipSaveInteger(p,value,encoding);
}
ZIPLIST_INCR_LENGTH(zl,1);
return zl;
}複製程式碼
我們來簡單解析一下這段程式碼:
- 這個函式是在指定的位置p插入一段新的資料,待插入資料的地址指標是s,長度為slen。插入後形成一個新的資料項,佔據原來p的配置,原來位於p位置的資料項以及後面的所有資料項,需要統一向後移動,給新插入的資料項留出空間。引數p指向的是ziplist中某一個資料項的起始位置,或者在向尾端插入的時候,它指向ziplist的結束標記
<zlend>
。 - 函式開始先計算出待插入位置前一個資料項的長度
prevlen
。這個長度要存入新插入的資料項的<prevrawlen>
欄位。 - 然後計算當前資料項佔用的總位元組數
reqlen
,它包含三部分:<prevrawlen>
,<len>
和真正的資料。其中的資料部分會通過呼叫zipTryEncoding
先來嘗試轉成整數。 - 由於插入導致的ziplist對於記憶體的新增需求,除了待插入資料項佔用的
reqlen
之外,還要考慮原來p位置的資料項(現在要排在待插入資料項之後)的<prevrawlen>
欄位的變化。本來它儲存的是前一項的總長度,現在變成了儲存當前插入的資料項的總長度。這樣它的<prevrawlen>
欄位本身需要的儲存空間也可能發生變化,這個變化可能是變大也可能是變小。這個變化了多少的值nextdiff
,是呼叫zipPrevLenByteDiff
計算出來的。如果變大了,nextdiff
是正值,否則是負值。 - 現在很容易算出來插入後新的ziplist需要多少位元組了,然後呼叫
ziplistResize
來重新調整大小。ziplistResize的實現裡會呼叫allocator的zrealloc
,它有可能會造成資料拷貝。 - 現在額外的空間有了,接下來就是將原來p位置的資料項以及後面的所有資料都向後挪動,併為它設定新的
<prevrawlen>
欄位。此外,還可能需要調整ziplist的<zltail>
欄位。 - 最後,組裝新的待插入資料項,放在位置p。
hash與ziplist
hash是Redis中可以用來儲存一個物件結構的比較理想的資料型別。一個物件的各個屬性,正好對應一個hash結構的各個field。
我們在網上很容易找到這樣一些技術文章,它們會說儲存一個物件,使用hash比string要節省記憶體。實際上這麼說是有前提的,具體取決於物件怎麼來儲存。如果你把物件的多個屬性儲存到多個key上(各個屬性值存成string),當然佔的記憶體要多。但如果你採用一些序列化方法,比如Protocol Buffers,或者Apache Thrift,先把物件序列化為位元組陣列,然後再存入到Redis的string中,那麼跟hash相比,哪一種更省記憶體,就不一定了。
當然,hash比序列化後再存入string的方式,在支援的操作命令上,還是有優勢的:它既支援多個field同時存取(hmset
/hmget
),也支援按照某個特定的field單獨存取(hset
/hget
)。
實際上,hash隨著資料的增大,其底層資料結構的實現是會發生變化的,當然儲存效率也就不同。在field比較少,各個value值也比較小的時候,hash採用ziplist來實現;而隨著field增多和value值增大,hash可能會變成dict來實現。當hash底層變成dict來實現的時候,它的儲存效率就沒法跟那些序列化方式相比了。
當我們為某個key第一次執行 hset key field value
命令的時候,Redis會建立一個hash結構,這個新建立的hash底層就是一個ziplist。
robj *createHashObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_HASH, zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}複製程式碼
上面的createHashObject
函式,出自object.c,它負責的任務就是建立一個新的hash結構。可以看出,它建立了一個type = OBJ_HASH
但encoding = OBJ_ENCODING_ZIPLIST
的robj物件。
實際上,本文前面給出的那個ziplist例項,就是由如下兩個命令構建出來的。
hset user:100 name tielei
hset user:100 age 20複製程式碼
每執行一次hset
命令,插入的field和value分別作為一個新的資料項插入到ziplist中(即每次hset
產生兩個資料項)。
當隨著資料的插入,hash底層的這個ziplist就可能會轉成dict。那麼到底插入多少才會轉呢?
還記得本文開頭提到的兩個Redis配置嗎?
hash-max-ziplist-entries 512
hash-max-ziplist-value 64複製程式碼
這個配置的意思是說,在如下兩個條件之一滿足的時候,ziplist會轉成dict:
- 當hash中的資料項(即field-value對)的數目超過512的時候,也就是ziplist資料項超過1024的時候(請參考t_hash.c中的
hashTypeSet
函式)。 - 當hash中插入的任意一個value的長度超過了64的時候(請參考t_hash.c中的
hashTypeTryConversion
函式)。
Redis的hash之所以這樣設計,是因為當ziplist變得很大的時候,它有如下幾個缺點:
- 每次插入或修改引發的realloc操作會有更大的概率造成記憶體拷貝,從而降低效能。
- 一旦發生記憶體拷貝,記憶體拷貝的成本也相應增加,因為要拷貝更大的一塊資料。
- 當ziplist資料項過多的時候,在它上面查詢指定的資料項就會效能變得很低,因為ziplist上的查詢需要進行遍歷。
總之,ziplist本來就設計為各個資料項挨在一起組成連續的記憶體空間,這種結構並不擅長做修改操作。一旦資料發生改動,就會引發記憶體realloc,可能導致記憶體拷貝。
下一篇我們將介紹quicklist,敬請期待。
關注微信公眾號: tielei-blog (張鐵蕾),第一時間獲取鐵蕾
的技術文章。