redis 系列——1、redis 字符串原理

概述

redis 作爲目前市面上應用最廣泛的 key-value 非關係型數據庫經常在項目中使用,它的高性能以及線程安全等優勢可以在很多場景中大放異彩。從本篇開始,我將通過一個系列的博客系統的整理 redis 相關的知識。本篇先從它的基礎類型開始,簡單介紹下 redis 字符串類型原理


redis 數據類型

redis 有以下五種常用的數據類型:

  • String:字符串類型
  • Hash:哈希類型
  • list:鏈表類型
  • set:集合類型
  • zSet:有序集合類型

1、String 字符串類型

redis 是使用C語言開發的,但是它的字符串沒有使用C語言原生的字符數組,而是構建了一種名 簡單動態字符串(simple dynamic String)的抽象類型,並且將 SDS 作爲 redis 默認的字符串表示。

舉個簡單的例子,當我們在 redis 客戶端執行以下命令:

redis> SET msg "hello world"
OK

那麼就可以在 redis 數據庫中保存一個鍵值對,其中:

  • 鍵值對的鍵是一個字符串對象,對應底層保存一個 SDS 類型的 “msg”
  • 鍵值對的值也是一個字符串對象,對應底層保存一個 SDS 類型的 “hello world”

這裏的 “OK” 是 redis 服務端添加成功後的返回值,提示客戶端添加成功。

需要注意的一點是,redis 中不是任何字符串都是使用 SDS 類型,對於一些不會改變的,如日誌類型的數據,還是採用C語言原生字符數組實現,示例如下:

redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye...");

1-1、SDS 原理

有了上面的介紹,下面我們來看看 SDS 的實現:

struct sdshdr {
    // 記錄buf數組中已使用字節的數量
    // 等於SDS所保存字符串的長度
    int len;
    // 記錄buf數組中未使用字節的數量
    int free;
    // 字符數組,用於保存字符串
    char buf[];
};

每個 SDS 字符串都通過如上一個 結構體 保存,下面我們看一個具體的示例:
SDS 示例

  • free 屬性的值等於0,表示數組中所有字節都被佔用
  • len 屬性的值等於5,表示保存的字符串長度等於5
  • buf 屬性是一個 char 類型的數組,數組的前 len 位保存了具體的字符串屬性值,最後一位保存 ‘\0’ 空字符

SDS 遵循C語言字符數組以空字符結束的慣例。其中爲空字符串分配額外的空間,保存空字符串到數組末尾等操作都是 SDS 函數內部實現好的,整個過程對用戶而言是無感知的。這樣做的好處是:SDS 可以直接複用一部分C語言字符串函數。舉個例子:

printf("%s", sds->buf);

當我們執行上述代碼時,會打印 “Redis”,而不是 “Redis\0”。因爲C語言原生字符串就是以空字符結尾的,SDS 無須在單獨編寫輸出代碼。


1-2、SDS 相比字符數組的優勢

有了上面的介紹,我們再來看看 SDS 相比字符數組有哪些優勢:

  • 常數複雜度獲取字符串長度
  • 杜絕緩衝區溢出
  • 減少修改字符串時所帶來的內存重分配次數
  • 二進制安全

1-2-1、常數複雜度獲取字符串長度

C語言字符數組本身是沒有保存字符串長度的,當我們獲取字符長度時,需要遍歷整個數組,直到掃描到 ‘\0’ 位停止。也就是說,如果 redis 使用字符數組來保存數據,那麼獲取字符串長度的時間複雜度爲 O(n),但如果使用 SDS 的話,因爲結構體自身屬性保含字符串長度,因此它的時間複雜度只有 O(1)。

其中需要說明的一點是:SDS 中 len 屬性的更新都是在 SDS API 執行過程中自動實現的,無須用戶手動修改該屬性值。

SDS 通過這種空間換時間的設計模式,使字符串長度的獲取不再成爲性能瓶頸。


1-2-2、杜絕緩衝區溢出

緩衝區溢出 也是由原生字符數組不保存長度所導致的。具體我們看示例:
緩衝區溢出
如上圖所示,存在字符串S1 和 S2,他們在物理地址上暫時是 連續的。如果此時我們調用如下方法:

strcat(s1, " Cluster"); 

在 s1 的字符串末尾連接 “Cluster” 字符串時,就可能導致以下結果:
溢出
字符串 s1 的數據溢出到了 s2 的內存地址,導致 s2 的數據被意外修改。

與C字符串不同,SDS 結構體則完全杜絕了緩衝區溢出的可能性:當我們調用函數修改 SDS 字符數組時,SDS API 首先會判斷內存是否夠用。如果不夠用的話,SDS 首先會擴展字符串的長度,然後再進行相關操作。整個擴展過程對外也是透明的,用戶無須手動操作。


1-2-3、減少修改字符串時所帶來的內存重分配次數

也正是因爲C語言字符數組本身不記錄字符串長度,因此對於一個包含N個字符數組的C字符串來說,這個字符串的長度總是一個N+1字符長的數組。因爲C字符數組和底層數組之間的關聯性關係,每次我們擴容或者縮短時,程序都需要爲這個字符數組執行一次內存重分配操作。

  • 如果程序執行增長字符串的操作,如連接字符串,那麼在執行函數前需要爲字符數組分配更大的內存數,否則就會產生緩衝區溢出
  • 如果程序執行減少字符串的長度,如截斷字符串,那麼在函數執行後需要爲字符數組重新分配來釋放不被使用的內存,如果忘記這一步就會產生內存泄露

由於內存重分配涉及很複雜的算法,並且分配過程中可能需要執行 系統調用,這對於非常重視性能的 redis 數據庫來說可能產生性能瓶頸。

爲了避免由於頻繁執行內存重分配所帶來的性能問題,redis 自身做了如下優化:

空間預分配:空間預分配用於優化字符串增長操作,當 SDS 需要擴容時,程序不僅會爲 SDS 分配修改必要的長度,還會爲 SDS 分配額外的長度。具體的分配規則如下:

  1. 修改之後,如果 SDS 的長度小於 1MB,那麼在原來的基礎上預分配和修改後長度相同的內存空間。假如修改之後,SDS的長度變爲13,此時需要爲 SDS 額外再分配13字節的長度,也就是 free 屬性和 len 屬性都等於13
  2. 修改之後,如果 SDS 的長度大於 1MB,那麼在原來的基礎上直接預分配 1MB空間。假如修改之後,SDS 的長度爲10MB,此時需要爲 SDS 額外再預分配1MB的長度。

需要注意的一點是,這裏的如果判斷都是根據計算得來,此時還沒有真正分配內存,真正分配內存都是在分配規則計算完畢後。SDS 通過這種預分配內存的方式,減少後續執行修改字符串長度時的內存重分配次數,以這種以空間換時間的方式提高效率。

惰性空間釋放:惰性空間釋放用於優化字符串減少操作,當 SDS 需要縮容時,程序不會立即執行內存重分配回收暫時沒有使用的內存,而是通過 free 屬性把它們先保存起來,以便後面再使用。

SDS 通過這種惰性空間釋放的方式,減少了內存重分配的次數,總得來說還是借鑑了以空間換時間的思想。需要明確的一點是:SDS 提供了相應的 API 回收內存,當我們真正需要釋放內存空間時,完全不用擔心惰性空間釋放會浪費內存資源。


1-2-4、二進制安全

由於C字符數組的種種限制,如必須符合ASCII規範、末尾必須以空字符串結尾,字符數組中不能包含空字符等,導致它只能保存文本數據,不能保存圖片、音頻、視頻等二進制數據。

爲了使 redis 滿足各種業務場景,SDS 的 API 都是二進制安全的。也就是說 SDS 不會對字符串的格式有任何限制,數據在寫入時是什麼樣子,在讀取時就是什麼樣子。例如當我們使用 SDS 保存特殊字符串時,在讀取過程中不會因爲空字符串而停止,必須讀取 len 長度的字符串纔會結束。

總結以下,redis 不是用來保存字符的,而是用來保存二進制數據的。這也是有時候 redis 被稱爲 字節數組 的主要原因。


1-2-5、兼容部分C字符串函數

雖然 SDS 的 API 都是二進制安全的,但是它還是保留了部分C字符數組的規範:SDS 的 buf 字符數組仍然以空字符結束,系統會爲它多分配一字節的空間,在字符串末尾保存空字符。

正是因爲這些規範,redis 可以直接調用部分 <String.h> 庫函數。這樣 SDS 就不需要重新實現部分已存在的函數。


1-3、SDS API

下面我通過表格的形式列舉出 redis 一些常用的 API 方法:
SDS API


參考:
《redis設計與實現》黃健宏著
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章