1. 程式人生 > >[轉]Redis內部資料結構詳解-sds

[轉]Redis內部資料結構詳解-sds

本文是《Redis內部資料結構詳解》系列的第二篇,講述Redis中使用最多的一個基礎資料結構:sds。

不管在哪門程式語言當中,字串都幾乎是使用最多的資料結構。sds正是在Redis中被廣泛使用的字串結構,它的全稱是Simple Dynamic String。與其它語言環境中出現的字串相比,它具有如下顯著的特點:

  • 可動態擴充套件記憶體。sds表示的字串其內容可以修改,也可以追加。在很多語言中字串會分為mutable和immutable兩種,顯然sds屬於mutable型別的。
  • 二進位制安全(Binary Safe)。sds能儲存任意二進位制資料,而不僅僅是可列印字元。
  • 與傳統的C語言字串型別相容。這個的含義接下來馬上會討論。

看到這裡,很多對Redis有所瞭解的同學可能已經產生了一個疑問:Redis已經對外暴露了一個字串結構,叫做string,那這裡所說的sds到底和string是什麼關係呢?可能有人會猜:string是基於sds實現的。這個猜想已經非常接近事實,但在描述上還不太準確。有關string和sds之間關係的詳細分析,我們放在後面再講。現在為了方便討論,讓我們先暫時簡單地認為,string的底層實現就是sds。

在討論sds的具體實現之前,我們先站在Redis使用者的角度,來觀察一下string所支援的一些主要操作。下面是一個操作示例:

Redis string操作示例

以上這些操作都比較簡單,我們簡單解釋一下:

  • 初始的字串的值設為”tielei”。
  • 第3步通過append命令對字串進行了追加,變成了”tielei zhang”。
  • 然後通過setbit命令將第53個bit設定成了1。bit的偏移量從左邊開始算,從0開始。其中第48~55bit是中間的空格那個字元,它的ASCII碼是0x20。將第53個bit設定成1之後,它的ASCII碼變成了0x24,打印出來就是’$’。因此,現在字串的值變成了”tielei$zhang”。
  • 最後通過getrange取從倒數第5個位元組到倒數第1個位元組的內容,得到”zhang”。

這些命令的實現,有一部分是和sds的實現有關的。下面我們開始詳細討論。

sds的資料結構定義

我們知道,在C語言中,字串是以’\0’字元結尾(NULL結束符)的字元陣列來儲存的,通常表達為字元指標的形式(char *)。它不允許位元組0出現在字串中間,因此,它不能用來儲存任意的二進位制資料。

我們可以在sds.h中找到sds的型別定義:

1
typedef char *sds;

肯定有人感到困惑了,竟然sds就等同於char ?我們前面提到過,sds和傳統的C語言字串保持型別相容,因此它們的型別定義是一樣的,都是char 。在有些情況下,需要傳入一個C語言字串的地方,也確實可以傳入一個sds。但是,sds和char *並不等同。sds是Binary Safe的,它可以儲存任意二進位制資料,不能像C語言字串那樣以字元’\0’來標識字串的結束,因此它必然有個長度欄位。但這個長度欄位在哪裡呢?實際上sds還包含一個header結構:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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[];
};

sds一共有5種類型的header。之所以有5種,是為了能讓不同長度的字串可以使用不同大小的header。這樣,短字串就能使用較小的header,從而節省記憶體。

一個sds字串的完整結構,由在記憶體地址上前後相鄰的兩部分組成:

  • 一個header。通常包含字串的長度(len)、最大容量(alloc)和flags。sdshdr5有所不同。
  • 一個字元陣列。這個字元陣列的長度等於最大容量+1。真正有效的字串資料,其長度通常小於最大容量。在真正的字串資料之後,是空餘未用的位元組(一般以位元組0填充),允許在不重新分配記憶體的前提下讓字串資料向後做有限的擴充套件。在真正的字串資料之後,還有一個NULL結束符,即ASCII碼為0的’\0’字元。這是為了和傳統C字串相容。之所以字元陣列的長度比最大容量多1個位元組,就是為了在字串長度達到最大容量時仍然有1個位元組存放NULL結束符。

除了sdshdr5之外,其它4個header的結構都包含3個欄位:

  • len: 表示字串的真正長度(不包含NULL結束符在內)。
  • alloc: 表示字串的最大容量(不包含最後多餘的那個位元組)。
  • flags: 總是佔用一個位元組。其中的最低3個bit用來表示header的型別。header的型別共有5種,在sds.h中有常量定義。
1
2
3
4
5
#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

sds的資料結構,我們有必要非常仔細地去解析它。

Redis dict結構舉例

上圖是sds的一個內部結構的例子。圖中展示了兩個sds字串s1和s2的記憶體結構,一個使用sdshdr8型別的header,另一個使用sdshdr16型別的header。但它們都表達了同樣的一個長度為6的字串的值:”tielei”。下面我們結合程式碼,來解釋每一部分的組成。

sds的字元指標(s1和s2)就是指向真正的資料(字元陣列)開始的位置,而header位於記憶體地址較低的方向。在sds.h中有一些跟解析header有關的巨集定義:

1
2
3
4
5
#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
#define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)

其中SDS_HDR用來從sds字串獲得header起始位置的指標,比如SDS_HDR(8, s1)表示s1的header指標,SDS_HDR(16, s2)表示s2的header指標。

當然,使用SDS_HDR之前我們必須先知道到底是哪一種header,這樣我們才知道SDS_HDR第1個引數應該傳什麼。由sds字元指標獲得header型別的方法是,先向低地址方向偏移1個位元組的位置,得到flags欄位。比如,s1[-1]和s2[-1]分別獲得了s1和s2的flags的值。然後取flags的最低3個bit得到header的型別。

  • 由於s1[-1] == 0x01 == SDS_TYPE_8,因此s1的header型別是sdshdr8。
  • 由於s2[-1] == 0x02 == SDS_TYPE_16,因此s2的header型別是sdshdr16。

有了header指標,就能很快定位到它的len和alloc欄位:

  • s1的header中,len的值為0x06,表示字串資料長度為6;alloc的值為0x80,表示字元陣列最大容量為128。
  • s2的header中,len的值為0x0006,表示字串資料長度為6;alloc的值為0x03E8,表示字元陣列最大容量為1000。(注意:圖中是按小端地址構成)

在各個header的型別定義中,還有幾個需要我們注意的地方:

  • 在各個header的定義中使用了attribute ((packed)),是為了讓編譯器以緊湊模式來分配記憶體。如果沒有這個屬性,編譯器可能會為struct的欄位做優化對齊,在其中填充空位元組。那樣的話,就不能保證header和sds的資料部分緊緊前後相鄰,也不能按照固定向低地址方向偏移1個位元組的方式來獲取flags欄位了。
  • 在各個header的定義中最後有一個char buf[]。我們注意到這是一個沒有指明長度的字元陣列,這是C語言中定義字元陣列的一種特殊寫法,稱為柔性陣列(flexible array member),只能定義在一個結構體的最後一個欄位上。它在這裡只是起到一個標記的作用,表示在flags欄位後面就是一個字元陣列,或者說,它指明瞭緊跟在flags欄位後面的這個字元陣列在結構體中的偏移位置。而程式在為header分配的記憶體的時候,它並不佔用記憶體空間。如果計算sizeof(struct sdshdr16)的值,那麼結果是5個位元組,其中沒有buf欄位。
  • sdshdr5與其它幾個header結構不同,它不包含alloc欄位,而長度使用flags的高5位來儲存。因此,它不能為字串分配空餘空間。如果字串需要動態增長,那麼它就必然要重新分配記憶體才行。所以說,這種型別的sds字串更適合儲存靜態的短字串(長度小於32)。

至此,我們非常清楚地看到了:sds字串的header,其實隱藏在真正的字串資料的前面(低地址方向)。這樣的一個定義,有如下幾個好處:

  • header和資料相鄰,而不用分成兩塊記憶體空間來單獨分配。這有利於減少記憶體碎片,提高儲存效率(memory efficiency)。
  • 雖然header有多個型別,但sds可以用統一的char *來表達。且它與傳統的C語言字串保持型別相容。如果一個sds裡面儲存的是可列印字串,那麼我們可以直接把它傳給C函式,比如使用strcmp比較字串大小,或者使用printf進行列印。

弄清了sds的資料結構,它的具體操作函式就比較好理解了。

sds的一些基礎函式

  • sdslen(const sds s): 獲取sds字串長度。
  • sdssetlen(sds s, size_t newlen): 設定sds字串長度。
  • sdsinclen(sds s, size_t inc): 增加sds字串長度。
  • sdsalloc(const sds s): 獲取sds字串容量。
  • sdssetalloc(sds s, size_t newlen): 設定sds字串容量。
  • sdsavail(const sds s): 獲取sds字串空餘空間(即alloc - len)。
  • sdsHdrSize(char type): 根據header型別得到header大小。
  • sdsReqType(size_t string_size): 根據字串資料長度計算所需要的header型別。

這裡我們挑選sdslen和sdsReqType的程式碼,察看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8:
return SDS_HDR(8,s)->len;
case SDS_TYPE_16:
return SDS_HDR(16,s)->len;
case SDS_TYPE_32:
return SDS_HDR(32,s)->len;
case SDS_TYPE_64:
return SDS_HDR(64,s)->len;
}
return 0;
}

static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5)
return SDS_TYPE_5;
if (string_size < 1<<8)
return SDS_TYPE_8;
if (string_size < 1<<16)
return SDS_TYPE_16;
if (string_size < 1ll<<32)
return SDS_TYPE_32;
return SDS_TYPE_64;
}

跟前面的分析類似,sdslen先用s[-1]向低地址方向偏移1個位元組,得到flags;然後與SDS_TYPE_MASK進行按位與,得到header型別;然後根據不同的header型別,呼叫SDS_HDR得到header起始指標,進而獲得len欄位。

通過sdsReqType的程式碼,很容易看到:

  • 長度在0和2^5-1之間,選用SDS_TYPE_5型別的header。
  • 長度在2^5和2^8-1之間,選用SDS_TYPE_8型別的header。
  • 長度在2^8和2^16-1之間,選用SDS_TYPE_16型別的header。
  • 長度在2^16和2^32-1之間,選用SDS_TYPE_32型別的header。
  • 長度大於2^32的,選用SDS_TYPE_64型別的header。能表示的最大長度為2^64-1。

注:sdsReqType的實現程式碼,直到3.2.0,它在長度邊界值上都一直存在問題,直到最近3.2 branch上的commit 6032340才修復。

sds的建立和銷燬

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
/* Empty strings are usually created in order to append. Use type 8
* since type 5 is not good at this. */
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer. */

sh = s_malloc(hdrlen+initlen+1);
if (!init)
memset(sh, 0, hdrlen+initlen+1);
if (sh == NULL) return NULL;
s = (char*)sh+hdrlen;
fp = ((unsigned char*)s)-1;
switch(type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
}
if (initlen && init)
memcpy(s, init, initlen);
s[initlen] = '\0';
return s;
}

sds sdsempty(void) {
return sdsnewlen("",0);
}

sds sdsnew(const char *init) {
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}

void sdsfree(sds s) {
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));
}

sdsnewlen建立一個長度為initlen的sds字串,並使用init指向的字元陣列(任意二進位制資料)來初始化資料。如果init為NULL,那麼使用全0來初始化資料。它的實現中,我們需要注意的是:

  • 如果要建立一個長度為0的空字串,那麼不使用SDS_TYPE_5型別的header,而是轉而使用SDS_TYPE_8型別的header。這是因為建立的空字串一般接下來的操作很可能是追加資料,但SDS_TYPE_5型別的sds字串不適合追加資料(會引發記憶體重新分配)。
  • 需要的記憶體空間一次性進行分配,其中包含三部分:header、資料、最後的多餘位元組(hdrlen+initlen+1)。
  • 初始化的sds字串資料最後會追加一個NULL結束符(s[initlen] = ‘\0’)。

關於sdsfree,需要注意的是:記憶體要整體釋放,所以要先計算出header起始指標,把它傳給s_free函式。這個指標也正是在sdsnewlen中呼叫s_malloc返回的那個地址。

sds的連線(追加)操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);

s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}

sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
}

sds sdscatsds(sds s, const sds t) {
return sdscatlen(s, t, sdslen(t));
}

sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;

/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;

len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;

type = sdsReqType(newlen);

/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
if (type == SDS_TYPE_5) type = SDS_TYPE_8;

hdrlen = sdsHdrSize(type);
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, newlen);
return s;
}

sdscatlen將t指向的長度為len的任意二進位制資料追加到sds字串s的後面。本文開頭演示的string的append命令,內部就是呼叫sdscatlen來實現的。

在sdscatlen的實現中,先呼叫sdsMakeRoomFor來保證字串s有足夠的空間來追加長度為len的資料。sdsMakeRoomFor可能會分配新的記憶體,也可能不會。

sdsMakeRoomFor是sds實現中很重要的一個函式。關於它的實現程式碼,我們需要注意的是:

  • 如果原來字串中的空餘空間夠用(avail >= addlen),那麼它什麼也不做,直接返回。
  • 如果需要分配空間,它會比實際請求的要多分配一些,以防備接下來繼續追加。它在字串已經比較長的情況下要至少多分配SDS_MAX_PREALLOC個位元組,這個常量在sds.h中定義為(1024*1024)=1MB。
  • 按分配後的空間大小,可能需要更換header型別(原來header的alloc欄位太短,表達不了增加後的容量)。
  • 如果需要更換header,那麼整個字串空間(包括header)都需要重新分配(s_malloc),並拷貝原來的資料到新的位置。
  • 如果不需要更換header(原來的header夠用),那麼呼叫一個比較特殊的s_realloc,試圖在原來的地址上重新分配空間。s_realloc的具體實現得看Redis編譯的時候選用了哪個allocator(在Linux上預設使用jemalloc)。但不管是哪個realloc的實現,它所表達的含義基本是相同的:它儘量在原來分配好的地址位置重新分配,如果原來的地址位置有足夠的空餘空間完成重新分配,那麼它返回的新地址與傳入的舊地址相同;否則,它分配新的地址塊,並進行資料搬遷。參見http://man.cx/realloc

從sdscatlen的函式介面,我們可以看到一種使用模式:呼叫它的時候,傳入一箇舊的sds變數,然後它返回一個新的sds變數。由於它的內部實現可能會造成地址變化,因此呼叫者在呼叫完之後,原來舊的變數就失效了,而都應該用新返回的變數來替換。不僅僅是sdscatlen函式,sds中的其它函式(比如sdscpy、sdstrim、sdsjoin等),還有Redis中其它一些能自動擴充套件記憶體的資料結構(如ziplist),也都是同樣的使用模式。

淺談sds與string的關係

現在我們回過頭來看看本文開頭給出的string操作的例子。

  • append操作使用sds的sdscatlen來實現。前面已經提到。
  • setbit和getrange都是先根據key取到整個sds字串,然後再從字串選取或修改指定的部分。由於sds就是一個字元陣列,所以對它的某一部分進行操作似乎都比較簡單。

但是,string除了支援這些操作之外,當它儲存的值是個數字的時候,它還支援incr、decr等操作。那麼,當string儲存數字值的時候,它的內部儲存還是sds嗎?實際上,不是了。而且,這種情況下,setbit和getrange的實現也會有所不同。這些細節,我們放在下一篇介紹robj的時候再進行系統地討論。

原文連結