作者:張仕華
ziplist簡介
Redis使用ziplist是爲了節省內存.以zset爲例,當zset元素個數少並且每個元素也比較小的時候,如果直接使用skiplist(可以理解爲多層的雙向鏈表),每個節點的前後指針這些元數據佔用空間的比例可能達到50%以上.而ziplist是分配在堆上的一塊連續內存,通過一定的編碼格式,使數據保存更加緊湊.如下是一個編碼爲ziplist的zset.
127.0.0.1:6666> zadd zs 100 'a'
(integer) 1
127.0.0.1:6666> zadd zs 200 'b'
(integer) 1
127.0.0.1:6666> object encoding zs
"ziplist"
ziplist格式
ziplist的格式如下圖所示:
ziplist各字段解釋如下:
- zlbytes:ziplist佔用的內存空間大小
- zltail:ziplist最後一個entry的偏移量
- zllen:ziplist中entry的個數.
- entry:每個元素
- 0xFF:ziplist的結束標誌
每個entry的字段解釋如下:
- prev_entry_len:前一個entry佔用的字節大小,佔用1個或者5個字節.當小於254時,佔用1字節,當大於等於254時,佔用5字節
- encoding:當前entry內容的編碼格式及其長度
- content:當前entry保存的內容
注意ziplist中有一個zltail字段是最後一個entry的偏移量,通過該字段定位到最後一個entry後,讀取prev_entry_len可以繼續向前定位上一個entry的起始地址.也就是說ziplist適合於從後往前遍歷.
bug原因及其復現
首先看下代碼中是如何修復該bug的:
@@ -778,7 +778,12 @@ unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned cha
/* 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. */
+ int forcelarge = 0;
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
+ if (nextdiff == -4 && reqlen < 4) {
+ nextdiff = 0;
+ forcelarge = 1;
+ }
/* Store offset because a realloc may change the address of zl. */
offset = p-zl;
@@ -791,7 +796,10 @@ unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned cha
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
/* Encode this entry's raw length in the next entry. */
- zipStorePrevEntryLength(p+reqlen,reqlen);
+ if (forcelarge)
+ zipStorePrevEntryLength(p+reqlen,reqlen);
+ else
+ zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
/* Update offset for tail */
ZIPLIST_TAIL_OFFSET(zl) =
通過把代碼反向修改回來,編譯之後執行如下命令可以導致Redis crash:
0.redis-cli del list
1.redis-cli rpush list one
2.redis-cli rpush list two
3.redis-cli rpush list
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
4.redis-cli rpush list
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
5.redis-cli rpush list three
6.redis-cli rpush list a
7.redis-cli lrem list 1
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
8.redis-cli linsert list after
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 10
9.redis-cli lrange list 0 -1
我們將以上命令的內存佔用情況以圖畫出來,表示如下:
每個小矩形框表示佔用內存字節數,大矩形框表示一個個entry,每個entry有三項,參見上文ziplist entry的格式介紹
左列是執行到第6條命令之後的內存佔用情況
接着執行第7條命令,刪除了第3個entry,此時第4個entry的前一個entry長度由255字節變爲5字節,所以prev_entry_len字段由佔用5個字節變爲佔用1個字節.參見圖中中間列的黃框.
注意此時會發生連鎖更新,因爲籃框部分的prev_entry_len此時等於253,也可以更新爲1個字節.但Redis中在連鎖更新的情況下爲了避免頻繁的realloc操作,這種情況下不進行縮容.
接着執行第8條命令,插入綠框中的數據,此時籃筐中的prev_entry_len是5個字節,綠框中的數據只佔用2字節,當將prev_entry_len更新爲1字節後,prev_entry_len多餘的4字節可以完整的容納綠框中的數據.
即雖然插入了數據,但realloc之後反而縮小了佔用的內存,從而導致ziplist中的數據損壞.
修復這個bug的代碼也就很容易理解了,即圖中右列藍框的prev_entry_len仍然保留爲5個字節.
Redis作者對該bug的思考
通過上邊的分析,是不是覺着很難理解?Redis作者也意識到由於連鎖更新的存在導致ziplist並不是簡單易懂.於是提出了一個優化後的替代結構listpack.
listpack主要做了如下兩點改進:
- 頭部省去了4個字節的zltail字段
- entry中不再保存prev_entry_len這個字段,而是改爲保存本entry自己的長度
整體結構如下:
<tot-bytes> <num-elements> <element-1> ... <element-N> <listpack-end-byte>
每個entry的結構如下:
<encoding-type><element-data><element-tot-len>
我們知道ziplist設計爲適合從尾部到頭部逐個遍歷,那麼listpack如何實現該功能呢?
首先通過tot-bytes偏移到結尾,然後從右到左讀取element-tot-len(注意該字段設計爲從右往左讀取),這樣既實現了尾部到頭部的遍歷,又沒有連鎖更新的情況.是不是很巧妙.