【Redis5源碼學習】2019-04-17 壓縮列表ziplist

baiyan
所有視頻:https://segmentfault.com/a/11...redis

爲何須要ziplist

乍一看標題,咱們可能還不知道ziplist是何許人也。可是若是我說list、hash、zset這幾種數據結構,你們就很熟悉了。而ziplist就是這幾種數據結構的底層實現之一:算法

  • list:3.2.x以前爲(ziplist + linkedlist)以後爲quicklist
  • hash:數據量小的時候使用ziplist,量大時使用hashtable(dict)
  • zset:數據量小的時候使用ziplist,量大時使用skiplist

咱們能夠看到,ziplist老是在一種列表、哈希、有序集合這幾種結構在存儲的數據量小的時會使用。隨着數據量的增加,會轉換到相對應較複雜的類型。咱們能夠猜想,ziplist是一種輕巧、簡單、且佔用內存小的數據結構。它可以解決在redis數據量小時的存儲問題。segmentfault

ziplist的結構

在redis的設計思想中,大多數狀況下,都是以時間換空間。因爲redis基於內存,且內存資源是至關寶貴的,因此節省空間的「性價比」相對於節省時間,顯然更高一些。在學習數據結構與算法的過程當中,咱們經常將數組和鏈表放在一塊兒比較。因爲數組使用一塊連續的內存,而鏈表分爲指針域和數據域,數組在空間利用率上明顯要高於鏈表。參考以上設計思想,若是讓咱們本身去設計ziplist的結構,咱們如何思考呢?數組

  • 須要一塊連續的內存空間去存儲真正的數據
  • 須要一些額外的信息字段去記錄它的長度、結束標誌、還有數據的總量等輔助信息

在ziplist中,是按照以下結構進行存儲的,是否符合你的預期呢?

每一個字段的含義以下:數據結構

  • zlbytes:4個字節。記錄壓縮列表總共佔用的字節數,在對壓縮列表進行內存重分配時使用
  • zltail:4個字節。可經過這個字段快速定位到鏈表末尾
  • zllen:2個字節。記錄總共有多少個entry
  • entry:具體數據的內容就存在這裏
  • zlend:1個字節。爲十六進制值0xFF,標記一個壓縮列表的末尾

具體的數據存放在entry中。在ziplist中,能夠存儲兩種數據:學習

  • 字符串(字節數組)
  • 整數

舉一個例子,一個zset在數據量少的狀況下,會將元素名和分值按從小到大的順序在ziplist的entry中連續存儲:

那麼問題來了,咱們在讀取數據的時候,咱們怎麼知道是應該按照讀取字符串仍是整數類型的方式去讀取呢?咱們須要知道當前entry存儲數據的類型,即一個encoding字段,用來標識當前entry數據的類型。
除此以外,咱們在查找一個元素的時候,須要對其進行遍歷,在ziplist中是如何遍歷的呢?回想咱們在數組中的遍歷方式:ui

普通數組的遍歷是根據數組裏存儲的 數據類型來找到下一個元素的,例如int類型的數組(也是指針)訪問下一個元素時每次只須要加上相應類型的指針偏移量便可(若是用下標法表示數組,p[0]到p[1]就等效於p+1*sizeof(int)這個過程;若是用指針法,能夠用p+1來表示,它也等效於p+1*sizeof(int))

那麼在ziplist中,咱們不知道數據類型,也不知道這個數據的長度,該如何將遍歷用的指針日後挪呢?這就須要一個字段去完成這個任務,這裏就是previous_entry_length,它記錄前一個entry的長度,能夠利用它完成壓縮列表的遍歷
最後,就是最重要的字段,即存儲真正數據的字段content
以咱們上圖的例子,繼續咱們畫出entry的結構:
編碼

  • previous_entry_length:記錄了壓縮列表中前一個entry的長度。佔用15字節
  • encoding:表示當前entry存儲數據的類型與數據的長度。佔用一、2或5字節
  • content:真正存儲數據的地方

ziplist的遍歷

遍歷是查找操做的基礎,學習任意一種數據結構,遍歷都是關鍵。spa

正向遍歷

正向遍歷ziplist:首先指針p在第一個entry的起始位置,即previous_entry_length字段的位置。因爲previous_entry_length可能佔用1個字節、也可能佔用5個字節,因此咱們須要知道如何區分這個字段佔用了1仍是5字節。表示方法以下:設計

  • 若是前一個entry的長度小於254字節時,previous_entry_length用1字節表示
  • 若是前一個entry的長度大於等於254字節時,previous_entry_length用5字節表示。注意此時第一個字節是固定的標誌0xFE(254),後面4個字節用來表示前一個entry的長度
  • 這樣一來,咱們就可以知道:因爲咱們當前的指針爲unsigned char *類型(見源碼),指針運算p+1就等於日後偏移1個字節(即8位)。因此只須要讀取當前指針的第一個字節的內容,即p[0]的值是否在二進制的00000000 ~ 11111110(即0~254)之間。若是在這個區間內,就說明previous_entry_length只佔用1個字節,使用p+1就可以獲得encoding的首地址了;若是p[0]的值爲11111111(255),說明previous_entry_length佔用了5個字節,使用p+5也可以獲得encoding的首地址。
  • 如今咱們的指針來到了encoding字段起始地址的位置。那麼,encoding字段是如何存儲數據類型和長度的呢?爲了節省encoding字段所佔用的內存空間,將全部字符數組(字符串)類型以及整數類型按照以下編碼方式區分:

  • 觀察上圖encoding的編碼方式,咱們發現,只須要讀取當前指針位置日後偏移兩位的內容,就可以獲得encoding字段的長度。(00、11佔用1字節;01佔用2字節;10佔用5字節)。那麼咱們相應的p+一、p+二、p+5便可將指針偏移到content的位置。
  • 因爲咱們在encoding字段中知道content字段的數據類型的長度(如int16等)再將指針日後偏移以前encoding字段中存儲的的相應數據類型長度,就能夠偏移到content字段的末尾了。後面若是有多個一樣的entry結構,也同理,這樣就成功實現了ziplist的正序遍歷。

反向遍歷

因爲previous_entry_length字段的存在,咱們首先取出外部zltail字段,也就是指向ziplist結構末尾的指針,而後一次又一次地將指針減去entry中previous_entry_length字段的值,就可以將指針偏移到ziplist的頭部,原理很簡單,相信你們都可以理解,再也不贅述。因此咱們可以發現,ziplist更適合從後往前遍歷。

redis編碼轉換的根本緣由

  • 其實ziplist就是借鑑了數組的思想,skiplist借鑑了鏈表的思想。不論是正向仍是反向遍歷,仍是在ziplist的插入或者刪除中須要將後面的元素日後挪或者往前挪,全部操做的複雜度均爲O(n)。比起zset的另外一種實現dict+skiplist中查詢O(1)的時間複雜度,還有插入以及刪除的O(logn)複雜度,ziplist在效率方面並無優點。可是咱們以前講過,redis的設計思想通常都是以時間換空間,因此,相比skiplist須要維護大量的指針、在多層上面重複的數據(skiplist佔用的空間爲2n,詳情請看上一篇筆記),ziplist在空間複雜度上優點盡顯。
  • 可是咱們不得不說,ziplist在時間複雜度上面的劣勢依然存在,因此咱們不能把劣勢無限放大開來,而是要揚長避短。因此,redis開發者也反覆權衡,考慮到了這一點。就拿zset來講,只有符合以下兩個條件,纔會使用ziplist編碼,不然使用skiplist進行編碼:
  • zset中的對象保存的元素數量不超過128個
  • zset中保存的全部元素成員的長度小於64字節
  • 這樣一來,因爲ziplist只處理少許、且規模很小的數據,這使得時間複雜度O(n)在ziplist處理少許數據的時候,這個n是很是小的。因此,這樣就可以將其時間複雜度的影響降到了最低,將其空間複雜度的優點發揮到最大,這就是爲何須要進行編碼轉換的根本緣由。

至於ziplist的關鍵之處就講完了。至於其增刪改查的具體源碼,有興趣的讀者能夠去ziplist.c中深刻查看,筆者在這篇文章裏再複製粘貼一次意義也不大。學習的過程當中,我閱讀了大量的資料,可是內容質量良莠不齊。這裏我想說,咱們在學習一種新知識的時候,不只僅要知道它是什麼樣子,也要知道它爲何是這樣的、爲何這麼作而不採用其它的替代方案?它的比較優點在哪裏?而不要簡單的堆砌概念。在學習的同時,若是沒有通過本身的思考,收效甚微。

相關文章
相關標籤/搜索