【Redis系列3】Redis列表物件之linkedlist(雙端列表)和ziplist(壓縮列表)及quicklick(快速列表)實現原理分析

雙子孤狼發表於2020-11-10

前言

上一篇我們分析了字串物件的底層儲存結構SDS,那麼這一篇我們繼續分析Redis中5種常用資料型別的第2種基本資料類列表物件的底層儲存結構。

列表物件

Redis3.2之前,列表物件其底層儲存結構可以有兩種,即:linkedlistziplist,而在Redis 3.2之後,列表物件底層儲存結構優化成為了另一種:quicklist。而quicklist可以認為是linkedlistziplist的結合體。

列表內部使用哪一種型別也是通過編碼來進行區分:

編碼屬性描述object encoding命令返回值
OBJ_ENCODING_LINKEDLIST使用linkedlist實現列表物件linkedlist
OBJ_ENCODING_ZIPLIST使用ziplist實現列表物件ziplist
OBJ_ENCODING_QUICKLIST使用quicklist實現列表物件quicklist

linkedlist

linkedlist是一個雙向列表,每個節點都會儲存指向上一個節點和指向下一個節點的指標。linkedlist因為每個節點的空間是不連續的,所以可能會造成過多的空間碎片。

linkedlist儲存結構

連結串列中每一個節點都是一個listNode物件(原始碼adlist.h內),不過需要注意的是,列表中的value其實也是一個字串物件,後面我們介紹的其他幾種資料型別其內部最終也是會巢狀字串物件:

typedef struct listNode {
    struct listNode *prev;//前一個節點
    struct listNode *next;//後一個節點
    void *value;//值(字串物件)
} listNode;

然後會將其再進行封裝成為一個list物件(原始碼adlist.h內):

typedef struct list {
    listNode *head;//頭節點
    listNode *tail;//尾節點
    void *(*dup)(void *ptr);//節點值複製函式
    void (*free)(void *ptr);//節點值釋放函式
    int (*match)(void *ptr, void *key);//節點值對比函式
    unsigned long len;//節點胡亮
} list;

Redis中對linkedlist的訪問是以NULL值為終點的,因為head節點的prev節點為NULL,tail節點的next節點為NULL。

所以,同樣的,在Redis3.2之前我們可以得到如下簡圖:
在這裡插入圖片描述
PS:想要詳細瞭解dictEntryredisObject物件以及編碼相關知識的的可以點選這裡

ziplist

ziplist是為了節省記憶體而開發的一種壓縮列表資料結構,後面講述的雜湊資料型別底層也用到了ziplist

ziplist是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構,一個ziplist可以包含任意多個entry,而每一個entry又可以儲存一個位元組陣列或者一個整數值。

ziplist和linkedlist最大的區別是ziplist不儲存指向上一個節點和下一個節點的指標,儲存的是上一個節點的長度和當前節點的長度,犧牲了部分讀寫效能來換取搞笑的記憶體利用率,是一種時間換空間的思想。

ziplist適用於欄位個數少和欄位值少的場景。

ziplist儲存結構

ziplist的組成結構為:

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

在這裡插入圖片描述

屬性型別長度說明
zlbytesuint32_t4位元組記錄壓縮列表佔用記憶體位元組數(包括本身所佔用的4個位元組)
zltailuint32_t4位元組記錄壓縮列表尾節點距離壓縮列表的起始地址有多少個位元組(通過這個值可以計算出尾節點的地址)
zllenuint16_t2位元組記錄壓縮列表中包含的節點數量,當列表值超過可以儲存的最大值(65535)時,次值固定儲存216-1(65535),因此此時需要遍歷整個壓縮列表才能計算出真實節點數
entry列表節點-壓縮列表中的各個節點,長度由儲存的實際資料決定
zlenduint8_t1位元組特殊字元0xFF(十進位制255),用來標記壓縮列表的末端(其他正常的節點沒有被標記為255的,因為255用來標識末尾,後面可以看到,正常節點都是標記為254)

entry儲存結構

ziplist 中的每個 entry 都以包含兩段資訊的後設資料作為字首,的組成結構為:

<prevlen> <encoding> <entry-data>
prevlen

prevlen屬性儲存了前一個entry的長度,以便能夠從後到前遍歷列表。 prevlen 的長度可能是1位元組也可能是5位元組:

  • 當連結串列的前一個entry佔用位元組數小於254,此時prevlen只用1個位元組進行表示。
<prevlen from 0 to 253> <encoding> <entry>
  • 當連結串列的前一個entry佔用位元組數大於等於254,此時prevlen用5個位元組來表示,其中第1個位元組的值是254(相當於是一個標記,代表後面跟了一個更大的值),後面4個位元組才是真正儲存前一個entry的佔用位元組數。
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>

PS:1個位元組完全你能儲存255,之所以只取到254是因為zlend就是固定的255,所以255這個數要用來判斷是否是ziplist的結尾。

encoding

encoding屬性儲存了當前entry所儲存資料的型別以及長度。前面我們提到,每一個entry中可以儲存位元組陣列和整數,而encoding屬性的第1個位元組就是用來確定當前entry儲存的是整數還是位元組陣列。當儲存整數時,第1個位元組的前兩位總是11,而儲存位元組陣列時,則可能是00、01和10。

  • 當儲存整數時,第1個位元組的前2位固定為11,第3和第4位用於指定將在這個頭之後儲存哪種型別的整數:
編碼長度entry儲存的資料
11000000int16_t型別整數
11010000int32_t型別整數
11100000int64_t型別整數
1111000024位有符號整數
111111108位有符號整數
1111xxxxxxxx代表區間0001-1101,儲存了一個介於0-12之間的整數,此時無需entry-data屬性

PS:xxxx四位編碼範圍是0000-1111,但是0000,1111和1110已經被佔用了,所以實際上的範圍是0001-1101,此時能儲存資料1-13,再減去1之後範圍就是0-12(為什麼要減1這個我沒想明白)。

編碼長度entry儲存的資料
00pppppp1位元組長度小於等於63位元組(6位)的位元組陣列
01pppppp qqqqqqqq2位元組長度小於等於16383位元組(14位)的位元組陣列
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt5位元組長度小於等於232-1位元組(32位)的位元組陣列,其中第1個位元組的後6位設定為0,暫時沒有用到,後面的32位(4個位元組)儲存了資料
entry-data

entry-data:具體資料。當儲存小整數時,encoding就是資料本身,此時沒有entry-data部分,沒有entry-data部分之後的ziplist結構如下:

<prevlen> <encoding>

壓縮列表中entry的資料結構定義如下(原始碼ziplist.c內),當然這個程式碼註釋寫了實際儲存並沒有用到這個結構,這個結構只是用來接收資料,所以瞭解一下就可以了:

typedef struct zlentry {
    unsigned int prevrawlensize;//儲存prevrawlen所佔用的位元組數
    unsigned int prevrawlen;//儲存上一個連結串列節點需要的位元組數
    unsigned int lensize;//儲存len所佔用的位元組數
    unsigned int len;//儲存連結串列當前節點的位元組數
    unsigned int headersize;//當前連結串列節點的頭部大小(prevrawlensize + lensize)即非資料域的大小
    unsigned char encoding;//編碼方式
    unsigned char *p;//指向當前節點的起始位置(因為列表內的資料也是一個字串物件)
} zlentry;

ziplist資料示例

上面講解了這麼多,聽起來非常複雜,下面我們就通過一個ziplist儲存整數為例子來分析一下。

[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
      |             |          |       |       |     |
   zlbytes        zltail     zllen    "2"     "5"   end

1、第一組4個位元組代表zlbytes,0f轉成二進位制就是1111也就是15,也就是這整個ziplist長度是15個位元組。
2、第二組4個位元組zltail,0c轉成二進位制就是1100也就是12,就是說[02 f6]這個尾節點距離起始位置有12個位元組。
3、第三組2個位元組就是記錄了當前ziplistentry的數量,02轉成二進位制就是10,也就是說當前ziplist有2個節點
4、[00 f3]就是第一個entry,f3轉成二進位制就是11110011,剛好對應了編碼1111xxxx,所以後面四位就是儲存了一個0-12的整數。0011轉成十進位制就是3,減去1得到2,所以第一個entry儲存的資料就是2。後面[02 f6]一樣的演算法可以得出就是5。
5、最後一組1個位元組[ff]轉成二進位制就是11111111,代表這是整個ziplist的結尾。

ziplist連鎖更新問題

前面提到entry中的prevlen屬性可能是1個位元組也可能是5個位元組,我們設想這麼一種場景:假設一個ziplist中,連續多個entry的長度都是一個接近但是又不到254的值(介於250~253之間),假如這時候新增一個entry1長度增加到大於254個位元組,那麼此時entry2prelen屬性就必須要由1個位元組變為5個位元組,也就是需要執行空間重分配,而此時因為entry2長度也增加了4個位元組,又大於254個位元組了了,那麼entry3prelen屬性也會被迫變為5個位元組,依此類推,這種產生連續多次空間重分配的現象就稱之為連鎖更新

PS:不僅僅是新增節點,執行刪除節點操作同樣可能會發生連鎖更新現象。

linkedlist和ziplist的選擇

在Redis3.2之前,linkedlistziplist兩種編碼可以進選擇切換,如果需要列表使用ziplist編碼進行儲存,則必須滿足以下兩個條件:

  • 列表物件儲存的所有字串元素的長度都小於64位元組。
  • 列表物件爆粗的元素數量小於512個。

一旦不滿足這兩個條件的任意一個,則會使用linkedlist編碼進行儲存。

PS:這兩個條件可以通過引數list-max-ziplist-valuelist-max-ziplist-entries進行修改。

quicklist

在Redis3.2之後,統一用quicklist來儲存列表物件,quicklist儲存了一個雙向列表,每個列表的節點是一個ziplist,所以實際上quicklist就是linkedlistziplist的結合。

quicklist內部儲存結構

quicklist中每一個節點都是一個quicklistNode物件,其資料結構定義為:

typedef struct quicklistNode {
    struct quicklistNode *prev;//前一個節點
    struct quicklistNode *next;//後一個節點
    unsigned char *zl;//當前指向的ziplist或者quicklistLZF
    unsigned int sz;//當前ziplist佔用位元組
    unsigned int count : 16;//ziplist中儲存的元素個數,16位元組(最大65535個)
    unsigned int encoding : 2; //是否採用了LZF壓縮演算法壓縮節點 1:RAW 2:LZF
    unsigned int container : 2; //儲存結構,NONE=1, ZIPLIST=2
    unsigned int recompress : 1; //當前ziplist是否需要再次壓縮(如果前面被解壓過則為true,表示需要再次被壓縮)
    unsigned int attempted_compress : 1;//測試用 
    unsigned int extra : 10; //後期留用
} quicklistNode;

然後各個quicklistNode就構成了一個列表quicklist

typedef struct quicklist {
    quicklistNode *head;//列表頭節點
    quicklistNode *tail;//列表尾節點
    unsigned long count;//ziplist中一共儲存了多少元素,即:每一個quicklistNode內的count相加
    unsigned long len; //雙向連結串列的長度,即quicklistNode的數量
    int fill : 16;//填充因子
    unsigned int compress : 16;//壓縮深度 0-不壓縮
} quicklist;

根據這兩個結構,我們可以得到Redis3.2之後的列表物件的一個簡圖:
在這裡插入圖片描述

quicklist的compress屬性

compress表示壓縮深度,可以通過引數list-compress-depth控制:

  • 0:不壓縮(預設值)
  • 1:首尾第1個元素不壓縮
  • 2:首位前2個元素不壓縮
  • 3:首尾前3個元素不壓縮
  • 以此類推

PS:之所以採取這種方式去控制是因為很多場景都是兩端的元素訪問率較高,而中間元素訪問率相對較低。

quicklistNode的zl指標

zl指標預設指向了ziplist,sz屬性記錄了當前ziplist佔用的位元組,不過這僅僅限於當前節點沒有被壓縮(LZF壓縮演算法)的情況,如果當前節點被壓縮了,那麼zl指標會指向另一個物件quicklistLZFquicklistLZF是一個4+N位元組的結構:

typedef struct quicklistLZF {
    unsigned int sz;// LZF大小
    char compressed[];//被壓縮的內容
} quicklistLZF;

quicklist對比原始兩種列表的改進

quicklist同樣採用了linkedlist的雙端列表特性,然後quicklist中的每個節點又是一個ziplist,所以quicklist就是綜合平衡考慮了空間碎片和讀寫效能兩個維度。使用quicklist需要注意以下2點:

  • 1、如果ziplist中的entry個數過少,極端情況就是隻有1個entry,此時就相當於退化成了一個普通的linkedlist
  • 2、如果ziplist中的entry過多,那麼也會導致一次性需要申請的記憶體空間過大,而且因為ziplist本身的就是以時間換空間,所以會過多entry也會影響到列表物件的讀寫效能。

ziplist中的entry個數可以通過引數list-max-ziplist-size來控制:

list-max-ziplist-size 1

注意:這個引數可以配置正數也可以配置負數。正數表示限制每個節點中的entry數量,如果是負數則只能為-1~-5

  • -1:每個ziplist最多隻能為4KB
  • -2:每個ziplist最多隻能為8KB
  • -3:每個ziplist最多隻能為16KB
  • -4:每個ziplist最多隻能為32KB
  • -5:每個ziplist最多隻能為64KB

總結

本文主要介紹了Redis中5種常用資料型別中的列表型別底層的儲存結構,並分別對其兩種底層資料linkedlistziplist進行了分析對比,最後分析了Redis3.2之後的底層資料型別quicklist的儲存原理。

下一篇,我們將分析Redis中5種常用資料型別中的第3種雜湊物件的底層儲存結構。

請關注我,和孤狼一起學習進步

相關文章