【Redis源碼研究】Redis的一個歷史bug及其後續改進

做者:張仕華git

ziplist簡介

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的格式以下圖所示:
ziplist
ziplist各字段解釋以下:redis

  • zlbytes:ziplist佔用的內存空間大小
  • zltail:ziplist最後一個entry的偏移量
  • zllen:ziplist中entry的個數.
  • entry:每一個元素
  • 0xFF:ziplist的結束標誌

每一個entry的字段解釋以下:curl

  • 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的,而後經過把代碼反向修改回來,能夠構造示例復現該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

  • 0: 空間相等
  • 4:須要更多空間
  • -4:空間富餘

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'六個元素.此時內存佔用狀況以下:
orig

每一個小矩形框表示佔用內存字節數,大矩形框表示一個個entry,每一個entry有三項,分別爲prev_entry_len,encoding和content字段

接着執行第7條命令,內存佔用狀況如圖,表示以下:
cascade update

刪除了第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做者對該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(注意該字段設計爲從右往左讀取),這樣既實現了尾部到頭部的遍歷,又沒有連鎖更新的狀況.是否是很巧妙.

參考文檔

相關文章
相關標籤/搜索