1. 程式人生 > >SDS 與 C 字串的區別

SDS 與 C 字串的區別

根據傳統, C 語言使用長度為 N+1 的字元陣列來表示長度為 N 的字串, 並且字元陣列的最後一個元素總是空字元 '\0' 。

比如說, 圖 2-3 就展示了一個值為 "Redis" 的 C 字串:

digraph {    label = "\n 圖 2-3    C 字串";    rankdir = LR;    node [shape = record];    //    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | '\\0' }"];}

C 語言使用的這種簡單的字串表示方式, 並不能滿足 Redis 對字串在安全性、效率、以及功能方面的要求, 本節接下來的內容將詳細對比 C 字串和 SDS 之間的區別, 並說明 SDS 比 C 字串更適用於 Redis 的原因。

常數複雜度獲取字串長度

因為 C 字串並不記錄自身的長度資訊, 所以為了獲取一個 C 字串的長度, 程式必須遍歷整個字串, 對遇到的每個字元進行計數, 直到遇到代表字串結尾的空字元為止, 這個操作的複雜度為 O(N) 。

舉個例子, 圖 2-4 展示了程式計算一個 C 字串長度的過程。

digraph {    rankdir = TB;    node [shape = record];    str [label = " <1> 'R' | <2> 'e' | <3> 'd' | <4> 'i' | <5> 's' | <6> '\\0' "];    node [shape = plaintext];    p1 [label = "len = 1"];    p1 -> str:1;}

digraph {    rankdir = TB;    node [shape = record];    str [label = " <1> 'R' | <2> 'e' | <3> 'd' | <4> 'i' | <5> 's' | <6> '\\0' "];    node [shape = plaintext];    p2 [label = "len = 2"];    p2 -> str:2;}

digraph {    rankdir = TB;    node [shape = record];    str [label = " <1> 'R' | <2> 'e' | <3> 'd' | <4> 'i' | <5> 's' | <6> '\\0' "];    node [shape = plaintext];    p3 [label = "len = 3"];    p3 -> str:3;}

digraph {    rankdir = TB;    node [shape = record];    str [label = " <1> 'R' | <2> 'e' | <3> 'd' | <4> 'i' | <5> 's' | <6> '\\0' "];    node [shape = plaintext];    p4 [label = "len = 4"];    p4 -> str:4;}

digraph {    rankdir = TB;    node [shape = record];    str [label = " <1> 'R' | <2> 'e' | <3> 'd' | <4> 'i' | <5> 's' | <6> '\\0' "];    node [shape = plaintext];    p5 [label = "len = 5"];    p5 -> str:5;}

digraph {    label = "\n 圖 2-4    計算 C 字串長度的過程";    rankdir = TB;    node [shape = record];    str [label = " <1> 'R' | <2> 'e' | <3> 'd' | <4> 'i' | <5> 's' | <6> '\\0' "];    node [shape = plaintext];    p6 [label = "發現空字元 \n 停止計數 \n 字串的長度為 5 位元組"];    p6 -> str:6;}

和 C 字串不同, 因為 SDS 在 len 屬性中記錄了 SDS 本身的長度, 所以獲取一個 SDS 長度的複雜度僅為 O(1) 。

舉個例子, 對於圖 2-5 所示的 SDS 來說, 程式只要訪問 SDS 的 len 屬性, 就可以立即知道 SDS 的長度為 5 位元組:

digraph {    label = "\n 圖 2-5    五位元組長的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 5 | <buf> buf"];    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | '\\0' }"];    //    sdshdr:buf -> buf;}

又比如說, 對於圖 2-6 展示的 SDS 來說, 程式只要訪問 SDS 的 len 屬性, 就可以立即知道 SDS 的長度為 11 位元組。

digraph {    label = "\n 圖 2-6    十一位元組長的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 11 | <buf> buf"];    buf [label = "{ 'h' | 'e' | 'l' | 'l' | 'o' | ' ' | 'w' | 'o' | 'r' | 'l' | 'd' | '\\0' }"];    //    sdshdr:buf -> buf;}

設定和更新 SDS 長度的工作是由 SDS 的 API 在執行時自動完成的, 使用 SDS 無須進行任何手動修改長度的工作。

通過使用 SDS 而不是 C 字串, Redis 將獲取字串長度所需的複雜度從 O(N) 降低到了 O(1) , 這確保了獲取字串長度的工作不會成為 Redis 的效能瓶頸。

比如說, 因為字串鍵在底層使用 SDS 來實現, 所以即使我們對一個非常長的字串鍵反覆執行 STRLEN 命令, 也不會對系統性能造成任何影響, 因為 STRLEN 命令的複雜度僅為 O(1) 。

杜絕緩衝區溢位

除了獲取字串長度的複雜度高之外, C 字串不記錄自身長度帶來的另一個問題是容易造成緩衝區溢位(buffer overflow)。

舉個例子, <string.h>/strcat 函式可以將 src 字串中的內容拼接到 dest 字串的末尾:

char *strcat(char *dest, const char *src);

因為 C 字串不記錄自身的長度, 所以 strcat 假定使用者在執行這個函式時, 已經為 dest 分配了足夠多的記憶體, 可以容納 src 字串中的所有內容, 而一旦這個假定不成立時, 就會產生緩衝區溢位。

舉個例子, 假設程式裡有兩個在記憶體中緊鄰著的 C 字串 s1 和 s2 , 其中 s1 儲存了字串 "Redis" , 而 s2 則儲存了字串 "MongoDB" , 如圖 2-7 所示。

digraph {    label = "\n 圖 2-7    在記憶體中緊鄰的兩個 C 字串";    rankdir = TB;    //    node [shape = record];    memory [label = " ... | <s1> 'R' | 'e' | 'd' | 'i' | 's' | '\\0' | <s2> 'M' | 'o' | 'n' | 'g' | 'o' | 'D' | 'B' | '\\0' | ... "];    //    node [shape = plaintext];    s1 -> memory:s1;    s2 -> memory:s2;}

如果一個程式設計師決定通過執行:

strcat(s1, " Cluster");

將 s1 的內容修改為 "Redis Cluster" , 但粗心的他卻忘了在執行 strcat 之前為 s1 分配足夠的空間, 那麼在 strcat 函式執行之後, s1 的資料將溢位到 s2 所在的空間中, 導致 s2 儲存的內容被意外地修改, 如圖 2-8 所示。

digraph {    label = "\n 圖 2-8    s1 的內容溢位到了 s2 所在的位置上";    rankdir = TB;    //    node [shape = record];    memory [label = " ... | <s1> 'R' | 'e' | 'd' | 'i' | 's' | ' ' | <s2> 'C' | 'l' | 'u' | 's' | 't' | 'e' | 'r' | '\\0' | ... "];    //    node [shape = plaintext];    s1 -> memory:s1;    s2 -> memory:s2;}

與 C 字串不同, SDS 的空間分配策略完全杜絕了發生緩衝區溢位的可能性: 當 SDS API 需要對 SDS 進行修改時, API 會先檢查 SDS 的空間是否滿足修改所需的要求, 如果不滿足的話, API 會自動將 SDS 的空間擴充套件至執行修改所需的大小, 然後才執行實際的修改操作, 所以使用 SDS 既不需要手動修改 SDS 的空間大小, 也不會出現前面所說的緩衝區溢位問題。

舉個例子, SDS 的 API 裡面也有一個用於執行拼接操作的 sdscat 函式, 它可以將一個 C 字串拼接到給定 SDS 所儲存的字串的後面, 但是在執行拼接操作之前, sdscat 會先檢查給定 SDS 的空間是否足夠, 如果不夠的話, sdscat 就會先擴充套件 SDS 的空間, 然後才執行拼接操作。

比如說, 如果我們執行:

sdscat(s, " Cluster");

其中 SDS 值 s 如圖 2-9 所示, 那麼 sdscat 將在執行拼接操作之前檢查 s 的長度是否足夠, 在發現 s 目前的空間不足以拼接 " Cluster" 之後, sdscat 就會先擴充套件 s 的空間, 然後才執行拼接 " Cluster" 的操作, 拼接操作完成之後的 SDS 如圖 2-10 所示。

digraph {    label = "\n 圖 2-9    sdscat 執行之前的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 5 | <buf> buf"];    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | '\\0' }"];    //    sdshdr:buf -> buf;}

digraph {    label = "\n 圖 2-10    sdscat 執行之後的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 13 | len \n 13 | <buf> buf"];    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | ' ' | 'C' | 'l' | 'u' | 's' | 't' | 'e' | 'r'| '\\0' | ... }"];    //    sdshdr:buf -> buf;}

注意圖 2-10 所示的 SDS : sdscat 不僅對這個 SDS 進行了拼接操作, 它還為 SDS 分配了 13 位元組的未使用空間, 並且拼接之後的字串也正好是 13 位元組長, 這種現象既不是 bug 也不是巧合, 它和 SDS 的空間分配策略有關, 接下來的小節將對這一策略進行說明。

減少修改字串時帶來的記憶體重分配次數

正如前兩個小節所說, 因為 C 字串並不記錄自身的長度, 所以對於一個包含了 N 個字元的 C 字串來說, 這個 C 字串的底層實現總是一個 N+1 個字元長的陣列(額外的一個字元空間用於儲存空字元)。

因為 C 字串的長度和底層陣列的長度之間存在著這種關聯性, 所以每次增長或者縮短一個 C 字串, 程式都總要對儲存這個 C 字串的陣列進行一次記憶體重分配操作:

  • 如果程式執行的是增長字串的操作, 比如拼接操作(append), 那麼在執行這個操作之前, 程式需要先通過記憶體重分配來擴充套件底層陣列的空間大小 —— 如果忘了這一步就會產生緩衝區溢位。
  • 如果程式執行的是縮短字串的操作, 比如截斷操作(trim), 那麼在執行這個操作之後, 程式需要通過記憶體重分配來釋放字串不再使用的那部分空間 —— 如果忘了這一步就會產生記憶體洩漏。

舉個例子, 如果我們持有一個值為 "Redis" 的 C 字串 s , 那麼為了將 s 的值改為 "Redis Cluster" , 在執行:

strcat(s, " Cluster");

之前, 我們需要先使用記憶體重分配操作, 擴充套件 s 的空間。

之後, 如果我們又打算將 s 的值從 "Redis Cluster" 改為 "Redis Cluster Tutorial" , 那麼在執行:

strcat(s, " Tutorial");

之前, 我們需要再次使用記憶體重分配擴充套件 s 的空間, 諸如此類。

因為記憶體重分配涉及複雜的演算法, 並且可能需要執行系統呼叫, 所以它通常是一個比較耗時的操作:

  • 在一般程式中, 如果修改字串長度的情況不太常出現, 那麼每次修改都執行一次記憶體重分配是可以接受的。
  • 但是 Redis 作為資料庫, 經常被用於速度要求嚴苛、資料被頻繁修改的場合, 如果每次修改字串的長度都需要執行一次記憶體重分配的話, 那麼光是執行記憶體重分配的時間就會佔去修改字串所用時間的一大部分, 如果這種修改頻繁地發生的話, 可能還會對效能造成影響。

為了避免 C 字串的這種缺陷, SDS 通過未使用空間解除了字串長度和底層陣列長度之間的關聯: 在 SDS 中, buf 陣列的長度不一定就是字元數量加一, 數組裡面可以包含未使用的位元組, 而這些位元組的數量就由 SDS 的 free 屬性記錄。

通過未使用空間, SDS 實現了空間預分配和惰性空間釋放兩種優化策略。

空間預分配

空間預分配用於優化 SDS 的字串增長操作: 當 SDS 的 API 對一個 SDS 進行修改, 並且需要對 SDS 進行空間擴充套件的時候, 程式不僅會為 SDS 分配修改所必須要的空間, 還會為 SDS 分配額外的未使用空間。

其中, 額外分配的未使用空間數量由以下公式決定:

  • 如果對 SDS 進行修改之後, SDS 的長度(也即是 len 屬性的值)將小於 1 MB , 那麼程式分配和 len 屬性同樣大小的未使用空間, 這時 SDS len 屬性的值將和 free 屬性的值相同。 舉個例子, 如果進行修改之後, SDS 的 len 將變成 13 位元組, 那麼程式也會分配 13位元組的未使用空間, SDS 的 buf 陣列的實際長度將變成 13 + 13 + 1 = 27 位元組(額外的一位元組用於儲存空字元)。
  • 如果對 SDS 進行修改之後, SDS 的長度將大於等於 1 MB , 那麼程式會分配 1 MB 的未使用空間。 舉個例子, 如果進行修改之後, SDS 的 len 將變成 30 MB , 那麼程式會分配 1 MB 的未使用空間, SDS 的 buf 陣列的實際長度將為 30 MB + 1 MB + 1 byte 。

通過空間預分配策略, Redis 可以減少連續執行字串增長操作所需的記憶體重分配次數。

舉個例子, 對於圖 2-11 所示的 SDS 值 s 來說, 如果我們執行:

sdscat(s, " Cluster");

那麼 sdscat 將執行一次記憶體重分配操作, 將 SDS 的長度修改為 13 位元組, 並將 SDS 的未使用空間同樣修改為 13 位元組, 如圖 2-12 所示。

digraph {    label = "\n 圖 2-11    執行 sdscat 之前的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 5 | <buf> buf"];    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | '\\0' }"];    //    sdshdr:buf -> buf;}

digraph {    label = "\n 圖 2-12    執行 sdscat 之後的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 13 | len \n 13 | <buf> buf"];    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | ' ' | 'C' | 'l' | 'u' | 's' | 't' | 'e' | 'r'| '\\0' | ... }"];    //    sdshdr:buf -> buf;}

如果這時, 我們再次對 s 執行:

sdscat(s, " Tutorial");

那麼這次 sdscat 將不需要執行記憶體重分配: 因為未使用空間裡面的 13 位元組足以儲存 9 位元組的 " Tutorial" , 執行 sdscat 之後的 SDS 如圖 2-13 所示。

digraph {    label = "\n 圖 2-13    再次執行 sdscat 之後的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 4 | len \n 22 | <buf> buf"];    //buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | ' ' | 'C' | 'l' | 'u' | 's' | 't' | 'e' | 'r'| ' ' | 'T' | 'u' | 't' | 'o' | 'r' | 'i' | 'a' | 'l' | '\\0' | ... }"];    buf [label = "{ 'R' | 'e' | 'd' | 'i' | 's' | ... | ' ' | 'T' | 'u' | 't' | 'o' | 'r' | 'i' | 'a' | 'l' | '\\0' | ... }"];    //    sdshdr:buf -> buf;}

在擴充套件 SDS 空間之前, SDS API 會先檢查未使用空間是否足夠, 如果足夠的話, API 就會直接使用未使用空間, 而無須執行記憶體重分配。

通過這種預分配策略, SDS 將連續增長 N 次字串所需的記憶體重分配次數從必定 N 次降低為最多 N 次。

惰性空間釋放

惰性空間釋放用於優化 SDS 的字串縮短操作: 當 SDS 的 API 需要縮短 SDS 儲存的字串時, 程式並不立即使用記憶體重分配來回收縮短後多出來的位元組, 而是使用 free 屬性將這些位元組的數量記錄起來, 並等待將來使用。

舉個例子, sdstrim 函式接受一個 SDS 和一個 C 字串作為引數, 從 SDS 左右兩端分別移除所有在 C 字串中出現過的字元。

比如對於圖 2-14 所示的 SDS 值 s 來說, 執行:

sdstrim(s, "XY");   // 移除 SDS 字串中的所有 'X' 和 'Y'

會將 SDS 修改成圖 2-15 所示的樣子。

digraph {    label = "\n 圖 2-14    執行 sdstrim 之前的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 11 | <buf> buf"];    buf [label = " { 'X' | 'Y' | 'X' | 'X' | 'Y' | 'a' | 'b' | 'c' | 'X' | 'Y' | 'Y' | '\\0' } "];    //    sdshdr:buf -> buf;}

digraph {    label = "\n 圖 2-15    執行 sdstrim 之後的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 8 | len \n 3 | <buf> buf"];    buf [label = " { 'a' | 'b' | 'c' | '\\0' | <1> | <2> | <3> | <4> | <5> | <6> | <7> | <8> } "];    //    sdshdr:buf -> buf;}

注意執行 sdstrim 之後的 SDS 並沒有釋放多出來的 8 位元組空間, 而是將這 8 位元組空間作為未使用空間保留在了 SDS 裡面, 如果將來要對 SDS 進行增長操作的話, 這些未使用空間就可能會派上用場。

舉個例子, 如果現在對 s 執行:

sdscat(s, " Redis");

那麼完成這次 sdscat 操作將不需要執行記憶體重分配: 因為 SDS 裡面預留的 8 位元組空間已經足以拼接 6 個位元組長的 " Redis" , 如圖 2-16 所示。

digraph {    label = "\n 圖 2-16    執行 sdscat 之後的的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 2 | len \n 9 | <buf> buf"];    buf [label = " { 'a' | 'b' | 'c' | ' ' | 'R' | 'e' | 'd' | 'i' | 's' | '\\0' | <1> | <2> } "];    //    sdshdr:buf -> buf;}

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

與此同時, SDS 也提供了相應的 API , 讓我們可以在有需要時, 真正地釋放 SDS 裡面的未使用空間, 所以不用擔心惰性空間釋放策略會造成記憶體浪費。

二進位制安全

C 字串中的字元必須符合某種編碼(比如 ASCII), 並且除了字串的末尾之外, 字串裡面不能包含空字元, 否則最先被程式讀入的空字元將被誤認為是字串結尾 —— 這些限制使得 C 字串只能儲存文字資料, 而不能儲存像圖片、音訊、視訊、壓縮檔案這樣的二進位制資料。

舉個例子, 如果有一種使用空字元來分割多個單詞的特殊資料格式, 如圖 2-17 所示, 那麼這種格式就不能使用 C 字串來儲存, 因為 C 字串所用的函式只會識別出其中的 "Redis" , 而忽略之後的 "Cluster" 。

digraph {    label = "\n 圖 2-17    使用空字元來分割單詞的特殊資料格式";    node [shape = record];    content [label = " 'R' | 'e' | 'd' | 'i' | 's' | '\\0' | 'C' | 'l' | 'u' | 's' | 't' | 'e' | 'r' | '\\0' "];}

雖然資料庫一般用於儲存文字資料, 但使用資料庫來儲存二進位制資料的場景也不少見, 因此, 為了確保 Redis 可以適用於各種不同的使用場景, SDS 的 API 都是二進位制安全的(binary-safe): 所有 SDS API 都會以處理二進位制的方式來處理 SDS 存放在 buf 數組裡的資料, 程式不會對其中的資料做任何限制、過濾、或者假設 —— 資料在寫入時是什麼樣的, 它被讀取時就是什麼樣。

這也是我們將 SDS 的 buf 屬性稱為位元組陣列的原因 —— Redis 不是用這個陣列來儲存字元, 而是用它來儲存一系列二進位制資料。

比如說, 使用 SDS 來儲存之前提到的特殊資料格式就沒有任何問題, 因為 SDS 使用 len 屬性的值而不是空字元來判斷字串是否結束, 如圖 2-18 所示。

digraph {    label = "\n 圖 2-18    儲存了特殊資料格式的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 14 | <buf> buf"];    buf [label = " { 'R' | 'e' | 'd' | 'i' | 's' | '\\0' | 'C' | 'l' | 'u' | 's' | 't' | 'e' | 'r' | '\\0' | '\\0' } "];    //    sdshdr:buf -> buf;}

通過使用二進位制安全的 SDS , 而不是 C 字串, 使得 Redis 不僅可以儲存文字資料, 還可以儲存任意格式的二進位制資料。

相容部分 C 字串函式

雖然 SDS 的 API 都是二進位制安全的, 但它們一樣遵循 C 字串以空字元結尾的慣例: 這些 API 總會將 SDS 儲存的資料的末尾設定為空字元, 並且總會在為 buf 陣列分配空間時多分配一個位元組來容納這個空字元, 這是為了讓那些儲存文字資料的 SDS 可以重用一部分 <string.h> 庫定義的函式。

digraph {    label = "\n 圖 2-19    一個儲存著文字資料的 SDS";    rankdir = LR;    node [shape = record];    //    sdshdr [label = "sdshdr | free \n 0 | len \n 11 | <buf> buf"];    buf [label = "{ 'h' | 'e' | 'l' | 'l' | 'o' | ' ' | 'R' | 'e' | 'd' | 'i' | 's' | '\\0' }"];    //    sdshdr:buf -> buf;}

舉個例子, 如圖 2-19 所示, 如果我們有一個儲存文字資料的 SDS 值 sds , 那麼我們就可以重用 <string.h>/strcasecmp 函式, 使用它來對比 SDS 儲存的字串和另一個 C 字串:

strcasecmp(sds->buf, "hello world");

這樣 Redis 就不用自己專門去寫一個函式來對比 SDS 值和 C 字串值了。

與此類似, 我們還可以將一個儲存文字資料的 SDS 作為 strcat 函式的第二個引數, 將 SDS 儲存的字串追加到一個 C 字串的後面:

strcat(c_string, sds->buf);

這樣 Redis 就不用專門編寫一個將 SDS 字串追加到 C 字串之後的函數了。

通過遵循 C 字串以空字元結尾的慣例, SDS 可以在有需要時重用 <string.h> 函式庫, 從而避免了不必要的程式碼重複。

總結

表 2-1 對 C 字串和 SDS 之間的區別進行了總結。


表 2-1 C 字串和 SDS 之間的區別

C 字串 SDS
獲取字串長度的複雜度為 O(N) 。 獲取字串長度的複雜度為 O(1) 。
API 是不安全的,可能會造成緩衝區溢位。 API 是安全的,不會造成緩衝區溢位。
修改字串長度 N 次必然需要執行 N 次記憶體重分配。 修改字串長度 N 次最多需要執行 N 次記憶體重分配。
只能儲存文字資料。 可以儲存文字或者二進位制資料。
可以使用所有 <string.h> 庫中的函式。 可以使用一部分 <string.h> 庫中的函式。