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

作者:張仕華

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
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

我們將以上命令的內存佔用情況以圖畫出來,表示如下:
cascade update

每個小矩形框表示佔用內存字節數,大矩形框表示一個個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(注意該字段設計爲從右往左讀取),這樣既實現了尾部到頭部的遍歷,又沒有連鎖更新的情況.是不是很巧妙.

參考文檔

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章