天天看點

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

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

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

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

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

因為圖檔數量巨大,是以我們就用10位數來表示圖檔ID和圖檔存儲對象ID,例如,圖檔ID為1101000051,它在存儲系統中對應的ID号是3301000051。

photo_id: 1101000051
photo_obj_id: 3301000051      

可以看到,圖檔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)結構體來儲存,如下圖所示:

redis“萬金油”的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的具體結構細節,我會在後面的課程中詳細介紹,現在你隻要了解它的基本結構和中繼資料開銷就行了。

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

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

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

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

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

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

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

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

因為10位數的圖檔ID和圖檔存儲對象ID是Long類型整數,是以可以直接用int編碼的RedisObject儲存。每個int編碼的RedisObject中繼資料部分占8位元組,指針部分被直接指派為8位元組的整數了。此時,每個ID會使用16位元組,加起來一共是32位元組。但是,另外的32位元組去哪兒了呢?

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

redis“萬金油”的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,表示清單結束。

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

壓縮清單之是以能節省記憶體,就在于它是用一系列連續的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集合中了。

以圖檔ID 1101000060和圖檔存儲對象ID 3302000080為例,我們可以把圖檔ID的前7位(1101000)作為Hash類型的鍵,把圖檔ID的最後3位(060)和圖檔存儲對象ID分别作為Hash類型值中的key和value。

按照這種設計方法,我在Redis中插入了一組圖檔ID及其存儲對象ID的記錄,并且用info指令檢視了記憶體開銷,我發現,增加一條記錄後,記憶體占用隻增加了16位元組,如下所示:

127.0.0.1:6379> info memory
# Memory
used_memory:1039120
127.0.0.1:6379> hset 1101000 060 3302000080
(integer) 1
127.0.0.1:6379> info memory
# Memory
used_memory:1039136      

在使用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集合就可以一直使用壓縮清單來節省記憶體空間了。

小結