1. 程式人生 > >Redis 的底層資料結構(SDS和連結串列)

Redis 的底層資料結構(SDS和連結串列)

Redis 是一個開源(BSD許可)的,記憶體中的資料結構儲存系統,它可以用作資料庫、快取和訊息中介軟體。可能幾乎所有的線上專案都會使用到 Redis,無論你是做快取、或是用作訊息中介軟體,用起來很簡單方便,但可能大多數人並沒有去深入底層的看看 Redis 的一些策略實現等等細節。

正好最近也在專案開發中遇到一些 Redis 相關的 Bug,由於不熟悉底層的一些實現,較為費勁的解決了,所以打算開這麼一個系列,記錄一下對於 Redis 底層的一些結構、策略的學習筆記。

第一部分我們打算從 Redis 的五種資料結構以及物件型別的實現開始,主要涉及內容如下,你也可以通過文末給出 GitHub 倉庫下載對應的思維導圖。

本篇文章打算介紹 SDS 簡單動態字串和雙端連結串列這兩種資料結構。

一、SDS 簡單動態字串

大家都知道 Redis 是由 C 語言作為底層程式語言實現的,而 C 語言中也是有字串這種資料結構的,它是一個字元陣列並且是一個以空字元結尾的字元陣列,這種結構對於 Redis 而言過於簡單了,於是 Redis 自行實現了 SDS 這種簡單動態字串結構,它其實和 Java 中 ArrayList 的實現是很類似的。

Redis 原始碼中 sds.h 檔案下,有五種 sdshdr,它們分別是:

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[];
};

其中,sdshdr5 的註釋表明,sdshdr5 is never used。sdshdr5 這種資料結構一般用於儲存長度小於 32 個字元的字串,但現在也已經不再使用這種結構了,再小長度的字串也建議使用 sdshdr8 進行儲存,因為 sdshdr5 少了兩個關鍵欄位,因此不具備動態擴容操作,一旦預分配的記憶體空間使用完,就需要重新分配記憶體並完成資料的複製遷移,在實際的生產環境中對於效能的影響還是很大的,所以進行了一個拋棄,但其實有些比較小的鍵依然會採用這種結構儲存。

關於 sdshdr5 我們不再多說,我們看其他四種結構的各個欄位,len 欄位表示當前字串總長度,也即當前字串已使用記憶體大小,alloc 表示為當前字串分配的總記憶體大小(不包括len以及flags欄位本身分配的記憶體),因為每一個結構在預分配的時候都會多分配一段記憶體空間,主要是為了方便以後的擴容。flags 的低三位表示當前 sds 的型別,高五位無用。低三位取值如下:

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

實際上,redis 對 sdshdr 記憶體分配是禁用記憶體對齊的,也就是說每個欄位分配的記憶體地址是緊緊排列在一起的, 所以 redis 中字串引數的傳遞直接使用 char* 指標。

可能有人會疑問,僅僅通過一個 char 指標如何確定當前字串的型別,其實由於 sdshdr 記憶體分配禁止記憶體對齊,所以 sds[-1] 其實指向的就是 flags 欄位的記憶體地址,通過 flags 欄位又可以得到當前 sds 屬於哪種型別,進而可以讀取頭部欄位確定 sds 的相關屬性。

接下來我們講講 sdshdr 相對於傳統的 C 語言字串,效能的提升在哪,以及具有哪些便捷的點。

首先,對於傳統的 C 字串,我想要獲取字串的長度,至少需要 O(n) 遍歷一遍陣列才行,而我們 sds 只需要 O(1) 的取 len 欄位的值即可。

其次,也是非常重要的一個設計,如果我們初始分配了一個字串物件,那麼如果我要在這個字串後面追加內容的話,限制於陣列的長度一經初始化是不能修改的,我們至少需要分配一個足夠大的陣列,然後將原先的字串進行一個拷貝。

sdshdr 每次為一個 sds 分配記憶體的時候都會額外分配一部分暫不使用的記憶體空間,一般額外的記憶體會等同於當前字串佔用的記憶體大小,如果超過 1MB,那麼額外空間的記憶體大小就是 1MB。每當執行 sdscat 這種方法的時候,程式會用 alloc-len 比較下剩下的空餘記憶體是否足夠分配追加的內容,如果不夠自然觸發記憶體重分配,而如果剩餘未使用記憶體空間足夠放下,那麼將直接進行分配,無需記憶體重分配。

通過這種預分配策略, SDS 將連續增長 N 次字串所需的記憶體重分配次數從必定 N 次降低為最多 N 次。

最後,對於常規的 C 語言字串,它通過判斷當前字元是否是空字元來決定字串的結尾,所以就要求你的字串中不能包含甚至一個空字元,否則空字元後面的字元都不能作為有效字元被讀取。而對於某些具有特殊格式要求的,需要使用空字元進行分隔作用的,那麼傳統的 C 字串就無法儲存了,而我們的 sds 不是通過空字元判斷字串結尾,而是通過 len 欄位的值判斷字串的結尾,所以說,sds 還具備二進位制安全這個特性,即它可以安全的儲存具備特殊格式要求的二進位制資料。

關於 sds 我們就簡單說到這,它是一種改良版的 C 字串,相容 C 語言中既有的函式 API,也通過一些手段提升了某些操作的效能,值得大家借鑑。

二、連結串列

連結串列這種資料結構相信大家也不陌生,有很多型別,比如單向連結串列,雙向連結串列,迴圈連結串列等,連結串列相對於陣列來說,一是不需要連續的記憶體塊地址,二是刪除和插入的時間複雜度是 O(1) 級別的,非常的高效,但比不上陣列的隨機訪問查詢方式。

一樣的那句話,沒有最好的資料結構,只有恰到好處的資料結構,比如我們後面要介紹的更高層次的資料結構,字典,它的底層其實就依賴的連結串列規避雜湊衝突,具體的我們後面再說。

redis 中藉助 C 語言實現了一個雙向連結串列結構:

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

pre 指標指向前一個節點,next 指標指向後一個節點,value 指向當前節點對應的資料物件。我盜一張圖描述整個串聯起來的連結串列結構:

雖然我通過連結串列的第一個頭節點就可以遍歷整個連結串列,但在 redis 向上封裝了一層結構,專門用於表示一個連結串列結構:

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;

head 指向連結串列的頭節點,tail 指向連結串列的尾節點,dup 函式用於連結串列轉移複製時對節點 value 拷貝的一個實現,一般來說用等於號足以,但某些特殊情況下可能會用到節點轉移函式,預設可以給這個函式賦值 NULL 即表示使用等於號進行節點轉移。free 函式用於釋放一個節點所佔用的記憶體空間,預設賦值 NULL 的話,即使用 redis 自帶的 zfree 函式進行記憶體空間釋放,我們也可以來看一下這個 zfree 函式。

void zfree(void *ptr) {
#ifndef HAVE_MALLOC_SIZE
    void *realptr;
    size_t oldsize;
#endif

    if (ptr == NULL) return;
#ifdef HAVE_MALLOC_SIZE
    update_zmalloc_stat_free(zmalloc_size(ptr));
    free(ptr);
#else
    realptr = (char*)ptr-PREFIX_SIZE;
    oldsize = *((size_t*)realptr);
    update_zmalloc_stat_free(oldsize+PREFIX_SIZE);
    free(realptr);
#endif
}

這裡會涉及到一個記憶體對齊的概念,就比如一個 64 位的作業系統,一次記憶體 IO 會固定取出 8 個位元組的記憶體資料出來,如果某個變數橫跨了兩個八位元組段,那麼 CPU 需要進行兩次的 IO 才能完整取出該變數的資料,引入記憶體對齊,是為了保證任意變數的記憶體分配不會出現上述的橫跨情況,具體的操作手法就是填充無用的記憶體位,當然這必然會造成記憶體碎片,不過這也是一種以空間換時間的策略,你也可以禁用它。

函式的上半部分是做一些判斷,如果確定了該指標指向的資料結構佔用的總記憶體,則直接呼叫 free 函式進行記憶體的釋放,否則需要進行一個計算。redis 中的 zmalloc 在每一次記憶體資料分配的時候都會追加一個 PREFIX_SIZE 的頭部資料塊,它的值等於當前系統的最大定址空間,比如 64 CPU的話,PREFIX_SIZE 就會佔用到 8 個位元組,並且這 8 個位元組內部儲存的是當前資料實際佔用記憶體大小。

所以這裡的話,ptr 指標向低位移動就是指向頭部 PREFIX_SIZE 欄位首地址,然後取出裡面儲存的值,也就是當前資料結構實際佔用的記憶體大小,最後加上它自身傳入 update_zmalloc_stat_free 函式中修改 used_memory 記憶體記錄指標的值,並在最後呼叫 free 函式釋放記憶體,包括頭部的部分。

其實我們扯遠了,繼續看資料結構,這裡如果還不是很明白的話,沒關係,後面我們還會繼續講的。

match 函式依然是一個多型的實現,只給出了定義,具體實現由你來決定,你也可以選擇不實現,它用於比較兩個連結串列節點的 value 值是否相等。返回 0 表示不相等,返回 1 表示相等。

最後一個 len 欄位描述的是,整個連結串列中所包含的節點數量。以上就是 redis 中連結串列的一個基本的定義,加上 list,最終連結串列結構在 redis 中呈現的抽象圖大概是這樣的,依然盜的圖:

綜上,我們介紹了 redis 中連結串列的一個基本實現情況,總結一下,它是一個雙端連結串列,也就是查詢某個節點的前後節點的時間複雜度都在 O(1),也是一個無環並具有首尾節點指標的連結串列,初次之外,還具有三個多型函式,用於節點間的複製、比較以及記憶體釋放,需要使用者自行實現。


關注公眾不迷路,一個愛分享的程式設計師。

公眾號回覆「1024」加作者微信一起探討學習!

每篇文章用到的所有案例程式碼素材都會上傳我個人 github

https://github.com/SingleYam/overview_java

歡迎來踩!