摘要
Redis不僅僅是一個key-value儲存,它更是一個資料結構服務,支援不同型別的值。這意味著在傳統的key-value儲存中,我們用string的key關聯string的value。而在Redis中,我們可以儲存的值不受限於string,我們還可以儲存複雜的資料結構。string是我們在使用Redis過程中能接觸到的最簡單的資料型別,也是Memcached中僅有的型別,因此對於Redis新手來說,首先選擇使用string型別是理所當然的。這篇文章主要介紹Redis的string型別的實現內幕。
初識:簡單動態字串
Redis中使用的字串是通過包裝的,基於c語言字元陣列實現的一個抽象資料結構,後文中提到的sds指的就是簡單動態字串,它的定義和實現在sds.h和sds.c中,結構是這樣的:
1 2 3 4 5 |
struct sdshdr { int len; int free; char buf[]; }; |
Redis中定義了這樣一個結構體來表示字串,欄位含義如下:
- len表示buf中儲存的字串的長度。
- free表示buf中空閒空間的長度。
- buf用於儲存字串內容。
舉個例子:
圖1
假設上面圖1是當前buf中儲存的內容,那麼這個時候len為8,free為2,sds的記憶體佔用量可以用下面公式表示:
1 |
sizeof(struct sdshdr) + len + free + 1 |
初識了sds之後,我們下面分別從使用字串的時候最關心的幾個點來繼續認識sds:
- 儲存內容
- 長度計算
- 字串拼接
- 字串截斷
儲存內容:二進位制安全字串
Redis keys是二進位制安全的,對於是不是二進位制安全,簡單理解就是對於字串結構,我們能不能用它來儲存二進位制。我們都知道傳統的C字串是zero-terminated的,也就是C語言字串函式庫認為字串是以’\0’結尾的,因此對於用來表示字串的C語言字元陣列中中間不能有’\0’,不然在處理的過程中會出錯,比如下面這段:
圖2
我們申請了length為9的char陣列,將每個字母都放到對應的位置,我們期望得到的是”Float Lu”這樣的字串,而實際C字串函式處理的過程中會以為這個字串是”Float”,而這並不是我們期望的結果。
而二進位制安全的字串,Redis中給的術語是binary-safe,它允許我們把圖2中表示的資料當做字串來使用,那這個二進位制有什麼關係呢,因為二進位制資料通常會有中間某個位元組儲存’\0’的這種情況,比如我們儲存一個JPEG格式圖片,因此二進位制安全的字串結構允許我們儲存像JPEG格式圖片的這種資料。
從而在Redis中我們不僅僅可以使用傳統字串來當做key,使用二進位制來作為key也是被允許的,比如圖片、視訊、音訊……whatever,然而你不要高興太早,Redis對key的長度是有限制的,最大長度是512MB。
長度計算:O(1)時間複雜度
c語言中strlen的實現
strlen在c語言中用來計算c語言字串的長度,strlen的實現很簡單,從記憶體中字串開始的位置開始掃描並計數,知道碰到第一個’\0’為止,這也是為什麼c語言字串是zero-terminated的原因。很顯然,strlen的時間複雜度是O(N)。
sds中sdslen的實現
sds中用於對字串長度計算的函式為sdslen,我們看一下它的實現:
1 2 3 4 |
static inline size_t sdslen(const sds s) { struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr))); return sh->len; } |
我們想要獲取的sds的長度就是sdshdr中定義的len的值,時間複雜度是O(1)。
字串拼接:動態擴容機制
通常情況我們對於字串的拼接不僅僅是一次,而是很多次,我們寫JAVA的通常很有感觸,比如我們要根據使用者名稱來拼接一個字串,又考慮到執行效率,我們通常會藉助於StringBuilder像下面這樣寫:
1 2 3 4 5 6 7 |
public String makeWelcomeStr(String username) { StringBuilder sb = new StringBuilder(); sb.append("welcome "); sb.append(username); sb.append("!"); return sb.toString(); } |
對於C語言來說我們並不能這麼瀟灑,我們需要先苦逼的申請一塊記憶體區域將”welcome “放入,當我們需要拼接username的時候,我們需要苦逼的再申請一塊記憶體,長度為原有內容長度加上username的長度,然後再將原有內容拷貝到新的記憶體區域,然後再放心的將username放入新的記憶體區域的後面……還有”!”沒有拼接呢,我天!
苦逼!
sds中我們不需要考慮拼接的時候要不要擴容,擴多少容等,這些sds都為我們做了,我們只需要簡單的呼叫sdscat即可(sds中用來拼接字串的函式是sdscat),sdscat的核心實現在sdscatlen和sdsMakeRoomFor中,假設我們正在拼接字串:
圖3
我的名字是”Float Lu”,我將它拼接在”welcome”後面,我不需要考慮buf的free長度是多少,能不能放下”Float Lu”,我們將要放的字串長度為8,看看sds是怎麼做的:
在拼接新的字串之前會檢查當前free是否夠用,如果當前的free空間大於等於8,則不需要申請記憶體,直接將字串放入,修改len和free。
如果空間不夠用,sds有一套擴容規則,接著上面的例子,老的內容長度為len=9,新的內容長度newlen=len+8,為16:
- 如果newlen小於1024(byte) * 1024(byte)=1(MB)則新的長度為二倍的newlen。
- 如果newlen的長度大於等於1MB,則新的newlen的長度為newlen的長度加上1MB。
(這讓我想起了Netty的記憶體擴容規則),接著上面的例子,擴容完之後的len為16,free為16,加上1位元組的’\0’。
這個時候我們再繼續拼接”!”的時候可以直接將”!”放入剛才申請多餘的記憶體區域內同時將len加1,將free減1即可。
sds通過預分配一些記憶體區域來減少記憶體申請,拷貝的次數,雖然預分配規則很簡單,但是是很有效的。
字串截斷:記憶體空間懶釋放
考慮到我們要清理字串中的一些內容,傳統的做法是新申請一塊記憶體區域,將需要保留的內容放入新的區域然後釋放原始區域,這其中必然會涉及記憶體的申請,拷貝。加入這個時候又有往剛才保留的字串後面拼接一個字串又要涉及一些重操作,比如記憶體申請,拷貝。。。
我們來看看sds是怎麼做的,在sds中提供了sdstrim這樣的一個方法,它的定義:
1 |
sds sdstrim(sds s, const char *cset) |
即清除s中所有在cset中出現過的字元,看一個例子:
1 2 3 |
s = sdsnew("AA...AA.aHelloWorld::"); s = sdstrim(s,"A. :"); printf("%s\n", s); |
結果是”Hello World”。
對於上面的情況,原來的len為21,假如free為0,清理完成之後不涉及記憶體的申請操作,len為10,free為11,加入這個時候有字串拼接需求,直接將內容放到free的11個位元組內即可,當然是如果放的下的話。
sds並不會立即釋放掉不需要的已經申請的記憶體,實際中,這些記憶體後續很可能還能會被用到,如果你擔心記憶體浪費的話,可以手動呼叫sds提供的介面釋放這些空間,比如sdsfree函式。
sds VS c語言字串
上面我們分別字串操作最常涉及到的一些問題認識了sds,最後我們通過將sds和c語言字串進行比較一下來總結sds的優缺點:
C語言 | sds |
佔用記憶體通常為內容長度 | 佔用記憶體包括結構體和free的長度 |
非二進位制安全 | 二進位制安全 |
長度計算時間複雜度為O(N) | 長度計算時間複雜度為O(1) |
需要掌握字串的長度 | sds幫助我們把握長度和記憶體申請 |
字串拼接每次要進行記憶體申請和拷貝 | 不一定內次都要申請記憶體和拷貝 |
總結
sds在Redis中作為字串基礎服務,為Redis的keys和其他涉及string操作的地方提供服務,sds的設計不僅考慮到api使用的安全性,更多的是為了提高效能,為高效能Redis奠定基礎。字串操作方面提高效能的核心點在於儘量減少記憶體的申請和記憶體拷貝,在設計的時候允許利用一定的記憶體空間換取時間效率。
參考文獻
《Redis Documentation》
《Redis2.8.13原始碼》
《Redis設計與實現》
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式