見微知著——Redis字串內部結構原始碼分析

java填坑路發表於2018-09-06

本篇我們開始講字典 key 的內部結構,也就是 sds 字串。首先它不是普通字串,而是 sds 字串,這個 sds 的意思是「Simple Dynamic String」,它的結構很簡單,它是動態的,意味著可以支援修改。不過即使是這樣簡單的字串結構,在結構設計上作者可是煞費苦心。

我們知道 C語言裡面的字串是以0x 結尾,通常就說是以 NULL 結尾。它不包含長度資訊,當我們需要獲取字串長度時,需要呼叫 strlen(s) 來獲取長度,它的時間複雜度是 O(n),如果一個字串太長,這個函式就太浪費 CPU了。

所以 Redis 不能這麼幹,它需要將長度資訊使用單獨的欄位進行儲存,這就需要一個額外的欄位,這個欄位也要佔用儲存空間。在日常使用中,小字串才是大頭,它的長度資訊往往只需要 1byte 儲存就可以了,可以表示最大長度為 255 的字串。如果字串再大一些,就需要 2byte,甚至是 3byte、4byte。Redis 會為不同長度的字串選擇不同長度的欄位來表示長度資訊。同時 Redis 為了可以直接使用標準C語言字串庫函式,sds 的字串內容還是以 NULL 結尾,這會額外多佔用一個位元組的空間。

sds 是動態字串,它需要支援追加操作,需要能擴充容量。如果字串放置的比較緊湊,追加時,就需要重新分配新的更大的儲存空間,然後進行內容的拷貝(不嚴格,想想為什麼)。如果追加的太頻繁,記憶體的分配和拷貝就會消耗大量 CPU。

圖片

所以 Redis 為動態字串設計了冗餘空間,追加時只要內容不是太大,是可以不必重新分配記憶體的,如果字串的長度是1024,Redis 會分配2048位元組的儲存空間,也就是 100% 的冗餘空間。這個設計非常類似於 Java 語言的 ArrayList 。不過 Redis 考慮的更加周到,當字串的長度超過 1M 時,它的冗餘空間只有 1M,避免出現太大的浪費。Redis 還限制了字串最大長度不得超過 512M。

下面是 sds 字串的結構定義原始碼

 

我們日常使用的字串都是隻讀的,一般只有拿字串當點陣圖使用時才會對字串進行追加和修改操作。為了避免浪費,Redis 在第一次建立 sds 字串時,不給它分配冗餘空間。在第一次追加操作之後才會分配 100% 的冗餘空間。

圖片

值得注意的是,我們平時使用的字串指標都是指向字串記憶體空間的頭部,但是在 Redis 裡面我們使用的 sds 字串指標指向的是字串記憶體空間的脖子部位,因為 sds 字串有自己的頭部資訊。

如果 sds 字串只是作為字典的 key 而存在,那麼字典裡面元素的 key 會直接指向 sds。如果 字串是作為 Redis的物件而存在,它還會包上一個通用的物件頭,也就是 RedisObject。物件頭的 ptr 欄位會指向 sds。

 

講到這裡,需要提一下現代計算機的結構上在 CPU 和 記憶體之間存在一個快取的結構,用來協調 CPU 的高效和訪存的相對緩慢的矛盾。我們平時聽到的 L1 Cache、L2 Cache就是這個快取。當 CPU 要訪問記憶體時先在快取裡找一找有沒有,如果沒有就去記憶體裡拿了之後放到快取裡,這個快取的最小單位一般是 64 位元組,也就是一次性快取連續的 64 位元組內容,這個最小單位稱為「快取行」。這樣下次獲取記憶體地址附近的資料時可以直接從快取中拿到。

對於 Redis 的字串物件來說,我們需要先訪問 redisObject 物件頭,拿到 ptr 指標,然後再訪問指向的 sds 字串。如果物件頭和 sds 字串相距較遠,就會存在快取穿透現象,效能就會打折。所以 Redis 為了優化硬體的快取命中,它為字串設計了一種特殊的編碼結構,這種結構就是 embstr 。它將 redisObject 物件頭和 sds 字串擠在一起連續儲存,可以一次性放到快取行裡,這樣就可以明顯提升快取命中率。

 

 

object 指令觀察一下物件的編碼型別來驗證一下這個計算是否正確。

 

注意到上面的輸出中出現了 encoding:int 型別的編碼,這是怎麼回事呢?原來 Redis 又對整型字串做了優化,當字串是可以用 long 型別表達的整數時,Redis 內部將會使用整型編碼。注意整數在 Redis 內部的型別 type 是字串。

 

我們再觀察一遍 redisObject 物件頭。

 

當字串內容可以用 long 整數表達時,物件頭的 ptr 指標將退化為一個 long 型的整數。也就是

 

如果這個整數太大,超出了 long 的表達範圍,就會使用 sds 字串表示,根據長短不同會分別選擇 embstr 和 raw 編碼型別。

我們再看一個很詭異的現象

 

注意 debug object 指令輸出的 Value at: xxxxxxx 這個表示 redisObject 物件頭的地址。為什麼值為 9999 時,兩個物件的地址是一樣的。而變成了 10000 地址就不一樣了呢?

 

這是因為「小整數物件快取」。Redis 在初始化的時候會構造 [0, 10000) 這1w個小整數物件持久放在記憶體裡,以後凡是在這個範圍內的整型字串都會直接使用共享的小整數物件。小整數物件的引用計數字段的值恆定為 INT_MAX。在很多物件導向的語言中,都有小整數物件快取的概念。

接下來我們仔細分析一下建立 embstr 的函式 createEmbeddedStringObject 的程式碼

 

我們可以看到物件頭和字串內容是通過一次zmalloc呼叫分配的,也就是說物件頭和字串內容是連續的分配在一起。還將 sds 字串的 flags 設定為 SDS_TYPE_8 說明它是一個短字串,長度可以直接用一個位元組就可以表示。同時在字串內容 buf 的尾部有 ` ` 標識,這是 C 字串的結束標誌。

歡迎工作一到五年的Java工程師朋友們加入Java架構開發:744677563

本群提供免費的學習指導 架構資料 以及免費的解答

不懂得問題都可以在本群提出來 之後還會有職業生涯規劃以及面試指導


相關文章