做者:張仕華git
Redis使用ziplist是爲了節省內存.以zset爲例,當zset元素個數少而且每一個元素也比較小的時候,若是直接使用skiplist(能夠理解爲多層的雙向鏈表),每一個節點的先後指針這些元數據佔用空間的比例可能達到50%以上.而ziplist是分配在堆上的一塊連續內存,經過必定的編碼格式,使數據保存更加緊湊.以下是一個編碼爲ziplist的zset.github
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各字段解釋以下:redis
每一個entry的字段解釋以下:curl
注意ziplist中有一個zltail字段是最後一個entry的偏移量,經過該字段定位到最後一個entry後,讀取prev_entry_len能夠繼續向前定位上一個entry的起始地址.也就是說ziplist適合於從後往前遍歷.函數
首先看下代碼中是如何修復該bug的,而後經過把代碼反向修改回來,能夠構造示例復現該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) =
能夠看到代碼中增長了一個判斷this
if (nextdiff == -4 && reqlen < 4)
咱們看看nextdiff是如何計算的編碼
int zipPrevLenByteDiff(unsigned char *p, unsigned int len) { unsigned int prevlensize; //宏,展開以後根據p[0]處的值計算出prevlensize,若是p[0]<254,prevlensize爲1,不然爲5 ZIP_DECODE_PREVLENSIZE(p, prevlensize); //zipStorePrevEntryLength函數若是第一個參數爲NULL,則根據len字段計算須要的字節數,同理,len<254爲1個字節,不然爲5個字節 return zipStorePrevEntryLength(NULL, len) - prevlensize; }
如上函數計算nextdiff,能夠看出,根據插入位置p當前保存prev_entry_len字段的字節數和即將插入的entry須要的字節數相減得出nextdiff.值有三種類型url
bug修復過程首先判斷nextdiff等於-4,即p位置的prev_entry_len爲5個字節,而當前要插入的entry的長度只須要1個字節去保存.而後判斷reqlen < 4.看到此處可能讀者會有疑惑,既然prev_entry_len長度已經爲5個字節了,那麼新插入的值prev_entry_len+encoding+content字段確定會大於5字節,爲何會出現小於4的狀況呢?
這種狀況確實比較費解,經過下文的構造示例咱們可以看出,在連鎖更新的時候,爲了防止大量的從新分配空間的動做,若是一個entry的長度只須要1個字節就可以保存,可是連鎖更新時若是原先已經爲prev_entry_len分配了5個字節,則不會進行縮容操做.
把bug修復代碼反向修改回來,編譯以後執行以下命令能夠致使Redis crash(注意前邊是命令編號,下文經過該編號解釋Redis中ziplist內存的變化狀況):spa
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
前6條命令會往一個list中分別插入'one','two',252個'A',250個'A','three','a'六個元素.此時內存佔用狀況以下:
每一個小矩形框表示佔用內存字節數,大矩形框表示一個個entry,每一個entry有三項,分別爲prev_entry_len,encoding和content字段
接着執行第7條命令,內存佔用狀況如圖,表示以下:
刪除了第3個entry,此時第4個entry的前一個entry長度由255字節變爲5字節(第2個entry此時爲第4個entry的前一個entry),因此prev_entry_len字段由佔用5個字節變爲佔用1個字節.參見圖中黃框部分.
注意此時會發生連鎖更新,由於藍框部分的prev_entry_len由257字節變爲253,也能夠更新爲1個字節.但Redis中在連鎖更新的狀況下爲了不頻繁的realloc操做,這種狀況下不進行縮容.
接着執行第8條命令,插入綠框中的數據(見圖第3列所示),此時藍筐中的prev_entry_len是5個字節,綠框中的數據只佔用2字節,當將prev_entry_len更新爲1字節後,prev_entry_len多餘的4字節能夠完整的容納綠框中的數據.
即雖然插入了數據,但realloc以後反而縮小了佔用的內存,從而致使ziplist中的數據損壞.
修復這個bug的代碼也就很容易理解了,即圖中第3列藍框的prev_entry_len仍然保留爲5個字節.
能夠進一步構造另外一種狀況,即第6步構造爲rpush list 10,則此時不會形成redis crash,而是會丟失10這個元素.讀者能夠畫出內存佔用圖自行分析
經過上邊的分析,是否是覺着很難理解?Redis做者也意識到因爲連鎖更新的存在致使ziplist並非簡單易懂.因而提出了一個優化後的替代結構listpack.
listpack主要作了以下兩點改進:
總體結構以下:
<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(注意該字段設計爲從右往左讀取),這樣既實現了尾部到頭部的遍歷,又沒有連鎖更新的狀況.是否是很巧妙.