跟着大彬讀源碼 - Redis 7 - 對象編碼之簡單動態字符串

Redis 沒有直接使用 C 語言傳統的字符串表示(以空字符串結尾的字符數組),而是構建了一種名爲簡單動態字符串(simple dynamic string)的抽象類型,並將 SDS 用做 Redis 的默認字符串表示。redis

在 Redis 中,C 字符串只會做爲字符串字面量用在一些無需對字符串進行修改的地方,好比打印日誌:算法

serverLog(LL_WARNING,"SIGTERM received but errors trying to shut down the server, check the logs for more information");sql

當 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 字符串,有幾大優勢:常數複雜度獲取字符串長度杜絕緩衝區溢出減小修改字符串時所需的內存重分配次數
相關文章
相關標籤/搜索