簡單動態字符串-redis設計與實現

簡單動態字符串

Sds (Simple Dynamic String,簡單動態字符串)是 Redis 底層所使用的字符串表示, 幾乎全部的 Redis 模塊中都用了 sds。html

本章將對 sds 的實現、性能和功能等方面進行介紹, 並說明 Redis 使用 sds 而不是傳統 C 字符串的緣由。python

sds 的用途

Sds 在 Redis 中的主要做用有如下兩個:redis

  1. 實現字符串對象(StringObject);
  2. 在 Redis 程序內部用做 char* 類型的替代品;

如下兩個小節分別對這兩種用途進行介紹。算法

實現字符串對象

Redis 是一個鍵值對數據庫(key-value DB), 數據庫的值能夠是字符串、集合、列表等多種類型的對象, 而數據庫的鍵則老是字符串對象。sql

對於那些包含字符串值的字符串對象來講, 每一個字符串對象都包含一個 sds 值。數據庫

「包含字符串值的字符串對象」,這種說法初聽上去可能會有點奇怪, 可是在 Redis 中, 一個字符串對象除了能夠保存字符串值以外, 還能夠保存 long 類型的值, 因此爲了嚴謹起見, 這裏須要強調一下: 當字符串對象保存的是字符串時, 它包含的纔是 sds 值, 不然的話, 它就是一個 long 類型的值。api

舉個例子, 如下命令建立了一個新的數據庫鍵值對, 這個鍵值對的鍵和值都是字符串對象, 它們都包含一個 sds 值:數組

redis> SET book "Mastering C++ in 21 days" OK redis> GET book "Mastering C++ in 21 days" 

如下命令建立了另外一個鍵值對, 它的鍵是字符串對象, 而值則是一個集合對象:緩存

redis> SADD nosql "Redis" "MongoDB" "Neo4j" (integer) 3 redis> SMEMBERS nosql 1) "Neo4j" 2) "Redis" 3) "MongoDB" 

用 sds 取代 C 默認的 char* 類型

由於 char* 類型的功能單一, 抽象層次低, 而且不能高效地支持一些 Redis 經常使用的操做(好比追加操做和長度計算操做), 因此在 Redis 程序內部, 絕大部分狀況下都會使用 sds 而不是 char* 來表示字符串。安全

性能問題在稍後介紹 sds 定義的時候就會說到, 由於咱們尚未了解過 Redis 的其餘功能模塊, 因此也沒辦法詳細地舉例說那裏用到了 sds , 不過在後面的章節中, 咱們會常常看到其餘模塊(幾乎每個)都用到了 sds 類型值。

目前來講, 只要記住這個事實便可: 在 Redis 中, 客戶端傳入服務器的協議內容、 aof 緩存、 返回給客戶端的回覆, 等等, 這些重要的內容都是由 sds 類型來保存的。

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 { // buf 已佔用長度 int len; // buf 剩餘可用長度 int free; // 實際保存字符串數據的地方 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 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 中, SDS_MAX_PREALLOC 的值爲 1024 1024 , 也就是說, 當大小小於 1MB 的字符串執行追加操做時,sdsMakeRoomFor 就爲它們分配多於所需大小一倍的空間; 當字符串的大小大於 1MB , 那麼 sdsMakeRoomFor 就爲它們額外多分配 1MB 的空間。

這種分配策略會浪費內存嗎?

執行過 APPEND 命令的字符串會帶有額外的預分配空間, 這些預分配空間不會被釋放, 除非該字符串所對應的鍵被刪除, 或者等到關閉 Redis 以後, 再次啓動時從新載入的字符串對象將不會有預分配空間。

由於執行 APPEND 命令的字符串鍵數量一般並很少, 佔用內存的體積一般也不大, 因此這通常並不算什麼問題。

另外一方面, 若是執行 APPEND 操做的鍵不少, 而字符串的體積又很大的話, 那可能就須要修改 Redis 服務器, 讓它定時釋放一些字符串鍵的預分配空間, 從而更有效地使用內存。

sds 模塊的 API

sds 模塊基於 sds 類型和 sdshdr 結構提供瞭如下 API :

函數 做用 算法複雜度
sdsnewlen 建立一個指定長度的 sds ,接受一個 C 字符串做爲初始化值 O(N)O(N)
sdsempty 建立一個只包含空白字符串 "" 的 sds O(1)O(1)
sdsnew 根據給定 C 字符串,建立一個相應的 sds O(N)O(N)
sdsdup 複製給定 sds O(N)O(N)
sdsfree 釋放給定 sds O(N)O(N)
sdsupdatelen 更新給定 sds 所對應 sdshdr 結構的 free 和 len O(N)O(N)
sdsclear 清除給定 sds 的內容,將它初始化爲 "" O(1)O(1)
sdsMakeRoomFor 對 sds 所對應 sdshdr 結構的 buf 進行擴展 O(N)O(N)
sdsRemoveFreeSpace 在不改動 buf 的狀況下,將 buf 內多餘的空間釋放出去 O(N)O(N)
sdsAllocSize 計算給定 sds 的 buf 所佔用的內存總數 O(1)O(1)
sdsIncrLen 對 sds 的 buf 的右端進行擴展(expand)或修剪(trim) O(1)O(1)
sdsgrowzero 將給定 sds 的 buf 擴展至指定長度,無內容的部分用 \0 來填充 O(N)O(N)
sdscatlen 按給定長度對 sds 進行擴展,並將一個 C 字符串追加到 sds 的末尾 O(N)O(N)
sdscat 將一個 C 字符串追加到 sds 末尾 O(N)O(N)
sdscatsds 將一個 sds 追加到另外一個 sds 末尾 O(N)O(N)
sdscpylen 將一個 C 字符串的部份內容複製到另外一個 sds 中,須要時對 sds 進行擴展 O(N)O(N)
sdscpy 將一個 C 字符串複製到 sds O(N)O(N)

sds 還有另外一部分功能性函數, 好比 sdstolower 、 sdstrim 、 sdscmp , 等等, 基本都是標準 C 字符串庫函數的 sds 版本, 這裏不一一列舉了。

小結

  • Redis 的字符串表示爲 sds ,而不是 C 字符串(以 \0 結尾的 char*)。
  • 對比 C 字符串, sds 有如下特性:
    • 能夠高效地執行長度計算(strlen);
    • 能夠高效地執行追加操做(append);
    • 二進制安全;
  • sds 會爲追加操做進行優化:加快追加操做的速度,並下降內存分配的次數,代價是多佔用了一些內存,並且這些內存不會被主動釋放。
相關文章
相關標籤/搜索