redis是我們使用非常多的一種緩存技術,他的性能極高,讀的速度是110000次/s,寫的速度是81000次/s。這么高的性能背后,到底是怎么樣的實現在支撐,這個系列的文章,我們一起去看看。
redis的底層數據結構有以下7種,包括簡單動態字符串(SDS),鏈表、字典、跳躍表、整數集合、壓縮列表、對象。今天我們一起看下簡單動態字符串(simple dynamic string),后面的文章以SDS簡稱。
SDS簡介
Redis沒有直接使用C語言傳統的字符串表示(以空字符結尾的字符串數組,以下簡稱C字符串)。C字符串並不能滿足redis對字符串安全性、效率以及功能的要求,所以Ridis自定義SDS抽象類型。
Redis中,C字符串只會作為字符串字面量(string literal)用在一些無須對字符串值進行修改的地方,比如打印日志。
在redis數據庫里,包含字符串值的鍵值對在底層都是由SDS實現的。除了用來保存數據庫中的字符串值之外,sds還被用來作緩沖區(buffer):AOF(一種持久化策略)模塊中的AOF緩沖區,以及客戶端狀態中的輸入緩沖區,都是由SDS實現的。
至於SDS的具體好處,我們會在后文有詳細的論述。
SDS定義
struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used 記錄buff數組中已使用字節的數量 */ uint64_t free; /* 記錄未使用字節數量*/ char buf[]; };
下圖展示了sds示例
SDS遵循C字符串以空字符結尾的慣例,保存空字符的1字節空間不計算在SDS的len屬性里面,並且為空字符分配額外的1字節空間,以及添加空字符到字符串末尾等操作,都是有SDS函數自動完成的,所以這個空字符對於SDS的使用者來說完全透明。遵循空字符結尾這一慣例的好處是,SDS可以直接重用一部分C字符串函數庫里面的函數。
常數復雜度獲取字符串長度
因為C字符串並不記錄自身的長度信息,所以為了獲取一個C字符串的長度,程序必須遍歷整個字符串,復雜度為O(N)。
和C字符串不同,因為SDS在len屬性中記錄了SDS本身的長度,所以獲取一個SDS長度的復雜度僅為O(1)。
通過使用SDS而不是C字符串,Redis將獲取字符串長度所需的復雜度從O(N)降低到了O(1),這確保了獲取字符串長度的工作不會成為Redis的性能瓶頸。這樣即使我們隊一個非常長的字符串鍵反復執行StrLen命令,也不會對系統性能造成任何影響。
杜絕緩沖區溢出
除了獲取字符串長度的復雜度高之外,C字符串不記錄自身長度帶來的另一個問題是容易造成緩沖區溢出(buffer overflow)。舉個例子,<string.h>/strcat函數可以將src字符串中的內容拼接到dest字符串末尾。char *strcat( char * dest, const char *src);
因為C字符串不記錄自身的長度,所以strcat嘉定用戶在執行這個函數時,已經為dest分配了足夠多的內存,但是一旦假定不成立,就會產生緩沖溢出(導致其他內存空間的數據被意外修改)。
與C字符串不同,SDS空間分配策略完全杜絕了發送緩沖區溢出的可能性:當SDS API需要對SDS進行修改時,API會先檢查SDS的空間是否滿足修改所需的要求,如果不滿足,API會自動將SDS的空間擴展至執行修改所需的大小,然后才執行實際的修改操作,所以使用SDS既不需要手動修改SDS空間大小,也不會出現前面所說的緩沖區溢出問題。
減少修改字符串時帶來的內存重分配次數
因為C字符串並不記錄自身長度,所以對於一個包含了N個字符串的C字符串來說,這個C字符串的底層實現總是一個N+1個字符長的數組(額外的一個用來保存空字符)。因為C字符串的長度和底層數組的長度之間存在着這種關聯性,所以每次增長或縮短C字符串,程序都總要對保存這個C字符串的數組進行一次內存重分配操作:因為內存重分配涉及復雜的算法,並且可能需要執行系統系統調用,所以它通常是一個比較耗時的操作,redis作為數據庫,經常被用於速度要求苛刻、數據頻繁修改的場合,如果每次修改字符串都需要執行一次內存重分配,那么效率會相當低。
為避免C字符串這種缺陷,SDS通過未使用空間解除了字符串長度和底層數組長度之間的關聯:在SDS中,buf數組的長度不一定就是字符串數量加一,數組里面可以包含未使用的字節,而這些字節的數量就是由SDS的free屬性記錄。通過未使用空間,SDS實現了空間預分配和惰性空間釋放兩種優化策略。
1、空間預分配
空間預分配用於優化SDS的字符串增長操作:當SDS的API對一個SDS進行修改,並且需要對SDS進行空間擴展的時候,程序不僅會為SDS分配分配修改所必須要的空間,還會為SDS分配額外的未使用空間(具體分配多少空間有一個特殊的算法,這里不做深入討論。)
在擴展SDS空間之前,SDS Api會先檢查未使用空間是否足夠,如果足夠的話,api會直接使用未使用空間,而無需執行內存分配。
通過這種預分配策略,SDS將連續增長N次字符串所需的內存重分配次數從必定N次降低為最多N次。
2、惰性空間釋放
惰性空間釋放用於優化SDS的字符串縮短操作:當SDS的ApI需要縮短SDS保存的字符串時,程序並不立即使用內存重分配來回收縮短后多出來的字節,而是使用free屬性見這些字節的數量記錄起來,並等待將來使用。
通過惰性空間釋放策略,SDS避免了縮短字符串時所需的內存重分配操作,並為將來可能有的增長操作提供了優化。
與此同時,SDS也提供了相應的API,讓我們可以在有需要時,真正地釋放SDS的未使用空間,所以不用擔心惰性空間釋放策略會造成內存浪費。
二進制安全
C字符串中的字符必須符合某種編碼(比如ASCII),並且除了字符串的末尾之外,字符串里面不能包含空字符,否則最先被程序讀入的空字符江北誤認為是字符串結尾,這些限制使得C字符串只能保存文本數據,而不能保存像圖片、音頻、視頻、壓縮文件這樣的二進制數據。
雖然數據庫一般用於保存文本數據,但使用數據庫來保存二進制數據的場景也不少見,因此,為了確保Redis可以適用於各種不同的使用場景,SDS的API都是二進制安全。
這也是我們將SDS的buf屬性稱為字節數組的原因—Redis不是用這個數組來保存字符,而是用它來保存一系列二進制數據。
兼容部分C字符串函數
雖然SDS的API都是二進制安全的,但它們一樣遵循C字符串以空字符結尾的慣例:這些API總會將SDS保存的數據末尾設置為空字符,並且總會在為BUF數組分配空間時多分配一個字節來容納這個空字符,這是為了讓那些保存文本數據的SDS可以重用一個部分<string.h>庫定義的函數。
比如我們可以重用<string.h>/strcasecmp函數,使用它來對比SDS保存的字符串和另一個C字符串。
strcasecmp(sds->buf,”hello world”);
這樣redis就不用自己專門去寫一個函數來對比SDS和C字符串的值了。
SDS API
參考資料:
redis設計與實現(第二版)
Redis in action