今天,我們先了解下 String 類型的內(nèi)存空間消耗問(wèn)題,以及選擇節(jié)省內(nèi)存開(kāi)銷的數(shù)據(jù)類型的解決方案。
我想和你分享一個(gè)之前我面臨的需求案例。
曾經(jīng),我們面臨著一個(gè)任務(wù),要?jiǎng)?chuàng)建一個(gè)高效的圖片存儲(chǔ)系統(tǒng),要求這個(gè)系統(tǒng)能夠快速記錄圖片 ID 和圖片在存儲(chǔ)系統(tǒng)中的唯一標(biāo)識(shí)(我們稱之為圖片存儲(chǔ)對(duì)象 ID)。此外,還需要能夠通過(guò)圖片 ID 快速檢索到相應(yīng)的圖片存儲(chǔ)對(duì)象 ID。
考慮到圖片數(shù)量龐大,我們決定使用 10 位數(shù)字來(lái)表示圖片 ID 和圖片存儲(chǔ)對(duì)象 ID。舉個(gè)例子,圖片 ID 可能是 1101000051,對(duì)應(yīng)的存儲(chǔ)對(duì)象 ID 則是 3301000051。
photo_id: 1101000051
photo_obj_id: 3301000051
這個(gè)案例很明顯地展現(xiàn)了“鍵 - 單值”模式。在這種模式中,每個(gè)鍵值對(duì)中的值都是一個(gè)單一的值,而不是一個(gè)值的集合,與 String 類型的數(shù)據(jù)存儲(chǔ)方式完美契合。
另外,String 類型的數(shù)據(jù)可以保存二進(jìn)制字節(jié)流,這使得它非常靈活,只需將數(shù)據(jù)轉(zhuǎn)換成二進(jìn)制字節(jié)數(shù)組,就可以輕松地進(jìn)行存儲(chǔ)。
因此,我們的初始解決方案是使用 String 類型來(lái)存儲(chǔ)數(shù)據(jù)。我們將圖片 ID 和圖片存儲(chǔ)對(duì)象 ID 分別用作鍵值對(duì)中的鍵和值,其中圖片存儲(chǔ)對(duì)象 ID 使用了 String 類型。
最初,我們成功地存儲(chǔ)了一億張圖片,大約使用了 6.4GB 的內(nèi)存。但是,隨著圖片數(shù)據(jù)不斷增加,我們開(kāi)始遇到了問(wèn)題,redis 實(shí)例的內(nèi)存使用量不斷上升,導(dǎo)致生成 RDB 文件時(shí)出現(xiàn)延遲的情況。顯然,String 類型并不是一個(gè)適合大規(guī)模數(shù)據(jù)存儲(chǔ)的理想選擇,因此我們需要尋找更為節(jié)省內(nèi)存開(kāi)銷的數(shù)據(jù)類型解決方案。
在這個(gè)過(guò)程中,我深入研究了 String 類型的底層結(jié)構(gòu),找出了它內(nèi)存開(kāi)銷較大的原因。這讓我對(duì)這個(gè)“通用型”的 String 數(shù)據(jù)類型有了新的認(rèn)識(shí),它并不適用于所有情況,尤其在內(nèi)存空間消耗方面存在明顯短板。
與此同時(shí),我還仔細(xì)研究了集合類型的數(shù)據(jù)結(jié)構(gòu),發(fā)現(xiàn)它們具有非常高效的內(nèi)存管理結(jié)構(gòu)。但是,集合類型的數(shù)據(jù)結(jié)構(gòu)通常用于保存一鍵多值的數(shù)據(jù),不太適用于直接存儲(chǔ)單一鍵對(duì)應(yīng)的單一值。因此,我們采用了二級(jí)編碼的方法,成功地使用集合類型來(lái)存儲(chǔ)單一鍵值對(duì)。這種改變顯著降低了 Redis 實(shí)例的內(nèi)存開(kāi)銷。
在本篇文章中,我將與你分享我在解決這一問(wèn)題過(guò)程中所獲得的經(jīng)驗(yàn)和方法,包括 String 類型的內(nèi)存開(kāi)銷問(wèn)題,可節(jié)省內(nèi)存的數(shù)據(jù)結(jié)構(gòu)選擇,以及如何使用集合類型來(lái)存儲(chǔ)單一鍵值對(duì)。如果你在使用 String 類型時(shí)也遇到了內(nèi)存開(kāi)銷較大的問(wèn)題,那么今天的解決方案可能會(huì)對(duì)你有所幫助。
接下來(lái),我們先來(lái)看看 String 類型的內(nèi)存都消耗在哪里了。
為什么 String 類型內(nèi)存開(kāi)銷大?
在剛才的案例中,我們保存了 1 億張圖片的信息,用了約 6.4GB 的內(nèi)存,一個(gè)圖片 ID 和圖片存儲(chǔ)對(duì)象 ID 的記錄平均用了 64 字節(jié)。
但問(wèn)題是,一組圖片 ID 及其存儲(chǔ)對(duì)象 ID 的記錄,實(shí)際只需要 16 字節(jié)就可以了。
我們來(lái)分析一下。圖片 ID 和圖片存儲(chǔ)對(duì)象 ID 都是 10 位數(shù),我們可以用兩個(gè) 8 字節(jié)的 Long 類型表示這兩個(gè) ID。因?yàn)?8 字節(jié)的 Long 類型最大可以表示 2 的 64 次方的數(shù)值,所以肯定可以表示 10 位數(shù)。但是,為什么 String 類型卻用了 64 字節(jié)呢?
其實(shí),除了記錄實(shí)際數(shù)據(jù),String 類型還需要額外的內(nèi)存空間記錄數(shù)據(jù)長(zhǎng)度、空間使用等信息,這些信息也叫作元數(shù)據(jù)。當(dāng)實(shí)際保存的數(shù)據(jù)較小時(shí),元數(shù)據(jù)的空間開(kāi)銷就顯得比較大了,有點(diǎn)“喧賓奪主”的意思。
那么,String 類型具體是怎么保存數(shù)據(jù)的呢?我來(lái)解釋一下。
當(dāng)你保存 64 位有符號(hào)整數(shù)時(shí),String 類型會(huì)把它保存為一個(gè) 8 字節(jié)的 Long 類型整數(shù),這種保存方式通常也叫作 int 編碼方式。
但是,當(dāng)你保存的數(shù)據(jù)中包含字符時(shí),String 類型就會(huì)用簡(jiǎn)單動(dòng)態(tài)字符串(Simple Dynamic String,SDS)結(jié)構(gòu)體來(lái)保存,如下圖所示:
圖片
buf:字節(jié)數(shù)組,保存實(shí)際數(shù)據(jù)。為了表示字節(jié)數(shù)組的結(jié)束,Redis 會(huì)自動(dòng)在數(shù)組最后加一個(gè)“”,這就會(huì)額外占用 1 個(gè)字節(jié)的開(kāi)銷。
len:占 4 個(gè)字節(jié),表示 buf 的已用長(zhǎng)度。
alloc:也占個(gè) 4 字節(jié),表示 buf 的實(shí)際分配長(zhǎng)度,一般大于 len。
可以看到,在 SDS 中,buf 保存實(shí)際數(shù)據(jù),而 len 和 alloc 本身其實(shí)是 SDS 結(jié)構(gòu)體的額外開(kāi)銷。
另外,對(duì)于 String 類型來(lái)說(shuō),除了 SDS 的額外開(kāi)銷,還有一個(gè)來(lái)自于 RedisObject 結(jié)構(gòu)體的開(kāi)銷。
因?yàn)?Redis 的數(shù)據(jù)類型有很多,而且,不同數(shù)據(jù)類型都有些相同的元數(shù)據(jù)要記錄(比如最后一次訪問(wèn)的時(shí)間、被引用的次數(shù)等),所以,Redis 會(huì)用一個(gè) RedisObject 結(jié)構(gòu)體來(lái)統(tǒng)一記錄這些元數(shù)據(jù),同時(shí)指向?qū)嶋H數(shù)據(jù)。
一個(gè) RedisObject 包含了 8 字節(jié)的元數(shù)據(jù)和一個(gè) 8 字節(jié)指針,這個(gè)指針再進(jìn)一步指向具體數(shù)據(jù)類型的實(shí)際數(shù)據(jù)所在,例如指向 String 類型的 SDS 結(jié)構(gòu)所在的內(nèi)存地址,可以看一下下面的示意圖。關(guān)于 RedisObject 的具體結(jié)構(gòu)細(xì)節(jié),我會(huì)在后面的課程中詳細(xì)介紹,現(xiàn)在你只要了解它的基本結(jié)構(gòu)和元數(shù)據(jù)開(kāi)銷就行了。
圖片
為了節(jié)省內(nèi)存空間,Redis 還對(duì) Long 類型整數(shù)和 SDS 的內(nèi)存布局做了專門的設(shè)計(jì)。
一方面,當(dāng)保存的是 Long 類型整數(shù)時(shí),RedisObject 中的指針就直接賦值為整數(shù)數(shù)據(jù)了,這樣就不用額外的指針再指向整數(shù)了,節(jié)省了指針的空間開(kāi)銷。
另一方面,當(dāng)保存的是字符串?dāng)?shù)據(jù),并且字符串小于等于 44 字節(jié)時(shí),RedisObject 中的元數(shù)據(jù)、指針和 SDS 是一塊連續(xù)的內(nèi)存區(qū)域,這樣就可以避免內(nèi)存碎片。這種布局方式也被稱為 embstr 編碼方式。
當(dāng)然,當(dāng)字符串大于 44 字節(jié)時(shí),SDS 的數(shù)據(jù)量就開(kāi)始變多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是會(huì)給 SDS 分配獨(dú)立的空間,并用指針指向 SDS 結(jié)構(gòu)。這種布局方式被稱為 raw 編碼模式。
為了幫助你理解 int、embstr 和 raw 這三種編碼模式,我畫(huà)了一張示意圖,如下所示:
圖片
好了,知道了 RedisObject 所包含的額外元數(shù)據(jù)開(kāi)銷,現(xiàn)在,我們就可以計(jì)算 String 類型的內(nèi)存使用量了。
因?yàn)?10 位數(shù)的圖片 ID 和圖片存儲(chǔ)對(duì)象 ID 是 Long 類型整數(shù),所以可以直接用 int 編碼的 RedisObject 保存。每個(gè) int 編碼的 RedisObject 元數(shù)據(jù)部分占 8 字節(jié),指針部分被直接賦值為 8 字節(jié)的整數(shù)了。此時(shí),每個(gè) ID 會(huì)使用 16 字節(jié),加起來(lái)一共是 32 字節(jié)。但是,另外的 32 字節(jié)去哪兒了呢?
Redis 會(huì)使用一個(gè)全局哈希表保存所有鍵值對(duì),哈希表的每一項(xiàng)是一個(gè) dictEntry 的結(jié)構(gòu)體,用來(lái)指向一個(gè)鍵值對(duì)。dictEntry 結(jié)構(gòu)中有三個(gè) 8 字節(jié)的指針,分別指向 key、value 以及下一個(gè) dictEntry,三個(gè)指針共 24 字節(jié),如下圖所示:
圖片
但是,這三個(gè)指針只有 24 字節(jié),為什么會(huì)占用了 32 字節(jié)呢?這就要提到 Redis 使用的內(nèi)存分配庫(kù) jemalloc 了。
jemalloc 在分配內(nèi)存時(shí),會(huì)根據(jù)我們申請(qǐng)的字節(jié)數(shù) N,找一個(gè)比 N 大,但是最接近 N 的 2 的冪次數(shù)作為分配的空間,這樣可以減少頻繁分配的次數(shù)。
舉個(gè)例子。如果你申請(qǐng) 6 字節(jié)空間,jemalloc 實(shí)際會(huì)分配 8 字節(jié)空間;如果你申請(qǐng) 24 字節(jié)空間,jemalloc 則會(huì)分配 32 字節(jié)。所以,在我們剛剛說(shuō)的場(chǎng)景里,dictEntry 結(jié)構(gòu)就占用了 32 字節(jié)。
好了,到這兒,你應(yīng)該就能理解,為什么用 String 類型保存圖片 ID 和圖片存儲(chǔ)對(duì)象 ID 時(shí)需要用 64 個(gè)字節(jié)了。
你看,明明有效信息只有 16 字節(jié),使用 String 類型保存時(shí),卻需要 64 字節(jié)的內(nèi)存空間,有 48 字節(jié)都沒(méi)有用于保存實(shí)際的數(shù)據(jù)。我們來(lái)?yè)Q算下,如果要保存的圖片有 1 億張,那么 1 億條的圖片 ID 記錄就需要 6.4GB 內(nèi)存空間,其中有 4.8GB 的內(nèi)存空間都用來(lái)保存元數(shù)據(jù)了,額外的內(nèi)存空間開(kāi)銷很大。那么,有沒(méi)有更加節(jié)省內(nèi)存的方法呢?
用什么數(shù)據(jù)結(jié)構(gòu)可以節(jié)省內(nèi)存?
Redis 有一種底層數(shù)據(jù)結(jié)構(gòu),叫壓縮列表(ziplist),這是一種非常節(jié)省內(nèi)存的結(jié)構(gòu)。
我們先回顧下壓縮列表的構(gòu)成。表頭有三個(gè)字段 zlbytes、zltAIl 和 zllen,分別表示列表長(zhǎng)度、列表尾的偏移量,以及列表中的 entry 個(gè)數(shù)。壓縮列表尾還有一個(gè) zlend,表示列表結(jié)束。
圖片
壓縮列表之所以能節(jié)省內(nèi)存,就在于它是用一系列連續(xù)的 entry 保存數(shù)據(jù)。每個(gè) entry 的元數(shù)據(jù)包括下面幾部分。
prev_len,表示前一個(gè) entry 的長(zhǎng)度。prev_len 有兩種取值情況:1 字節(jié)或 5 字節(jié)。取值 1 字節(jié)時(shí),表示上一個(gè) entry 的長(zhǎng)度小于 254 字節(jié)。雖然 1 字節(jié)的值能表示的數(shù)值范圍是 0 到 255,但是壓縮列表中 zlend 的取值默認(rèn)是 255,因此,就默認(rèn)用 255 表示整個(gè)壓縮列表的結(jié)束,其他表示長(zhǎng)度的地方就不能再用 255 這個(gè)值了。所以,當(dāng)上一個(gè) entry 長(zhǎng)度小于 254 字節(jié)時(shí),prev_len 取值為 1 字節(jié),否則,就取值為 5 字節(jié)。
len:表示自身長(zhǎng)度,4 字節(jié);
encoding:表示編碼方式,1 字節(jié);
content:保存實(shí)際數(shù)據(jù)。
這些 entry 會(huì)挨個(gè)兒放置在內(nèi)存中,不需要再用額外的指針進(jìn)行連接,這樣就可以節(jié)省指針?biāo)加玫目臻g。
我們以保存圖片存儲(chǔ)對(duì)象 ID 為例,來(lái)分析一下壓縮列表是如何節(jié)省內(nèi)存空間的。
每個(gè) entry 保存一個(gè)圖片存儲(chǔ)對(duì)象 ID(8 字節(jié)),此時(shí),每個(gè) entry 的 prev_len 只需要 1 個(gè)字節(jié)就行,因?yàn)槊總€(gè) entry 的前一個(gè) entry 長(zhǎng)度都只有 8 字節(jié),小于 254 字節(jié)。這樣一來(lái),一個(gè)圖片的存儲(chǔ)對(duì)象 ID 所占用的內(nèi)存大小是 14 字節(jié)(1+4+1+8=14),實(shí)際分配 16 字節(jié)。
Redis 基于壓縮列表實(shí)現(xiàn)了 List、Hash 和 Sorted Set 這樣的集合類型,這樣做的最大好處就是節(jié)省了 dictEntry 的開(kāi)銷。當(dāng)你用 String 類型時(shí),一個(gè)鍵值對(duì)就有一個(gè) dictEntry,要用 32 字節(jié)空間。但采用集合類型時(shí),一個(gè) key 就對(duì)應(yīng)一個(gè)集合的數(shù)據(jù),能保存的數(shù)據(jù)多了很多,但也只用了一個(gè) dictEntry,這樣就節(jié)省了內(nèi)存。
這個(gè)方案聽(tīng)起來(lái)很好,但還存在一個(gè)問(wèn)題:在用集合類型保存鍵值對(duì)時(shí),一個(gè)鍵對(duì)應(yīng)了一個(gè)集合的數(shù)據(jù),但是在我們的場(chǎng)景中,一個(gè)圖片 ID 只對(duì)應(yīng)一個(gè)圖片的存儲(chǔ)對(duì)象 ID,我們?cè)撛趺从眉项愋湍兀繐Q句話說(shuō),在一個(gè)鍵對(duì)應(yīng)一個(gè)值(也就是單值鍵值對(duì))的情況下,我們?cè)撛趺从眉项愋蛠?lái)保存這種單值鍵值對(duì)呢?
如何用集合類型保存單值的鍵值對(duì)?
在保存單值的鍵值對(duì)時(shí),可以采用基于 Hash 類型的二級(jí)編碼方法。這里說(shuō)的二級(jí)編碼,就是把一個(gè)單值的數(shù)據(jù)拆分成兩部分,前一部分作為 Hash 集合的 key,后一部分作為 Hash 集合的 value,這樣一來(lái),我們就可以把單值數(shù)據(jù)保存到 Hash 集合中了。
以圖片 ID 1101000060 和圖片存儲(chǔ)對(duì)象 ID 3302000080 為例,我們可以把圖片 ID 的前 7 位(1101000)作為 Hash 類型的鍵,把圖片 ID 的最后 3 位(060)和圖片存儲(chǔ)對(duì)象 ID 分別作為 Hash 類型值中的 key 和 value。
按照這種設(shè)計(jì)方法,我在 Redis 中插入了一組圖片 ID 及其存儲(chǔ)對(duì)象 ID 的記錄,并且用 info 命令查看了內(nèi)存開(kāi)銷,我發(fā)現(xiàn),增加一條記錄后,內(nèi)存占用只增加了 16 字節(jié),如下所示:
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 類型時(shí),每個(gè)記錄需要消耗 64 字節(jié),這種方式卻只用了 16 字節(jié),所使用的內(nèi)存空間是原來(lái)的 1/4,滿足了我們節(jié)省內(nèi)存空間的需求。
不過(guò),你可能也會(huì)有疑惑:“二級(jí)編碼一定要把圖片 ID 的前 7 位作為 Hash 類型的鍵,把最后 3 位作為 Hash 類型值中的 key 嗎?”其實(shí),二級(jí)編碼方法中采用的 ID 長(zhǎng)度是有講究的。
Redis Hash 類型的兩種底層實(shí)現(xiàn)結(jié)構(gòu),分別是壓縮列表和哈希表。
那么,Hash 類型底層結(jié)構(gòu)什么時(shí)候使用壓縮列表,什么時(shí)候使用哈希表呢?其實(shí),Hash 類型設(shè)置了用壓縮列表保存數(shù)據(jù)時(shí)的兩個(gè)閾值,一旦超過(guò)了閾值,Hash 類型就會(huì)用哈希表來(lái)保存數(shù)據(jù)了。
這兩個(gè)閾值分別對(duì)應(yīng)以下兩個(gè)配置項(xiàng):
hash-max-ziplist-entries:表示用壓縮列表保存時(shí)哈希集合中的最大元素個(gè)數(shù)。
hash-max-ziplist-value:表示用壓縮列表保存時(shí)哈希集合中單個(gè)元素的最大長(zhǎng)度。
如果我們往 Hash 集合中寫入的元素個(gè)數(shù)超過(guò)了 hash-max-ziplist-entries,或者寫入的單個(gè)元素大小超過(guò)了 hash-max-ziplist-value,Redis 就會(huì)自動(dòng)把 Hash 類型的實(shí)現(xiàn)結(jié)構(gòu)由壓縮列表轉(zhuǎn)為哈希表。
一旦從壓縮列表轉(zhuǎn)為了哈希表,Hash 類型就會(huì)一直用哈希表進(jìn)行保存,而不會(huì)再轉(zhuǎn)回壓縮列表了。在節(jié)省內(nèi)存空間方面,哈希表就沒(méi)有壓縮列表那么高效了。
為了能充分使用壓縮列表的精簡(jiǎn)內(nèi)存布局,我們一般要控制保存在 Hash 集合中的元素個(gè)數(shù)。所以,在剛才的二級(jí)編碼中,我們只用圖片 ID 最后 3 位作為 Hash 集合的 key,也就保證了 Hash 集合的元素個(gè)數(shù)不超過(guò) 1000,同時(shí),我們把 hash-max-ziplist-entries 設(shè)置為 1000,這樣一來(lái),Hash 集合就可以一直使用壓縮列表來(lái)節(jié)省內(nèi)存空間了。
小結(jié)
在這篇文章中,我們將顛覆以往對(duì) String 數(shù)據(jù)類型的傳統(tǒng)認(rèn)知。以前,String 被視為一種“萬(wàn)金油”,在各種場(chǎng)合都被廣泛使用。然而,當(dāng)存儲(chǔ)的鍵值對(duì)數(shù)據(jù)本身占用的內(nèi)存空間較小時(shí),String 類型的元數(shù)據(jù)開(kāi)銷占據(jù)了主導(dǎo)地位。這些開(kāi)銷包括 RedisObject 結(jié)構(gòu)、SDS 結(jié)構(gòu)以及dictEntry 結(jié)構(gòu)的內(nèi)存消耗。
為了應(yīng)對(duì)這種情況,我們可以采用壓縮列表(ziplist)來(lái)存儲(chǔ)數(shù)據(jù)。當(dāng)然,當(dāng)使用 Hash 這種集合類型來(lái)保存單一鍵值對(duì)數(shù)據(jù)時(shí),我們需要將單一值數(shù)據(jù)分割成兩部分,分別作為 Hash 集合的鍵和值。就像之前案例中使用了二級(jí)編碼來(lái)表示圖片 ID那樣,我們鼓勵(lì)你將這一方法應(yīng)用到你的具體場(chǎng)景中。這不僅可以減少內(nèi)存開(kāi)銷,還能提高 Redis 的性能。希望這個(gè)解決方案對(duì)你的應(yīng)用有所幫助。