見微知著 —— Redis 字符串內部結構源碼分析
本篇我們開始講字典 key 的內部結構,也就是 sds 字符串。首先它不是普通字符串,而是 sds 字符串,這個 sds 的意思是「Simple Dynamic String」,它的結構很簡單,它是動態的,意味著可以支持修改。不過即使是這樣簡單的字符串結構,在結構設計上作者可是煞費苦心。
正文
我們知道 C語言裏面的字符串是以0x\0結尾,通常就說是以 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 的尾部有 ‘\0‘ 標識,這是 C 字符串的結束標誌。
見微知著 —— Redis 字符串內部結構源碼分析