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

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

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

第一部分咱們打算從 Redis 的五種數據結構以及對象類型的實現開始,主要涉及內容以下,你也能夠經過文末給出 GitHub 倉庫下載對應的思惟導圖。程序員

image

本篇文章打算介紹 SDS 簡單動態字符串和雙端鏈表這兩種數據結構。github

1、SDS 簡單動態字符串

你們都知道 Redis 是由 C 語言做爲底層編程語言實現的,而 C 語言中也是有字符串這種數據結構的,它是一個字符數組而且是一個以空字符結尾的字符數組,這種結構對於 Redis 而言過於簡單了,因而 Redis 自行實現了 SDS 這種簡單動態字符串結構,它其實和 Java 中 ArrayList 的實現是很相似的。redis

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,也經過一些手段提高了某些操做的性能,值得你們借鑑。

2、鏈表

鏈表這種數據結構相信你們也不陌生,有不少類型,好比單向鏈表,雙向鏈表,循環鏈表等,鏈表相對於數組來講,一是不須要連續的內存塊地址,二是刪除和插入的時間複雜度是 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
github.com/SingleYam/o…
歡迎來踩!
相關文章
相關標籤/搜索