今天,我們先了解下 String 類型的內存空間消耗問題,以及選擇節省內存開銷的數據類型的解決方案。
我想和你分享一個之前我面臨的需求案例。
曾經,我們面臨著一個任務,要創建一個高效的圖片存儲系統,要求這個系統能夠快速記錄圖片 ID 和圖片在存儲系統中的唯一標識(我們稱之為圖片存儲對象 ID)。此外,還需要能夠通過圖片 ID 快速檢索到相應的圖片存儲對象 ID。
考慮到圖片數量龐大,我們決定使用 10 位數字來表示圖片 ID 和圖片存儲對象 ID。舉個例子,圖片 ID 可能是 1101000051,對應的存儲對象 ID 則是 3301000051。
photo_id: 1101000051
photo_obj_id: 3301000051
這個案例很明顯地展現了“鍵 - 單值”模式。在這種模式中,每個鍵值對中的值都是一個單一的值,而不是一個值的集合,與 String 類型的數據存儲方式完美契合。
另外,String 類型的數據可以保存二進制字節流,這使得它非常靈活,只需將數據轉換成二進制字節數組,就可以輕松地進行存儲。
因此,我們的初始解決方案是使用 String 類型來存儲數據。我們將圖片 ID 和圖片存儲對象 ID 分別用作鍵值對中的鍵和值,其中圖片存儲對象 ID 使用了 String 類型。
最初,我們成功地存儲了一億張圖片,大約使用了 6.4GB 的內存。但是,隨著圖片數據不斷增加,我們開始遇到了問題,Redis 實例的內存使用量不斷上升,導致生成 RDB 文件時出現延遲的情況。顯然,String 類型并不是一個適合大規模數據存儲的理想選擇,因此我們需要尋找更為節省內存開銷的數據類型解決方案。
在這個過程中,我深入研究了 String 類型的底層結構,找出了它內存開銷較大的原因。這讓我對這個“通用型”的 String 數據類型有了新的認識,它并不適用于所有情況,尤其在內存空間消耗方面存在明顯短板。
與此同時,我還仔細研究了集合類型的數據結構,發現它們具有非常高效的內存管理結構。但是,集合類型的數據結構通常用于保存一鍵多值的數據,不太適用于直接存儲單一鍵對應的單一值。因此,我們采用了二級編碼的方法,成功地使用集合類型來存儲單一鍵值對。這種改變顯著降低了 Redis 實例的內存開銷。
在本篇文章中,我將與你分享我在解決這一問題過程中所獲得的經驗和方法,包括 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)結構體來保存,如下圖所示:
圖片
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 還對 Long 類型整數和 SDS 的內存布局做了專門的設計。
一方面,當保存的是 Long 類型整數時,RedisObject 中的指針就直接賦值為整數數據了,這樣就不用額外的指針再指向整數了,節省了指針的空間開銷。
另一方面,當保存的是字符串數據,并且字符串小于等于 44 字節時,RedisObject 中的元數據、指針和 SDS 是一塊連續的內存區域,這樣就可以避免內存碎片。這種布局方式也被稱為 embstr 編碼方式。
當然,當字符串大于 44 字節時,SDS 的數據量就開始變多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是會給 SDS 分配獨立的空間,并用指針指向 SDS 結構。這種布局方式被稱為 raw 編碼模式。
為了幫助你理解 int、embstr 和 raw 這三種編碼模式,我畫了一張示意圖,如下所示:
圖片
好了,知道了 RedisObject 所包含的額外元數據開銷,現在,我們就可以計算 String 類型的內存使用量了。
因為 10 位數的圖片 ID 和圖片存儲對象 ID 是 Long 類型整數,所以可以直接用 int 編碼的 RedisObject 保存。每個 int 編碼的 RedisObject 元數據部分占 8 字節,指針部分被直接賦值為 8 字節的整數了。此時,每個 ID 會使用 16 字節,加起來一共是 32 字節。但是,另外的 32 字節去哪兒了呢?
Redis 會使用一個全局哈希表保存所有鍵值對,哈希表的每一項是一個 dictEntry 的結構體,用來指向一個鍵值對。dictEntry 結構中有三個 8 字節的指針,分別指向 key、value 以及下一個 dictEntry,三個指針共 24 字節,如下圖所示:
圖片
但是,這三個指針只有 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 集合中了。
以圖片 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# Memoryused_memory:1039120127.0.0.1:6379> hset 1101000 060 3302000080(integer) 1127.0.0.1:6379> info memory# Memoryused_memory:1039136
在使用 String 類型時,每個記錄需要消耗 64 字節,這種方式卻只用了 16 字節,所使用的內存空間是原來的 1/4,滿足了我們節省內存空間的需求。
不過,你可能也會有疑惑:“二級編碼一定要把圖片 ID 的前 7 位作為 Hash 類型的鍵,把最后 3 位作為 Hash 類型值中的 key 嗎?”其實,二級編碼方法中采用的 ID 長度是有講究的。
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 被視為一種“萬金油”,在各種場合都被廣泛使用。然而,當存儲的鍵值對數據本身占用的內存空間較小時,String 類型的元數據開銷占據了主導地位。這些開銷包括 RedisObject 結構、SDS 結構以及dictEntry 結構的內存消耗。
為了應對這種情況,我們可以采用壓縮列表(ziplist)來存儲數據。當然,當使用 Hash 這種集合類型來保存單一鍵值對數據時,我們需要將單一值數據分割成兩部分,分別作為 Hash 集合的鍵和值。就像之前案例中使用了二級編碼來表示圖片 ID那樣,我們鼓勵你將這一方法應用到你的具體場景中。這不僅可以減少內存開銷,還能提高 Redis 的性能。希望這個解決方案對你的應用有所幫助。
本文鏈接:http://www.tebozhan.com/showinfo-26-14711-0.htmlRedis中萬金油的String,為什么不好用了?
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com