蜻蜓點水說說Redis的String的奧祕

CodeBear發表於2020-07-27

本篇部落格參考:掘金Redis小冊 敖丙

如果面試官問你,單執行緒的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:
image.png

"e"的ascii碼是65,二進位制是101,二進位制是1100101:
image.png

然後形成如下的點陣圖:
image.png

下面就需要利用位操作來進行設定:

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就是一個陣列,而且還是一個byte[]。

SDS

不管在什麼程式語言、儲存引擎中,String都是應用最廣泛的,而在不同的程式語言、儲存引擎中,String可能有不同的實現,在Redis中,String的底層就是SDS,它的全稱是Simple Dynamic String。

Redis是C語言開發的,Redis為什麼不直接利用C語言的字串,而要“別出心裁”的自己構建SDS資料結構來實現字串呢?

我們先來這個SDS是個什麼鬼:

struct sdshdr {
    int len;
    int free;
    char buf[];
};

SDS的定義比較簡單,只有3個欄位,而且從字面上就可以看出是什麼意思:

  • len:儲存字串的實際長度
  • free:儲存剩餘(空閒)的空間
  • buf[]:儲存實際資料

下面我們就來看下SDS和C語言的字串有什麼區別:

  • 求字串長度
    在C語言中,求字串的長度只能遍歷,時間複雜度是o(n),單執行緒的Redis表示鴨梨山大,但是現在引入了一個欄位來儲存字串的實際長度,時間複雜度瞬間降低成了o(1)。
  • 二進位制安全
    在C語言中,讀取字串遵循的是“遇零則止”,即,讀取字串,當讀取到“\0”,就認為已經讀到了結尾,哪怕後面還有字串也不會讀取了,像圖片、音訊等二進位制資料,經常會穿插“\0”在其中,好端端的圖片、音訊就毀了...但是現在有了一個欄位來儲存字串的實際長度,讀取字串的時候,先看下這個字串的長度是多少,然後往後讀多少位就可以了。
  • 緩衝區溢位
    字串拼接是開發中常見的操作,C語言的字串是不記錄字串長度的,一旦我們呼叫了拼接函式,而沒有提前計算好記憶體,就會產生緩衝區溢位的情況,但是現在引入了free欄位,來記錄剩餘的空間,做拼接操作之前,先去看下還有多少剩餘空間,如果夠,那就放心的做拼接操作,不夠,就進行擴容。
  • 減少記憶體重分配次數
  1. 空間預分配:當對字串進行拼接操作的時候,Redis會很貼心的分配一定的剩餘空間,這塊剩餘空間現在看起來是有點浪費,但是我們如果繼續拼接,這塊剩餘空間的作用就出來了。
  2. 惰性空間釋放:當我們做了字串縮減的操作,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。

再來看看這兩種儲存形式有什麼區別:
image.png
embstr的儲存形式比較緊湊,Redis的物件頭和SDS物件存在一起(連續)。

image.png
一般來說,在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的定義,檢視
image.png
可以看到變化很大,為什麼要做那麼大的改變?更節省記憶體,當字串長度比較小的時候,會用
sdshdr8來儲存,len和alloc共佔用2byte,flags佔用1byte,\0結尾佔用1byte,一共是4byte,64byte-16byte(物件頭)-4byte=44byte,所以在高版本的Redis下,embstr、raw的分界線為44。

怎麼樣,沒想到吧,我們Redis經常使用的String竟然牽扯到那麼多東西,而這些東西就可以區分平庸開發和優秀開發,成為一個優秀的開發,要學習的東西還有很多很多。

相關文章