1. 程式人生 > >Redis大資料量(百億級)Key儲存需求及解決方案

Redis大資料量(百億級)Key儲存需求及解決方案

問題導讀:

1. 需求背景是什麼?

2. 儲存何種資料?

3. 資料特點是什麼?

4. 存在哪些技術挑戰?

5. 解決方案有哪些?

6. md5雜湊桶的方法需要注意哪些問題?

7. 測試結果是什麼?

解決方案:

1 需求背景

    該應用場景為 DMP(Data Management Platform:資料管理平臺,是把分散的多方資料進行整合納入統一的技術平臺,並對這些資料進行標準化和細分,讓使用者可以把這些細分結果推向現有的互動營銷環境裡的平臺)快取儲存需求,DMP 需要管理非常多的第三方 id 資料,其中包括各媒體 cookie 與自身 cookie(以下統稱supperid)的 mapping 關係,還包括了 supperid 的人口標籤、移動端 id(主要是idfa(Identifier for Advertising:廣告識別符號)和 imei(International Mobile Equipment Identity:國際移動裝置識別碼的縮寫。俗稱“手機串號”、“手機串碼”、“手機序列號”,用於在GSM行動網路中識別每一部獨立的手機,相當於手機的身份證號碼) )的人口標籤,以及一些黑名單 id、 ip 等資料。

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

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

2 儲存何種資料

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

1)  PC 端的ID:

媒體編號 - 媒體cookie => supperid

supperid => {

age => 年齡段編碼,

gender => 性別編碼,

geo => 地理位置編碼

}

2)  Device 端的ID:

imei or idfa => {

age => 年齡段編碼,

gender => 性別編碼,

geo => 地理位置編碼

}

    顯然, PC 資料需要儲存兩種:key => value 和 key => hashmap,而 Device 資料需要儲存一種 key => hashmap 即可。

3 資料特點

    短key短value,其中supperid為21位數字:比如 160524201514168952222;imei為小寫md5:比如2d131005dc0f37d362a5d97094103633;idfa 為大寫帶 ”- ”md5:比如: 51DFFC83-9541-4411-FA4F-356927E39D04;媒體自身的 cookie 長短不一;需要為全量資料提供服務,supperid 是百億級、媒體對映是千億級、移動id是幾十億級;每天有十億級別的 mapping 關係產生;對於較大時間視窗內可以預判熱資料(有一些存留的穩定 cookie);對於當前 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(廣告Campaign是指廣告主在一段明確的期間裡(如一年),推出一系列擁有共同主題或訊息的廣告,以期建立廣告訊息的累積效果,塑造品牌與企業一致的形象,並給予目標受眾持續而深刻的刺激與衝擊),半個月、幾個月)訪問行為上會有一定的重複性。

    資料初始化之前,我們先利用 hbase(HBase是一個分散式的、面向列的開源資料庫,HBase不同於一般的關係資料庫,它是一個適合於非結構化資料儲存的資料庫。另一個不同的是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(key1) 值作為 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重啟,然後強制的 fail over 切換主從,這樣相當於給 master 整理了記憶體的碎片。推薦 Google-tcmalloc, facebook-jemalloc記憶體分配,可以在value不大時減少記憶體碎片和記憶體消耗。有人測過,大value情況下反而libc更節約。

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

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

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

3)典型的時間換空間的做法,由於我們的業務場景並不是要求在極高的 qps(每秒查詢率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 租值也是可以考慮的。