你需要知道的那些 redis 資料結構(前篇)

餓了麼物流技術團隊發表於2019-06-19

作者簡介

世宇,一個喜歡吉他、MDD 攝影、自走棋的工程師,屬於餓了麼上海物流研發部。目前負責的是網格商圈、代理商基礎產線,平時喜歡專研技術,主攻 Java。

redis 對於團隊中的同學們來說是非常熟悉的存在了,我們常用它來做快取、或是實現分散式鎖等等。對於其 api 中提供的幾種資料結構,大家也使用得得心應手。

api 中的資料結構有如下幾種:

  • string
  • list
  • hash
  • set
  • sorted set

這些 api 提供的“資料結構”,在 redis 的官方文件中有詳細的介紹。就不多做展開,本次重點在於討論 redis 資料結構的內部更底層的實現。如下:

  • sds
  • adlist(在 3.2 版本中被 quicklist 所代替)
  • dict
  • skiplist
  • intset
  • ziplist
  • object

在學習瞭解 redis 幾個底層資料結構的過程中,處處可以體會到作者在設計 redis 時對於效能與空間的思考。附 redis 原始碼下載。本期主要介紹 sds 和 ziplist。

一、sds 簡單動態字串

1、sds 結構

redis 沒有直接使用 C 語言傳統的字串表示(以空字元結尾的字元陣列,以下簡稱 C 字串), 而是自己構建了一種名為簡單動態字串(simple dynamic string,sds)的抽象型別,並將 sds 用作 redis 的預設字串表示。

根據傳統,C 語言使用長度為 N+1 的字元陣列來表示長度為 N 的字串, 並且字元陣列的最後一個元素總是空字元 '\0' 。如下圖:

img

因為 C 字串並不記錄自身的長度資訊,所以為了獲取一個 C 字串的長度,程式必須遍歷整個字串, 對遇到的每個字元進行計數,直到遇到代表字串結尾的空字元為止,這個操作的複雜度為 O(N) 。

和 C 字串不同,因為 sds 在 len 屬性中記錄了 sds 本身的長度,所以獲取一個 sds 長度的複雜度僅為 O(1) 。與此同時,它還通過 alloc 屬性記錄了自己的總分配空間。下圖為 sds 的資料結構:

image.png

區別於 C 字串,sds 有自己獨特的 header,而且多達 5 種,結構如下:

typedef char *sds;

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
複製程式碼

之所以有 5 種,是為了能讓不同長度的字串可以使用不同大小的 header。這樣,短字串就能使用較小的 header,從而節省記憶體。

通過使用 sds 而不是 C 字串,redis 將獲取字串長度所需的複雜度從 O(N) 降低到了 O(1) ,這是一種以空間換時間的策略,確保了獲取字串長度的工作不會成為 redis 的效能瓶頸。

2、記憶體分配策略

再來看 sds 的定義,它是簡單動態字串。可動態擴充套件記憶體也是它的特性之一。sds 表示的字串其內容可以修改,也可以追加。在很多語言中字串會分為 mutable 和 immutable 兩種,顯然 sds 屬於 mutable 型別的。當 sds API 需要對 sds 進行修改時, API 會先檢查 sds 的空間是否滿足修改所需的要求, 如果不滿足的話,API 會自動將 sds 的空間擴充套件至足以執行修改所需的大小,然後才執行實際的修改操作,所以使用 sds 既不需要手動修改 sds 的空間大小, 也不會出現 C 語言中可能面臨的緩衝區溢位問題。

提到字串變化就不得不提到記憶體重分配這個問題,對於一個 C 字串,每次發生變更,程式都總要對儲存個 C 字串的陣列進行一次記憶體重分配操作:

  • 如果程式執行的是增長字串的操作,比如拼接操作(append),那麼在執行這個操作之前, 程式需要先通過記憶體重分配來擴充套件底層陣列的空間大小 —— 如果忘了這一步就會產生緩衝區溢位。
  • 如果程式執行的是縮短字串的操作,比如截斷操作(trim),那麼在執行這個操作之後, 程式需要通過記憶體重分配來釋放字串不再使用的那部分空間 —— 如果忘了這一步就會產生記憶體洩漏。

因為記憶體重分配涉及複雜的演算法,並且可能需要執行系統呼叫,所以它通常是一個比較耗時的操作:

  • 在一般程式中, 如果修改字串長度的情況不太常出現, 那麼每次修改都執行一次記憶體重分配是可以接受的。
  • 但是 redis 作為一個記憶體資料庫, 經常被用於速度要求嚴苛、資料被頻繁修改的場合, 如果每次修改字串的長度都需要執行一次記憶體重分配的話, 那麼光是執行記憶體重分配的時間就會佔去修改字串所用時間的一大部分, 如果這種修改頻繁地發生的話, 可能還會對效能造成影響。

為了避免 C 字串的這種缺陷,sds 通過未使用空間解除了字串長度和底層陣列長度之間的關聯:在 sds 中,buf 陣列的長度不一定就是字元數量加一,陣列裡面可以包含未使用的位元組,而這些未使用位元組的數量可以由 sds 的 alloc 屬性減去len屬性得到。

通過未使用空間,sds 實現了空間預分配和惰性空間釋放兩種優化策略。

空間預分配

空間預分配用於優化 sds 的字串增長操作:當 sds 的 API 對一個 sds 進行修改,並且需要對 sds 進行空間擴充套件的時候,程式不僅會為 sds 分配修改所必須要的空間,還會為 sds 分配額外的未使用空間,並根據新分配的空間重新定義 sds 的 header。此部分的程式碼邏輯如下:

    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);
複製程式碼

簡單來說就是:

  • 如果對 sds 進行修改之後,sds 的長度(也即是 len 屬性的值)將小於 1 MB ,那麼程式分配和 len 屬性同樣大小的未使用空間,這時 SDSsdsalloc 屬性的值將正好為 len 屬性的值的兩倍。舉個例子, 如果進行修改之後,sds 的 len 將變成 13 位元組,那麼程式也會分配 13 位元組的未使用空間,alloc 屬性將變成 26位元組,sds 的 buf 陣列的實際長度將變成 13 + 13 + 1 = 27 位元組(額外的一位元組用於儲存空字元)。
  • 如果對 sds 進行修改之後,sds 的長度將大於等於 1 MB ,那麼程式會分配 1 MB 的未使用空間。舉個例子, 如果進行修改之後,sds 的 len 將變成 30 MB,那麼程式會分配 1 MB 的未使用空間,alloc 屬性將變成 31 MB ,sds 的 buf 陣列的實際長度將為 30 MB + 1 MB + 1 byte

通過空間預分配策略,Redis 可以減少連續執行字串增長操作所需的記憶體重分配次數。通過這種空間換時間的預分配策略,sds 將連續增長 N 次字串所需的記憶體重分配次數從必定 N 次降低為最多 N 次。

記憶體預分配策略僅在 sds 擴充套件的時候才觸發,而新建立的 sds 長度和 C 字串一致,是長度 + 1byte。

惰性空間釋放

惰性空間釋放用於優化 sds 的字串縮短操作:當 sds 的 API 需要縮短 sds 儲存的字串時, 程式並不立即使用記憶體重分配來回收縮短後多出來的位元組,而是使用 free 屬性將這些位元組的數量記錄起來, 並等待將來使用。

通過惰性空間釋放策略,sds 避免了縮短字串時所需的記憶體重分配操作, 併為將來可能有的增長操作提供了優化。與此同時,sds 也提供了相應的 API sdsfree,讓我們可以在有需要時, 真正地釋放 sds 裡面的未使用空間,所以不用擔心惰性空間釋放策略會造成記憶體浪費。原始碼如下:

/* Free an sds string. No operation is performed if 's' is NULL. */
void sdsfree(sds s) {
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}
複製程式碼

細想一下,惰性空間釋放策略也是空間換時間策略的實現之一,作者對於效能的追求是非常執著的。當然也不是說為了效能,就不在乎記憶體的使用了,且看下一部分。

二、ziplist壓縮連結串列

1、ziplist介紹

The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values,where integers are encoded as actual integers instead of a series ofcharacters. It allows push and pop operations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.

這是位於 ziplist.c 頭部的一段介紹。翻譯過來就是:ziplist 是一個經過特殊編碼的雙向連結串列,它的設計目標就是為了提高儲存效率。ziplist 可以用於儲存字串或整數,其中整數是按真正的二進位制表示進行編碼的,而不是編碼成字串序列。它能以 O(1) 的時間複雜度在表的兩端提供 pushpop 操作。然而,由於 ziplist 的每次變更操作都需要一次記憶體重分配,ziplist 實際的複雜度和其實際使用的記憶體量有關。

ziplist 充分體現了 Redis 對於儲存效率的追求。一個普通的雙向連結串列,連結串列中每一項都佔用獨立的一塊記憶體,各項之間用地址指標(或引用)連線起來。這種方式會帶來大量的記憶體碎片,而且地址指標也會佔用額外的記憶體。而 ziplist 卻是將表中每一項存放在前後連續的地址空間內,一個 ziplist 整體佔用一大塊記憶體。它是一個表(list),但其實不是一個連結串列(linked list) -- zhangtielei

2、ziplist 結構

image.png

ziplist entry 結構

ziplist 中的每個節點都以包含兩個部分的後設資料為字首資訊。首先,有 prevlen 儲存前一個節點的長度,這提供了能夠從尾到頭遍歷列。其次,encoding 表示了節點型別,是整數或是字串,在本例中字串也表示字串有效負載的長度。所以完整的條目儲存如下:

<prevlen> <encoding> <entry-data>
複製程式碼

有的時候 encoding 也會用於表示節點資料本身,比如較小的整數,在這種情況下 節點會被省去,此時只需如下結構即可表示一個節點,這也是為節省記憶體而設計:

<prevlen> <encoding>
複製程式碼

上一個節點的長度 <prevlen> 是按以下方式編碼的:如果上一節點長度小於 254 位元組,則它將只使用一個位元組,表示長度為一個未指定的 8 位整數。當長度大於或等於 254 時,將消耗 5 個位元組。第一個位元組設定為 254(0xFE),表示後面的值較大。剩下的 4 個位元組將前一個條目的長度作為值。

節點的的 encoding 欄位取決於節點的內容。當該節點是一個字串時,首先是編碼的前 2 位 byte 將儲存用於儲存字串長度的編碼型別,後跟字串的實際長度。當條目為整數時前 2 位都設定為 1,後 2 位用於指定此節點將儲存哪種整數。不同 encoding 型別和編碼如下。

|00pppppp| - 佔用空間 1 byte
表示長度小於等於63位元組的字串(6 bits)。
如:"pppppp" 表示無符號6bit的字串長度。

|01pppppp|qqqqqqqq| - 佔用空間  2 bytes
表示長度小於等於16383位元組的字串(14 bits)。

|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 佔用空間  5 bytes
表示長度大等於16384位元組的字串(14 bits)。
只有後面的4個位元組表示長度,最多32^2-1。不使用第一個位元組的6個低位,並且全部設定為零。

|11000000| - 佔用空間  3 bytes
後面兩個位元組表示 int16_t 的無符號整數 (2 bytes)。

|11010000| - 佔用空間  5 bytes
後面四個位元組表示 int32_t 的無符號整數 (4 bytes)。

|11100000| - 佔用空間 9 bytes
後面八個位元組表示 int32_t 的無符號整數 (8 bytes).

|11110000| - 佔用空間 4 bytes
後面三個位元組表示24bits的有符號整數 (3 bytes).

|11111110| - 2 bytes
後面一個位元組表示8bits的有符號整數 (1 byte).

|1111xxxx| - (xxxx 在 0000 到 1101 之間) 的4bits整數.
但是它其實只用來表示0到12,因為0000、1111、1110都已經被別的encoding使用過了,
所以這種情況下需要用這4bit所對應的值減去1來獲取它真實表示的值。

|11111111| - 表示ziplist結尾的特殊節點。
複製程式碼

其後的 entry-data 就用於儲存 encoding 中定義的資料了。

總結一下:

  • ziplist 體現了 Redis 對於儲存效率的追求,它是一種為節約記憶體而開發的順序型資料結構。
  • ziplist 被用作列表鍵和雜湊鍵的底層實現之一。
  • ziplist 可以包含多個節點,每個節點可以儲存一個位元組陣列或者整數值。
  • ziplist 的設計為將各個資料項挨在一起組成連續的記憶體空間,這種結構並不擅長做修改操作。一旦資料發生改動,就會引發記憶體重分配。

三、本期總結

redis 在設計中並不是一味得追求效能,儲存效率也是它追求的一個目標,不止 sds 和 ziplist,其他的底層資料結構也是在追求時間複雜度和空間效率這一目標中的產物。通過解析 redis 的資料結構設計,能更好的幫助我們理解 redis 使用過程中的執行過程和原理。

下一期會解析 quicklist,敬請期待!

參考資料

  1. redis設計與實現
  2. redis原始碼
  3. redis內部資料詳解





閱讀部落格還不過癮?

歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動

你需要知道的那些 redis 資料結構(前篇)
部落格轉載、線下活動及合作等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通

相關文章