1. 程式人生 > >大資料量的方案收集--AdMaster 如何駕馭百億級Key實時Redis 叢集

大資料量的方案收集--AdMaster 如何駕馭百億級Key實時Redis 叢集

注:本文轉載自公眾號AdMaster

作為技術驅動的營銷資料公司,AdMaster每天處理超過100億的資料請求,每天對1000億資料進行上千種維度計算,每天增加超過5T資料量,為來自各行業的客戶提供7*24小時資料應用服務。在這樣領先的技術佈局下,無論是資料實時性還是資料安全,都能得到最高級別的保障。在資料實時處理方面,以AdMaster旗下兩款領先和已獲行業認可的重型SaaS產品AdMaster DMP和SmartServing來說,如何強大的底層構架技術能夠支援AdMaster DMP和SmartServing為廣告主提供營銷過程中的資料實時查詢、透明、高效直採媒介優質資源的投放控制及複雜人群的實時定向等領先功能?以下唯技術牛才懂的火星語,帶大家感受下大資料技術之美!

應用場景

該應用場景為解決AdMaster DMP快取儲存需求,DMP需要管理非常多的第三方id資料,其中包括各媒體cookie id與自身cookie id(以下統稱admckid)的mapping關係,還包括了admckid的人口標籤、移動端id(主要是idfa和imei)的人口標籤,以及一些黑名單id、ip等資料。

在HDFS的幫助下離線儲存千億記錄並不困難,然而DMP還需要提供毫秒級的實時查詢。由於cookie這種id本身具有不穩定性,所以很多的真實使用者的瀏覽行為會導致大量的新cookie生成,只有及時同步mapping的資料才能命中DMP的人口標籤,無法通過預熱來獲取較高的命中,這就跟快取儲存帶來了極大的挑戰。

經過實際測試,對於上述資料,常規儲存超過五十億的kv記錄就需要1T多的記憶體,如果需要做高可用多副本那帶來的消耗是巨大的,另外kv的長短不齊也會帶來很多記憶體碎片,這就需要超大規模的儲存方案來解決上述問題。

儲存何種資料

人口標籤主要是cookie id、imei、idfa以及其對應的gender(性別)、age(年齡段)、geo(地域)等;mapping關係主要是媒體cookie id對admckid的對映。以下是資料儲存示例:

資料特點

  1. 短key短value:其中superid為19位字元:比如s17b2661d0354ba3380;imei為小寫md5:比如2d131005dc0f37d362a5d97094103633;idfa為大寫帶”-”md5:比如:51DFFC83-9541-4411-FA4F-356927E39D04;

  2. 媒體自身的cookie id長短不一;

  3. 需要為全量資料提供服務,admckid是百億級(一個月)、媒體對映是千億級、移動id是幾十億級;

  4. 每天有幾十億級別的mapping關係產生;

  5. 對於較大時間視窗內可以預判熱資料(有一些存留的穩定cookie);

  6. 對於當前mapping資料無法預判熱資料,有很多是新生成的cookie;

存在的技術挑戰

1)長短不一容易造成記憶體碎片;

2)由於指標大量存在,記憶體膨脹率比較高,一般在7倍,純記憶體儲存通病;

3)雖然可以通過cookie的行為預判其熱度,但每天新生成的id依然很多(百分比比較敏感,暫不透露);

4)由於服務要求在公網環境(國內公網延遲60ms以下)下100ms以內,所以原則上當天新更新的mapping和人口標籤需要全部in memory,而不會讓請求落到後端的冷資料;

5)業務方面,所有資料原則上至少保留1個月甚至更久;

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

解決方案

淘汰策略

儲存吃緊的一個重要原因在於每天會有很多新資料入庫,所以及時清理資料尤為重要。主要方法就是發現和保留熱資料淘汰冷資料。

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

資料初始化之前,我們先利用hbase將日誌的id聚合去重,劃定TTL的範圍,一般是1個月,這樣可以砍掉近1個月未出現的id。另外在Redis中設定過期時間是1個月,當有訪問並命中時,對key進行續命,延長過期時間,未在1個月出現的自然淘汰。這樣可以針對穩定cookie或id有效,實際證明,續命的方法對idfa和imei比較實用,長期積累可達到非常理想的命中。

減少膨脹

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的長度可以縮短到一半。

下面是具體的實現方式:

引數bit決定了最終BucketId空間的大小,空間大小集合是2的整數冪次的離散值。這裡解釋一下為何一個位元組中只有7位可用,是因為Redis儲存key時需要是ASCII(0~127),而不是byte array。如果規劃百億級儲存,計劃每個桶分擔10個kv,那麼我們只需2^30=1073741824的桶個數即可,也就是最終key的個數。

減少碎片

碎片主要原因在於記憶體無法對齊、過期刪除後,記憶體無法重新分配。通過上文描述的方式,我們可以將人口標籤和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更節約。

md5雜湊桶的方法需要注意的問題

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

2)適合短小value,如果value太大或欄位太多並不適合,因為這種方式必須要求把value一次性取出,比如人口標籤是非常小的編碼,甚至只需要3、4個bit(位)就能裝下。

3)典型的時間換空間的做法,由於我們的業務場景並不是要求在極高的QPS之下,一般每天億到十億級別的量,所以合理利用CPU租值,也是十分經濟的。

4)由於使用了資訊摘要降低了key的大小以及約定長度,所以無法從Redis裡面random出key。如果需要匯出,必須在冷資料中匯出。

5)expire需要自己實現,目前的演算法很簡單,由於只有在寫操作時才會增加消耗,所以在寫操作時按照一定的比例抽樣,用HLEN命中判斷是否超過15個entry,超過才將過期的key刪除,TTL的時間戳儲存在value的前32bit中。

6)桶的消耗統計是需要做的。需要定期清理過期的key,保證Redis的查詢不會變慢。

測試結果

人口標籤和mapping的資料100億條記錄。

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

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

上面的公式可以計算桶消耗的概率分佈。公式是唬人用的,只是為了提醒大家不要想當然的認為桶消耗是完全均勻的,有可能有的桶會有上百個key。但事實並不沒有那麼誇張。試想一下投硬幣,結果只有兩種正反面。相當於只有兩個桶,如果你投上無限多次,每一次相當於一次伯努利實驗,那麼兩個桶必然會十分的均勻。概率分佈就像上帝施的魔咒一樣,當你面對大量的桶進行很多的廣義的伯努利實驗。桶的消耗分佈就會趨於一種穩定的值。接下來我們就瞭解一下桶消耗分佈具體什麼情況:

通過取樣統計

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

100億節約了1.8T記憶體。相當於節約了原先的78%記憶體,而且桶消耗指標遠沒有達到預計的底線值15。

對於未出現的桶也是存在一定量的,如果過多會導致規劃不準確,其實數量是符合二項分佈的,對於230桶儲存232kv,不存在的桶大概有(百萬級別,影響不大):

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租值也是需要考慮的。