我們都知道,Redis
是由C
語言編寫的。在C
語言中,字符串標准形式是以空字符\0
作為結束符的,但是Redis
里面的字符串卻沒有直接沿用C
語言的字符串。主要是因為C
語言中獲取字符串長度可以調用strlen
這個標准函數,這個函數的時間復雜度是O(N)
,由於Redis
是單線程的,承受不了這個時間復雜度。
Redis中string的存儲方式
在上一篇文章中,我們介紹了Redis
的RedisObject
的數據結構,如下所示:
typedef struct redisObject {
// 對外的類型 string list set hash zset等 4bit
unsigned type:4;
// 底層存儲方式 4bit
unsigned encoding:4;
// LRU 時間 24bit
unsigned lru:LRU_BITS;
// 引用計數 4byte
int refcount;
// 指向對象的指針 8byte
void *ptr;
} robj;
對於不同的對象,Redis
會使用不同的類型來存儲。對於同一種類型type
會有不同的存儲形式encoding
。對於string
類型的字符串,其底層編碼方式共有三種,分別為int
、embstr
和raw
。
int
:當存儲的字符串全是數字時,此時使用int
方式來存儲;embstr
:當存儲的字符串長度小於44個字符時,此時使用embstr
方式來存儲;raw
:當存儲的字符串長度大於44個字符時,此時使用raw
方式來存儲;
使用object encoding key
可以查看key
對應的encoding
類型,如下所示:
對於embstr
和raw
這兩種encoding
類型,其存儲方式還不太一樣。對於embstr
類型,它將RedisObject
對象頭和SDS
對象在內存中地址是連在一起的,但對於raw
類型,二者在內存地址不是連續的。
SDS
在介紹string
類型的存儲類型時,我們說到,對於embstr
和raw
兩種類型其存儲方式不一樣,但ptr
指針最后都指向一個SDS
的結構。那什么是SDS
呢?Redis
中的字符串稱之為Simple Dynamic String
,簡稱為SDS
。與普通C
語言的原始字符串結構相比,sds
多了一個sdshdr
的頭部信息,sdshdr
基本數據結構如下所示:
struct sdsshr<T>{
T len;//數組長度
T alloc;//數組容量
unsigned flags;//sdshdr類型
char buf[];//數組內容
}
可以看出,SDS
的結構有點類似於Java
中的ArrayList
。buf[]
表示真正存儲的字符串內容,alloc
表示所分配的數組的長度,len
表示字符串的實際長度,並且由於len
這個屬性的存在,Redis
可以在O(1)
的時間復雜度內獲取數組長度。
為了追求對於內存的極致優化,對於不同長度的字符串,Redis
底層會采用不同的結構體來表示。在Redis
中的sds.h
源碼中存在着五種sdshdr
,分別如下:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
上面說了,Redis
底層會根據字符串的長度來決定具體使用哪種類型的sdshdr
。可以看出,sdshdr5
明顯區別於其他四種結構,它一般只用於存儲長度不會變化,且長度小於32個字符的字符串。但現在一般都不再使用該結構,因為其結構沒有len
和alloc
這兩個屬性,不具備動態擴容操作,一旦預分配的內存空間使用完,就需要重新分配內存並完成數據的復制和遷移,類似於ArrayList
的擴容操作,這種操作對性能的影響很大。
上面介紹sdshdr
屬性的時候說過,flag
這個屬性用於標識使用哪種sdshdr
類型,flag
的低三位標識當前sds
的類型,分別如下所示:
#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
同時,注意到在每個sdshdr
的頭定義上都有一個attribute((packed))
,這個是為了告訴gcc
取消優化對齊,這樣,每個字段分配的內存地址就是緊緊排列在一起的,Redis
中字符串參數的傳遞直接使用char*
指針,其實現原理在於,由於sdshdr
內存分配禁止了優化對齊,所以sds[-1]
指向的就是flags
屬性的內存地址,而通過flags
屬性又可以確定sdshdr
的屬性,進而可以讀取頭部字段確定sds
的相關屬性。
sds的邏輯圖如下所示:
sdshdr的優勢
相比較於C
語言原始的字符串,sdshdr
的具備一些優勢。
長度獲取
由於sdshdr
中存在len
這個屬性,所以可以在O(1)
的時間復雜度下獲得長度;而傳統的C
語言得使用strlen
這個標准函數獲取,時間復雜度為O(N)
。
避免頻繁的內存分配
原始的C
語言一直使用與長度匹配的內存,這樣在追加字符串導致字符串長度發生變化時,就必須進行內存的重新分配。內存重新分配涉及到復雜算法和系統調用,耗費性能和時間。對於Redis
來說,它是單線程的,如果使用原始的字符串結構,勢必會引發頻繁的內存重分配,這個顯然是不合理的。
因而,sds
每次進行內存分配時,都會通過內存的預分配來減少因為修改字符串而引發的內存重分配次數。這個原理可以參數Java
中的ArrayList
,一般在使用ArrayList
時都會建議使用帶有容量的構造方式,這樣可以避免頻繁resize
。
對於SDS
來說,當其使用append
進行字符串追加時,程序會用 alloc-len 比較下剩下的空余內存是否足夠分配追加的內容,如果不夠自然觸發內存重分配,而如果剩余未使用內存空間足夠放下,那么將直接進行分配,無需內存重分配。其擴容策略為,當字符串占用大小小於1M時,每次分配為len
* 2,也就是保留100%的冗余;大於1M后,為了避免浪費,只多分配1M的空間。
通過這種預分配策略, SDS 將連續增長 N 次字符串所需的內存重分配次數從必定 N 次降低為最多 N 次。
緩沖區溢出
緩沖區溢出是指當某個數據超過了處理程序限制的范圍時,程序出現的異常操作。原始的C
語言中,是由編碼者自己來分配字符串的內存,當出現內存分配不足時就會發生緩存區溢出。而sds
的修改函數在修改前會判斷內存,動態的分配內存,杜絕了緩沖區溢出的可能性。
二進制安全
對於原始的C
語言字符串來說,它會通過判斷當前字符串中是否存在空字符\0
來確定是否已經是字符串的結尾。因而在某些情況下,如使用空格進行分割一段字符串時,或者是圖片或者視頻等二進制文件中存在\0
等,就會出問題。而sds
不是通過空字符串來判斷字符串是否已經到結尾,而是通過len
這個字段的值。所以說,sds
還具備二進制安全這個特性,即可以安全的存儲具有特殊格式的二進制數據。
總結
參考
https://juejin.im/post/5d7dac02518825297023fb35#comment