Redis 動態字串 SDS 原始碼解析
本文作者: Pushy
本文連結: http://pushy.site/2019/12/21/redis-sds/
版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY-NC-SA 3.0 許可協議。轉載請註明出處!
1. 什麼是 SDS
眾所周知,在 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 通過 len
和 free
屬性值來描述位元組陣列 buf
當前的儲存狀況,這樣在之後的擴充套件和其他操作中有很大的作用,還能以 O(1) 的複雜度獲取到字串的長度(我們知道,C 自帶的字串本身並不記錄長度資訊,只能遍歷整個字串統計)。
那麼為什麼 Redis 要自己實現一套字串資料解構呢?下面慢慢來研究!
2. SDS 的優勢
杜絕緩衝區溢位
除了獲取字串長度的複雜度為較高之外,C 字串不記錄自身長度資訊帶來的另一個問題就是容易造成記憶體溢位。舉個例子,通過 C 內建的 strcat
方法將字串 motto
追加到 s1
字串後邊:
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
,是一段相鄰的記憶體塊:
所以一旦通過 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 字串的存放了:
而與 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 字串太多的效能更好的優點。什麼叫牛逼?這就叫牛逼