Redis數據庫里面的每個鍵值對(key-value pair)都是由對象(object)組成的:
- 其中,數據庫鍵總是一個字符串對象(string object);
- 而數據庫鍵的值可以是字符串對象、 列表對象(list object)、 哈希對象(hash object)、 集合對象(set object)、 有序集合對象(sorted set object)這五種對象中的其中一種。
Redis沒有使用C語言傳統的字符串表示
(在 C 語言中,字符串實際上是使用 null 字符 '\0' 終止的一維字符數組。因此,一個以 null 結尾的字符串,包含了組成字符串的字符。
下面的聲明和初始化創建了一個 "Hello" 字符串。由于在數組的末尾存儲了空字符,所以字符數組的大小比單詞 "Hello" 的字符數多一個。
char greeting[6] = {'H', 'e', 'l', 'l', 'o', '\0'};)
而是自己構建了一種名為簡單動態字符串(simple dynamic string,SDS)的抽象類型,并將SDS用作Redis的默認字符串表示。
- SDS的定義
struct sdshdr {
// 記錄 buf 數組中已使用字節的數量
// 等于 SDS 所保存字符串的長度
int len;
// 記錄 buf 數組中未使用字節的數量
int free;
// 字節數組,用于保存字符串
char buf[];
};
圖 2-1 展示了一個 SDS 示例:
free 屬性的值為 0 , 表示這個 SDS 沒有分配任何未使用空間。
len 屬性的值為 5 , 表示這個 SDS 保存了一個五字節長的字符串。
buf 屬性是一個 char 類型的數組, 數組的前五個字節分別保存了 'R' 、 'e' 、 'd' 、 'i' 、 's' 五個字符, 而最后一個字節則保存了空字符 '\0' 。
SDS 遵循 C 字符串以空字符結尾的慣例, 保存空字符的 1 字節空間不計算在 SDS 的 len 屬性里面, 并且為空字符分配額外的 1 字節空間, 以及添加空字符到字符串末尾等操作都是由 SDS 函數自動完成的, 所以這個空字符對于 SDS 的使用者來說是完全透明的。
遵循空字符結尾這一慣例的好處是, SDS 可以直接重用一部分 C 字符串函數庫里面的函數。
- Demo
redis> SET msg "hello world"
OK
那么Redis將在數據庫中創建一個新的鍵值對,其中:
- 鍵值對的K是一個字符串對象,對象的底層實現是一個保存著字符串“msg”的SDS。
- 鍵值對的V也是一個字符串對象, 對象的底層實現是一個保存著字符串 "hello world" 的 SDS。
redis> RPUSH fruits "apple" "banana" "cherry"
(integer) 3
(TODO)
除了用來保存數據庫中的字符串值之外, SDS 還被用作緩沖區(buffer): AOF 模塊中的 AOF 緩沖區, 以及客戶端狀態中的輸入緩沖區, 都是由 SDS 實現的, 在之后介紹 AOF 持久化和客戶端狀態的時候, 我們會看到 SDS 在這兩個模塊中的應用。
- SDS的優點
C 字符串 | SDS |
---|---|
獲取字符串長度的復雜度為 O(N) | 獲取字符串長度的復雜度為 O(1) |
API 是不安全的,可能會造成緩沖區溢出 | API 是安全的,不會造成緩沖區溢出 |
修改字符串長度 N 次必然需要執行 N 次內存重分配 | 修改字符串長度 N 次最多需要執行 N 次內存重分配 |
只能保存文本數據 | 可以保存文本或者二進制數據 |
可以使用所有 <string.h> 庫中的函數 | 可以使用一部分 <string.h> 庫中的函數 |
- 常數復雜度獲取字符串長度
和 C 字符串不同, 因為 SDS 在 len 屬性中記錄了 SDS 本身的長度, 所以獲取一個 SDS 長度的復雜度僅為 O(1) 。
舉個例子, 對于圖 2-5 所示的 SDS 來說, 程序只要訪問 SDS 的 len 屬性, 就可以立即知道 SDS 的長度為 5 字節:
設置和更新 SDS 長度的工作是由 SDS 的 API 在執行時自動完成的, 使用 SDS 無須進行任何手動修改長度的工作。
通過使用 SDS 而不是 C 字符串, Redis 將獲取字符串長度所需的復雜度從 O(N) 降低到了 O(1) , 這確保了獲取字符串長度的工作不會成為 Redis 的性能瓶頸。
比如說, 因為字符串鍵在底層使用 SDS 來實現, 所以即使我們對一個非常長的字符串鍵反復執行 STRLEN 命令, 也不會對系統性能造成任何影響, 因為 STRLEN 命令的復雜度僅為 O(1) 。
- 杜絕緩沖區溢出
與 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 所示。
注意圖 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
屬性同樣大小的未使用空間, 這時 SDSlen
屬性的值將和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 可以減少連續執行字符串增長操作所需的內存重分配次數。
-
二進制安全
C 字符串中的字符必須符合某種編碼(比如 ASCII), 并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否則最先被程序讀入的空字符將被誤認為是字符串結尾 —— 這些限制使得 C 字符串只能保存文本數據, 而不能保存像圖片、音頻、視頻、壓縮文件這樣的二進制數據。
舉個例子, 如果有一種使用空字符來分割多個單詞的特殊數據格式, 如圖 2-17 所示, 那么這種格式就不能使用 C 字符串來保存, 因為 C 字符串所用的函數只會識別出其中的 "Redis" , 而忽略之后的 "Cluster" 。
雖然數據庫一般用于保存文本數據, 但使用數據庫來保存二進制數據的場景也不少見, 因此, 為了確保 Redis 可以適用于各種不同的使用場景, SDS 的 API 都是二進制安全的(binary-safe): 所有 SDS API 都會以處理二進制的方式來處理 SDS 存放在 buf 數組里的數據, 程序不會對其中的數據做任何限制、過濾、或者假設 —— 數據在寫入時是什么樣的, 它被讀取時就是什么樣。
這也是我們將 SDS 的 buf 屬性稱為字節數組的原因 —— Redis 不是用這個數組來保存字符, 而是用它來保存一系列二進制數據。
比如說, 使用 SDS 來保存之前提到的特殊數據格式就沒有任何問題, 因為 SDS 使用 len 屬性的值而不是空字符來判斷字符串是否結束, 如圖 2-18 所示。
通過使用二進制安全的 SDS , 而不是 C 字符串, 使得 Redis 不僅可以保存文本數據, 還可以保存任意格式的二進制數據。
- 兼容部分 C 字符串函數
字符串對象的編碼可以是 int 、 raw 或者 embstr 。
如果一個字符串對象保存的是整數值, 并且這個整數值可以用 long 類型來表示, 那么字符串對象會將整數值保存在字符串對象結構的 ptr 屬性里面(將 void* 轉換成 long ), 并將字符串對象的編碼設置為 int 。
如果字符串對象保存的是一個字符串值, 并且這個字符串值的長度大于 39 字節, 那么字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串值, 并將對象的編碼設置為 raw 。
如果字符串對象保存的是一個字符串值, 并且這個字符串值的長度小于等于 39 字節, 那么字符串對象將使用 embstr 編碼的方式來保存這個字符串值。
embstr 編碼是專門用于保存短字符串的一種優化編碼方式, 這種編碼和 raw 編碼一樣, 都使用 redisObject 結構和 sdshdr 結構來表示字符串對象, 但 raw 編碼會調用兩次內存分配函數來分別創建 redisObject 結構和 sdshdr 結構, 而 embstr 編碼則通過調用一次內存分配函數來分配一塊連續的空間, 空間中依次包含 redisObject 和 sdshdr 兩個結構, 如圖 8-3 所示。
embstr 編碼的字符串對象在執行命令時, 產生的效果和 raw 編碼的字符串對象執行命令時產生的效果是相同的, 但使用 embstr 編碼的字符串對象來保存短字符串值有以下好處:
- embstr 編碼將創建字符串對象所需的內存分配次數從 raw 編碼的兩次降低為一次。
- 釋放 embstr 編碼的字符串對象只需要調用一次內存釋放函數, 而釋放 raw 編碼的字符串對象需要調用兩次內存釋放函數。
- 因為 embstr 編碼的字符串對象的所有數據都保存在一塊連續的內存里面, 所以這種編碼的字符串對象比起 raw 編碼的字符串對象能夠更好地利用緩存帶來的優勢。
表 8-6 總結并列出了字符串對象保存各種不同類型的值所使用的編碼方式。
表 8-6 字符串對象保存各類型值的編碼方式
值 | 編碼 |
---|---|
可以用 long 類型保存的整數。 | int |
可以用 long double 類型保存的浮點數。 | embstr 或者 raw |
字符串值, 或者因為長度太大而沒辦法用 long 類型表示的整數, 又或者因為長度太大而沒辦法用 long double 類型表示的浮點數。 | embstr 或者 raw |
編碼的轉換
int 編碼的字符串對象和 embstr 編碼的字符串對象在條件滿足的情況下, 會被轉換為 raw 編碼的字符串對象。
對于 int 編碼的字符串對象來說, 如果我們向對象執行了一些命令, 使得這個對象保存的不再是整數值, 而是一個字符串值, 那么字符串對象的編碼將從 int 變為 raw 。
在下面的示例中, 我們通過 APPEND 命令, 向一個保存整數值的字符串對象追加了一個字符串值, 因為追加操作只能對字符串值執行, 所以程序會先將之前保存的整數值 10086 轉換為字符串值 "10086" , 然后再執行追加操作, 操作的執行結果就是一個 raw 編碼的、保存了字符串值的字符串對象:
redis> SET number 10086
OK
redis> OBJECT ENCODING number
"int"
redis> APPEND number " is a good number!"
(integer) 23
redis> GET number
"10086 is a good number!"
redis> OBJECT ENCODING number
"raw"
另外, 因為 Redis 沒有為 embstr 編碼的字符串對象編寫任何相應的修改程序 (只有 int 編碼的字符串對象和 raw 編碼的字符串對象有這些程序), 所以 embstr 編碼的字符串對象實際上是只讀的: 當我們對 embstr 編碼的字符串對象執行任何修改命令時, 程序會先將對象的編碼從 embstr 轉換成 raw , 然后再執行修改命令; 因為這個原因, embstr 編碼的字符串對象在執行修改命令之后, 總會變成一個 raw 編碼的字符串對象。
以下代碼展示了一個 embstr 編碼的字符串對象在執行 APPEND 命令之后, 對象的編碼從 embstr 變為 raw 的例子:
redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"
redis> APPEND msg " again!"
(integer) 18
redis> OBJECT ENCODING msg
"raw"