跟著大彬讀原始碼 - Redis 9 - 物件編碼之 三種list

北國丶風光發表於2019-08-07

Redis 底層使用了 ziplist、skiplist 和 quicklist 三種 list 結構來實現相關物件。顧名思義,ziplist 更節省空間、skiplist 則注重查詢效率,quicklist 則對空間和時間進行折中。

在典型的雙向連結串列中,我們有稱為節點的結構,它表示列表中的每個值。每個節點都有三個屬性:指向列表中的前一個和下一個節點的指標,以及指向節點中字串的指標。而每個值字串值實際上儲存為三個部分:一個表示長度的整數、一個表示剩餘空閒位元組數的整數以及字串本身後跟一個空字元。

可以看到,連結串列中的每一項都佔用獨立的一塊記憶體,各項之間用地址指標(或引用)連線起來。這種方式會帶來大量的記憶體碎片,而且地址指標也會佔用額外的記憶體。這就是普通連結串列的記憶體浪費問題。

此外,在普通連結串列中執行隨機查詢操作時,它的時間複雜度為 O(n),這對於注重效率的 Redis 而言也是不可接受的。這是普通連結串列的查詢效率太低問題。

針對上述兩個問題,Redis 設計了ziplist(壓縮列表)skiplist(跳躍表)快速連結串列進行相關優化。

1 ziplist

對於 ziplist,它要解決的就是記憶體浪費的問題。也就是說,它的設計目標就是是為了節省空間,提高儲存效率

基於此,Redis 對其進行了特殊設計,使其成為一個經過特殊編碼的雙向連結串列。將表中每一項存放在前後連續的地址空間內,一個 ziplist 整體佔用一大塊記憶體。它是一個表(list),但其實不是一個連結串列(linked list)。

除此之前,ziplist 為了在細節上節省記憶體,對於值的儲存採用了變長的編碼方式,大概意思是說,對於大的整數,就多用一些位元組來儲存,而對於小的整數,就少用一些位元組來儲存。

也正是為了這種高效率的儲存,ziplist 有很多 bit 級別的操作,使得程式碼變得較為晦澀難懂。不過不要緊,我們本節的目標之一是為了瞭解 ziplist 對比普通連結串列,做了哪些優化,可以更好的節省空間

接下來我們來正式認識下壓縮列表的結構。

1.1 壓縮列表的結構

一個壓縮列表可以包含任意多個節點(entry),每個節點可以儲存一個位元組陣列或者一個整數值。

圖 1-1 展示了壓縮列表的各個組成部分:

圖 1-1:壓縮列表的結構

相關欄位說明如下:

  • zlbytes:4 位元組,表示 ziplist 佔用的位元組總數(包括 zlbytes 本身佔用的 4 個位元組)。
  • zltail:4 位元組,表示 ziplist 表中最後一項距離列表的起始地址有多少位元組,也就是表尾節點的偏移量。通過此欄位,程式可以快速確定表尾節點的地址。
  • zllen:2 位元組:表示 ziplist 的節點個數。要注意的是,由於此欄位只有 16bit,所以可表達的最大值為 2^16-1。一旦列表節點個數超過這個值,就要遍歷整個壓縮列表才能獲取真實的節點數量。
  • entry:表示 ziplist 的節點。長度不定,由儲存的內容決定。要注意的是,列表的節點(entry)也有自己的資料結構,後續會詳細說明。
  • zlend:ziplist 的結束標記,值固定為 255,用於標記壓縮列表的末端。

圖 1-2 展示了一個包含五個節點的壓縮列表:

圖 1-2:包含五個節點的壓縮列表

  • 列表 zlbytes 屬性的值為 0xd2(十進位制 210),表示壓縮列表的總長為 210 位元組。
  • 列表 zltail 屬性的值為 0xb3,(十進位制 179),表示尾結點相比列表的起始地址有 179 位元組的距離。假如列表的起始地址為 p,那麼指標 p + 179 = entry5 的地址。
  • 列表 zllen 屬性的值為 0x5(十進位制 5),表示壓縮列表包含 5 個節點。

1.2 列表節點的結構

節點的結構原始碼如下(ziplist.c):

typedef struct zlentry {
    unsigned int prevrawlensize, prevrawlen;
    unsigned int lensize, len;
    unsigned int headersize;
    unsigned char encoding;
    unsigned char *p;
} zlentry;

如圖 1-3,展示了壓縮列表節點的結構。

圖 1-3:壓縮列表節點結構

  • prevrawlen:表示前一個節點佔用的總位元組數。此欄位是為了讓 ziplist 能夠從後向前遍歷。
  • encoding:此欄位記錄了節點的 content 屬性中所儲存的資料型別。
  • lensize:此欄位記錄了節點所儲存的資料長度。
  • headersize:此欄位記錄了節點 header 的大小。
  • *p:此欄位記錄了指向節點儲存內容的指標。

1.3 壓縮列表如何節省了記憶體

回到我們最開始對普通連結串列的認識,普通連結串列中,每個節點包:

  • 一個表示長度的整數
  • 一個表示剩餘空閒位元組數的整數
  • 字串本身
  • 結尾空字元。

以圖 1-4 為例:

圖 1-4:普通鏈

圖 1-4 展示了一個普通連結串列的三個節點,這三個節點中,每個節點實際儲存內容只有 1 位元組,但是它們除了實際儲存內容外,還都要有:

  • 3 個指標 - 佔用 3 byte
  • 2 個整數 - 佔用 2 byte
  • 內容字串 - 佔用 1 byte
  • 結尾空字元的空間 - 佔用 1 byte

這樣來看,儲存 3 個位元組的資料,至少需要 21 位元組的開銷。可以看到,這樣的儲存效率是很低的。

另一方面,普通連結串列通過前後指標來關聯節點,地址不連續,多節點時容易產生記憶體碎片,降低了記憶體的使用率。

最後,普通連結串列對儲存單位的操作粒度是 byte,這種方式在儲存較小整數或字串時,每個位元組實際上會有很大的空間是浪費的。就像上面三個節點中,用來儲存剩餘空閒位元組數的整數,實際儲存空間只需要 1 bit,但是有了 1 byte 來表示剩餘空間大小,這一個 byte 中,剩餘 7 個 bit 就被浪費了。

那麼,Redis 是如何使用 ziplist 來改造普通連結串列的呢?通過以下兩方面:

一方面,ziplist 使用一整塊連續記憶體,避免產生記憶體碎片,提高了記憶體的使用率

另一方面,ziplist 將儲存單位的操作粒度從 byte 降低到 bit,有效的解決了儲存較小資料時,單個位元組中浪費 bit 的問題。

2 skiplist

skiplist 是一種有序資料結構,它通過在每個節點中維持多個指向其他節點的指標,來達到快速訪問節點的目的。

skiplist 本質上是一種查詢結構,用於解決演算法中的查詢問題。即根據指定的值,快速找到其所在的位置。

此外,我們知道,"查詢" 問題的解決方法一般分為兩大類:平衡樹雜湊表。有趣的是,skiplist 這種查詢結構,因為其特殊性,並不在上述兩大類中。但在大部分情況下,它的效率可以喝平衡樹想媲美,而且跳躍表的實現要更為簡單,所以有不少程式都使用跳躍表來代替平衡樹。

本節沒有介紹跳躍表的定義及其原理,有興趣的童鞋可以參考這裡

認識了跳躍表是什麼,以及做什麼的,接下來,我們再來看下在 redis 中,是怎麼實現跳躍表的。

server.h 中可以找到跳躍表的原始碼,如下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

Redis 中的 skiplist 和普通的 skiplist 相比,在結構上並沒有太大不同,只是在一些細節上有以下差異:

  • 分數(score)可以重複。就是說 Redis 中的 skiplist 在分值欄位上是允許重複的,而普通的 skiplist 則不允許重複。
  • 第一層連結串列不是單向連結串列,而是雙向連結串列。這種方式可以用倒序方式獲取一個範圍內的元素。
  • 比較時,除了比較 score 之外,還比較資料本身。在 Redis 的 skiplist 中,資料本身的內容是這份資料的唯一標識,而不是由 score 欄位做唯一標識。此外,當多個元素分數相同時,還需要根據資料內容進行字典排序。

3 quicklist

對於 quicklist,在 quicklist.c 中有以下說明:

A doubly linked list of ziplists

它是一個雙向連結串列,並且是一個由 ziplist 組成的雙向連結串列。

相關原始碼結構可在 quicklist.h 中查詢,如下:

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 12 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
 * 'sz' is byte length of 'compressed' field.
 * 'compressed' is LZF data with total (compressed) length 'sz'
 * NOTE: uncompressed length is stored in quicklistNode->sz.
 * When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

/* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: -1 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned int len;           /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

上面介紹連結串列的時候有說過,連結串列由多個節點組成。而對於 quicklist 而言,它的每一個節點都是一個 ziplist。quicklist 這樣設計,其實就是我們篇頭所說的,是一個空間和時間的折中。

ziplist 相比普通連結串列,主要優化了兩個點:降低記憶體開銷減少記憶體碎片。正所謂,事物總是有兩面性。ziplist 通過連續記憶體解決了普通連結串列的記憶體碎片問題,但與此同時,也帶來了新的問題:不利於修改操作

由於 ziplist 是一整塊連續記憶體,所以每次資料變動都會引發一次記憶體的重分配。當在 ziplist 很大的時候,每次重分配都會出現大批量的資料拷貝操作,降低效能。

於是,結合了雙向連結串列和 ziplist 的優點,就有了 quicklist。

quicklist 的基本思想就是,給每一個節點的 ziplist 分配合適的大小,避免出現因資料拷貝,降低效能的問題。這又是一個需要找平衡點的難題。我們先從儲存效率上分析:

  • 每個 quicklist 節點上的 ziplist 越短,則記憶體碎片越多。而記憶體碎片多了,就很有可能產生很多無法被利用的記憶體小碎片,降低儲存效率。
  • 每個 quicklist 節點上的 ziplist 越長,則為 ziplist 分配大塊連續記憶體的難度就越大。有可能出現,記憶體裡有很多較小塊的記憶體,卻找不到一塊足夠大的空閒空間分配給 ziplist 的情況。這同樣會降低儲存效率。

可見,一個 quicklist 節點上的 ziplist 需要保持一個合理的長度。這裡的合理取決於實際應用場景。基於此,Redis 提供了一個配置引數,讓使用者可以根據情況,自己調整:

list-max-ziplist-size -2

這個引數可以取正值,也可以取負值。

當取正值的時候,表示按照資料項個數來限定每個 quicklist 節點上 ziplist 的長度。比如配置為 2 時,就表示 quicklist 的每個節點上的 ziplist 最多包含 2 個資料項。

當取負值的時候,表示按照佔用位元組數來限定每個 quicklist 節點上 ziplist 的長度。此時,它的取值範圍是 [-1, -5],每個值對應不同含義:

  • -1:每個 quicklist 節點上的 ziplist 大小不能超過 4Kb;
  • -2:每個 quicklist 節點上的 ziplist 大小不能超過 8Kb(預設值);
  • -3:每個 quicklist 節點上的 ziplist 大小不能超過 16Kb;
  • -4:每個 quicklist 節點上的 ziplist 大小不能超過 32Kb;
  • -5:每個 quicklist 節點上的 ziplist 大小不能超過 64Kb;

總結

  1. 普通連結串列存在兩個問題:記憶體利用率低容易產生記憶體碎片
  2. ziplist 使用連續記憶體,減少記憶體碎片,提供記憶體利用率。
  3. skiplist 可以用相對簡單的實現,達到和平衡樹相同的查詢效率。
  4. quicklist 汲取了普通連結串列和壓縮連結串列的優點,保證效能的前提下,儘可能的提高記憶體利用率。

相關文章