Redis原始碼學習——基礎資料結構之SDS

weixin_33766168發表於2016-07-13

Redis資料結構-SDS

Redis是一個開源(BSD許可),記憶體儲存的資料結構伺服器,可用作資料庫,快取記憶體和訊息佇列代理。

首先介紹下Redis的基礎資料結構 —— SDS
Redis沒有使用傳統C語言的字串(字元陣列)表示。而是自己構建了一種名為sds(Simple Dymamic String)的抽象型別,作為redis的預設字元型別。 SDS用於儲存資料庫中的字串值,使用者客戶端的輸入的緩衝區,AOF模組中的緩衝區都是由SDS實現的。

SDS相比於C字串的優點:

  1. 常數複雜度獲取字串長度
  2. 緩衝區溢位
  3. 減少改變字串長度時帶來的記憶體重新分配
  4. 二進位制安全

同時,sds也支援c字串的部分操作函式。

SDS的資料結構:

以下是SDS的資料結構

/*
 * 儲存字串物件的結構
 */
struct sdshdr {
    
    // buf 中已佔用空間的長度
    int len;

    // buf 中剩餘可用空間的長度
    int free;

    // 資料空間
    char buf[];
};
// ps: 在redis 3.0中  為了更加節省記憶體,可用的sdshdr分成4種,len和free屬性分別可以是uint8_t,uint16_t,uint32_t,uint64_t 這四種型別,會隨著sds所儲存的字串長度不同,而分配為不同的sdshdr。 

獲取長度;

Redis中獲取字元長度的操作是
STRLEN key

C語言中的字串並不記錄自身的長度資訊。 如果我們想要獲取一個c字串的長度,我們要遍歷整個字串,直到遇到代表結尾符的'/n'為止。 毫無疑問,其複雜度是O(N)。
而在sds中,我們記錄了每個sds物件中所存字串的長度。 sds提供了一系列操作sds的函式,若出現改變陣列長度的草走,都會同步更新len欄位,保證len欄位的實時性。 這樣每個STRLEN的複雜度就變成了O(1).

快取區溢位:

C語言中提供了strcat方法,可以將strSrc字串拼到strDest字串尾部。 然而每次執行strcat操作時,都假設了我們已經strDest指標分配了足夠多的記憶體。 然而一旦當分配的記憶體不足, 機會出現快取區溢位。如下:

#include <stdio.h>
#include <string.h>

int main(void)
{    
    char dest[20] = "Hey ";
    for(int i = 0; i < 20; i++) {
        strcat(dest, ", Man");    
        printf("%d time, lenght is: %ld \n", i, strlen(dest));
    }
    return 0;
}

執行結果:

0 time, lenght is: 9
1 time, lenght is: 14
2 time, lenght is: 19
[1]    29751 abort      ./str

可以看到,當執行到第三次的時候,並不會由於dest已經快到其最大容量。 所以第四次strcat執行時,會出現溢位,中斷程式。
而在sds中,執行sdscat操作,會判斷為目標sds物件所分配的記憶體是否可以容納拼接後的字元記憶體。 程式碼如下:

sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}
sds sdscatlen(sds s, const void *t, size_t len) {
    struct sdshdr *sh;
    
    // 獲取原字元長度
    size_t curlen = sdslen(s);

    // 擴充套件空間。 若原指標分配記憶體不足,則重新分配記憶體。 返回新的地址
    // T = O(N)
    s = sdsMakeRoomFor(s,len);

    // 若記憶體不足直接返回
    if (s == NULL) return NULL;

    // 獲取sds控制程式碼sdshdr 的指標位置
    sh = (void*) (s-(sizeof(struct sdshdr)));
    
    // 複製將t中字串複製到目標字串後面
    // T = O(N)
    memcpy(s+curlen, t, len);

    // 更新屬性
    sdssetlen(s, curlen+len);

    // 新增新結尾符號
    s[curlen+len] = '\0';

    // 返回新 sds
    return s;
}

可以看到, 每次執行字串拼接操作,都會判斷所分配的記憶體是否足夠,如果不足,會重新分配記憶體。 其他操作,例如sdsrange(僅保留部分字串),sdstrim(裁剪特定字元)都會有以上類似邏輯,保證每次操作都會即時釋放多餘記憶體,且不會出現記憶體不足。

減少改變字串長度時帶來的記憶體重新分配

然而通過C字串執行會改變字串長度的操作, 也可以通過判斷字串長度實現預分配記憶體。每次記憶體重新分配都要將原記憶體中的字元複製到新記憶體中, 複雜度是O(N),然而當我們頻繁地對一個字串進行改變長度的操作,會導致每次操作都引起一次O(N)的操作。
在sds中, 每次重新分配記憶體都會預留一部分作為buffer,我們可以從上文的程式碼中看到,重新分配記憶體是通過sdsMakeRoomFor函式呼叫。 那麼我們看下sdsMakeRoomFor中,分配記憶體的策略:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;

    // 獲取s目前剩餘的空間長度
    size_t free = sdsavail(s);

    size_t len, newlen;

    // s 目前的空餘空間已經足夠,無須再進行擴充套件,直接返回
    if (free >= addlen) return s;

    // 獲取 s 目前已佔用空間的長度
    len = sdslen(s);
    
    // 獲取控制程式碼指標位置
    sh = (void*) (s-(sizeof(struct sdshdr)));

    // 擴充套件後s至少需要的長度
    newlen = (len+addlen);

    // 根據新長度,為 s 分配新空間所需的大小
    if (newlen < SDS_MAX_PREALLOC)
        // 如果新長度小於 SDS_MAX_PREALLOC 
        // 那麼為它分配兩倍於所需長度的空間
        // 對新建的sds或者重新分配記憶體的sds,都會採用此策略,保留1倍的長度
        newlen *= 2;
    else
        // 否則,所保留的buffer長度為 SDS_MAX_PREALLOC
        newlen += SDS_MAX_PREALLOC;
    
    // T = O(N)
    // 分配記憶體,獲取新的指標地址
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);

    // 若記憶體不足,分配失敗,返回
    if (newsh == NULL) return NULL;

    // 設定剩餘空間長度。
    newsh->free = newlen - len;

    // 返回 sds
    return newsh->buf;
}

redis中為了減少記憶體的重新分配, 使用了預留buffer的方法。 將原來n次字串操作一定有n次O(N)複雜度的記憶體分配, 調整為最多有n次O(N)複雜度的記憶體分配。
tips: 當執行sdstrim這樣減少字串長度的操作時, 即時裁剪後多餘的記憶體大於len的一半,sds也不會立即將多餘的空間釋放,而是保留下來未將來的增長操作做了優化。 且提供了sdsRemoveFreeSpace這個APi,用於我們可以手動將這樣多餘的記憶體釋放。

二進位制安全

二進位制安全是一個主要用來處理字串操作的程式設計術語。二進位制安全功能本質上是把輸入當作一個沒有任何特殊的原生流,其在操作上應包含一個字元所能有的256種可能的值(假設為8為字元)。
由於C字串需要通過'0'判斷字串的結尾。 所以當我們儲存字串時,需要將'0'這樣的特殊字元過濾掉。 在sds中,由於我們維護了字串的長度,所以並沒有這樣的顧慮。 sds符合二進位制安全。

支援c字串的部分操作函式

由於sds的api提供的入參都是sds格式,指向的都是其buf屬性的指標位置。 我們以一個建立sds的api為例:

sds sdsnewlen(const void *init, size_t initlen) {

    struct sdshdr *sh;
    // do somethings to create sds...
    
    sh->len = initlen;
    sh->free = 0;
    sh->buf[initlen] = '\0';

    // 返回 buf 部分,而不是整個 sdshdr
    return (char*)sh->buf;
}

我們可以看到,sds物件,我們獲取的並不是整個sdshdr指標的位置, 而是其buf屬性的指標,也就是存字串的指標位置,且sds遵守了以'0'作為一個字串結尾的條件(但並不是通過'0'的位置判斷字串的長度)。 這樣就保證了我們對一部分C字串介面傳入sds物件的指標,是可以當作char[]用的。

相關文章