Redis字符串的底層實現SDS

【引子】

Redis沒有直接使用C語言傳統的字符串表示,而是自己構建了一種名爲簡單動態字符串(Simple Dynamic String,SDS)的抽象類型,並將SDS用作Redis的默認字符串表示。

在Redis裏面,包含字符串值的鍵值對在底層都是由SDS實現的,比如:

set moremoney programmer

那麼Redis將在數據庫中創建一個新的鍵值對,其中:

  • 該鍵值對的鍵moremoney是一個字符串對象,底層實現是一個保存了字符串“moremoney”的SDS
  • 該鍵值對的值programmer也是一個字符串對象,底層實現是一個保存了字符串“programmer”的SDS

除了以上用處,SDS還被用作AOF模塊中的AOF緩衝區,以及客戶端狀態中的輸入緩衝區等。

【SDS的定義】

SDS是通過C語言來實現的,我那會兒讀大學時,學校最先開始教的是C語言,所以我對C語言也還算有點熟悉。

每個sds.h/sdshdr結構表示一個SDS值(爲什麼這麼設計後續會分析):

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

簡單來說:

  • 當free屬性的值爲0,則表示這個SDS沒有分配任何未使用的空間;反之,則分配了free個字節的未使用的空間;
  • 字符串的長度爲len
  • buf屬性是一個char類型的數組,數組的前面保存了該字符串的每個字符,最後一個字節則保存了一個空字符’\0’

SDS遵循了C字符串以空字符結尾的慣例,保存空字符的1字節不會計算在len屬性裏面(也就是說保存“hello world”的字符串的SDS的len爲11),並且爲空字符分配的這個額外的1字節的空間,以及添加空字符到字符串末尾等操作,都是由SDS函數自動完成的,也就是說,這個空字符對於SDS的使用者或者Redis的使用者來說是完全透明的,這也體現了封裝的思想,對外隱藏實現的細節,簡化外部使用者。

SDS的設計者遵循空字符結尾的慣例的一個明顯的好處,就是SDS可以直接重用一部分C字符串函數庫裏面的函數,比如打印字符串。

【SDS與C字符串的區別】

根據傳統,C語言使用長度爲N+1的字符數組來表示長度爲N的字符串,因爲最後一個元素是空字符’\0’,表示C字符串的結尾。

C語言使用的這種實現方式,並不能滿足Redis對字符串在安全性、效率,以及功能方面的要求。

獲取字符串長度

因爲C字符串並沒有記錄字符串的長度信息,所以在獲取C字符串長度的時候,必須遍歷整個C字符串,遍歷到的每個字符都要做計數+1,直到遇到代表字符串結尾的空字符’\0’爲止(不計算進去),這個操作的時間複雜度就是O(N),N爲字符串長度。

SDS就不同了,由於SDS直接存儲了字符串的長度len,想要獲取一個用SDS表示的字符串的長度,只要讀取len屬性的值就行了,即時間複雜度僅爲O(1)。

而且,無需使用者操心的是:設置和更新SDS的len屬性的工作,都是由操作SDS的API在執行的時候自動完成的。

通過使用SDS,Redis將獲取字符串長度所需的時間,從C字符串的O(N)降低到了O(1),這確保了獲取字符串長度的工作不會成爲Redis的瓶頸。

杜絕緩衝區溢出

不記錄C字符串的長度,容易造成緩衝區溢出(buffer overflow)。比如通過C函數strcat,將src字符串中的內容拼接到dest字符串的末尾:

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

由於C字符串不記錄自身的長度,如果用戶在執行strcat函數時,沒有給dest分配足夠的內容來容納src,就會產生緩衝區溢出。

SDS的空間分配策略,能夠完全杜絕緩衝區溢出的發生:當SDS API需要對SDS進行修改時,API會先檢查SDS的空間是否滿足修改的需求,若不滿足,則API會自動將SDS的空間擴展以至於能夠放下src。

修改SDS字符串N次,真的會像C字符串那樣,一定做N次內存重分配嗎?

我們從前面的文章瞭解到“C字符串不記錄自身的長度”,通過最後一個空字符’\0’來表示字符串結束。所以每次拉長或縮短C字符串,程序總要對保存這個C字符串的數組進行一次內存重分配:

  • 拉長:程序需要先通過內存重分配來擴展底層數組的空間,如果忘了這一步就會產生緩衝區溢出
  • 縮短:程序需要先通過內存重分配來釋放字符串不再使用的那部分空間,如果忘了這一步就會產生內存泄漏

由於涉及複雜的算法,且可能需要執行系統調用,所以相對來說,內存重分配是一個比較耗時的操作。Redis作爲一個對性能有極致要求的數據庫,如果這種操作頻繁的發生,可能就會造成一些瓶頸了。

爲了避免C字符串的這種缺陷,SDS的設計者相處了一個巧妙的辦法:通過未使用空間來解除字符串長度和底層數組長度之間的關聯。在SDS中,buf數組的長度不一定就是字符數量+1(+1是最後還是存了空字符’\0’),宿主裏面可以包含未使用的字節,這些未使用的字節數量就是free的值。

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

  • 空間預分配:當API對一個SDS進行修改,並且需要對SDS空間進行擴展,程序不僅會爲SDS分配修改所必須的空間,還會額外分配“未使用空間”,額外分配策略有2種:

    1. 如果對SDS進行修改之後,len小於1MB,那麼程序分配和len一樣大小的未使用空間
    2. 如果對SDS進行修改之後,len大於1MB,那麼程序分配1MB的未使用空間

    空間預分配的好處是,減少連續執行字符串增長操作所需的內存重分配次數:在擴展SDS空間之前,SDS API會先檢查未使用空間是否足夠,如果足夠的話,API就會直接使用未使用空間,而不會執行內存重分配。

  • 惰性空間釋放:當需要縮短SDS保存的字符串時,程序並不立即使用內存重分配來回收縮短後多出來的字節,而是使用free屬性來將這些字節記錄起來,可供將來擴展使用,相當於優化了將來可能有的增長操作,因爲空出來了字節,那麼能夠容納增長的字符的話就不需要再內存重分配了。

可能你會擔心,惰性空間釋放這個特性,會造成內存泄漏,其實SDS提供了相關API,讓我們在有需要的時候,真正的釋放SDS的未使用空間。

二進制安全

之前有篇文章有提到“二進制安全是什麼?”,這裏不再詳述,簡單來說就是C字符串存在二進制安全的問題,它職能保存文本數據,而不能保存像圖片、音頻、視頻壓縮文件等二進制數據,因爲遇到’\0’就是一個結束了的C字符串。

而SDS的API都是二進制安全的,所有API以處理二進制的方式來處理SDS存放的buf數組裏的數據,程序不會對其中的數據做任何限制、過濾、或者轉義,數據在寫入時是怎麼樣的,被讀取時就是什麼樣。這也是將SDS的buf數組叫做字節數組的原因——Redis不是用這個數組來保存字符,而使用它來直接保存二進制數據。

所以,Redis不僅可以保存文本,還可以保存任意的二進制數據。非常強大。

兼容部分C字符串函數

前面提到過,SDS遵循了C字符串以空字符’\0’結尾的慣例,並且SDS的API自動維護了這個空字符,所以SDS可以重用一部分<string.h>庫定義的函數。這樣Redis就不用重複去造輪子了。

--------貓瑪尼分割線--------

公衆號:貓瑪尼

博客:https://blog.moremoney.ink/

CSDN:https://blog.csdn.net/luoyanjiewade

知乎:https://www.zhihu.com/people/luo-yan-jie-70/activities

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章