深挖 Redis 6.0 源碼—— SDS
本文精選自 Doocs 開源社區(qū)旗下“源碼獵人”項目,作者楊立濱。
項目將會持續(xù)更新,歡迎 Star 關(guān)注。
項目地址:https://github.com/doocs/source-code-hunter
SDS(Simple Dynamic Strings, 簡單動態(tài)字符串)是 Redis 的一種基本數(shù)據(jù)結(jié)構(gòu),主要是用于存儲字符串和整數(shù)。?這篇文章里,我們就來探討一下 Redis SDS 這種數(shù)據(jù)結(jié)構(gòu)的底層實現(xiàn)原理。
學習之前,首先我們要明確,Redis 是一個使用 C 語言編寫的鍵值對存儲系統(tǒng)。
前置思考
我們首先考慮一個問題,如何實現(xiàn)一個二進制安全的字符串?
在 C 語言中,\0?表示字符串結(jié)束,如果字符串中本身就包含?\0?字符,那么字符串就會在?\0?處被截斷,即非二進制安全;若通過使用一個 len 屬性,來判斷字符串是否結(jié)束,就可以保證讀寫字符串時不受到?\0?的影響,則是二進制安全。同時 len 屬性也能保證在 O(1) 時間內(nèi)獲取字符串的長度。
Redis 3.2 以前的 SDS 實現(xiàn)
在 Redis 3.2 版本以前,SDS 的結(jié)構(gòu)如下:
struct sdshdr {unsigned int len;unsigned int free;char buf[];};
其中,buf 表示數(shù)據(jù)空間,用于存儲字符串;len 表示 buf 中已占用的字節(jié)數(shù),也即字符串長度;free 表示 buf 中剩余可用字節(jié)數(shù)。
字段 len 和 free 各占 4 字節(jié),緊接著存放字符串。
這樣做有以下幾個好處:
?用單獨的變量 len 和 free,可以方便地獲取字符串長度和剩余空間;?內(nèi)容存儲在動態(tài)數(shù)組 buf 中,SDS 對上層暴露的指針指向 buf,而不是指向結(jié)構(gòu)體 SDS。因此,上層可以像讀取 C 字符串一樣讀取 SDS 的內(nèi)容,兼容 C 語言處理字符串的各種函數(shù),同時也能通過 buf 地址的偏移,方便地獲取其他變量;?讀寫字符串不依賴于?\0,保證二進制安全。
但其實以上的設計是存在一些問題的,對于不同長度的字符串,是否有必要使用 len 和 free 這 2 個 4 字節(jié)的變量?4 字節(jié)的 len,可表示的字符串長度為?2^32,而在實際應用中,存放于 Redis 中的字符串往往沒有這么長,因此,空間的使用上能否進一步壓縮?
那么接下來,我們就來看看最新的 Redis 是如何根據(jù)字符串的長度,使用不同的數(shù)據(jù)結(jié)構(gòu)進行存儲的。
Redis SDS 最新實現(xiàn)
在 Redis 3.2 版本之后(v3.2 - v6.0),Redis 將 SDS 劃分為 5 種類型:
?sdshdr5:長度小于 1 字節(jié)?sdshdr8:長度 1 字節(jié)?sdshdr16:長度 2 字節(jié)?sdshdr32:長度 4 字節(jié)?sdshdr64:長度 8 字節(jié)
Redis 增加了一個 flags 字段來標識類型,用一個字節(jié)(8 位)來存儲。
其中:前 3 位表示字符串的類型;剩余 5 位,可以用來存儲長度小于 32 的短字符串。
struct __attribute__ ((__packed__)) sdshdr5 {unsigned char flags; /* 前3位存儲類型,后5位存儲長度 */char buf[]; /* 動態(tài)數(shù)組,存放字符串 */};
而對于長度大于 31 的字符串,僅僅靠 flags 的后 5 位來存儲長度明顯是不夠的,需要用另外的變量來存儲。sdshdr8、sdshdr16、sdshdr32、sdshdr64 的數(shù)據(jù)結(jié)構(gòu)定義如下,其中 len 表示已使用的長度,alloc 表示總長度,buf 存儲實際內(nèi)容,而 flags 的前 3 位依然存儲類型,后 5 位則預留。
struct __attribute__ ((__packed__)) sdshdr8 {uint8_t len; /* 已使用長度,1字節(jié) */uint8_t alloc; /* 總長度,1字節(jié) */unsigned char flags; /* 前3位存儲類型,后5位預留 */char buf[];};struct __attribute__ ((__packed__)) sdshdr16 {uint16_t len; /* 已使用長度,2字節(jié) */uint16_t alloc; /* 總長度,2字節(jié) */unsigned char flags; /* 前3位存儲類型,后5位預留 */char buf[];};struct __attribute__ ((__packed__)) sdshdr32 {uint32_t len; /* 已使用長度,4字節(jié) */uint32_t alloc; /* 總長度,4字節(jié) */unsigned char flags; /* 前3位存儲類型,后5位預留 */char buf[];};struct __attribute__ ((__packed__)) sdshdr64 {uint64_t len; /* 已使用長度,8字節(jié) */uint64_t alloc; /* 總長度,8字節(jié) */unsigned char flags; /* 前3位存儲類型,后5位預留 */char buf[];};
注意,一般情況下,結(jié)構(gòu)體會按照所有變量大小的最小公倍數(shù)做字節(jié)對齊,而用?packed?修飾后,結(jié)構(gòu)體則變?yōu)?1 字節(jié)對齊。這樣做的好處有二:一是節(jié)省內(nèi)存,比如 sdshdr32 可節(jié)約 3 個字節(jié);二是?SDS 返回給上層的是指向 buf 的指針,此時按 1 字節(jié)對齊,所以可在創(chuàng)建 SDS 后,通過?(char*)sh+hdrlen?得到 buf 指針地址,也可以通過?buf[-1]?找到 flags。
以上,Redis 根據(jù)字符串長度的不同,選擇對應的數(shù)據(jù)結(jié)構(gòu)進行存儲。接下來,我們就來看看 Redis 字符串的相關(guān) API 實現(xiàn)。
SDS API 實現(xiàn)
1. 創(chuàng)建字符串
sds sdsnewlen(const void *init, size_t initlen) {void *sh;sds s;// 根據(jù)字符串長度計算相應類型char type = sdsReqType(initlen);// 如果創(chuàng)建的是""字符串,強轉(zhuǎn)為SDS_TYPE_8if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;// 根據(jù)類型計算頭部所需長度(頭部包含 len、alloc、flags)int hdrlen = sdsHdrSize(type);// 指向flags的指針unsigned char *fp;// 創(chuàng)建字符串,+1是因為 `\0` 結(jié)束符sh = s_malloc(hdrlen+initlen+1);if (sh == NULL) return NULL;if (init==SDS_NOINIT)init = NULL;else if (!init)memset(sh, 0, hdrlen+initlen+1);// s指向bufs = (char*)sh+hdrlen;// s減1得到flagsfp = ((unsigned char*)s)-1;...// 在s末尾添加\0結(jié)束符s[initlen] = '\0';// 返回指向buf的指針sreturn s;}
創(chuàng)建 SDS 的大致流程是這樣的:首先根據(jù)字符串長度計算得到 type,根據(jù) type 計算頭部所需長度,然后動態(tài)分配內(nèi)存空間。
注意:
1.創(chuàng)建空字符串時,SDS_TYPE_5?被強制轉(zhuǎn)換為?SDS_TYPE_8(原因是創(chuàng)建空字符串后,內(nèi)容可能會頻繁更新而引發(fā)擴容操作,故直接創(chuàng)建為 sdshdr8)2.長度計算有?+1?操作,因為結(jié)束符?\0?會占用一個長度的空間。3.返回的是指向 buf 的指針 s。
2. 清空字符串
SDS 提供了兩種清空字符串的方法。
一種是通過 s 偏移得到結(jié)構(gòu)體的地址,然后調(diào)用?s_free?直接釋放內(nèi)存。
void sdsfree(sds s) {if (s == NULL) return;// s減去頭部的大小得到結(jié)構(gòu)體的地址s_free((char*)s-sdsHdrSize(s[-1]));}
另一種是通過重置 len 屬性值而達到清空字符串的目的,本質(zhì)上 buf 并沒有被真正清除,新的數(shù)據(jù)會直接覆蓋 buf 中原有的數(shù)據(jù),無需申請新的內(nèi)存空間。
void sdsclear(sds s) {// 將len屬性置為0sdssetlen(s, 0);s[0] = '\0';}
3. 拼接字符串
SDS 拼接字符串的實現(xiàn)如下:
sds sdscatsds(sds s, const sds t) {return sdscatlen(s, t, sdslen(t));}
可以看到?sdscatsds?內(nèi)部調(diào)用的是?sdscatlen。
而?sdscatlen?內(nèi)部的實現(xiàn)相對復雜一些,由于拼接字符串可能涉及 SDS 的擴容,因此?sdscatlen?內(nèi)部調(diào)用?sdsMakeRoomFor?對拼接的字符串做檢查:若無需擴容,直接返回 s;若需要擴容,則返回擴容好的新字符串 s。
sds sdscatlen(sds s, const void *t, size_t len) {// 計算當前字符串長度size_t curlen = sdslen(s);// 確保s的剩余空間足以拼接上ts = sdsMakeRoomFor(s,len);if (s == NULL) return NULL;// 拼接s、tmemcpy(s+curlen, t, len);// 更新s的len屬性sdssetlen(s, curlen+len);// s末尾添加\0結(jié)束符s[curlen+len] = '\0';return s;}
SDS 的擴容策略是這樣的:
1.若 SDS 中剩余空閑長度 avail 大于或等于新增內(nèi)容的長度 addlen,無需擴容。2.若 SDS 中剩余空閑長度 avail 小于或等于 addlen,則分情況討論:新增后總長度?len+addlen < 1MB?的,按新長度的 2 倍擴容;新增后總長度?len+addlen >= 1MB?的,按新長度加上?1MB?擴容。
sds sdsMakeRoomFor(sds s, size_t addlen) {void *sh, *newsh;// 當前剩余長度size_t avail = sdsavail(s);size_t len, newlen;char type, oldtype = s[-1] & SDS_TYPE_MASK;int hdrlen;/* 剩余長度>=新增字符串長度,直接返回 */if (avail >= addlen) return s;// 計算當前字符串長度lenlen = sdslen(s);sh = (char*)s-sdsHdrSize(oldtype);// 計算新長度newlen = (len+addlen);// 新長度<1MB,按新長度的2倍擴容if (newlen < SDS_MAX_PREALLOC)newlen *= 2;// 否則按新長度+1MB擴容elsenewlen += SDS_MAX_PREALLOC;// 計算新長度所屬類型type = sdsReqType(newlen);/* type5不支持擴容,強轉(zhuǎn)為type8 */if (type == SDS_TYPE_5) type = SDS_TYPE_8;hdrlen = sdsHdrSize(type);if (oldtype==type) {// 類型沒變,直接通過realloc擴大動態(tài)數(shù)組即可。newsh = s_realloc(sh, hdrlen+newlen+1);if (newsh == NULL) return NULL;s = (char*)newsh+hdrlen;} else {// 類型改變了,則說明頭部長度也發(fā)生了變化,不進行realloc操作,而是直接重新開辟內(nèi)存newsh = s_malloc(hdrlen+newlen+1);if (newsh == NULL) return NULL;// 原內(nèi)存拷貝到新的內(nèi)存地址上memcpy((char*)newsh+hdrlen, s, len+1);// 釋放原先空間s_free(sh);s = (char*)newsh+hdrlen;// 為flags賦值s[-1] = type;// 為len屬性賦值sdssetlen(s, len);}// 為alloc屬性賦值sdssetalloc(s, newlen);return s;}
總結(jié)
1.SDS 返回的是指向 buf 的指針,兼容了 C 語言操作字符串的函數(shù),讀取內(nèi)容時,通過 len 屬性來限制讀取的長度,不受?\0?影響,從而保證二進制安全;2.Redis 根據(jù)字符串長度的不同,定義了多種數(shù)據(jù)結(jié)構(gòu),包括:sdshdr5/sdshdr8/sdshdr16/sdshdr32/sdshdr64。3.SDS 在設計字符串修改出會調(diào)用?sdsMakeRoomFor?函數(shù)進行檢查,根據(jù)不同情況進行擴容。
全文完!
希望本文對大家有所幫助。如果感覺本文有幫助,有勞轉(zhuǎn)發(fā)或點一下“在看”!讓更多人收獲知識!
長按識別下圖二維碼,關(guān)注公眾號「Doocs 開源社區(qū)」,第一時間跟你們分享好玩、實用的技術(shù)文章與業(yè)內(nèi)最新資訊。
