Redis中一個String類型引發的慘案

​ 曾經看到這麼一個案例,有一個團隊需要開發一個圖片存儲系統,要求這個系統能快速記錄圖片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類型底層使用的是壓縮列表這種數據結構。

到此這篇關於Redis中一個String類型引發的慘案的文章就介紹到這瞭,更多相關Redis String類型內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: