選擇合適Redis數據結構,減小80%的內存佔用

轉載自:《redis探祕:選擇合適的數據結構,減小80%的內存佔用》- 武偉峯 京東零售
實戰技巧,深刻淺出還手把手教學,值得參考java


前言

redis做爲目前最流行的nosql緩存數據庫,憑藉其優異的性能、豐富的數據結構已成爲大部分場景下首選的緩存工具。redis

因爲redis是一個純內存的數據庫,在存放大量數據時,內存的佔用將會很是可觀。那麼在一些場景下,經過選用合適數據結構來存儲,能夠大幅減小內存的佔用,甚至於能夠減小80%-99%的內存佔用。算法


利用zipList來替代大量的Key-Value

先來看一下場景,在Dsp廣告系統、海量用戶系統常常會碰到這樣的需求,要求根據用戶的某個惟一標識迅速到該用戶id。譬如根據mac地址或uuid或手機號的md5,去查詢到該用戶的id。spring

特色是數據量很大、千萬或億級別,key是比較長的字符串,如32位的md5或者uuid這種。sql

若是不加以處理,直接以key-value形式進行存儲,咱們能夠簡單測試一下,往redis裏插入1千萬條數據,1550000000 - 1559999999,形式就是key(md5(1550000000))→ value(1550000000)這種。數據庫

而後在Redis內用命令info memory看一下內存佔用。 數組

能夠看到,這1千萬條數據,佔用了redis共計1.17G的內存。當數據量變成1個億時,實測大約佔用8個G。緩存

一樣的一批數據,咱們換一種存儲方式,先來看結果:springboot

在咱們利用zipList後,內存佔用爲123M,大約減小了85%的空間佔用,這是怎麼作到的呢?數據結構

redis的底層存儲來剖析。

redis數據結構和編碼方式

redis如何存儲字符串

string是redis裏最經常使用的數據結構,redis的默認字符串和C語言的字符串不一樣,它是本身構建了一種名爲「簡單動態字符串SDS」的抽象類型。

具體到string的底層存儲,redis共用了三種方式,分別是int、embstr和raw。

譬如set k1 abc和set k2 123就會分別用embstr、int。當value的長度大於44(或39,不一樣版本不同)個字節時,會採用raw。

int是一種定長的結構,佔8個字節(注意,至關於java裏的long),只能用來存儲長整形。

embstr是動態擴容的,每次擴容1倍,超過1M時,每次只擴容1M。

raw用來存儲大於44個字節的字符串。

具體到咱們的案例中,key是32個字節的字符串(embstr),value是一個長整形(int),因此若是能將32位的md5變成int,那麼在key的存儲上就能夠直接減小3/4的內存佔用。

這是第一個優化點。

redis如何存儲Hash

從1.1的圖上咱們能夠看到Hash數據結構,在編碼方式上有兩種,1是hashTable,2是zipList。

hashTable你們很熟悉,和java裏的hashMap很像,都是數組+鏈表的方式。java裏hashmap爲了減小hash衝突,設置了負載因子爲0.75。一樣,redis的hash也有相似的擴容負載因子。細節不提,只須要留個印象,用hashTable編碼的話,則會花費至少大於存儲的數據25%的空間才能存下這些數據。它大概長這樣:

zipList,壓縮鏈表,它大概長這樣:

能夠看到,zipList最大的特色就是,它根本不是hash結構,而是一個比較長的字符串,將key-value都按順序依次擺放到一個長長的字符串裏來存儲。若是要找某個key的話,就直接遍歷整個長字符串就行了。

因此很明顯,zipList要比hashTable佔用少的多的空間。可是會耗費更多的cpu來進行查詢。

那麼什麼時候用hashTable、zipList呢?在redis.conf文件中能夠找到:

就是當這個hash結構的內層field-value數量不超過512,而且value的字節數不超過64時,就使用zipList。

經過實測,value數量在512時,性能和單純的hashTable幾乎無差異,在value數量不超過1024時,性能僅有極小的下降,不少時候能夠忽略掉。

而內存佔用,zipList可比hashTable下降了極多。

這是第二個優化點。

用zipList來代替key-value

經過上面的知識,咱們得出了兩個結論。用int做爲key,會比string省不少空間。用hash中的zipList,會比key-value省巨大的空間。

那麼咱們就來改造一下當初的1千萬個key-value。

第一步:

咱們要將1千萬個鍵值對,放到N個bucket中,每一個bucket是一個redis的hash數據結構,而且要讓每一個bucket內不超過默認的512個元素(若是改了配置文件,如1024,則不能超過修改後的值),以免hash將編碼方式從zipList變成hashTable。

1千萬 / 512 = 19531。因爲未來要將全部的key進行哈希算法,來儘可能均攤到全部bucket裏,但因爲哈希函數的不肯定性,未必能徹底平均分配。因此咱們要預留一些空間,譬如我分配25000個bucket,或30000個bucket。

第二步:

選用哈希算法,決定將key放到哪一個bucket。這裏咱們採用高效並且均衡的知名算法crc32,該哈希算法能夠將一個字符串變成一個long型的數字,經過獲取這個md5型的key的crc32後,再對bucket的數量進行取餘,就能夠肯定該key要被放到哪一個bucket中。

第三步:

經過第二步,咱們肯定了key即將存放在的redis裏hash結構的外層key,對於內層field,咱們就選用另外一個hash算法,以免兩個徹底不一樣的值,經過crc32(key) % COUNT後,發生field再次相同,產生hash衝突致使值被覆蓋的狀況。內層field咱們選用bkdr哈希算法(或直接選用Java的hashCode),該算法也會獲得一個long整形的數字。value的存儲保持不變。

第四步:

裝入數據。原來的數據結構是key-value,0eac261f1c2d21e0bfdbd567bb270a68 →  1550000000。

如今的數據結構是hash,key爲14523,field是1927144074,value是1550000000。

經過實測,將1千萬數據存入25000個bucket後,總體hash比較均衡,每一個bucket下大概有300多個field-value鍵值對。理論上只要不發生兩次hash算法後,均產生相同的值,那麼就能夠徹底依靠key-field來找到原始的value。這一點能夠經過計算總量進行確認。實際上,在bucket數量較多時,且每一個bucket下,value數量不是不少,發生連續碰撞機率極低,實測在存儲50億個手機號狀況下,未發生明顯碰撞。

測試查詢速度:

在存儲完這1千萬個數據後,咱們進行了查詢測試,採用key-value型和hash型,分別查詢100萬條數據,看一下對查詢速度的影響。

key-value耗時:1065三、10790、1131八、9900、11270、11029毫秒

hash-field耗時:1204二、1134九、1112六、1135五、11168毫秒。

能夠看到,總體上採用hash存儲後,查詢100萬條耗時,也僅僅增長了500毫秒不到。對性能的影響極其微小。但內存佔用從1.1G變成了120M,帶來了接近90%的內存節省。


總結

大量的key-value,佔用過多的key,redis裏爲了處理hash碰撞,須要佔用更多的空間來存儲這些key-value數據。

若是key的長短不一,譬若有些40位,有些10位,由於對齊問題,那麼將產生巨大的內存碎片,佔用空間狀況更爲嚴重。因此,保持key的長度統一(譬如統一採用int型,定長8個字節),也會對內存佔用有幫助。

string型的md5,佔用了32個字節。而經過hash算法後,將32降到了8個字節的長整形,這顯著下降了key的空間佔用。

zipList比hashTable明顯減小了內存佔用,它的存儲很是緊湊,對查詢效率影響也很小。因此應善於利用zipList,避免在hash結構裏,存放超過512個field-value元素。

若是value是字符串、對象等,應儘可能採用byte[]來存儲,一樣能夠大幅下降內存佔用。譬如能夠選用google的Snappy壓縮算法,將字符串轉爲byte[],很是高效,壓縮率也很高。

爲減小redis對字符串的預分配和擴容(每次翻倍),形成內存碎片,不該該使用append,setrange等。而是直接用set,替換原來的。


方案缺點:

hash結構不支持對單個field的超時設置。但能夠經過代碼來控制刪除,對於那些不須要超時的長期存放的數據,則沒有這種顧慮。

存在較小的hash衝突機率,對於對數據要求極其精確的場合,不適合用這種壓縮方式。

基於上述方案,我改寫了springboot源碼的redisTemplate,提供了一個CompressRedisTemplate類,能夠直接當成redisTemplate使用,它會自動將key-value轉爲hash進行存儲,以達到上述目的。

後續,咱們會基於更極端一些的場景,如統計獨立訪客等,來看一下redis的不常見的數據結構,是如何將內存佔用由20G下降到5M。

如需代碼案例,請聯繫做者:wuweifeng10@jd.com

相關文章
相關標籤/搜索