Redis 設計與實現 3:字串 SDS

小新是也發表於2020-12-25

本文的分析沒有特殊說明都是基於 Redis 6.0 版本原始碼
redis 6.0 原始碼:https://github.com/redis/redis/tree/6.0

在 Redis 中,字串都用自定義的結構簡單動態字串(Simple Dynamic Strings,SDS)
Redis 中使用到的字串都是用 SDS,例如 key、string 型別的值、sorted set 的 member、hash 的 field 等等等等。。。

資料結構

舊版本的結構

3.2 版本之前,sds 的定義是這樣的:

struct sdshdr {
	// buf 陣列中已使用的位元組數量,也就是 sds 本身的字串長度
    unsigned int len;
    // buf 陣列中未使用的位元組數量
    unsigned int free;
    // 位元組陣列,用於儲存字串
    char buf[];
};

舊版本 SDS 結構示例

這樣的結構有幾個好處

  • 單獨記錄長度len,獲取字串長度的時間複雜度是 $O(1)$ 。傳統的 C 字串獲取長度需要遍歷字串,直到遇到\0,時間複雜度是 $O(N)$。
  • buf 陣列末尾遵循 C 字串以 \0 結尾的慣例,可以相容 C 處理字串的函式。
  • 減少修改字串帶來的記憶體重分配次數,Redis 使用了 空間預分配(預先申請大一點點的空間) 和 空間惰性釋放(字串變短修改len欄位即可)來減少字串修改引起的記憶體重新分配。
  • 不以\0為結尾的判斷,二進位制安全。因為圖片等二進位制資料中,可能包含\0,傳統 C 字串一遇到 \0 就認為字串結束了,會導致不能完整儲存。

缺點:

  • lenfree 的定義用了 4 個位元組,可以表示 2^32 的長度。但是我們實際使用的字串,往往沒有那麼長。4 個位元組造成了浪費。

新版本的結構

舊版本中我們說到,lenfree 的缺點是用了太長的變數,新版本解決了這個問題。
我們來看一下新版本的 SDS 結構。

在 Redis 3.2 版本之後,Redis 將 SDS 劃分為 5 種型別:

型別 位元組
sdshdr5 < 1 <8
sdshdr8 1 8
sdshdr16 2 16
sdshdr32 4 32
sdshdr64 8 64

新版本新增加了一個 flags 欄位來標識型別,長度 1 位元組(8 位)。
型別只佔用了前 3 位。在 sdshdr5 中,後 5 位用來儲存字串的長度。其他型別後 5 位沒有用。

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 前 3 位儲存型別,後 5 位儲存字串長度 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字串長度,1 位元組 8 位 */
    uint8_t alloc; /* 申請的總長度,1 位元組 8 位 */
    unsigned char flags; /* 前 3 位儲存型別,後 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 字串長度,2 位元組 16 位 */
    uint16_t alloc; /* 申請的總長度,2 位元組 16 位 */
    unsigned char flags; /* 前 3 位儲存型別,後 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* 字串長度,4 位元組 32 位 */
    uint32_t alloc; /* 申請的總長度,4 位元組 32 位 */
    unsigned char flags; /* 前 3 位儲存型別,後 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* 字串長度,8 位元組 64 位 */
    uint64_t alloc; /* 申請的總長度,8 位元組 64 位 */
    unsigned char flags; /* 前 3 位儲存型別,後 5 位未使用 */
    char buf[];
};

優點:

  • 舊版本相對於傳統 C 字串的優點,新版本都有
  • 相對於舊版本,新版本可以通過字串的長度,選擇不同的結構,可以節約記憶體
  • 使用 __attribute__ ((__packed__)) ,讓編譯器取消結構在編譯過程中的優化對齊,按照實際佔用位元組數進行對齊,可以節約記憶體

SDS 的初始化

sds 的定義,跟傳統的C語言字串保持型別相容 char *。但是 sds 是二進位制安全的,中間可能包含\0

sds.h

typedef char *sds;

sds.c

// 初始化 sds
sds sdsnewlen(const void *init, size_t initlen) {
	// 指向 sdshdr 開始地方的指標
    void *sh;
    // sds 實際是一個指標,指向 buf 開始的位置
    sds s;
    // 根據初始化的長度,返回 sds 的型別
    char type = sdsReqType(initlen);
    // initlen == 0,是空字串,空字串往往就是用來往後新增位元組的,使用 SDS_TYPE_8 比 SDS_TYPE_5 更好
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    // 根據型別獲取 struct sdshdr 的長度
    int hdrlen = sdsHdrSize(type);
    // flags 欄位的指標
    unsigned char *fp;
	
	// 開始分配空間,+1 是為了最後一個的結束符號 \0
    sh = s_malloc(hdrlen+initlen+1);
    if (sh == NULL) return NULL;
    // const char *SDS_NOINIT = "SDS_NOINIT";
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
    	// 不是 init 則清空 sh 的記憶體
        memset(sh, 0, hdrlen+initlen+1);
    // s 指向了 buf 開始的地址
    // 從上面結構可以看出,記憶體地址的順序: len, alloc, flag, buf
    // 因為 buf 本身不佔用空間,hdrlen 實際上就是結構的頭(len、alloc、flags)
    s = (char*)sh+hdrlen;
    // flags 佔用 1 個位元組,所以 s 退一位就是 flags 的開始位置了
    fp = ((unsigned char*)s)-1;
    switch(type) {
        case SDS_TYPE_5: {
        	// #define SDS_TYPE_BITS 3
        	// 前 3 位儲存型別,後 5 位儲存長度
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
        	// define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
        	// sh 變數賦值了 struct sdshdr
            SDS_HDR_VAR(8,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        // 下面是對 SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64 的初始化,跟 SDS_TYPE_8 的類似,篇幅有限,省略...
    }
    // 如果 init 非空,則把 init 字串賦值給 s,實際上也是 buf 的初始化
    if (initlen && init)
        memcpy(s, init, initlen);
   	// 最後加一個結束標誌 \0
    s[initlen] = '\0';
    return s;
}

SDS 的擴/縮容

擴容

擴容就不跟初始化一樣寫註釋寫得那麼詳細了,直接拉最重要的幾句程式碼就行。

sds sdsMakeRoomFor(sds s, size_t addlen) {
    // #define SDS_MAX_PREALLOC (1024*1024)
    // 當新的長度小於 1M 的時候,長度會增長一倍
    // 當新的長度達到 1M 之後,最多就增長 1M 了
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    // ...
}

縮容

sds 縮短不會真正縮小 buf,而是隻改長度而已,型別也不變。

sds.c

// 刪掉字串的左右字元中指定的字元
sds sdstrim(sds s, const char *cset) {
    char *start, *end, *sp, *ep;
    size_t len;

    sp = start = s;
    ep = end = s+sdslen(s)-1;
    while(sp <= end && strchr(cset, *sp)) sp++;
    while(ep > sp && strchr(cset, *ep)) ep--;
    len = (sp > ep) ? 0 : ((ep-sp)+1);
    if (s != sp) memmove(s, sp, len);
    
    // 結尾符
    s[len] = '\0';
    // 縮短長度
    sdssetlen(s,len);
    return s;
}

sds.h

static inline void sdssetlen(sds s, size_t newlen) {
	// 設定sds長度,只是修改 sdshdr 結構中的長度欄位,型別不會變
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            {
                unsigned char *fp = ((unsigned char*)s)-1;
                *fp = (unsigned char)(SDS_TYPE_5 | (newlen << SDS_TYPE_BITS));
            }
            break;
        case SDS_TYPE_8:
            SDS_HDR(8,s)->len = (uint8_t)newlen;
            break;
        case SDS_TYPE_16:
            SDS_HDR(16,s)->len = (uint16_t)newlen;
            break;
        case SDS_TYPE_32:
            SDS_HDR(32,s)->len = (uint32_t)newlen;
            break;
        case SDS_TYPE_64:
            SDS_HDR(64,s)->len = (uint64_t)newlen;
            break;
    }
}

相關文章