經過前面對Redis原始碼的瞭解,令人印象深刻的也許就是Redis各種節約記憶體手段。而Redis對於記憶體的節約可以說是費盡心思,今天我就再來介紹一種Redis為了節約記憶體而創造的儲存結構——壓縮列表(ziplist)。
儲存結構
ziplist是zset和hash在元素數量較少時使用的一種儲存結構。它的特點儲存於一塊連續的記憶體,元素與元素之間沒有空隙。我們可以用DEBUG OBJECT命令來檢視一個zset的編碼格式:
127.0.0.1:6379> ZADD db 1.0 mysql 2.0 mongo 3.0 redis
(integer) 3
127.0.0.1:6379> DEBUG OBJECT db
Value at:0x7f5bf1908070 refcount:1 encoding:ziplist serializedlength:39 lru:9589668 lru_seconds_idle:12
複製程式碼
那麼ziplist究竟是一種怎樣的結構的,話不多說,直接看圖。
ZIPLIST OVERALL LAYOUT
接下來我們挨個解釋一下每一部分儲存的內容:
- zlbytes:32位無符號整數,儲存的是包括它自己在內的整個ziplist所佔用的位元組數
- zltail:32位無符號整數,儲存的是最後一個entry的偏移量,用來快速定位最後一個元素
- zllen:16位無符號整數,用於儲存entry的數量,當元素數量大於216-2時,這個值就被設定為216-1。我們想知道元素的數量就需要遍歷整個列表
- entry:表示儲存的元素
- zlend:8位無符號整數,用於標識整個ziplist的結尾。它的值是255。
ZIPLIST ENTRIES
瞭解了ziplist的大概結構以後,我們剖析更深一層的entry的結構。
對於每個entry都有兩個字首
- prevlen:表示前一個元素的長度,它與zltail欄位結合使用可以實現快速的從後向前定位元素
- encoding:表示元素的編碼格式,它用來表示元素是整數還是字串,如果是字串,也表示字串的長度
- entry-data:元素的資料,它並不是一定存在,對於某些編碼而言,編碼本身也是資料,因此這一部分可以省略
這裡要解釋一點,prevlen是一個變長的整數,當前一個元素的長度小於254時,它僅需要一個位元組(8位無符號整數)表示,如果元素的長度大於(或等於)254位元組,prevlen就用5個位元組來表示,其中第一個位元組是254,後4個位元組表示前一個元素的長度。
encoding欄位決定了元素的內容。如果entry儲存的是字串,那麼就通過encoding的前兩位來區分不同長度的字串,如果entry儲存的內容是整數,那麼前兩位都會被設定為1,再後面兩位用來區分整數的型別。
- |00pppppp|:字串長度小於63位元組,pppppp是6位無符號整數,用來表示字串長度
- |01pppppp|qqqqqqqq|:字串長度小於等於16383位元組,後面14位表示字串長度
- |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt|:字串長度大於等於16384位元組,後4個位元組表示字串長度
- |11000000|:16位整數,後面跟2個位元組儲存整數
- |11010000|:32位整數,後面跟4個位元組儲存整數
- |11100000|:64位整數,後面跟8個位元組儲存整數
- |11110000|:24位整數,後面跟3個位元組儲存整數
- |11111110|:8位整數,後面跟1個位元組儲存整數
- |1111xxxx|:(xxxx 取值從0000到1101)表示0到12的整數,讀到的xxxx減1為實際表示的整數。這就是前面提到的省略entry-data的情況
- |11111111|:ziplist的結束值,也就是zlend的值
說了這麼多,也許你還是不太清楚ziplist儲存的內容究竟要表示什麼,我們還是來舉一個栗子
[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
這是一個實際的ziplist儲存的內容,我們就一起來解讀一下。
首先是4個位元組的zlbytes,ziplist一共是15個位元組,因此zlbytes的值是0x0f;接下來是4個位元組的zltail,偏移量是12,因此zltail的值是0x0c;後兩個位元組是zllen,也就是一共兩個元素;第一個元素的prevlen為00,0xf3表示元素值是2:1111 0011符合上述第9條,讀到xxxx為3,需要減1,因此實際值是2;第二個元素同理,0xf6表示的值是5,最後0xff表示這個ziplist結束。
這時,我向這個ziplist中又加了一個元素,是一個字串,請大家自行解讀下面的entry(注意,只是entry)。友情提示:需要查詢ASCII碼錶來解讀
[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]
增加元素
瞭解了ziplist的儲存之後,我們再來看一下ziplist是如何增加元素的。前面提到過,ziplist儲存結構用於元素數量少的zset和hash。那麼我們就以zset為例,一起追蹤原始碼,瞭解ziplist增加元素的過程。
我們從ZADD命令執行的函式zaddCommand()開始。
void zaddCommand(client *c) {
zaddGenericCommand(c,ZADD_NONE);
}
複製程式碼
它只是簡單呼叫了zaddGenericCommand()函式,傳入了客戶端物件c和一個標誌位,表示要執行ZADD命令,因為這個函式同樣也是ZINCRBY要執行的函式(傳入的標誌是ZADD_INCR)。
而在zaddGenericCommand()函式中,首先對引數進行了處理,並且做了一些校驗。
/* Lookup the key and create the sorted set if does not exist. */
zobj = lookupKeyWrite(c->db,key);
if (zobj == NULL) {
if (xx) goto reply_to_client; /* No key + XX option: nothing to do. */
if (server.zset_max_ziplist_entries == 0 ||
server.zset_max_ziplist_value < sdslen(c->argv[scoreidx+1]->ptr))
{
zobj = createZsetObject();
} else {
zobj = createZsetZiplistObject();
}
dbAdd(c->db,key,zobj);
} else {
if (zobj->type != OBJ_ZSET) {
addReply(c,shared.wrongtypeerr);
goto cleanup;
}
}
複製程式碼
然後判斷key是否存在,如果存在,驗證資料型別;否則建立一個新的zset物件。這裡可以看到,當
zset_max_ziplist_entries為0或者第一個元素的長度大於zset_max_ziplist_value時,建立zset物件,否則建立ziplist物件。建立好物件之後,就開始遍歷元素,執行zsetAdd函式了:
for (j = 0; j < elements; j++) {
double newscore;
score = scores[j];
int retflags = flags;
ele = c->argv[scoreidx+1+j*2]->ptr;
int retval = zsetAdd(zobj, score, ele, &retflags, &newscore);
if (retval == 0) {
addReplyError(c,nanerr);
goto cleanup;
}
if (retflags & ZADD_ADDED) added++;
if (retflags & ZADD_UPDATED) updated++;
if (!(retflags & ZADD_NOP)) processed++;
score = newscore;
}
複製程式碼
這個函式用來增加新元素或者更新元素的score。這個函式中判斷了zset物件的編碼方式,對壓縮列表ziplist和跳躍列表skiplist分開處理,跳躍列表是zset的另一種編碼方式,這個我們以後再介紹,本文我們只關注ziplist。
if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
unsigned char *eptr;
if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {
/* NX? Return, same element already exists. */
if (nx) {
*flags |= ZADD_NOP;
return 1;
}
/* Prepare the score for the increment if needed. */
if (incr) {
score += curscore;
if (isnan(score)) {
*flags |= ZADD_NAN;
return 0;
}
if (newscore) *newscore = score;
}
/* Remove and re-insert when score changed. */
if (score != curscore) {
zobj->ptr = zzlDelete(zobj->ptr,eptr);
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
*flags |= ZADD_UPDATED;
}
return 1;
} else if (!xx) {
/* Optimize: check if the element is too large or the list
* becomes too long *before* executing zzlInsert. */
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries)
zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
if (sdslen(ele) > server.zset_max_ziplist_value)
zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
if (newscore) *newscore = score;
*flags |= ZADD_ADDED;
return 1;
} else {
*flags |= ZADD_NOP;
return 1;
}
}
複製程式碼
可以看到,這裡首先呼叫zzlFind()函式查詢對應的元素,如果元素存在,那麼就判斷是否包含引數NX或者是否是INCR操作。如果修改了元素的分數,則先刪除原有的元素,再重新增加;如果元素不存在,就直接執行zzlInsert()函式,再insert之後,會判斷是否需要改為跳躍列表儲存。這裡有兩個條件:
- zset元素數量大於zset_max_ziplist_entries(預設128)
- 新增的元素長度大於zset_max_ziplist_value(預設64)
滿足任意一個條件,zset都會使用跳躍列表來儲存。
我們繼續追蹤zzlInsert()函式。
unsigned char *zzlInsert(unsigned char *zl, sds ele, double score) {
unsigned char *eptr = ziplistIndex(zl,0), *sptr;
double s;
while (eptr != NULL) {
sptr = ziplistNext(zl,eptr);
serverAssert(sptr != NULL);
s = zzlGetScore(sptr);
if (s > score) {
/* First element with score larger than score for element to be
* inserted. This means we should take its spot in the list to
* maintain ordering. */
zl = zzlInsertAt(zl,eptr,ele,score);
break;
} else if (s == score) {
/* Ensure lexicographical ordering for elements. */
if (zzlCompareElements(eptr,(unsigned char*)ele,sdslen(ele)) > 0) {
zl = zzlInsertAt(zl,eptr,ele,score);
break;
}
}
/* Move to next element. */
eptr = ziplistNext(zl,sptr);
}
/* Push on tail of list when it was not yet inserted. */
if (eptr == NULL)
zl = zzlInsertAt(zl,NULL,ele,score);
return zl;
}
複製程式碼
它首先定位了zset的第一個元素,如果該元素不為空,就比較該元素的分數s與要插入的元素分數score,如果s>score,就插入到當前位置,如果分數相同,則比較元素(按字典序)。插入後,將後面的元素依次移到下一位。
unsigned char *zzlInsertAt(unsigned char *zl, unsigned char *eptr, sds ele, double score) {
unsigned char *sptr;
char scorebuf[128];
int scorelen;
size_t offset;
scorelen = d2string(scorebuf,sizeof(scorebuf),score);
if (eptr == NULL) {
zl = ziplistPush(zl,(unsigned char*)ele,sdslen(ele),ZIPLIST_TAIL);
zl = ziplistPush(zl,(unsigned char*)scorebuf,scorelen,ZIPLIST_TAIL);
} else {
/* Keep offset relative to zl, as it might be re-allocated. */
offset = eptr-zl;
zl = ziplistInsert(zl,eptr,(unsigned char*)ele,sdslen(ele));
eptr = zl+offset;
/* Insert score after the element. */
serverAssert((sptr = ziplistNext(zl,eptr)) != NULL);
zl = ziplistInsert(zl,sptr,(unsigned char*)scorebuf,scorelen);
}
return zl;
}
複製程式碼
在zzlInsertAt()函式中,主要是呼叫了ziplistPush()或者ziplistInsert()將元素和分數插入列表尾部或中間。插入順序是先插入元素,然後插入分數。
接下來就到了ziplist.c檔案中,真正向壓縮列表中插入元素了。關鍵程式碼在__ziplistInsert()函式中。
首先需要計算插入位置前一個元素的長度,儲存到當前entry的prevlen。
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);
}
}
複製程式碼
這裡區分了是否是在尾部插入元素的情況,如果是在尾部,就可以通過ziplist中的zltail欄位直接定位。接下來就是嘗試對插入的元素進行編碼,判斷是否可以儲存為整數,如果不能,就按照字串的編碼格式來儲存。
if (zipTryEncoding(s,slen,&value,&encoding)) {
/* 'encoding' is set to the appropriate integer encoding */
reqlen = zipIntSize(encoding);
} else {
/* 'encoding' is untouched, however zipStoreEntryEncoding will use the
* string length to figure out how to encode it. */
reqlen = slen;
}
複製程式碼
這一步判斷是節省記憶體的關鍵,它會使用我們前面介紹的儘量小的編碼格式來進行編碼。編碼完成後就要計算當前entry的長度,包括prevlen、encoding和entry-data,並且需要保證後一個entry(如果有的話)的prevlen能夠儲存當前entry的長度。這裡呼叫的是zipPrevLenByteDiff()函式,需要的prevlen的長度和現有的prevlen的長度的差值,也就是說如果返回為整數,表示需要更多空間。
在這之後就要呼叫zrealloc()來擴充套件空間了。這裡有可能會在原來的基礎上進行擴充套件,也有可能重新分配一塊記憶體,然後將原來的ziplist整體遷移。如果ziplist佔用較大記憶體時,整體遷移的代價是很高的。有了足夠的空間之後,就是把當前位置的entry向後移一位了,然後要修改這個entry的prevlen。更新zltail。
if (nextdiff != 0) {
offset = p-zl;
zl = __ziplistCascadeUpdate(zl,p+reqlen);
p = zl+offset;
}
複製程式碼
nextdiff是前面zipPrevLenByteDiff()函式的返回值,它不為0表示需要更多空間(小於0時被置為0)。這時後面的元素需要級聯更新。所有的這些處理完畢之後,我們終於可以把要插入的entry寫入當前位置了,並且將ziplist的長度加1。
級聯更新
如果一個entry的長度小於254位元組,那麼後一個元素的prevlen就用一個位元組來儲存,否則就要用5個位元組儲存。當我們插入一個元素時,如果它的長度大於253位元組,那麼原來的entry就可能從1個位元組變成5個位元組,而如果由於這一變化導致這個entry的長度大於254位元組,那麼後面的元素也要更新。到後面甚至有可能導致重新分配記憶體的問題,所以級聯更新是一件很可怕的事情。
接下來就通過原始碼,看一下級聯更新的具體步驟。(檢視ziplist.c檔案的__ziplistCascadeUpdate函式)
首先,判斷當前entry是否是最後一個,如果是,則跳出級聯更新。
if (p[rawlen] == ZIP_END) break;
複製程式碼
接著判斷了下一個entry的prevlen長度是否發生變化,如果沒有變化,也不用繼續進行級聯更新。
if (next.prevrawlen == rawlen) break;
複製程式碼
而如果下一個entry的prevlen長度需要擴充套件,那麼就先呼叫ziplistResize擴充套件記憶體,然後要更新zltail。要將後面的entry向後移動,再開始判斷下一個entry是否需要更新。
if (next.prevrawlensize < rawlensize) {
/* The "prevlen" field of "next" needs more bytes to hold
* the raw length of "cur". */
offset = p-zl;
extra = rawlensize-next.prevrawlensize;
zl = ziplistResize(zl,curlen+extra);
p = zl+offset;
/* Current pointer and offset for next element. */
np = p+rawlen;
noffset = np-zl;
/* Update tail offset when next element is not the tail element. */
if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
}
/* Move the tail to the back. */
memmove(np+rawlensize,
np+next.prevrawlensize,
curlen-noffset-next.prevrawlensize-1);
zipStorePrevEntryLength(np,rawlen);
/* Advance the cursor */
p += rawlen;
curlen += extra;
}
複製程式碼
如果後面的entry的prevlen大於需要的長度呢,此時應該收縮prevlen,如果要進行收縮,那麼可能會繼續級聯更新。這太麻煩了,所以這裡選擇了浪費一些空間,用5個位元組的空間來儲存1個位元組可以儲存的內容。如果prevlen的長度等於需要的長度,就直接更新內容。
if (next.prevrawlensize > rawlensize) {
/* This would result in shrinking, which we want to avoid.
* So, set "rawlen" in the available bytes. */
zipStorePrevEntryLengthLarge(p+rawlen,rawlen);
} else {
zipStorePrevEntryLength(p+rawlen,rawlen);
}
/* Stop here, as the raw length of "next" has not changed. */
break;
複製程式碼
除了新增操作以外,刪除操作也有可能引起級聯更新。假設我們有3個entry是下面的情況
我們可以知道,entry2的prevlen需要5個位元組,entry3的prevlen只需要1個位元組。而如果我們刪除了entry2,那麼entry3的prevlen就需要擴充套件到5個位元組,這一操作就有可能引起級聯更新,後面的情況和新增節點時一樣。
總結
最後做一個總結:
- 壓縮列表是zset和hash元素個數較少時的儲存結構
- ziplist由zlbytes、zltail、zllen、entry、zlend這五部分組成
- 每個entry由prevlen、encoding和entry-data三部分組成
- ziplist增加元素時,需要重新計算插入位置的entry的prevlen(prevlen的長度為1位元組或5位元組),這一操作有可能引起級聯更新。