Redis百億級Key存儲方案

1 需求背景前端

該應用場景爲DMP緩存存儲需求,DMP須要管理很是多的第三方id數據,其中包括各媒體cookie與自身cookie(如下統稱supperid)的mapping關係,還包括了supperid的人口標籤、移動端id(主要是idfa和imei)的人口標籤,以及一些黑名單id、ip等數據。redis

在hdfs的幫助下離線存儲千億記錄並不困難,然而DMP還須要提供毫秒級的實時查詢。因爲cookie這種id自己具備不穩定性,因此不少的真實用戶的瀏覽行爲會致使大量的新cookie生成,只有及時同步mapping的數據才能命中DMP的人口標籤,沒法經過預熱來獲取較高的命中,這就跟緩存存儲帶來了極大的挑戰。算法

通過實際測試,對於上述數據,常規存儲超過五十億的kv記錄就須要1T多的內存,若是須要作高可用多副本那帶來的消耗是巨大的,另外kv的長短不齊也會帶來不少內存碎片,這就須要超大規模的存儲方案來解決上述問題。後端

2 存儲何種數據 緩存

人⼝標籤主要是cookie、imei、idfa以及其對應的gender(性別)、age(年齡段)、geo(地域)等;mapping關係主要是媒體cookie對supperid的映射。如下是數據存儲⽰示例:cookie

1) PC端的ID:app

媒體編號-媒體cookie=>supperiddom

supperid => { age=>年齡段編碼,gender=>性別編碼,geo=>地理位置編碼 }測試

2) Device端的ID:優化

imei or idfa => { age=>年齡段編碼,gender=>性別編碼,geo=>地理位置編碼 }

顯然PC數據須要存儲兩種key=>value還有key=>hashmap,⽽而Device數據須要存儲⼀一種

key=>hashmap便可。

3 數據特色

  1. 短key短value:其中superid爲21位數字:好比1605242015141689522;imei爲小寫md5:好比2d131005dc0f37d362a5d97094103633;idfa爲大寫帶」-」md5:好比:51DFFC83-9541-4411-FA4F-356927E39D04;
  2. 媒體自身的cookie長短不一;
  3. 須要爲全量數據提供服務,supperid是百億級、媒體映射是千億級、移動id是幾十億級;
  4. 天天有十億級別的mapping關係產生;
  5. 對於較大時間窗口內能夠預判熱數據(有一些存留的穩定cookie);
  6. 對於當前mapping數據沒法預判熱數據,有不少是新生成的cookie;

 

4 存在的技術挑戰

1)長短不一容易形成內存碎片;

2)因爲指針大量存在,內存膨脹率比較高,通常在7倍,純內存存儲通病;

3)雖然能夠經過cookie的行爲預判其熱度,但天天新生成的id依然不少(百分比比較敏感,暫不透露);

4)因爲服務要求在公網環境(國內公網延遲60ms如下)下100ms之內,因此原則上當天新更新的mapping和人口標籤須要所有in memory,而不會讓請求落到後端的冷數據;

5)業務方面,全部數據原則上至少保留35天甚至更久;

6)內存至今也比較昂貴,百億級Key乃至千億級存儲方案勢在必行!

5 解決方案

5.1 淘汰策略 

存儲吃緊的一個重要緣由在於天天會有不少新數據入庫,因此及時清理數據尤其重要。主要方法就是發現和保留熱數據淘汰冷數據。

網民的量級遠遠達不到幾十億的規模,id有必定的生命週期,會不斷的變化。因此很大程度上咱們存儲的id其實是無效的。而查詢其實前端的邏輯就是廣告曝光,跟人的行爲有關,因此一個id在某個時間窗口的(多是一個campaign,半個月、幾個月)訪問行爲上會有必定的重複性。

數據初始化以前,咱們先利用hbase將日誌的id聚合去重,劃定TTL的範圍,通常是35天,這樣能夠砍掉近35天未出現的id。另外在Redis中設置過時時間是35天,當有訪問並命中時,對key進行續命,延長過時時間,未在35天出現的天然淘汰。這樣能夠針對穩定cookie或id有效,實際證實,續命的方法對idfa和imei比較實用,長期積累可達到很是理想的命中。

 

5.2 減小膨脹

Hash表空間大小和Key的個數決定了衝突率(或者用負載因子衡量),再合理的範圍內,key越多天然hash表空間越大,消耗的內存天然也會很大。再加上大量指針自己是長整型,因此內存存儲的膨脹十分可觀。先來談談如何把key的個數減小

你們先來了解一種存儲結構。咱們指望將key1=>value1存儲在redis中,那麼能夠按照以下過程去存儲。先用固定長度的隨機散列md5(key)值做爲redis的key,咱們稱之爲BucketId,而將key1=>value1存儲在hashmap結構中,這樣在查詢的時候就可讓client按照上面的過程計算出散列,從而查詢到value1。

過程變化簡單描述爲:get(key1) -> hget(md5(key1), key1) 從而獲得value1。 

若是咱們經過預先計算,讓不少key能夠在BucketId空間裏碰撞,那麼能夠認爲一個BucketId下面掛了多個key。好比平均每一個BucketId下面掛10個key,那麼理論上咱們將會減小超過90%的redis key的個數。

具體實現起來有一些麻煩,並且用這個方法以前你要想好容量規模。咱們一般使用的md5是32位的hexString(16進制字符),它的空間是128bit,這個量級太大了,咱們須要存儲的是百億級,大約是33bit,因此咱們須要有一種機制計算出合適位數的散列,並且爲了節約內存,咱們須要利用所有字符類型(ASCII碼在0~127之間)來填充,而不用HexString,這樣Key的長度能夠縮短到一半。

下面是具體的實現方式 

public static byte [] getBucketId(byte [] key, Integer bit) {

MessageDigest mdInst = MessageDigest.getInstance("MD5");

mdInst.update(key);

byte [] md = mdInst.digest();

byte [] r = new byte[(bit-1)/7 + 1];// 由於一個字節中只有7位可以表示成單字符

int a = (int) Math.pow(2, bit%7)-2;

md[r.length-1] = (byte) (md[r.length-1] & a);

System.arraycopy(md, 0, r, 0, r.length);

for(int i=0;i<r.length;i++) {

if(r[i]<0) r[i] &= 127;

}

return r;

} 

參數bit決定了最終BucketId空間的大小,空間大小集合是2的整數冪次的離散值。這裏解釋一下爲什麼一個字節中只有7位可用,是由於redis存儲key時須要是ASCII(0~127),而不是byte array。若是規劃百億級存儲,計劃每一個桶分擔10個kv,那麼咱們只需2^30=1073741824的桶個數便可,也就是最終key的個數。

 

5.3 減小碎片

 

碎片主要緣由在於內存沒法對齊、過時刪除後,內存沒法從新分配。經過上文描述的方式,咱們能夠將人口標籤和mapping數據按照上面的方式去存儲,這樣的好處就是redis key是等長的。另外對於hashmap中的key咱們也作了相關優化,截取cookie或者deviceid的後六位做爲key,這樣也能夠保證內存對齊,理論上會有衝突的可能性,但在同一個桶內後綴相同的機率極低(試想id幾乎是隨機的字符串,隨意10個由較長字符組成的id後綴相同的機率*桶樣本數=發生衝突的指望值<<0.05,也就是說出現一個衝突樣本則是極小機率事件,並且這個機率能夠經過調整後綴保留長度控制指望值)。而value只存儲age、gender、geo的編碼,用三個字節去存儲。

另外提一下,減小碎片還有個很low可是有效的方法,將slave重啓,而後強制的failover切換主從,這樣至關於給master整理的內存的碎片。

推薦Google-tcmalloc, facebook-jemalloc內存分配,能夠在value不大時減小內存碎片和內存消耗。有人測過大value狀況下反而libc更節約。

 

6. md5散列桶的方法須要注意的問題

 

1)kv存儲的量級必須事先規劃好,浮動的範圍大概在桶個數的十到十五倍,好比我就想存儲百億左右的kv,那麼最好選擇30bit~31bit做爲桶的個數。也就是說業務增加在一個合理的範圍(10~15倍的增加)是沒問題的,若是業務太多倍數的增加,會致使hashset增加過快致使查詢時間增長,甚至觸發zip-list閾值,致使內存急劇上升。

2)適合短小value,若是value太大或字段太多並不適合,由於這種方式必需要求把value一次性取出,好比人口標籤是很是小的編碼,甚至只須要三、4個bit(位)就能裝下。

3)典型的時間換空間的作法,因爲咱們的業務場景並非要求在極高的qps之下,通常天天億到十億級別的量,因此合理利用CPU租值,也是十分經濟的。

4)因爲使用了信息摘要下降了key的大小以及約定長度,因此沒法從redis裏面random出key。若是須要導出,必須在冷數據中導出。

5)expire須要本身實現,目前的算法很簡單,因爲只有在寫操做時纔會增長消耗,因此在寫操做時按照必定的比例抽樣,用HLEN命中判斷是否超過15個entry,超過纔將過時的key刪除,TTL的時間戳存儲在value的前32bit中。

6)桶的消耗統計是須要作的。須要按期清理過時的key,保證redis的查詢不會變慢。

 

7. 測試結果

 

人口標籤和mapping的數據100億條記錄。

優化前用2.3T,碎片率在2左右;優化後500g,而單個桶的平均消耗在4左右。碎片率在1.02左右。查詢時這對於cpu的耗損微乎其微。

另外須要提一下的是,每一個桶的消耗實際上並非均勻的,而是符合多項式分佈的。

 

 

上面的公式能夠計算桶消耗的機率分佈。公式是唬人用的,只是爲了提醒你們不要想固然的認爲桶消耗是徹底均勻的,有可能有的桶會有上百個key。但事實並不沒有那麼誇張。試想一下投硬幣,結果只有兩種正反面。至關於只有兩個桶,若是你投上無限屢次,每一次至關於一次伯努利實驗,那麼兩個桶必然會十分的均勻。機率分佈就像上帝施的魔咒同樣,當你面對大量的桶進行不少的廣義的伯努利實驗。桶的消耗分佈就會趨於一種穩定的值。接下來咱們就瞭解一下桶消耗分佈具體什麼狀況:

經過採樣統計

31bit(20多億)的桶,平均4.18消耗

 

 

100億節約了1.8T內存。至關於節約了原先的78%內存,並且桶消耗指標遠沒有達到預計的底線值15。

對於未出現的桶也是存在必定量的,若是過多會致使規劃不許確,其實數量是符合二項分佈的,對於2^30桶存儲2^32kv,不存在的桶大概有(百萬級別,影響不大):

 

Math.pow((1 - 1.0 / Math.pow(2, 30)), Math.pow(2, 32)) * Math.pow(2, 30);

 

對於桶消耗不均衡的問題沒必要太擔憂,隨着時間的推移,寫入時會對HLEN超過15的桶進行削減,根據多項式分佈的原理,當實驗次數多到必定程度時,桶的分佈就會趨於均勻(硬幣投擲無數次,那麼正反面出現次數應該是一致的),只不過咱們經過expire策略削減了桶消耗,實際上對於每一個桶已經經歷了不少的實驗發生。

 

總結:信息摘要在這種場景下不只能節約key存儲,對齊了內存,還能讓Key按照多項式分佈均勻的散列在更少許的key下面從而減小膨脹,另外無需在給key設置expire,也很大程度上節約了空間。

這也印證了時間換空間的基本理論,合理利用CPU租值也是須要考慮的。

相關文章
相關標籤/搜索