跟著大彬讀原始碼 - Redis 7 - 物件編碼之簡單動態字串

北國丶風光發表於2019-07-29

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 示例:

圖 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 字串的優勢:

  1. 獲取字串長度;
  2. 緩衝區溢位;
  3. 修改字串時的記憶體重分配次數;
  4. 二進位制安全;

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 所示:

圖 2-1:在記憶體中緊鄰的兩個 C 字串

如果我們執行下面語句:

strcat(s1, " 666");

將 s1 的內容修改為 "redis 666",但卻沒有在執行 strcat() 之前為 s1 分配足夠的空間,那麼在執行 strcat() 之後,s1 的資料將移除到 s2 所在的空間,導致 s2 儲存的內容被意外修改,如圖 2-2 所示:

圖 2-2:s1 的內容溢位到了 s2 的空間中

與 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 中所有指定的字元:

圖 2-3:進行縮短操作的 SDS

對上圖 SDS,執行:
sdstrim(s, "l"); // 移除 SDS 字串中所有的 'l'

會將 SDS 修改為圖 2-4 所示:

圖2-4:移除所有'l'後的SDS

可以看到,執行 sdstrim() 之後的 SDS 並沒有釋放多出來的 3 位元組空間,而是將這 3 位元組空間作為未使用空間保留在了 SDS 裡面,以待備用。

正是通過惰性空間釋放策略,SDS 避免了縮短字串時所需的記憶體重分配操作,併為將來可能的增長操作提供了優化。

此外,SDS 也提供了相應的 API,讓我們在有需要時,真正的釋放 SDS 的未使用空間,避免造成記憶體浪費。

總結

  1. Redis 只會使用 C 字串作為字面量,大多數情況下,使用 SDS 作為字串表示。
  2. SDS 對比 C 字串,有幾大優點:常數複雜度獲取字串長度杜絕緩衝區溢位減少修改字串時所需的記憶體重分配次數

相關文章