曾經看到這麼一個案例,有一個團隊需要開發一個圖檔存儲系統,要求這個系統能快速記錄圖檔ID和圖檔存儲對象ID,同時還需要能夠根據圖檔的ID快速找到圖檔存儲對象ID。我們假設用10位數來表示圖檔ID和圖檔存儲對象ID,例如圖檔的ID為1101021043,它所對應的圖檔存儲對象的ID為2301010051,可以看到圖檔ID和圖檔存儲ID正好是一一對應的,是典型的key-value形式,是以首先會想到直接使用String類型來儲存資料。把圖檔ID和圖檔存儲ID分别作為鍵值對的key和value來儲存。但是随着存儲的資料量越來越大,Redis的記憶體的使用量也快速上升,結果遇到了大記憶體Redis執行個體因為生成RDB而響應變慢的問題。很顯然String類型并不是一種好的選擇,
那有什麼辦法可以降低記憶體消耗嗎?
String類型的資料結構
首先我們得先了解為什麼String儲存資料時所消耗的記憶體空間較大。在剛才的案例中,由于圖檔ID和圖檔存儲對象ID都是10位數,我們可以用兩個8位元組的Long類型來表示這兩個ID。是以一組圖檔ID及其存儲對象ID的記錄,實際隻需要16位元組就可以了。但是通過對Redis記憶體分析,一組圖檔ID及其存儲對象ID卻占用了64位元組,那為什麼String類型會用64位元組呢。其實,除了要記錄實際的資料,String類型還需要額外的記憶體空間來記錄資料的長度、空間使用資訊等,這些資訊也叫做中繼資料。當實際儲存的資料較小時,中繼資料的空間開銷就顯的比較大了。我們先來看一下String類型是如何儲存資料的。當你儲存64位有符号的整數時,String類型會把它儲存為一個8位元組的Long類型整數,這種儲存方式通常也叫作int編碼方式。但是,當你儲存的資料中包含字元時,String類型就會用簡單動态字元串結構體(SDS)來儲存。如下圖所示:

- len:4個位元組,表示buf的已用長度。
- alloc:4個位元組,表示buf配置設定的長度,一般大于len。
- buf:位元組數組,儲存實際資料。為了表示數組的結尾,Redis會自動在數組最後添加一個”\0"。
可以看到,在SDS結構體中,除了有儲存實際資料的buf,還有len和alloc的額外中繼資料的開銷。另外對于String類型來說,除了SDS的額外開銷外,還有一個叫做RedisObject結構體的開銷。因為Redis的資料類型有很多,不同的資料類型都有相同的中繼資料要記錄(例如最後一次通路時間),是以Redis會采用一個叫做RedisObject結構體來統一記錄這些中繼資料。一個RedisObject包含了一個8位元組的中繼資料和一個8位元組的指針,這個指針指向具體資料所在,例如String類型的SDS結構體所在的記憶體位址。如下圖所示:
為了節省記憶體空間,Redis對Long類型整數和SDS的記憶體布局做了專門的設計。一方面,當儲存的是 Long 類型整數時,RedisObject 中的指針就直接指派為整數資料了,這樣就不用額外的指針再指向整數了,節省了指針的空間開銷。另一方面,當儲存的是字元串資料,并且字元串小于等于 44 位元組時,RedisObject 中的中繼資料、指針和 SDS 是一塊連續的記憶體區域,這樣就可以避免記憶體碎片。這種布局方式也被稱為 embstr 編碼方式。當字元串大于44位元組時,SDS的資料量就開始變多了,Redis 就不再把SDS 和
RedisObject 布局在一起了,而是會給 SDS 配置設定獨立的空間,并用指針指向 SDS 結構。這種布局方式被稱為 raw 編碼模式。如下圖所示:
現在我們來計算一下一對圖檔ID和圖檔存儲對象ID的記憶體的使用量。由于10位數的圖檔ID和圖檔存儲對象ID是Long類型整數,是以可以直接用int編碼的RedisObject儲存。相對應的RedisObject中繼資料部分占8位元組,指針部分被直接指派為8位元組的整數了。此時,每個ID會使用16位元組,加起來一共是32位元組。但是,另外的 32 位元組去哪兒了呢?
由于Redis是使用全局哈希表來儲存所有的鍵值對,哈希表的每一項是一個dictEntity的結構體來指向一個鍵值對。dictEntity由三個8位元組的指針組成,分别來指向key、value以及下一個dictEntity。如下圖所示。
由于Redis使用的記憶體配置設定庫為jemalloc,jemalloc在配置設定記憶體時,會根據申請的位元組數N,找一個比N大的,最接近N的2的幂次數作為配置設定的空間。
是以申請一個24位元組的dictEntity,實際會配置設定32個位元組。
到目前位置,你應該明白了為什麼String類型來儲存圖檔ID和圖檔存儲對象ID會占用64個位元組了。一個有效資訊隻有16個位元組,在使用String類型儲存時,卻要占用64個位元組記憶體空間,有48個位元組用來儲存中繼資料資訊了,這是不是極大的浪費了記憶體空間。那麼有沒有更加節省記憶體的方法呢?
用壓縮清單節省記憶體
Redis裡有一種叫做壓縮清單的結構,非常節省記憶體。我們先回顧一下壓縮清單的構成。表頭有三個字段zlbytes、zllen和zltail,分别表示清單的長度、清單尾的偏移量以及清單中entry的個數。壓縮清單表尾有一個zlend,表示清單結束。如下圖所示。
由于壓縮清單采用一系列的entry儲存資料,這些entry會挨個兒放置在記憶體中,不需要再用額外的指針進行連接配接,這樣就可以節省指針所占用的空間。每個entry由以下幾部分組成。
- pre_len:表示前一個entry的長度。prev_len有兩種取值情況:1 位元組或 5 位元組。當上一個 entry 長度小于 254 位元組時,prev_len 取值為 1 位元組,否則,就取值為 5 位元組。
- len:表示自身的長度,占4個位元組。
- encoding:表示編碼方式,占1個位元組。
- content:儲存實際資料。
假設我們使用entry來儲存圖檔存儲對象ID(占8個位元組),此時,每個entry的prev_len占用1個位元組就行,因為每一個entry的前一個entry的長度小于264位元組。這樣一來,一個圖檔對象ID所占用的記憶體大小是14(1+4+1+8)個位元組,實際上會配置設定16個位元組。
Redis裡基于壓縮清單實作了List、Hash和Sorted Set集合類型,這樣做的最大好處就是節省了dictEntity的記憶體開銷。對于String類型來說,一個鍵值對就有一個dictEntity,占用32個位元組。對于集合類型來說,一個key對應了很多資料,卻隻是占用了一個dictEntity,這樣就節省了記憶體空間。
如何用集合類型存儲單值的鍵值對的資料
在儲存單值鍵值對的資料時,我們可以使用基于Hash類型的二級編碼方式。這裡所說的二級編碼,是指把單值的資料拆成兩部分,前一部分作為Hash的key,後一部分作為Hash的value。 以圖檔的ID為1101021043,它所對應的圖檔存儲對象的ID為2301010051為例,我們将圖檔的ID的前7位(1101021)作為Hash類型的鍵,後3位(043)和圖檔存儲對象ID為2301010051作為Hash類型的key和value。我們按照這種設計,在Redis中插入一條記錄,隻占用了16位元組,是以和使用String類型占用64位元組對比,節省了很多空間。 最後,我們再思考一個問題,為什麼要把圖檔ID的前7位作為Hash類型的鍵,後3位作為Hash類型的key呢。我們在Redis存儲結構裡介紹過Redis的Hash類型的兩種底層實作結構,分别是壓縮清單和哈希表。Hash 類型設定了用壓縮清單儲存資料時的兩個門檻值,一旦超過了門檻值,Hash 類型就會用哈希表來儲存資料了。這兩個門檻值分别對應以下兩個配置項:
- hash-max-ziplist-entries:表示用壓縮清單儲存時哈希集合中的最大元素個數。
- hash-max-ziplist-value:表示用壓縮清單儲存時哈希集合中單個元素的最大長度。
在記憶體節省空間方面,哈希表就沒有壓縮清單那麼高效。我們隻用後3位作為Hash類型的key,也就保證哈希集合中元素的個數不會超過1000,同時我們通過設定hash-max-ziplist-entries=1000,來確定Hash類型底層使用的是壓縮清單這種資料結構。
好了,今天的介紹就到這裡。更多硬核知識,請關注公序員學長 。