Redis 的底層數據結構(SDS和鏈表)


Redis 是一個開源(BSD許可)的,內存中的數據結構存儲系統,它可以用作數據庫、緩存和消息中間件。可能幾乎所有的線上項目都會使用到 Redis,無論你是做緩存、或是用作消息中間件,用起來很簡單方便,但可能大多數人並沒有去深入底層的看看 Redis 的一些策略實現等等細節。

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

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

image

本篇文章打算介紹 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 指向當前節點對應的數據對象。我盜一張圖描述整個串聯起來的鏈表結構:

image

雖然我通過鏈表的第一個頭節點就可以遍歷整個鏈表,但在 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 中呈現的抽象圖大概是這樣的,依然盜的圖:

image

綜上,我們介紹了 redis 中鏈表的一個基本實現情況,總結一下,它是一個雙端鏈表,也就是查找某個節點的前后節點的時間復雜度都在 O(1),也是一個無環並具有首尾節點指針的鏈表,初次之外,還具有三個多態函數,用於節點間的復制、比較以及內存釋放,需要使用者自行實現。


關注公眾不迷路,一個愛分享的程序員。

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

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

https://github.com/SingleYam/overview_java

歡迎來踩!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM