選單

“萬金油”的String,為什麼不好用了?

從今天開始,我們就要進入“實踐篇”了。接下來,我們會用5節課的時間學習“資料結構”。我會介紹節省記憶體開銷以及儲存和統計海量資料的資料型別及其底層資料結構,還會圍繞典型的應用場景(例如地址位置查詢、時間序列資料庫讀寫和訊息佇列存取),跟你分享使用Redis的資料型別和module擴充套件功能來滿足需求的具體方案。

今天,我們先了解下String型別的記憶體空間消耗問題,以及選擇節省記憶體開銷的資料型別的解決方案。

先跟你分享一個我曾經遇到的需求。

當時,我們要開發一個圖片儲存系統,要求這個系統能快速地記錄圖片ID和圖片在儲存系統中儲存時的ID(可以直接叫作圖片儲存物件ID)。同時,還要能夠根據圖片ID快速查詢到圖片儲存物件ID。

可以看到,圖片ID和圖片儲存物件ID正好一一對應,是典型的“鍵-單值”模式。所謂的“單值”,就是指鍵值對中的值就是一個值,而不是一個集合,這和String型別提供的“一個鍵對應一個值的資料”的儲存形式剛好契合。

而且,String型別可以儲存二進位制位元組流,就像“萬金油”一樣,只要把資料轉成二進位制位元組陣列,就可以儲存了。

所以,我們的第一個方案就是用String儲存資料。我們把圖片ID和圖片儲存物件ID分別作為鍵值對的key和value來儲存,其中,圖片儲存物件ID用了String型別。

剛開始,我們儲存了1億張圖片,大約用了6。4GB的記憶體。但是,隨著圖片資料量的不斷增加,我們的Redis記憶體使用量也在增加,結果就遇到了大記憶體Redis例項因為生成RDB而響應變慢的問題。很顯然,String型別並不是一種好的選擇,我們還需要進一步尋找能節省記憶體開銷的資料型別方案。

在這個過程中,我深入地研究了String型別的底層結構,找到了它記憶體開銷大的原因,對“萬金油”的String型別有了全新的認知:String型別並不是適用於所有場合的,它有一個明顯的短板,就是它儲存資料時所消耗的記憶體空間較多。

同時,我還仔細研究了集合型別的資料結構。我發現,集合型別有非常節省記憶體空間的底層實現結構,但是,集合型別儲存的資料模式,是一個鍵對應一系列值,並不適合直接儲存單值的鍵值對。所以,我們就使用二級編碼的方法,實現了用集合型別儲存單值鍵值對,Redis例項的記憶體空間消耗明顯下降了。

這節課,我就把在解決這個問題時學到的經驗和方法分享給你,包括String型別的記憶體空間消耗在哪兒了、用什麼資料結構可以節省記憶體,以及如何用集合型別儲存單值鍵值對。如果你在使用String型別時也遇到了記憶體空間消耗較多的問題,就可以嘗試下今天的解決方案了。

接下來,我們先來看看String型別的記憶體都消耗在哪裡了。

為什麼String型別記憶體開銷大?

在剛才的案例中,我們儲存了1億張圖片的資訊,用了約6。4GB的記憶體,一個圖片ID和圖片儲存物件ID的記錄平均用了64位元組。

但問題是,一組圖片ID及其儲存物件ID的記錄,實際只需要16位元組就可以了。

我們來分析一下。圖片ID和圖片儲存物件ID都是10位數,我們可以用兩個8位元組的Long型別表示這兩個ID。因為8位元組的Long型別最大可以表示2的64次方的數值,所以肯定可以表示10位數。但是,為什麼String型別卻用了64位元組呢?

其實,除了記錄實際資料,String型別還需要額外的記憶體空間記錄資料長度、空間使用等資訊,這些資訊也叫作元資料。當實際儲存的資料較小時,元資料的空間開銷就顯得比較大了,有點“喧賓奪主”的意思。

那麼,String型別具體是怎麼儲存資料的呢?我來解釋一下。

當你儲存64位有符號整數時,String型別會把它儲存為一個8位元組的Long型別整數,這種儲存方式通常也叫作int編碼方式。

但是,當你儲存的資料中包含字元時,String型別就會用簡單動態字串(Simple Dynamic String,SDS)結構體來儲存,如下圖所示:

“萬金油”的String,為什麼不好用了?

buf

:位元組陣列,儲存實際資料。為了表示位元組陣列的結束,Redis會自動在陣列最後加一個“\0”,這就會額外佔用1個位元組的開銷。

len

:佔4個位元組,表示buf的已用長度。

alloc

:也佔個4位元組,表示buf的實際分配長度,一般大於len。

可以看到,在SDS中,buf儲存實際資料,而len和alloc本身其實是SDS結構體的額外開銷。

另外,對於String型別來說,除了SDS的額外開銷,還有一個來自於RedisObject結構體的開銷。

因為Redis的資料型別有很多,而且,不同資料型別都有些相同的元資料要記錄(比如最後一次訪問的時間、被引用的次數等),所以,Redis會用一個RedisObject結構體來統一記錄這些元資料,同時指向實際資料。

一個RedisObject包含了8位元組的元資料和一個8位元組指標,這個指標再進一步指向具體資料型別的實際資料所在,例如指向String型別的SDS結構所在的記憶體地址,可以看一下下面的示意圖。關於RedisObject的具體結構細節,我會在後面的課程中詳細介紹,現在你只要瞭解它的基本結構和元資料開銷就行了。

“萬金油”的String,為什麼不好用了?

為了節省記憶體空間,Redis還對Long型別整數和SDS的記憶體佈局做了專門的設計。

一方面,當儲存的是Long型別整數時,RedisObject中的指標就直接賦值為整數資料了,這樣就不用額外的指標再指向整數了,節省了指標的空間開銷。

另一方面,當儲存的是字串資料,並且字串小於等於44位元組時,RedisObject中的元資料、指標和SDS是一塊連續的記憶體區域,這樣就可以避免記憶體碎片。這種佈局方式也被稱為embstr編碼方式。

當然,當字串大於44位元組時,SDS的資料量就開始變多了,Redis就不再把SDS和RedisObject佈局在一起了,而是會給SDS分配獨立的空間,並用指標指向SDS結構。這種佈局方式被稱為raw編碼模式。

為了幫助你理解int、embstr和raw這三種編碼模式,我畫了一張示意圖,如下所示:

“萬金油”的String,為什麼不好用了?

好了,知道了RedisObject所包含的額外元資料開銷,現在,我們就可以計算String型別的記憶體使用量了。

因為10位數的圖片ID和圖片儲存物件ID是Long型別整數,所以可以直接用int編碼的RedisObject儲存。每個int編碼的RedisObject元資料部分佔8位元組,指標部分被直接賦值為8位元組的整數了。此時,每個ID會使用16位元組,加起來一共是32位元組。但是,另外的32位元組去哪兒了呢?

我在第2講中說過,Redis會使用一個全域性雜湊表儲存所有鍵值對,雜湊表的每一項是一個dictEntry的結構體,用來指向一個鍵值對。dictEntry結構中有三個8位元組的指標,分別指向key、value以及下一個dictEntry,三個指標共24位元組,如下圖所示:

“萬金油”的String,為什麼不好用了?

但是,這三個指標只有24位元組,為什麼會佔用了32位元組呢?這就要提到Redis使用的記憶體分配庫jemalloc了。

jemalloc在分配記憶體時,會根據我們申請的位元組數N,找一個比N大,但是最接近N的2的冪次數作為分配的空間,這樣可以減少頻繁分配的次數。

舉個例子。如果你申請6位元組空間,jemalloc實際會分配8位元組空間;如果你申請24位元組空間,jemalloc則會分配32位元組。所以,在我們剛剛說的場景裡,dictEntry結構就佔用了32位元組。

好了,到這兒,你應該就能理解,為什麼用String型別儲存圖片ID和圖片儲存物件ID時需要用64個位元組了。

你看,明明有效資訊只有16位元組,使用String型別儲存時,卻需要64位元組的記憶體空間,有48位元組都沒有用於儲存實際的資料。我們來換算下,如果要儲存的圖片有1億張,那麼1億條的圖片ID記錄就需要6。4GB記憶體空間,其中有4。8GB的記憶體空間都用來儲存元資料了,額外的記憶體空間開銷很大。那麼,有沒有更加節省記憶體的方法呢?

用什麼資料結構可以節省記憶體?

Redis有一種底層資料結構,叫壓縮列表(ziplist),這是一種非常節省記憶體的結構。

我們先回顧下壓縮列表的構成。表頭有三個欄位zlbytes、zltail和zllen,分別表示列表長度、列表尾的偏移量,以及列表中的entry個數。壓縮列表尾還有一個zlend,表示列表結束。

壓縮列表之所以能節省記憶體,就在於它是用一系列連續的entry儲存資料。每個entry的元資料包括下面幾部分。

prev_len

,表示前一個entry的長度。prev_len有兩種取值情況:1位元組或5位元組。取值1位元組時,表示上一個entry的長度小於254位元組。雖然1位元組的值能表示的數值範圍是0到255,但是壓縮列表中zlend的取值預設是255,因此,就預設用255表示整個壓縮列表的結束,其他表示長度的地方就不能再用255這個值了。所以,當上一個entry長度小於254位元組時,prev_len取值為1位元組,否則,就取值為5位元組。

len

:表示自身長度,4位元組;

encoding

:表示編碼方式,1位元組;

content

:儲存實際資料。

這些entry會挨個兒放置在記憶體中,不需要再用額外的指標進行連線,這樣就可以節省指標所佔用的空間。

我們以儲存圖片儲存物件ID為例,來分析一下壓縮列表是如何節省記憶體空間的。

每個entry儲存一個圖片儲存物件ID(8位元組),此時,每個entry的prev_len只需要1個位元組就行,因為每個entry的前一個entry長度都只有8位元組,小於254位元組。這樣一來,一個圖片的儲存物件ID所佔用的記憶體大小是14位元組(1+4+1+8=14),實際分配16位元組。

Redis基於壓縮列表實現了List、Hash和Sorted Set這樣的集合型別,這樣做的最大好處就是節省了dictEntry的開銷。當你用String型別時,一個鍵值對就有一個dictEntry,要用32位元組空間。但採用集合型別時,一個key就對應一個集合的資料,能儲存的資料多了很多,但也只用了一個dictEntry,這樣就節省了記憶體。

這個方案聽起來很好,但還存在一個問題:在用集合型別儲存鍵值對時,一個鍵對應了一個集合的資料,但是在我們的場景中,一個圖片ID只對應一個圖片的儲存物件ID,我們該怎麼用集合型別呢?換句話說,在一個鍵對應一個值(也就是單值鍵值對)的情況下,我們該怎麼用集合型別來儲存這種單值鍵值對呢?

如何用集合型別儲存單值的鍵值對?

在儲存單值的鍵值對時,可以採用基於Hash型別的二級編碼方法。這裡說的二級編碼,就是把一個單值的資料拆分成兩部分,前一部分作為Hash集合的key,後一部分作為Hash集合的value,這樣一來,我們就可以把單值資料儲存到Hash集合中了。

按照這種設計方法,我在Redis中插入了一組圖片ID及其儲存物件ID的記錄,並且用info命令查看了記憶體開銷,我發現,增加一條記錄後,記憶體佔用只增加了16位元組,如下所示:

在使用String型別時,每個記錄需要消耗64位元組,這種方式卻只用了16位元組,所使用的記憶體空間是原來的1/4,滿足了我們節省記憶體空間的需求。

不過,你可能也會有疑惑:“二級編碼一定要把圖片ID的前7位作為Hash型別的鍵,把最後3位作為Hash型別值中的key嗎?”

其實,二級編碼方法中採用的ID長度是有講究的

在第2講中,我介紹過Redis Hash型別的兩種底層實現結構,分別是壓縮列表和雜湊表。

那麼,Hash型別底層結構什麼時候使用壓縮列表,什麼時候使用雜湊表呢?其實,Hash型別設定了用壓縮列表儲存資料時的兩個閾值,一旦超過了閾值,Hash型別就會用雜湊表來儲存資料了。

這兩個閾值分別對應以下兩個配置項:

hash-max-ziplist-entries:表示用壓縮列表儲存時雜湊集合中的最大元素個數。

hash-max-ziplist-value:表示用壓縮列表儲存時雜湊集合中單個元素的最大長度。

如果我們往Hash集合中寫入的元素個數超過了hash-max-ziplist-entries,或者寫入的單個元素大小超過了hash-max-ziplist-value,Redis就會自動把Hash型別的實現結構由壓縮列表轉為雜湊表。

一旦從壓縮列表轉為了雜湊表,Hash型別就會一直用雜湊表進行儲存,而不會再轉回壓縮列表了。在節省記憶體空間方面,雜湊表就沒有壓縮列表那麼高效了。

為了能充分使用壓縮列表的精簡記憶體佈局,我們一般要控制儲存在Hash集合中的元素個數

。所以,在剛才的二級編碼中,我們只用圖片ID最後3位作為Hash集合的key,也就保證了Hash集合的元素個數不超過1000,同時,我們把hash-max-ziplist-entries設定為1000,這樣一來,Hash集合就可以一直使用壓縮列表來節省記憶體空間了。

小結

這節課,我們打破了對String的認知誤區,以前,我們認為String是“萬金油”,什麼場合都適用,但是,在儲存的鍵值對本身佔用的記憶體空間不大時(例如這節課裡提到的的圖片ID和圖片儲存物件ID),String型別的元資料開銷就佔據主導了,這裡麵包括了RedisObject結構、SDS結構、dictEntry結構的記憶體開銷。

針對這種情況,我們可以使用壓縮列表儲存資料。當然,使用Hash這種集合型別儲存單值鍵值對的資料時,我們需要將單值資料拆分成兩部分,分別作為Hash集合的鍵和值,就像剛才案例中用二級編碼來表示圖片ID,希望你能把這個方法用到自己的場景中。

最後,我還想再給你提供一個小方法:如果你想知道鍵值對採用不同型別儲存時的記憶體開銷,可以在這個網址裡輸入你的鍵值對長度和使用的資料型別,這樣就能知道實際消耗的記憶體大小了。建議你把這個小工具用起來,它可以幫助你充分地節省記憶體。

每課一問

按照慣例,給你提個小問題:除了String型別和Hash型別,你覺得,還有其他合適的型別可以應用在這節課所說的儲存圖片的例子嗎?

作者:一隻睡著的貓

連結:https://juejin。cn/post/7022927348240482340

著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。