如果面試官問你,單線程的Redis為什么那么快,你可能脫口而出,因為單線程,避免上下文切換;因為基於內存,比硬盤讀寫快很多;因為采用的是多路復用網絡模型。不管你是否真的理解了,這個回答足以應付一半以上的面試官了,但是如果可以再進行補充就更好了:因為Redis對各種數據結構進行了精心的設計,比如String采用的是SDS,比如list采用的是ziplist,quicklist等等,可能這樣的回答就比較出彩了,至少可以說出部分面試者不太清楚的事情。今天我們就來看看Redis中最常用的String數據結構的奧秘。
從位操作說起
bitmap的應用場景很多,比如大名鼎鼎的布隆過濾器(之前的博客有介紹過:《大白話布隆過濾器》),比如統計指定用戶在一年內任意日期內的登錄情況,統計任意日期內,所有用戶的登錄情況等等,都可以用bitmap來實現(之前的博客也有介紹過:《有點長的博客:Redis不是只有get set那么簡單》),所以好好看看bitmap還是很有必要的,不過本篇博客不打算詳細介紹bitmap,只是通過bitmap引出我們今天的話題,而bitmap的核心就是位操作。
如果我們要往Redis塞入一個value為“hello”的key,這個所有人都會:
test:1>set key hello
"OK"
test:1>get key
"hello"
如果我們要利用位操作實現這個需求呢?什么,我沒聽錯把,位操作也可以實現這個需求嗎?當然可以,因為在Redis中,String就是用byte數組來存儲的。
什么,你不信?那請繼續看下去。
要用位操作實現這個需求,我們要獲得“hello”的ascii碼,接着計算出二進制:
比如,“h”的ascii碼是104,二進制是1101000:
"e"的ascii碼是65,二進制是101,二進制是1100101:
然后形成如下的位圖:
下面就需要利用位操作來進行設置:
test:1>setbit s 1 1
"0"
test:1>setbit s 2 1
"0"
test:1>setbit s 4 1
"0"
test:1>setbit s 10 1
"0"
test:1>setbit s 13 1
"0"
test:1>setbit s 9 1
"0"
test:1>setbit s 15 1
"0"
setbit的順序可以隨意調整,只要最終得到的位圖是如上形式的就OK了。(我這里就調整了下seitbit的順序,好吧,我承認其實我是打錯了,又懶得再去打一遍,反正最終形成的位圖是一樣的)。
然后我們get一下:
test:1>get s
"he"
很神奇,有木有,這也說明了在Redis的底層,String就是一個數組。
SDS
不管在什么編程語言、存儲引擎中,String都是應用最廣泛的,而在不同的編程語言、存儲引擎中,String可能有不同的實現,在Redis中,String的底層就是SDS,它的全稱是Simple Dynamic String。
Redis是C語言開發的,C語言是沒有現成的字符串類型的,而是用字符數組來表示字符串,Redis為什么不直接這么做呢,而要“別出心裁”的自己構建SDS數據結構來實現字符串呢?
我們先來這個SDS是個什么鬼:
struct sdshdr {
int len;
int free;
char buf[];
};
SDS的定義比較簡單,只有3個字段,而且從字面上就可以看出是什么意思:
- len:存儲字符串的實際長度
- free:存儲剩余(空閑)的空間
- buf[]:存儲實際數據
我們可以看到,其實SDS結構也包含了字節數組,但是不同的是,新增了兩個字段。
為了方便起見,下面的文章把直接使用字節數組來表示字符串稱為C語言的字符串,但是大家要明白,C語言是沒有現成的字符串類型的。
下面我們來看下SDS和C語言的字符串有什么區別:
- 求字符串長度
C語言的字符串,求字符串的長度只能遍歷,時間復雜度是O(N),單線程的Redis表示鴨梨山大,但是現在引入了一個字段來存儲字符串的實際長度,時間復雜度瞬間降低成了O(1)。 - 二進制安全
在C語言中,讀取字符串遵循的是“遇零則止”,即,讀取字符串,當讀取到“\0”,就認為已經讀到了結尾,哪怕后面還有字符串也不會讀取了,像圖片、音頻等二進制數據,經常會穿插“\0”在其中,好端端的圖片、音頻就毀了...但是現在有了一個字段來存儲字符串的實際長度,讀取字符串的時候,先看下這個字符串的長度是多少,然后往后讀多少位就可以了。 - 緩沖區溢出
字符串拼接是開發中常見的操作,C語言的字符串是不記錄字符串長度的,一旦我們調用了拼接函數,而沒有提前計算好內存,就會產生緩沖區溢出的情況,但是現在引入了free字段,來記錄剩余的空間,做拼接操作之前,先去看下還有多少剩余空間,如果夠,那就放心的做拼接操作,不夠,就進行擴容。 - 減少內存重分配次數
- 空間預分配:當對字符串進行拼接操作的時候,Redis會很貼心的分配一定的剩余空間,這塊剩余空間現在看起來是有點浪費,但是我們如果繼續拼接,這塊剩余空間的作用就出來了。
- 惰性空間釋放:當我們做了字符串縮減的操作,Redis並不會馬上回收空間,因為你可能即將又要做字符串的拼接操作,如果你再次操作,還是沒有用到這部分空間,Redis也會去回收這部分空間。
擴容策略
字符串小於1M,采用的是加倍擴容的策略,也就是多分配100%的剩余空間,當大於1M,每次擴容,只會多分配1M的剩余空間。
最大長度
Redis 規定字符串的長度不得超過 512M 字節。
embstr raw
Redis的字符串有兩種存儲方式,一種是embstr,一種是raw,當長度<=44,采用embstr 來存儲:
set codebear abcdefghijklmnopqrstuvwxyz012345678912345678
"OK"
debug object codebear
"Value at:0x7f4050476880 refcount:1 encoding:embstr serializedlength:45 lru:1999016 lru_seconds_idle:36"
當長度>44,改用raw來存儲:
set codebear abcdefghijklmnopqrstuvwxyz0123456789123456781
"OK"
debug object codebear
"Value at:0x7f404ac30100 refcount:1 encoding:raw serializedlength:46 lru:1999188 lru_seconds_idle:3"
網上也有一些博客說是以39為分界線,為什么會有兩種答案呢?繼續看下去就明白了。
我們先來看看Redis的對象頭,查看:
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
Redis對象頭占 4bit+4bit+24bit+4byte+8byte(指針,在64 bit system下,占8byte)=32bit+12byte=4byte+12byte=16byte。
再來看看這兩種存儲形式有什么區別:
embstr的存儲形式比較緊湊,Redis的對象頭和SDS對象存在一起(連續)。
一般來說,在raw的存儲形式下,Redis的對象頭和SDS對象不存在一起(不連續)。
我們可以簡單的理解為,一塊內存的大小為64byte。
好了,前置內容介紹完畢了,我們來看看Redis3.0版本的SDS的定義,查看:
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
Redis對象頭占了16byte,SDS對象的len和free又占了8byte,64-16-8=40,同時保存的字符串會以\0結尾,又占用了1byte,所以實際存儲的字符串只能<=39位,所以在低版本的Redis下,embstr、raw的分界線為39。
再來看看Redis5.0版本的SDS的定義,查看:
可以看到變化很大,為什么要做那么大的改變?更節省內存,當字符串長度比較小的時候,會用
sdshdr8來存儲,len和alloc共占用2byte,flags占用1byte,\0結尾占用1byte,一共是4byte,64byte-16byte(對象頭)-4byte=44byte,所以在高版本的Redis下,embstr、raw的分界線為44。
怎么樣,沒想到吧,我們Redis經常使用的String竟然牽扯到那么多東西,而這些東西就可以區分平庸開發和優秀開發,成為一個優秀的開發,要學習的東西還有很多很多。