baiyanredis
所有視頻:https://segmentfault.com/a/11...數據庫
- 今天咱們正式進入redis5源碼的學習。redis是一個由C語言編寫、基於內存、單進程、可持久化的Key-Value型數據庫,解決了磁盤存取速度慢的問題,大幅提高了數據訪問速度,因此它經常被用做緩存。
- 那麼爲何redis會如此之快呢?讓咱們首先從內部存儲的數據結構的角度,一步一步揭開它神祕的面紗。
- 在redis的set、get等經常使用命令中,最嘗試用的就是字符串類型。在redis中,存儲字符串的數據類型,叫作簡單動態字符串(Simple Dynamic String),即SDS,它在redis中是如何實現的呢?
引入
- 回顧咱們以前在PHP7源碼分析中講到的zend_string結構:
struct _zend_string {
zend_refcounted_h gc; /*引用計數,與垃圾回收相關,暫不展開*/
zend_ulong h; /* 冗餘的hash值,計算數組key的哈希值時避免重複計算*/
size_t len; /* 存長度 */
char val[1]; /* 柔性數組,真正存放字符串值 */
};
SDS新老結構的對比
- 在redis3.2.x以前,SDS的存儲結構以下:
struct sdshdr {
int len; //存長度
int free; //存字符串內容的柔性數組的剩餘空間
char buf[]; //柔性數組,真正存放字符串值
};
- 以「Redis」字符串爲例,咱們看一下它在舊版SDS結構中是如何存儲的:
![](http://static.javashuo.com/static/loading.gif)
- free字段爲0,表明buf字段沒有剩餘存儲空間
- len字段爲5,表明字符串長度爲5
- buf字段存儲真正的字符串內容「Redis」
- 存儲字符串內容的柔性數組佔用內存大小爲6字節,其他字段所佔用8個字節(4+4+6 = 14字節)
- 在新版本redis5中,爲了進一步減小字符串存儲過程當中的內存佔用,劃分了5種適應不一樣字符串長度專用的存儲結構:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; //低三位存儲類型,高5位存儲字符串長度,這種字符串存儲類型不多使用
char buf[]; //存儲字符串內容的柔性數組
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; //字符串長度
uint8_t alloc; //已分配的總空間
unsigned char flags; //標識是哪一種存儲類型
char buf[]; //存儲字符串內容的柔性數組
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; //字符串長度
uint16_t alloc; //已分配的總空間
unsigned char flags; //標識是哪一種存儲類型
char buf[]; //存儲字符串內容的柔性數組
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; //字符串長度
uint32_t alloc; //已分配的總空間
unsigned char flags; //標識是哪一種存儲類型
char buf[]; //存儲字符串內容的柔性數組
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; //字符串長度
uint64_t alloc; //已分配的總空間
unsigned char flags; //標識是哪一種存儲類型
char buf[]; //存儲字符串內容的柔性數組
};
- 咱們能夠看到,SDS的存儲結構由一種變成了五種,他們之間的不一樣就在於存儲字符串長度的len字段和存儲已分配字節數的alloc字段的類型,分別佔用了一、二、四、8字節(不考慮sdshdr5類型),這決定了這種結構可以最大存儲多長的字符串(2^8/2^16/2^32/2^64)。
- 咱們注意,這些結構體中都帶有__attribute__ ((__packed__))關鍵字,它告訴編譯器不進行結構體的內存對齊。這個關鍵字咱們下文會詳細講解。關於結構體內存對齊是什麼,請參考【PHP7源碼學習】2019-03-08 PHP內存管理2筆記。
利用gdb查看SDS的存儲結構
- 接着說咱們以前存儲「Redis」的例子,咱們須要先對其進行gdb,觀察"Redis」字符串使用了哪一種結構,gdb的步驟以下:
- 首先到官網下載源碼包,編譯
- 啓動一個終端,進入redis源碼的src目錄下,後臺啓動一個redis-server:
./redis-server &
ps -aux |grep redis
- 記錄下這個pid,而後利用gdb -p命令調試該端口(如端口號是11430):
gdb -p 11430
- 接着在setCommand函數處打一個斷點,這個函數用來執行set命令,而後使用c命令執行到斷點處:
(gdb) b setCommand
(gdb) c
- 有了redis服務端,咱們還要啓動一個redis客戶端,接下來啓動另外一個終端(一樣在src目錄下),啓動客戶端:
./redis-cli
- 接着咱們在redis客戶端中執行set命令,咱們設置了一個key爲Redis,值爲1的key-value對:
127.0.0.1:6379> set Redis 1
- 返回咱們以前終端中的服務端,咱們發現它停在了setCommand處:
![](http://static.javashuo.com/static/loading.gif)
- 接着一直n下去,直到setGenericCommand函數,s進去,就能夠看到咱們的key 「Redis」了,它是一個rObj結構(咱們暫時不看),裏面的ptr就指向字符串結構的buf字段,咱們強轉一下,可以看到字符串內容「Redis」。
![](http://static.javashuo.com/static/loading.gif)
- 咱們知道,不管是這五種結構中的哪種,其前一位必定是flag字段,咱們打印它的值,它的值爲1。那麼1是什麼含義呢,它被用來標識是這五種字符串結構中的哪種:
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
- 它的值爲1,表明是sdshdr8類型,咱們能夠畫出當前字符串的存儲結構圖:
![](http://static.javashuo.com/static/loading.gif)
- 咱們能夠看到,它總共佔用3+6 = 9字節,比以前的14字節節省了5字節。經過對以前長度和alloc字段的細化(由以前的int轉爲int八、int1六、int3二、int64),這樣一來,就會大大節省redis存儲字符串所佔用的內存空間。內存空間是很是寶貴的,並且redis中最經常使用的數據類型就是字符串類型。雖然看起來節省的空間不多,但因爲它很是經常使用,因此這樣作的好處是無窮大的。
關鍵字__attribute__ ((packed))的做用
- 該關鍵字用來告知編譯器不須要進行結構體的內存對齊。
- 爲了測試__attribute__ ((packed))關鍵字在redis字符串結構中的做用,咱們寫以下一段測試代碼:
#include "stdio.h"
int main(){
struct __attribute__ ((__packed__)) sdshdr64{
long long len;
long long alloc;
unsigned char flags;
char buf[];
};
struct sdshdr64 s;
s.len = 1;
s.alloc = 2;
printf("sizeof sds64 is %d", sizeof(s));
return 1;
}
- 咱們定義一個結構體,其字段和redis中的字符串結構基本一致。若是加上__attribute__ ((__packed__)) ,應該不是內存對齊的。若是去掉它,就應該是內存對齊的,會比前一種狀況更加浪費內存,因此會對齊會節省內存。咱們如今猜測的內存結構圖應該以下所示:
![](http://static.javashuo.com/static/loading.gif)
- 咱們首先驗證加上__attribute__ ((__packed__)) 的狀況,咱們預期應該是不對齊的,在gdb中內存地址以下:
![](http://static.javashuo.com/static/loading.gif)
- 咱們看到,buf確實是從0x171地址處開始的,並無對齊。那麼咱們看另外一種狀況,去掉__attribute__ ((__packed__)),再進行gdb調試:
![](http://static.javashuo.com/static/loading.gif)
- 你們看這張圖,是否是和上一張圖一摸同樣(我真的去掉了而且從新編譯了!!!)。這說明在當前狀況下,redis字符串結構中的柔性數組的起始位置並不受是否加__attribute__ ((__packed__))關鍵字而影響,是緊跟在結構體後面的,因此節省內存這個說法並不成立。(不必定是全部狀況下柔性數組都緊跟在結構體後面,若是把buf的類型改成int就不是緊跟在後面,你們感興趣能夠本身調試一下)。
- 那麼,爲何這裏要加上__attribute__ ((__packed__)呢?咱們換個思路,既然不能節省空間,那麼能不能節省時間呢?會不會操做非對齊的結構體性能更好、效率更高,或者是寫代碼更方便、可閱讀性強呢?
- 筆者在這裏的猜測是比較方便工程中的代碼編寫,可閱讀性更強,個人參考以下:
- 在sizeof運算符中,它返回的是結構體佔用空間的大小,和是否對齊有很大關係。好比上例中的結構體,若是不加上__attribute__ ((__packed__)),說明須要內存對齊,sizeof(struct s)的返回結果應該爲24(8+8+8);若是加上__attribute__ ((__packed__)),說明不須要對齊,返回的結果應該爲17(8+8+1),咱們打印一下:
![](http://static.javashuo.com/static/loading.gif)
- 結果和咱們預期的一致。咱們知道,在以前咱們gdb的時候,rObj的指針直接指向柔性數組buf的地址,即字符串內容的起始地址。那麼如何知道它的len和alloc的值呢?只須要用buf的地址ptr - sizeof(struct s)便可。在這裏,若是加上__attribute__ ((__packed__)),它返回的結果是17,那麼直接作減法,就能夠到結構體開頭的位置,便可直接讀取len的值。若是不加__attribute__ ((__packed__)),它返回的結果是24,作減法就會的到錯誤的位置,這就是緣由所在,在源碼中咱們也能夠看到,它確實是這麼找到當前字符串結構體的頭部的:
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
- 那麼咱們可能會問了,你剛纔不是還用buf[-1]也能訪問到嗎?或者buf[-17],應該也能訪問到len吧。這裏筆者簡單猜測多是上一種寫法,在工程的代碼實現中,更加易讀也更加方便。更加深層的緣由仍待討論。
爲何須要alloc字段
- 在以前的講解中,咱們一直沒有提到alloc字段的做用。咱們知道,它是目前給存儲字符串的柔性數組總共分配了多少字節的空間。那麼記錄這個字段的做用何在呢?那就是空間預分配和惰性空間釋放的設計思想了。
- 空間預分配:在須要對 SDS 進行空間擴展的時候, 程序不只會爲 SDS 分配修改所必需要的空間, 還會爲 SDS 分配額外的未使用空間。舉一個例子,咱們將字符串「Redis」擴展到「Redis111」,應用程序並不只僅分配3個字節,僅僅讓它剛好知足分配的長度,而是會額外分配一些空間。具體如何分配,見下述代碼註釋。咱們講其中一種分配方式,假設它會分配8字節的內存空間。如今總共的內存空間爲5+8 = 13,而咱們只用了前8個內存空間,還剩下5個內存空間未使用。那麼咱們爲何要這樣作呢?這是由於若是咱們再繼續對它進行擴展,如改爲「Redis11111」,在擴展 SDS 空間以前,SDS API 會先檢查未使用空間是否足夠,若是足夠的話,API 就會直接使用未使用空間那麼咱們就不用再進行系統調用申請一次空間了,直接把追加的「11」放到以前分配過的空間處便可。這樣一來,會大大減小使用內存分配系統調用的次數,提升了性能與效率。空間預分配的代碼以下:
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;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC) //SDS_MAX_PREALLOC = 1MB,若是擴容後的長度小於1MB,直接額外分配擴容後字符串長度*2的空間
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC; //擴容後長度大於等於1MB,額外分配擴容後字符串+1MB的空間
...
真正的去分配空間
...
sdssetalloc(s, newlen);
return s;
}
- 上述sdsavail函數在獲取字符串剩餘可用空間的時候,就會使用到alloc字段。它記錄了分配的總空間大小,方便咱們在進行字符串追加操做的時候,判斷是否須要額外分配空間。當前剩餘的可用空間大小爲alloc - len,即已分配總空間大小alloc - 當前使用的空間大小len
static inline size_t sdsavail(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5: {
return 0;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
return sh->alloc - sh->len;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
return sh->alloc - sh->len;
}
}
return 0;
}
- 惰性空間釋放:惰性空間釋放用於優化 SDS 的字符串截取或縮短操做。當 SDS 的 API 須要縮短 SDS 保存的字符串時,程序並不當即回收縮短後多出來的字節。這樣一來,若是未來要對 SDS 進行增加操做的話,這些未使用空間就可能會派上用場。好比咱們將「Redis111」縮短爲「Redis」,而後又改爲「Redis111」,這樣,若是咱們馬上回收縮短後多出來的字節,而後再從新分配內存空間,是很是浪費時間的。若是等待一段時間以後再回收,能夠很好地避免了縮短字符串時所需的內存重分配操做, 併爲未來可能有的增加操做提供了擴展空間。源碼中一個清空字符串的SDS API以下:
/* Modify an sds string in-place to make it empty (zero length).
* However all the existing buffer is not discarded but set as free space
* so that next append operations will not require allocations up to the
* number of bytes previously available. */
void sdsclear(sds s) {
sdssetlen(s, 0);
s[0] = '\0';
}
- 咱們重點看上面的註釋:可是全部額外分配的空間並不會隨着清空字符串而釋放,因此下一個對字符串的追加操做並不會再次進行內存分配的系統調用。而源碼中也並無直接調用任何函數,對清空操做以後的剩餘空間馬上進行釋放,也驗證了咱們以前的猜測。