Redis 沒有直接使用 C 語言傳統的字串表示(以空字串結尾的字元陣列),而是構建了一種名為簡單動態字串(simple dynamic string)的抽象型別,並將 SDS 用作 Redis 的預設字串表示。
在 Redis 中,C 字串只會作為字串字面量用在一些無需對字串進行修改的地方,比如列印日誌:
serverLog(LL_WARNING,"SIGTERM received but errors trying to shut down the server, check the logs for more information");
當 Redis 需要的不僅僅是一個字串字面量,而是一個可以被修改的字串值時,Redis 就會適應 SDS 來表示字串。比如在資料庫中,包含字串值的鍵值對在底層都是由 SDS 實現的。
還是拿簡單的 SET 命令舉例,執行以下命令
redis> SET msg "hello world"
ok
那麼,Redis 將在資料中建立一個新的鍵值對,其中:
- 鍵值對的鍵是一個字串對著,物件的底層實現是一個儲存著字串 "msg" 的 SDS。
- 鍵值對的值也是一個字串物件,物件的底層實現是一個儲存著字串 "hello world" 的 SDS。
除了用來儲存資料庫中的字串值之外, SDS 還被用作緩衝區。AOF 模組中的 AOF 緩衝區,以及客戶端狀態中的輸入緩衝區,都是由 SDS 實現的。
接下來,我們就來詳細認識下 SDS。
1 SDS 的定義
在 sds.h 中,我們會看到以下結構:
typedef char *sds;
可以看到,SDS 等同於 char * 型別。這是因為 SDS 需要和傳統的 C 字串儲存相容,因此將其型別設定為 char 。但是要注意的是,SDS 並不等同 char ,它還包括一個 header 結構,共有 5 中型別的 header,原始碼如下:
struct __attribute__ ((__packed__)) sdshdr5 { // 已棄用
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 長度小於 2^8 的字串型別
uint8_t len; // SDS 所儲存的字串長度
uint8_t alloc; // SDS 分配的長度
unsigned char flags; // 標記位,佔 1 位元組,使用低 3 位儲存 SDS 的 type,高 5 位不使用
char buf[]; // 儲存的真實字串資料
};
struct __attribute__ ((__packed__)) sdshdr16 { // 長度小於 2^16 的字串型別
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 { // 長度小於 2^32 的字串型別
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 { // 長度小於 2^64 的字串型別
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[];
};
之所以會有 5 種型別的 header,是為了能讓不同長度的字串使用對應大小的 header,提高記憶體利用率。
一個 SDS 的完整結構,由記憶體地址上前後相鄰的兩部分組成:
- header:包括字串的長度(len),最大容量(alloc)和 flags(不包含 sdshdr5)。
- buf[]:一個字串陣列。這個陣列的長度等於最大容量加 1,儲存著真正的字串資料。
圖 1-1 展示了一個 SDS 示例:
示例中,各欄位說明如下:
- alloca:SDS 分配的空間大小。圖中表示分配的空間大小為 10。
- len:SDS 儲存字串大小。圖中表示儲存了 5 個位元組的字串。
- buf[]:這個陣列的長度等於最大容量加 1,儲存著真正的字串資料。圖中表示數字的前 5 個位元組分別儲存了 'H'、'e'、'l'、'l'、'o' 五個字元,而最後一個位元組則儲存了空字串 '\0'。
SDS 遵循 C 字串以空字元結尾的慣例,儲存空字元的大小不計算在 SDS 的 len 屬性中。此外,新增空字串到字串末尾等操作,都是由 SDS 函式(sds.c 檔案中的相關函式)自動完成的。
而且,遵循空字元結尾的慣例,還可以直接重用一部分 C 字串函式庫中的函式。
例如,我們可以直接使用 printf()
函式列印 s->buf
:
printf("%s", s->buf);
這樣,我們可以直接使用 C 函式來列印字串 "Redis",無需為 SDS 編寫轉碼的列印函式。
2 SDS 對比 C 字串有哪些優勢
在 C 語言中,使用長度為 N+1 的字元陣列來表示長度為 N 的字串,並且字元陣列的最後一個元素總是空字元 "\0"。
C 語言使用的這種字串表示方式,並不能滿足 Redis 對字串再安全性、效率及功能方面的要求。因此,Redis 設計出了 SDS,來滿足自己的相關需求。接下來,我們從以下幾方面來認識 SDS 對比 C 字串的優勢:
- 獲取字串長度;
- 緩衝區溢位;
- 修改字串時的記憶體重分配次數;
- 二進位制安全;
2.1 常數複雜度獲取字串長度
由於 C 字串並不記錄自身的長度資訊,所以在 C 語言中,為了獲取一個 C 字串的長度,程式必須遍歷整個字串,直到遇到代表字串結尾的空字元為止,這個操作的複雜度為 O(N)。
這個複雜度對於 Redis 而言,一旦碰上非常長的字串,使用 STRLEN
命令時,很容易對系統效能造成影響。
和 C 字串不同的是,因為 SDS 在 len 屬性中記錄了 SDS 儲存的字串的長度,所以獲取一個 SDS 長度的複雜度僅為 O(1)。
而且設定和更新 SDS 長度的工作都是由 SDS 的 API 在執行時自動完成的,所以使用 SDS 無需進行任何手動修改長度的工作。
通過使用 SDS,Redis 將獲取字串長度所需的複雜度從 O(N) 降低到了 O(1),確保了獲取字串長度的工作不會成為 Redis 的效能瓶頸。
2.2 杜絕緩衝區溢位
C 字串不記錄自身長度,不僅使得獲取字串長度的複雜度較高,還容易造成緩衝區溢位(buffer overflow)。
C 語言中的 strcat()
函式可以將 src 字串中的內容拼接到 dest 字串的末尾:
char *strcat(char *dest, const char *src);
因為 C 字串不記錄自身的長度,所以 strcat 函式執行時,假定使用者已經為 dest 分配了足夠多的記憶體,可以容納 src 字串中的所有內容。而一旦這個假定不成立,就會產生緩衝區溢位。
舉個例子,假設程式裡有兩個在記憶體中緊鄰著的 C 字串 s1 和 s2,其中 s1 儲存了字串 "redis",s2 儲存了字串 "mysql",儲存結構如圖 2-1 所示:
如果我們執行下面語句:
strcat(s1, " 666");
將 s1 的內容修改為 "redis 666",但卻沒有在執行 strcat()
之前為 s1 分配足夠的空間,那麼在執行 strcat()
之後,s1 的資料將移除到 s2 所在的空間,導致 s2 儲存的內容被意外修改,如圖 2-2 所示:
與 C 字串不同的是,SDS 的空間分配策略完全杜絕了發生緩衝區溢位的可能性:當 SDS 的 API 需要對 SDS 進行修改時,API 會先檢查 SDS 的空間十分滿足修改所需的要求,如果不滿足的話,API 會自動將 SDS 的空間擴充套件至執行修改所需的大小,然後再執行實際的修改操作,所以使用 SDS 既不需要手動修改 SDS 的空間大小,也不會出現前面所說的緩衝區溢位問題。
2.3 減少記憶體重分配次數
由於 C 字串的長度 slen 和底層陣列的長度 salen 總存在著下述關係:
salen = slen + 1; // 1 是空字元的長度
因此,每次增長或縮短一個 C 字串,總要對 C 字串的陣列進行一次記憶體重分配操作:
- 增長字串。程式需要通過記憶體重分配來擴充套件底層陣列的空間的大小,如果漏了這步,就可能會產生緩衝區溢位。
- 縮短字串。程式需要通過記憶體重分配來釋放底層陣列不再使用的空間,如果漏了這步,就可能會產生記憶體洩漏。
而記憶體重分配涉及複雜的演算法,並且可能需要執行系統呼叫,所以記憶體重分配是一個較為耗時的過程。
對於 Redis 而言,一切耗時的操作都要優化。基於此,SDS 對於字串的增長和縮短操作,通過空間預分配和惰性空間釋放兩種方式來優化。
2.3.1 空間預分配
空間預分配是指:在需要對 SDS 的空間進行擴充套件時,程式不是僅僅分配所必需的的空間,還會為 SDS 分配額外的未使用空間。
關於 SDS 的空間擴充套件,原始碼如下:
# sds.c/sdsMakeRoomFor()
...
newlen = (len+addlen); // SDS 最新長度
if (newlen < SDS_MAX_PREALLOC) // 預分配最大值 SDS_MAX_PREALLOC 在 sds.h 中定義,值為 1024*1024
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
...
由原始碼可以看出,空間擴充套件分為兩種情況:
- 新長度小於預分配最大值。此時,程式將直接為 SDS 新增最新長度大小的未使用空間。舉個栗子,現有一個長度為 10 位元組的字串 s1,當給 s1 追加字串 "redis",那麼,程式將除了分配足夠 s1 使用的空間,還會為 s1 再分配最新長度大小的預使用空間。所以,s1 的實際長度就變為:
15 + 15 + 1 = 31
個位元組。 - 新長度大於預分配最大值。此時,由於最新字串較大,程式不會預分配這麼多空間,只會給預分配最大值的空間。舉個栗子,現有長度為 3M 的字串 s2,當給 s1 追加一個 2M 大小的字串,那麼程式除了新增 2M 來儲存新增的長度,還會為 s2 再分配 1M(SDS_MAX_PREALLOC)的預使用空間。所以,s2 的實際長度就變為:
3M + 2M +1M + 1byte
。
正是通過預分配的策略,Redis 減少了執行字串增長操作所需的記憶體重分配次數,保證了 Redis 不會因字串增長操作損耗效能。
2.3.2 惰性空間釋放
預分配對應字串的增長操作,而空間釋放則對應字串的縮短操作。
惰性空間釋放是指:在對 SDS 進行縮短操作時,程式不立即回收縮短後多出來的位元組,等待將來使用。
舉個栗子,我們使用 sdstrim()
函式,移除下圖 SDS 中所有指定的字元:
對上圖 SDS,執行:
sdstrim(s, "l"); // 移除 SDS 字串中所有的 'l'
會將 SDS 修改為圖 2-4 所示:
可以看到,執行 sdstrim()
之後的 SDS 並沒有釋放多出來的 3 位元組空間,而是將這 3 位元組空間作為未使用空間保留在了 SDS 裡面,以待備用。
正是通過惰性空間釋放策略,SDS 避免了縮短字串時所需的記憶體重分配操作,併為將來可能的增長操作提供了優化。
此外,SDS 也提供了相應的 API,讓我們在有需要時,真正的釋放 SDS 的未使用空間,避免造成記憶體浪費。
總結
- Redis 只會使用 C 字串作為字面量,大多數情況下,使用 SDS 作為字串表示。
- SDS 對比 C 字串,有幾大優點:常數複雜度獲取字串長度、杜絕緩衝區溢位、減少修改字串時所需的記憶體重分配次數。