深入理解Redis 數據結構—簡單動態字符串sds

  • 2021 年 11 月 29 日
  • 筆記

Redis是用ANSI C語言編寫的,它是一個高性能的key-value數據庫,它可以作用在數據庫、緩存和消息中間件。其中 Redis 鍵值對中的鍵都是 string 類型,而鍵值對中的值也是有 string 類型,在 Redis 中 string 類型運用還是很廣泛的。本文主要介紹 string 的數據結構—— 簡單動態字符串(Simple Dynamic String) 簡稱sds。

sds 實現

sds 的數據結構:

struct sdshdr {

     //buf 已佔用的長度
     int len;

     // buf 剩餘的可用的長度
     int free;
   
     // 保存字符串數據的地方
     char buf[]; 
}

結構 sdshdr 保存了 len、free 和 buf 三個屬性,分別記錄字符的已使用的長度,未使用的長度,以及實際保存字符串的數組。
以下是一個新建的,保存 hello world 字符串的 sdshdr 結構:

struct sdshdr {
    len = 5;
    free = 0;
    buf = "hello\0"; 
}
  • free 屬性值為0,表示這個sds沒有分配未使用的空間。
  • len 屬性值為5,表示這個sds保存了一個五位元組長的字符串。
  • buf 屬性是一個 char 類型的數組,數組的前五個位元組分別保存了 ‘h’、’e’、’l’、’l’、’o’ 五個字符,而最後一個位元組保存了空字符’\0’。

sds 遵守 C 字符串以空字符串結尾的慣例,保存的空字符串一個位元組空間不計算在 sds 的 len 屬性裏面。添加空字符串到字符串末尾等操作,都是由 sds 函數自動完成的,所以這個空字符對於使用者來說完全是透明的。

通過 len 屬性,可以實現時間複雜度 O(1) 的長度計算。另外通過對 buf 分配一些額外的空間,並使用 free 記錄未使用空間的長度,sdshdr 可以減少內存的重新分配。這是 sds 相對 c 字符串的一個優勢。

為何 Redis 不用 C 語言表示字符串

Redis 是使用 C 語言開發的,而在使用最多的字符串上,Redis 沒有使用 C 語言傳統的字符串表示,而且使用自己構建的簡單動態字符串(sds)
在 C 語言中,字符串可以用一個 \0 結尾的 char 數組表示。比如 hello world 在 C 語言中就可以表示為”hello world\0″。數組一般初始化以後長度就已經固定了,不能支持字符串追加append長度計算操作:

  • 每次計算字符串長度都要遍歷一遍數組,所以時間複雜度是O(N)
  • 對字符串每次進行追加操作,需要對字符串進行一次內存分配

sds 優化追加字符操作

Redis 作為數據庫,對於查詢速度要求嚴格,數據修改也比較頻繁,如果每次修改字符串都需要執行一次內存分配的話,都會佔用大量的時間。所以 Redis 選擇了 sds 而不是 C 字符串,sds 可以減少追加字符的內存分配。通過舉例來說明,執行以下操作時,sds 內部的變化:

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 = 17;
     free = 17;
     buf = "hello world again\0                ";
}

當調用 set 命令創建 sdshdr 時,Redis 沒有給 sdshdr 分配多餘的空間,free 屬性為0。而在執行 append 操作之後,Redis 為 buf 分配了多於所需空間一倍的大小。

在執行 append 命令之後,保存 “hello world again” 共需要17 + 1 個位元組,但是程序為 sdshdr 分配了 17 + 17 + 1 = 35 個位元組,而後續如果在對 sdshdr 進行追加操作,只要追加的長度不超過 free 屬性值,那麼就不需要對 buf 進行內存重分配。

比如執行以後命令並不會引起 buf 的內存重分配,因為新追加的字符串長度小於17:

redis> append msg  " again"
(integer) 23

對應的 sdshdr 結構如下:

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

redis 內存分配可以查看源碼 sds.s/sdsMakeRoomFor,sdsMakeRoomFor 函數描述了內存分配的策略,下面的該函數的偽代碼:

// sdshdr:追加前的字符
// addlen:追加字符串
sds sdsMakeRoomFor(sdshdr, addlen) {
   
    // 多餘空間大於追加空間,無序再分配內存,直接返回
    if (free >= addlen) return s;
    // 計算新字符的長度
    newlen = (len+addlen); 
   // 如果新字符的長度小於 SDS_MAX_PREALLOC,就分配兩倍新字符空間
  //  如果新字符的長度大於 SDS_MAX_PREALLOC,就分配新字符空間 + SDS_MAX_PREALLOC 空間
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    // 分配內存
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    // 更新 free 屬性
    newsh.free = newlen - len;
    return newsh;
}

而對於字符的縮短操作,Redis 保存縮短後的字符串,此時並不會進行內存重分配,而是使用 free 屬性記錄縮短的字符長度。

總結

Redis 的 string 類型為何使用sds而不是 C 字符串,因為sds有兩點優勢:

  • 計算字符長度,C 字符串複雜度O(n),而 sds 複雜度為 O(1)
  • 字符追加操作,C 字符串每次都需要對內存進行重分配,而 sds 每次會進行動態擴容,當添加字符小於空閑字符時,不會對內容進行分配,減少系統等待時間

參考

Redis 設計與實現

如果覺得文章對你有幫助的話,請點個推薦吧!