Redis動態字串

___波子Max.發表於2020-12-05

Q: 什麼是 SDS

A:SDS 是 Redis 在實現過程中使用的一種「動態字串」。由於 Redis 的程式碼基本都是通過 C 語言來實現的,所以 SDS 在最底層還是依賴於char buf[]來儲存資料。SDS 物件的資料結構大致如下圖所示

可以看出,SDS 結構體成員中有三個屬性:len,free,buf。其中 len 標識一個 SDS 物件管理的字串有效字元是多少個,而 free 則代表這個 SDS 在不擴充空間的前提下還可以儲存多少個有效字元,buf 則是一個char[]型別的指標,它指向一段連續的記憶體空間,這裡才是真正儲存字串的地方(有效字串是指除\0以外的字串集合)。

Q: 有了 C 字串,為什麼還需要 SDS?

A:通過閱讀相關資料以及對 Redis 文件的查閱,可以總結出以下幾點使用 SDS 而不適用原生 C 字串的好處

* 更高效的獲取一個 SDS 物件內儲存的字串的長度
* 杜絕緩衝區溢位
* 減少因字串的修改導致的頻繁分配和回收記憶體空間操作
* 二進位制安全
* 和 C 語言有關字串的庫函式有一個更高的相容性

其實看到這裡,如果你之前使用其他語言中的「普通陣列」實現過一個「動態陣列」的話,那麼除了「二進位制安全」這一條好處可能不太理解之外,其餘的應該都比較熟悉。下面我們就來分別說一下這幾個好處。

Q: 如何更高效的獲取字串的長度?

A:這個問題在傳統的 C 字串中算是一個痛點。在一個線性的資料結構中,我們都只能通過遍歷這個資料結構中所有的有效元素才能夠獲取它準確的長度,這個操作的時間複雜度是 O(N) 級別。但是當我們只是把 C 字串作為 SDS 這個資料結構中的一個成員時,我們就可以通過增加另外一個成員len來實時的計算字串的準確長度。計算的方式也很簡單,就是在字串做「新增元素」的操作時對len+1,做「減少元素」的操作時對len-1。這樣一來,就可以通過訪問len來獲取 SDS 記憶體儲的字串的長度。類似於這樣的實現:

void add(char a){
    buf[len++] = a;
}

void sub(char a){
    len--;
}

int length(char a){
    return len;
}

Q: 如何杜絕緩衝區溢位?

A:緩衝區溢位換成另外一種更加直白的說法:篡改了記憶體中本不屬於你的空間中的資料。這種現象在字串拼接以及字串的新增字元的操作中比較常見。處理這種問題的辦法也很簡單:在記憶體容量允許的情況下,當一個字串需要更多的記憶體空間的時候,重新分配1塊「更大」的連續空間,將原來空間中的有效資料 copy 過去。其中,檢測是否超出剩餘空間,完全可以使用free屬性的值,因為它代表了陣列中現在還有多少可用的空間。 如果你認真的閱讀了上一段的內容,就可以發現,在防止緩衝區溢位的過程中有幾個「醜陋」的步驟:

  1. 可能多次在記憶體中分配一段連續的空間
  2. 可能多次將原來空間中的有效資料 copy 到新的空間中
  3. 分配出去的空間如果沒有回收,一直在持續分配,可能會出現記憶體洩漏

針對於新出現的問題,我們採取了以下辦法來解決:

  1. 按照一定的策略分配新的記憶體空間,儘量減少分配次數
  2. 當空閒空間達到一定閾值的時候,回收多餘的記憶體空間

在 Redis 中,通過兩個步驟來確定「預分配」空間的大小:

  1. 如果修改之後的字串長度(len)小於1MB,除了分配必要的空間之外,還需要分配大小等同於len的空閒空間。例如,修改之後的字串長度為10(len=10),那麼在修改之後,新的記憶體空間大小為=10+10+1=21。
  2. 如果修改之後的字串長度(len) 大於1MB,除了分配必要的空間之外,還需要分配大小等同於1MB的空閒空間。

在 SDS 相關的修改操作中,會先對可用空間和實際所需要的空間進行對比,若超出,則會分配新的空間,否則使用舊的空間。通過上面的策略,基本上可以把「重新分配記憶體空間」和「將原來空間中的有效資料 copy 到新的空間中 」的次數由每次必定發生,降低到最多發生 N 次(N 為修改操作進行的次數)。

這裡插入一個筆者小的心得:很多程式設計師在解決問題的時候都傾向於找到一個完美的解決方案,若是筆者的話,可能在看到這個問題的時候也會想,能否有一個完美的辦法來解決上面的問題。但是,我們可以看到,在 Redis 這種工業級的專案中,它採取的方案仍然是很普通的,甚至是我們平時做練習就會用到的「實現」。一個看似「延遲讓風險發生」的辦法,有的時候就是最「完美」的辦法。程式設計師更多的應該關注如何解決問題,而不是如何「完美」的解決問題。

除了通過分配「預留空間」的方式來減少「分配」操作的次數之外,我們還擔心的一點就是,如果一直無限制的進行分配,那麼記憶體終有耗盡的時候。這就是我們常說的記憶體洩漏問題。想解決它也很簡單,就是按照一定的策略回收已經分配的記憶體空間。比如:當一個 SDS 繫結的記憶體空間的使用量已經低於25%,那麼我們就將它的記憶體空間縮小為原來的一半。至於為什麼只縮小原來的一般而不是全部將空餘空間回收,仔細思考一下就知道,如果回收的方式過於極端,那麼就將「預分配」空間的優勢全部抹殺了(增加記憶體分配的次數)。

所以,在 SDS 相關的修改(主要是刪除元素)操作中,不會立刻對空閒的空間進行回收,而是將它們作為「預留空間」。為了防止「記憶體洩漏」,Redis 提供了專門的 API,真正的對記憶體空間進行釋放。

Q: 如何保證 SDS 是二進位制安全的?

A:「二進位制安全」聽起來是個比較陌生的詞,但是如果你綜合了 C 語言字串的特點和二進位制內容的特點就可以知道,二進位制安全主要是防止它的內容中出現像\0這種特殊字元,干擾了對原字串的正確解釋。聽起來比較高大上的問題,往往解決它的方案都是比較簡單的。在 Redis 中,為了保證「二進位制安全」,不在使用 C 語言字串的\0字元作為其所儲存的字串的邊界,而是使用len 這個屬性,標識字串中有效字元的個數。

雖然,為了保證「二進位制安全」我們可以無視 C 語言字串以\0作為字串結尾的事實。但是,多數情況下大家還是會使用 Redis 儲存「文字資訊」(符合 C 語言字串規則的,內容中不含有\0)。此時,對他們的操作可能要依賴於 C 語言和字串相關的庫函式,所以,在 SDS 的實現中會保持這樣兩個慣例:

  1. 給字串分配記憶體空間時會考慮多分配1byte 的空間給\0
  2. 在修改字串內容的時候,都會在最後追加一個\0字元

Redis 中的字串

在 C 語言中,字串可以用一個 \0 結尾的 char 陣列來表示。比如說, hello world 在 C 語言中就可以表示為 "hello world\0" 。這種簡單的字串表示,在大多數情況下都能滿足要求,但是,它並不能高效地支援長度計算和追加(append)這兩種操作:

  • 每次計算字串長度(strlen(s))的複雜度為 θ(N)θ(N) 。
  • 對字串進行 N 次追加,必定需要對字串進行 N 次記憶體重分配(realloc)。

在 Redis 內部, 字串的追加和長度計算很常見, 而 APPEND 和 STRLEN 更是這兩種操作,在 Redis 命令中的直接對映, 這兩個簡單的操作不應該成為效能的瓶頸。另外, Redis 除了處理 C 字串之外, 還需要處理單純的位元組陣列, 以及伺服器協議等內容, 所以為了方便起見, Redis 的字串表示還應該是二進位制安全的: 程式不應對字串裡面儲存的資料做任何假設, 資料可以是以 \0 結尾的 C 字串, 也可以是單純的位元組陣列, 或者其他格式的資料。

考慮到這兩個原因, Redis 使用 sds 型別替換了 C 語言的預設字串表示: sds 既可高效地實現追加和長度計算, 同時是二進位制安全的。

sds 的實現

在前面的內容中, 我們一直將 sds 作為一種抽象資料結構來說明, 實際上, 它的實現由以下兩部分組成:

typedef char *sds;

struct sdshdr 
{
    int len;    // buf 已佔用長度
    int free;   // buf 剩餘可用長度
    char buf[]; // 實際儲存字串資料的地方
};

其中,型別 sds 是 char * 的別名(alias),而結構 sdshdr 則儲存了 len 、 free 和 buf 三個屬性。

作為例子,以下是新建立的,同樣儲存 hello world 字串的 sdshdr 結構:

struct sdshdr {
    len = 11;
    free = 0;
    buf = "hello world\0";  // buf 的實際長度為 len + 1
};

通過 len 屬性, sdshdr 可以實現複雜度為 θ(1)θ(1) 的長度計算操作。

另一方面, 通過對 buf 分配一些額外的空間, 並使用 free 記錄未使用空間的大小, sdshdr 可以讓執行追加操作所需的記憶體重分配次數大大減少, 下一節我們就會來詳細討論這一點。

當然, sds 也對操作的正確實現提出了要求 —— 所有處理 sdshdr 的函式,都必須正確地更新 len 和 free 屬性,否則就會造成 bug 。

優化追加操作

在前面說到過,利用 sdshdr 結構,除了可以用 θ(1)θ(1) 複雜度獲取字串的長度之外,還可以減少追加(append)操作所需的記憶體重分配次數,以下就來詳細解釋這個優化的原理。

為了易於理解,我們用一個 Redis 執行例項作為例子,解釋一下,當執行以下程式碼時, Redis 內部發生了什麼:

redis> SET msg "hello world"
OK

redis> APPEND msg " again!"
(integer) 18

redis> GET msg
"hello world again!"

首先, SET 命令建立並儲存 hello world 到一個 sdshdr 中,這個 sdshdr 的值如下:

struct sdshdr {
    len = 11;
    free = 0;
    buf = "hello world\0";
}

當執行 APPEND 命令時,相應的 sdshdr 被更新,字串 " again!" 會被追加到原來的 "hello world" 之後:

struct sdshdr {
    len = 18;
    free = 18;
    buf = "hello world again!\0                  ";     // 空白的地方為預分配空間,共 18 + 18 + 1 個位元組
}

注意, 當呼叫 SET 命令建立 sdshdr 時, sdshdr 的 free 屬性為 0 , Redis 也沒有為 buf 建立額外的空間 —— 而在執行 APPEND 之後, Redis 為 buf 建立了多於所需空間一倍的大小。

在這個例子中, 儲存 "hello world again!" 共需要 18 + 1 個位元組, 但程式卻為我們分配了 18 + 18 + 1 = 37 個位元組 —— 這樣一來, 如果將來再次對同一個 sdshdr 進行追加操作, 只要追加內容的長度不超過 free 屬性的值, 那麼就不需要對 buf 進行記憶體重分配。

比如說, 執行以下命令並不會引起 buf 的記憶體重分配, 因為新追加的字串長度小於 18 :

redis> APPEND msg " again!"
(integer) 25

再次執行 APPEND 命令之後, msg 的值所對應的 sdshdr 結構可以表示如下:

struct sdshdr {
    len = 25;
    free = 11;
    buf = "hello world again! again!\0           ";     // 空白的地方為預分配空間,共 18 + 18 + 1 個位元組
}

sds.c/sdsMakeRoomFor 函式描述了 sdshdr 的這種記憶體預分配優化策略, 以下是這個函式的虛擬碼版本:

def sdsMakeRoomFor(sdshdr, required_len):

    # 預分配空間足夠,無須再進行空間分配
    if (sdshdr.free >= required_len):
        return sdshdr

    # 計算新字串的總長度
    newlen = sdshdr.len + required_len

    # 如果新字串的總長度小於 SDS_MAX_PREALLOC
    # 那麼為字串分配 2 倍於所需長度的空間
    # 否則就分配所需長度加上 SDS_MAX_PREALLOC 數量的空間
    if newlen < SDS_MAX_PREALLOC:
        newlen *= 2
    else:
        newlen += SDS_MAX_PREALLOC

    # 分配記憶體
    newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)

    # 更新 free 屬性
    newsh.free = newlen - sdshdr.len

    # 返回
    return newsh

在目前版本的 Redis 中(筆者手中的版本為redis-6.0.9), 

#ifndef __SDS_H
#define __SDS_H

#define SDS_MAX_PREALLOC (1024*1024)
extern const char *SDS_NOINIT;

#include <sys/types.h>
#include <stdarg.h>
#include <stdint.h>

typedef char *sds;

....

void *sds_malloc(size_t size);
void *sds_realloc(void *ptr, size_t size);
void sds_free(void *ptr);

#ifdef REDIS_TEST
int sdsTest(int argc, char *argv[]);
#endif

#endif

可以看到,SDS_MAX_PREALLOC 的值為 1024 * 1024 , 也就是說, 當大小小於 1MB 的字串執行追加操作時,sdsMakeRoomFor 就為它們分配多於所需大小一倍的空間; 當字串的大小大於 1MB , 那麼 sdsMakeRoomFor 就為它們額外多分配 1MB 的空間。

這種分配策略會浪費記憶體嗎?

  • 執行過 APPEND 命令的字串會帶有額外的預分配空間, 這些預分配空間不會被釋放, 除非該字串所對應的鍵被刪除, 或者等到關閉 Redis 之後, 再次啟動時重新載入的字串物件將不會有預分配空間。
  • 因為執行 APPEND 命令的字串鍵數量通常並不多, 佔用記憶體的體積通常也不大, 所以這一般並不算什麼問題。
  • 另一方面, 如果執行 APPEND 操作的鍵很多, 而字串的體積又很大的話, 那可能就需要修改 Redis 伺服器, 讓它定時釋放一些字串鍵的預分配空間, 從而更有效地使用記憶體。

總結

1. 獲取字串長度時,C字串需要遍歷字串直到找到‘\0’為止,它的複雜度為O(n),而SDS直接訪問len屬性就可以直接獲取字串的長度,複雜度為O(1)。

2. SDS的API杜絕快取區溢位,SDS呼叫SdsCat時,會首先判斷 sds的空間是否充足,如果不夠要先擴充套件SDS,再進行字串拼接。

3. 為了減少記憶體重分配的效能影響,SDS的字串增長會做記憶體預分配操作,通過預分配策略,可以有效的減少redis分配記憶體的次數。

4. SDS是二進位制安全的,C字串通過判斷是否為‘\0’找字串結尾,而SDS通過len屬性來找字串結尾,這樣就不怕字串中間有'\0'。

此外,

  • Redis 的字串表示為 sds ,而不是 C 字串(以 \0 結尾的 char*)。
  • 對比 C 字串, sds 有以下特性:
    • 可以高效地執行長度計算(strlen);
    • 可以高效地執行追加操作(append);
    • 二進位制安全;
  • sds 會為追加操作進行優化:加快追加操作的速度,並降低記憶體分配的次數,代價是多佔用了一些記憶體,而且這些記憶體不會被主動釋放。

相關文章