你須要知道的那些 redis 數據結構(前篇)

做者簡介html

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

redis 對於團隊中的同窗們來講是很是熟悉的存在了,咱們經常使用它來作緩存、或是實現分佈式鎖等等。對於其 api 中提供的幾種數據結構,你們也使用得駕輕就熟。算法

api 中的數據結構有以下幾種:數據庫

  • string
  • list
  • hash
  • set
  • sorted set

這些 api 提供的「數據結構」,在 redis 的官方文檔中有詳細的介紹。就很少作展開,本次重點在於討論 redis 數據結構的內部更底層的實現。以下:api

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

在學習瞭解 redis 幾個底層數據結構的過程當中,到處能夠體會到做者在設計 redis 時對於性能與空間的思考。附 redis 源碼下載。本期主要介紹 sds 和 ziplist。數組

1、sds 簡單動態字符串

一、sds 結構

redis 沒有直接使用 C 語言傳統的字符串表示(以空字符結尾的字符數組,如下簡稱 C 字符串), 而是本身構建了一種名爲簡單動態字符串(simple dynamic string,sds)的抽象類型,並將 sds 用做 redis 的默認字符串表示。緩存

根據傳統,C 語言使用長度爲 N+1 的字符數組來表示長度爲 N 的字符串, 而且字符數組的最後一個元素老是空字符 '\0' 。以下圖:bash

img

由於 C 字符串並不記錄自身的長度信息,因此爲了獲取一個 C 字符串的長度,程序必須遍歷整個字符串, 對遇到的每一個字符進行計數,直到遇到表明字符串結尾的空字符爲止,這個操做的複雜度爲 O(N) 。數據結構

和 C 字符串不一樣,由於 sds 在 len 屬性中記錄了 sds 自己的長度,因此獲取一個 sds 長度的複雜度僅爲 O(1) 。與此同時,它還經過 alloc 屬性記錄了本身的總分配空間。下圖爲 sds 的數據結構:app

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 的性能瓶頸。

二、內存分配策略

再來看 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]));
}
複製代碼

細想一下,惰性空間釋放策略也是空間換時間策略的實現之一,做者對於性能的追求是很是執着的。固然也不是說爲了性能,就不在意內存的使用了,且看下一部分。

2、ziplist壓縮鏈表

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

二、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、11十一、1110都已經被別的encoding使用過了,
因此這種狀況下須要用這4bit所對應的值減去1來獲取它真實表示的值。

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

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

總結一下:

  • ziplist 體現了 Redis 對於存儲效率的追求,它是一種爲節約內存而開發的順序型數據結構。
  • ziplist 被用做列表鍵和哈希鍵的底層實現之一。
  • ziplist 能夠包含多個節點,每一個節點能夠保存一個字節數組或者整數值。
  • ziplist 的設計爲將各個數據項挨在一塊兒組成連續的內存空間,這種結構並不擅長作修改操做。一旦數據發生改動,就會引起內存重分配。

3、本期總結

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

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

參考資料

  1. redis設計與實現
  2. redis源碼
  3. redis內部數據詳解





閱讀博客還不過癮?

歡迎你們掃二維碼經過添加羣助手,加入交流羣,討論和博客有關的技術問題,還能夠和博主有更多互動

博客轉載、線下活動及合做等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通
相關文章
相關標籤/搜索