前言
創(chuàng)新互聯(lián)專業(yè)為企業(yè)提供岱岳網(wǎng)站建設、岱岳做網(wǎng)站、岱岳網(wǎng)站設計、岱岳網(wǎng)站制作等企業(yè)網(wǎng)站建設、網(wǎng)頁設計與制作、岱岳企業(yè)網(wǎng)站模板建站服務,十余年岱岳做網(wǎng)站經(jīng)驗,不只是建網(wǎng)站,更提供有價值的思路和整體網(wǎng)絡服務。redis作為目前最流行的nosql緩存數(shù)據(jù)庫,憑借其優(yōu)異的性能、豐富的數(shù)據(jù)結構已成為大部分場景下選的緩存工具。
由于redis是一個純內存的數(shù)據(jù)庫,在存放大量數(shù)據(jù)時,內存的占用將會非??捎^。那么在一些場景下,通過選用合適數(shù)據(jù)結
構來存儲,可以大幅減少內存的占用,甚至于可以減少80%-99%的內存占用。
利用zipList來替代大量的Key-Value
先來看一下場景,在Dsp廣告系統(tǒng)、海量用戶系統(tǒng)經(jīng)常會碰到這樣的需求,要求根據(jù)用戶的某個唯一標識迅速查到該用戶id。
譬如根據(jù)mac地址或uuid或手機號的md5,去查詢到該用戶的id。
特點是數(shù)據(jù)量很大、千萬或億級別,key是比較長的字符串,如32位的md5或者uuid這種。
如果不加以處理,直接以key-value形式進行存儲,我們可以簡單測試一下,往redis里插入1千萬條數(shù)據(jù),1550000000 - 155
9999999,形式就是key(md5(1550000000))→ value(1550000000)這種。
然后在Redis內用命令info memory看一下內存占用。
可以看到,這1千萬條數(shù)據(jù),占用了redis共計1.17G的內存。當數(shù)據(jù)量變成1個億時,實測大約占用8個G。
同樣的一批數(shù)據(jù),我們換一種存儲方式,先來看結果:
在我們利用zipList后,內存占用為123M,大約減少了85%的空間占用,這是怎么做到的呢?
redis的底層存儲來剖析。
redis數(shù)據(jù)結構和編碼方式
redis如何存儲字符串
string是redis里最常用的數(shù)據(jù)結構,redis的默認字符串和C語言的字符串不同,它是自己構建了一種名為“簡單動態(tài)字符串
SDS”的抽象類型。
具體到string的底層存儲,redis共用了三種方式,分別是int、embstr和raw。
譬如set k1 abc和set k2 123就會分別用embstr、int。當value的長度大于44(或39,不同版本不一樣)個字節(jié)時,會采用
raw。
int是一種定長的結構,占8個字節(jié)(注意,相當于java里的long),只能用來存儲長整形。
embstr是動態(tài)擴容的,每次擴容1倍,超過1M時,每次只擴容1M。
raw用來存儲大于44個字節(jié)的字符串。
具體到我們的案例中,key是32個字節(jié)的字符串(embstr),value是一個長整形(int),所以如果能將32位的md5變成int,
那么在key的存儲上就可以直接減少3/4的內存占用。
這是第一個優(yōu)化點。
redis如何存儲Hash
從1.1的圖上我們可以看到Hash數(shù)據(jù)結構,在編碼方式上有兩種,1是hashTable,2是zipList。
hashTable大家很熟悉,和java里的hashMap很像,都是數(shù)組+鏈表的方式。java里hashmap為了減少hash沖突,設置了負載
因子為0.75。同樣,redis的hash也有類似的擴容負載因子。細節(jié)不提,只需要留個印象,用hashTable編碼的話,則會花費至
少大于存儲的數(shù)據(jù)25%的空間才能存下這些數(shù)據(jù)。它大概長這樣:
zipList,壓縮鏈表,它大概長這樣:
可以看到,zipList大的特點就是,它根本不是hash結構,而是一個比較長的字符串,將key-value都按順序依次擺放到一個
長長的字符串里來存儲。如果要找某個key的話,就直接遍歷整個長字符串就好了。
所以很明顯,zipList要比hashTable占用少的多的空間。但是會耗費更多的cpu來進行查詢。
那么何時用hashTable、zipList呢?在redis.conf文件中可以找到:
就是當這個hash結構的內層field-value數(shù)量不超過512,并且value的字節(jié)數(shù)不超過64時,就使用zipList。
通過實測,value數(shù)量在512時,性能和單純的hashTable幾乎無差別,在value數(shù)量不超過1024時,性能僅有極小的降低,很
多時候可以忽略掉。
而內存占用,zipList可比hashTable降低了極多。
這是第二個優(yōu)化點。
用zipList來代替key-value
通過上面的知識,我們得出了兩個結論。用int作為key,會比string省很多空間。用hash中的zipList,會比key-value省巨大
的空間。
那么我們就來改造一下當初的1千萬個key-value。
第一步:
我們要將1千萬個鍵值對,放到N個bucket中,每個bucket是一個redis的hash數(shù)據(jù)結構,并且要讓每個bucket內不超過默認
的512個元素(如果改了配置文件,如1024,則不能超過修改后的值),以避免hash將編碼方式從zipList變成hashTable。
1千萬 / 512 = 19531。由于將來要將所有的key進行哈希算法,來盡量均攤到所有bucket里,但由于哈希函數(shù)的不確定性,
未必能完全平均分配。所以我們要預留一些空間,譬如我分配25000個bucket,或30000個bucket。
第二步:
選用哈希算法,決定將key放到哪個bucket。這里我們采用高效而且均衡的知名算法crc32,該哈希算法可以將一個字符串變成
一個long型的數(shù)字,通過獲取這個md5型的key的crc32后,再對bucket的數(shù)量進行取余,就可以確定該key要被放到哪個
bucket中。
第三步:
通過第二步,我們確定了key即將存放在的redis里hash結構的外層key,對于內層field,我們就選用另一個hash算法,以避免
兩個完全不同的值,通過crc32(key) % COUNT后,發(fā)生field再次相同,產(chǎn)生hash沖突導致值被覆蓋的情況。內層field我
們選用bkdr哈希算法(或直接選用Java的hashCode),該算法也會得到一個long整形的數(shù)字。value的存儲保持不變。
第四步:
裝入數(shù)據(jù)。原來的數(shù)據(jù)結構是key-value,0eac261f1c2d21e0bfdbd567bb270a68 → 1550000000。
現(xiàn)在的數(shù)據(jù)結構是hash,key為14523,field是1927144074,value是1550000000。
通過實測,將1千萬數(shù)據(jù)存入25000個bucket后,整體hash比較均衡,每個bucket下大概有300多個field-value鍵值對。理論
上只要不發(fā)生兩次hash算法后,均產(chǎn)生相同的值,那么就可以完全依靠key-field來找到原始的value。這一點可以通過計算總
量進行確認。實際上,在bucket數(shù)量較多時,且每個bucket下,value數(shù)量不是很多,發(fā)生連續(xù)碰撞概率極低,實測在存儲
50億個手機號情況下,未發(fā)生明顯碰撞。
測試查詢速度:
在存儲完這1千萬個數(shù)據(jù)后,我們進行了查詢測試,采用key-value型和hash型,分別查詢100萬條數(shù)據(jù),看一下對查詢速度
的影響。
key-value耗時:10653、10790、11318、9900、11270、11029毫秒
hash-field耗時:12042、11349、11126、11355、11168毫秒。
可以看到,整體上采用hash存儲后,查詢100萬條耗時,也僅僅增加了500毫秒不到。對性能的影響極其微小。但內存占用從
1.1G變成了120M,帶來了接近90%的內存節(jié)省。
總結
大量的key-value,占用過多的key,redis里為了處理hash碰撞,需要占用更多的空間來存儲這些key-value數(shù)據(jù)。
如果key的長短不一,譬如有些40位,有些10位,因為對齊問題,那么將產(chǎn)生巨大的內存碎片,占用空間情況更為嚴重。所以
,保持key的長度統(tǒng)一(譬如統(tǒng)一采用int型,定長8個字節(jié)),也會對內存占用有幫助。
string型的md5,占用了32個字節(jié)。而通過hash算法后,將32降到了8個字節(jié)的長整形,這顯著降低了key的空間占用。
zipList比hashTable明顯減少了內存占用,它的存儲非常緊湊,對查詢效率影響也很小。所以應善于利用zipList,避免在
hash結構里,存放超過512個field-value元素。
如果value是字符串、對象等,應盡量采用byte[]來存儲,同樣可以大幅降低內存占用。譬如可以選用google的Snappy壓縮算
法,將字符串轉為byte[],非常高效,壓縮率也很高。
為減少redis對字符串的預分配和擴容(每次翻倍),造成內存碎片,不應該使用append,setrange等。而是直接用set,替
換原來的。
方案缺點:
hash結構不支持對單個field的超時設置。但可以通過代碼來控制刪除,對于那些不需要超時的長期存放的數(shù)據(jù),則沒有這種
顧慮。
存在較小的hash沖突概率,對于對數(shù)據(jù)要求極其精確的場合,不適合用這種壓縮方式。
基于上述方案,我改寫了springboot源碼的redisTemplate,提供了一個CompressRedisTemplate類,可以直接當成
redisTemplate使用,它會自動將key-value轉為hash進行存儲,以達到上述目的。
后續(xù),我們會基于更極端一些的場景,如統(tǒng)計獨立訪客等,來看一下redis的不常見的數(shù)據(jù)結構,是如何將內存占用由20G降低
到5M。