Redis 動態字符串 SDS 源碼解析

本文做者: Pushy
本文連接: http://pushy.site/2019/12/21/redis-sds/
版權聲明: 本博客全部文章除特別聲明外,均採用 CC BY-NC-SA 3.0 許可協議。轉載請註明出處!java

1. 什麼是 SDS

衆所周知,在 Redis 的五種數據解構中,最簡單的就是字符串:redis

redis> set msg "Hello World"

而 Redis 並無直接使用 C 語言傳統的字符串表示,而是本身構建了一個名爲簡單動態字符串(Simple dynamic string,即 SDS)的抽象數據結構。數據庫

執行上面的 Redis 命令,在 Server 的數據庫中將建立一個鍵值對,即:數組

  • 鍵爲 「msg」 的 SDS;
  • 值爲 「Hello World」 的 SDS。

咱們再來看下 SDS 的定義,在 Redis 的源碼目錄 sds.h 頭文件中,定義了 SDS 的結構體:安全

struct sdshdr {
    // 記錄 buf 數組中當前已使用的字節數量
    unsigned int len;
    // 記錄 buf 數組中空閒空間長度
    unsigned int free;
    // 字節數組
    char buf[];

};

能夠看到,SDS 經過 lenfree 屬性值來描述字節數組 buf 當前的存儲情況,這樣在以後的擴展和其餘操做中有很大的做用,還能以 O(1) 的複雜度獲取到字符串的長度(咱們知道,C 自帶的字符串自己並不記錄長度信息,只能遍歷整個字符串統計)網絡

那麼爲何 Redis 要本身實現一套字符串數據解構呢?下面慢慢來研究!數據結構

2. SDS 的優點

杜絕緩衝區溢出

除了獲取字符串長度的複雜度爲較高以外,C 字符串不記錄自身長度信息帶來的另外一個問題就是容易形成內存溢出。舉個例子,經過 C 內置的 strcat 方法將字符串 motto 追加到 s1 字符串後邊:curl

void wrong_strcat() {
    char *s1, *s2;

    s1 = malloc(5 * sizeof(char));
    strcpy(s1, "Hello");
    s2 = malloc(5 * sizeof(char));
    strcpy(s2, "World");

    char *motto = " To be or not to be, this is a question.";
    s1 = strcat(s1, motto);

    printf("s1 = %s \n", s1);
    printf("s2 = %s \n", s2);
}

// s1 = Hello To be or not to be, this is a question. 
// s2 = s a question.

可是輸出卻出乎意料,咱們只想修改 s1 字符串的值,而 s2 字符串也被修改了。這是由於 strcat 方法假定用戶在執行前已經爲 s1 分配了足夠的內存,能夠容納 motto 字符串中的內容。而一旦這個假設不成立,就會產生緩衝區溢出函數

經過 Debug 咱們看到,s1 變量內存的初始位置爲 94458843619936 (10進制), s2 初始位置爲 94458843619968,是一段相鄰的內存塊:性能

wrong_strcat.png

因此一旦經過 strcat 追加到 s1 的字符串 motto 的長度大於 s1 到 s2 的內存地址間隔時,將會修改到 s2 變量的值。而正確的作法應該是在 strcat 以前爲 s1 從新調整內存大小,這樣就不會修改 s2 變量的值了:

void correct_strcat() {
    char *s1, *s2;

    s1 = malloc(5 * sizeof(char));
    strcpy(s1, "Hello");
    s2 = malloc(5 * sizeof(char));
    strcpy(s2, "World");

    char *motto = " To be or not to be, this is a question.";
    // 爲 s1 變量擴展內存,擴展的內存大小爲 motto * sizeof(char) + 空字符結尾(1)
    s1 = realloc(s1, (strlen(motto) * sizeof(char)) + 1);
    s1 = strcat(s1, motto);

    printf("s1 = %s \n", s1);
    printf("s2 = %s \n", s2);
}

// s1 = Hello To be or not to be, this is a question. 
// s2 = World

能夠看到,擴容後的 s1 變量內存地址起始位置變爲了 94806242149024(十進制),s2 起始地址爲 94806242148992。這時候 s1 與 s2 內存地址的間隔大小已經足夠 motto 字符串的存放了:

correct_strcat.png

而與 C 字符串不一樣, SDS 的空間分配策略徹底杜絕了發生緩衝區溢出的可能性,具體的實如今 sds.c 中。經過閱讀源碼,咱們能夠明白之因此 SDS 能杜絕緩衝區溢出是由於再調用 sdsMakeRoomFor 時,會檢查 SDS 的空間是否知足修改所需的要求(即 free >= addlen 條件),若是知足 Redis 將會將 SDS 的空間擴展至執行所需的大小,在執行實際的 concat 操做,這樣就避免了溢出發生:

// 與 C 語言 string.h/strcat 功能相似,其將一個 C 字符串追加到 sds
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}

sds sdscatlen(sds s, const char *t, size_t len) {
    struct sdshdr *sh;
    size_t curlen = sdslen(s);  // 獲取 sds 的 len 屬性值

    s = sdsMakeRoomFor(s, len);
    if (s == NULL) return NULL;
    // 將 sds 轉換爲 sdshdr,下邊會介紹
    sh = (void *) (s - sizeof(struct sdshdr));
    // 將字符串 t 複製到以 s+curlen 開始的內存地址空間
    memcpy(s + curlen, t, len);
    sh->len = curlen + len;     // concat後的長度 = 原先的長度 + len
    sh->free = sh->free - len;  // concat後的free = 原來 free 空間大小 - len
    s[curlen + len] = '\0';     // 與 C 字符串同樣,都是以空字符 \0 結尾
    return s;
}

// 確保有足夠的空間容納加入的 C 字符串, 而且還會分配額外的未使用空間
// 這樣就杜絕了發生緩衝區溢出的可能性
sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);  // 當前 free 空間大小
    size_t len, newlen;

    if (free >= addlen) {
        /* 若是空餘空間足夠容納加入的 C 字符串大小, 則直接返回, 不然將執行下邊的代碼進行擴展 buf 字節數組 */
        return s;
    }
    len = sdslen(s);  // 當前已使用的字節數量
    sh = (void *) (s - (sizeof(struct sdshdr)));
    newlen = (len + addlen);  // 拼接後新的字節長度

    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL; // 申請內存失敗

    /* 新的 sds 的空餘空間 = 新的大小 - 拼接的 C 字符串大小 */
    newsh->free = newlen - len;
    return newsh->buf;
}

另外,在看源碼時我對 sh = (void *) (s - sizeof(struct sdshdr)); 一臉懵逼,若是不懂能夠看:Redis(一)之 struct sdshdr sh = (void) (s-(sizeof(struct sdshdr)))講解

減小修改字符帶來的內存重分配次數

對於包含 N 個字符的 C 字符串來講,底層老是由 N+1 個連續內存的數組來實現。因爲存在這種關係,所以每次修改時,程序都須要對這個 C 字符串數組進行一次內存重分配操做:

  • 若是是拼接操做:擴展底層數組的大小,防止出現緩衝區溢出(前面提到的);
  • 若是是截斷操做:須要釋放不使用的內存空間,防止出現內存泄漏

Redis 做爲頻繁被訪問修改的數據庫,爲了減小修改字符帶來的內存重分配的性能影響,SDS 也變得很是須要。由於在 SDS 中,buf 數組的長度不必定就是字符串數量 + 1,能夠包含未使用的字符,經過 free 屬性值記錄。經過未使用空間,SDS 實現瞭如下兩種優化策略:

Ⅰ、空間預分配

空間預分配用於優化 SDS 增加的操做:當對 SDS 進行修改時,而且須要對 SDS 進行空間擴展時,Redis 不只會爲 SDS 分配修改所必須的空間,還會對 SDS 分配額外的未使用空間

在前面的 sdsMakeRoomFor 方法能夠看到,額外分配的未使用空間數量存在兩種策略:

  • SDS 小於 SDS_MAX_PREALLOC:這時 len 屬性值將會和 free 屬性相等;
  • SDS 大於等於 SDS_MAX_PREALLOC:直接分配 SDS_MAX_PREALLOC 大小。
sds sdsMakeRoomFor(sds s, const char *t, size_t len) {
    ...
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL;
    newsh->free = newlen - len;
    return newsh->buf;
}

經過空間預分配策略,Redis 能夠減小連續執行字符串增加操做所需的內存重分配次數。

Ⅱ、惰性空間釋放

惰性空間釋放用於優化 SDS 字符串縮短操做,當須要縮短 SDS 保存的字符串時,Redis 並不當即使用內存重分配來回收縮短多出來的字節,而是使用 free 屬性將這些字節記錄起來,並等待來使用

舉個例子,能夠看到執行完 sdstrim 並無當即回收釋放多出來的 22 字節的空間,而是經過 free 變量值保存起來。當執行 sdscat 時,先前所釋放的 22 字節的空間足夠容納追加的 C 字符串 11 字節的大小,也就不須要再進行內存空間擴展重分配了。

#include "src/sds.h"

int main() {
    // sds{len = 32, free = 0, buf = "AA...AA.a.aa.aHelloWorld     :::"}
    s = sdsnew("AA...AA.a.aa.aHelloWorld     :::");  
    // sds{len = 10, free = 22, buf = "HelloWorld"}
    s = sdstrim(s, "Aa. :");  
    // sds{len = 21, free = 11, buf = "HelloWorld! I'm Redis"}
    s = sdscat(s, "! I'm Redis");   
    return 0;
}

經過惰性空間釋放策略,SDS 避免了縮短字符串時所需內存重分配操做,並會未來可能增加操做提供優化。與此同時,SDS 也有相應的 API 真正地釋放 SDS 的未使用空間。

二進制安全

C 字符串必須符合某種編碼,而且除了字符串的末尾以外,字符串不能包含空字符(\0),不然會被誤認爲字符串的末尾。這些限制致使不能保存圖片、音頻等這種二進制數據。

可是 Redis 就能夠存儲二進制數據,緣由是由於 SDS 是使用 len 屬性值而不是空字符來判斷字符串是否結束的。

兼容部分 C 字符串函數

咱們發現, SDS 的字節數組有和 C 字符串類似的地方,例如也是以 \0 結尾(可是不是以這個標誌做爲字符串的結束)。這就使得 SDS 能夠重用 <string.h> 庫定義的函數:

#include <stdio.h>
#include <strings.h>
#include "src/sds.h"

int main() {
    s = sdsnew("Cat");
    // 根據字符集比較大小
    int ret = strcasecmp(s, "Dog");
    printf("%d", ret);
    return 0;
}

3. 總結

看完 Redis 的 SDS 的實現,終於知道 Redis 只因此快,確定和 epoll 的網絡 I/O 模型分不開,可是也和底層優化的簡單數據結構分不開。

SDS 精妙之處在於經過 len 和 free 屬性值來協調字節數組的擴展和伸縮,帶來了較比 C 字符串太多的性能更好的優勢。什麼叫牛逼?這就叫牛逼!

相關文章
相關標籤/搜索