Redis HyperLogLog 是用來做基數統計的算法,HyperLogLog 的優點是,在輸入元素的數量或者體積非常非常大時,計算基數所需的空間總是固定 的、並且是很小的。
在 Redis 里面,每個 HyperLogLog 鍵只需要花費 12 KB 內存,就可以計算接近 2^64 個不同元素的基 數。這和計算基數時,元素越多耗費內存就越多的集合形成鮮明對比。
但是,因為 HyperLogLog 只會根據輸入元素來計算基數,而不會儲存輸入元素本身,所以 HyperLogLog 不能像集合那樣,返回輸入的各個元素。
什么是基數?
比如數據集 {1, 3, 5, 7, 5, 7, 8}, 那么這個數據集的基數集為 {1, 3, 5 ,7, 8}, 基數(不重復元素)為5。 基數估計就是在誤差可接受的范圍內,快速計算基數。
pfadd key element [element...] 將所有元素參數添加到 HyperLogLog 數據結構中,如果至少有個元素被添加返回 1, 否則返回 0
pfcount key [key...] 返回給定 HyperLogLog 的基數估算值。返回給定 HyperLogLog 的基數值,如果多個 HyperLogLog 則返回基數估值之和
pfmerge destkey sourcekey [sourcekey...] 將多個 HyperLogLog 合並為一個 HyperLogLog ,合並后的 HyperLogLog 的基數估算值是通過對所有 給定 HyperLogLog 進行並集計算得出的。
基數的應用實例
下面通過一個實例說明基數在電商數據分析中的應用。
假設一個淘寶網店在其店鋪首頁放置了10個寶貝鏈接,分別從Item01到Item10為這十個鏈接編號。店主希望可以在一天中隨時查看從今天零點開始到目前這十個寶貝鏈接分別被多少個獨立訪客點擊過。所謂獨立訪客(Unique Visitor,簡稱UV)是指有多少個自然人,例如,即使我今天點了五次Item01,我對Item01的UV貢獻也是1,而不是5。
用術語說這實際是一個實時數據流統計分析問題。
要實現這個統計需求。需要做到如下三點:
1、對獨立訪客做標識
2、在訪客點擊鏈接時記錄下鏈接編號及訪客標記
3、對每一個要統計的鏈接維護一個數據結構和一個當前UV值,當某個鏈接發生一次點擊時,能迅速定位此用戶在今天是否已經點過此鏈接,如果沒有則此鏈接的UV增加1
下面分別介紹三個步驟的實現方案
對獨立訪客做標識
客觀來說,目前還沒有能在互聯網上准確對一個自然人進行標識的方法,通常采用的是近似方案。例如通過登錄用戶+cookie跟蹤的方式:當某個用戶已經登錄,則采用會員ID標識;對於未登錄用戶,則采用跟蹤cookie的方式進行標識。為了簡單起見,我們假設完全采用跟蹤cookie的方式對獨立訪客進行標識。
記錄鏈接編號及訪客標記
這一步可以通過JavaScript埋點及記錄accesslog完成,具體原理和實現方案可以參考我之前的一篇文章:網站統計中的數據收集原理及實現。
實時UV計算
可以看到,如果將每個鏈接被點擊的日志中訪客標識字段看成一個集合,那么此鏈接當前的UV也就是這個集合的基數,因此UV計算本質上就是一個基數計數問題。
在實時計算流中,我們可以認為任何一次鏈接點擊均觸發如下邏輯(偽代碼描述):
- cand_counting(item_no, user_id) {
- if (user_id is not in the item_no visitor set) {
- add user_id to item_no visitor set;
- cand[item_no]++;
- }
- }
邏輯非常簡單,每當有一個點擊事件發生,就去相應的鏈接被訪集合中尋找此訪客是否已經在里面,如果沒有則將此用戶標識加入集合,並將此鏈接的UV加1。
雖然邏輯非常簡單,但是在實際實現中尤其面臨大數據場景時還是會遇到諸多困難,下面一節我會介紹兩種目前被業界普遍使用的精確算法實現方案,並通過分析說明當數據量增大時它們面臨的問題。
傳統的基數計數實現
接着上面的例子,我們看一下目前常用的基數計數的實現方法。
基於B樹的基數計數
對上面的偽代碼做一個簡單分析,會發現關鍵操作有兩個:查找-迅速定位當前訪客是否已經在集合中,插入-將新的訪客標識插入到訪客集合中。因此,需要為每一個需要統計UV的點(此處就是十個寶貝鏈接)維護一個查找效率較高的數據結構,又因為實時數據流的關系,這個數據結構需要盡量在內存中維護,因此這個數據結構在空間復雜度上也要比較適中。綜合考慮一種傳統的做法是在實時計算引擎采用了B樹來組織這個集合。下圖是一個示意圖:
之所以選用B樹是因為B樹的查找和插入相關高效,同時空間復雜度也可以接受(關於B樹具體的性能分析請參考這里)。
這種實現方案為一個基數計數器維護一棵B樹,由於B樹在查找效率、插入效率和內存使用之間非常平衡,所以算是一種可以接受的解決方案。但是當數據量特別巨大時,例如要同時統計幾萬個鏈接的UV,如果要將幾萬個鏈接一天的訪問記錄全部維護在內存中,這個內存使用量也是相當可觀的(假設每個B樹占用1M內存,10萬個B樹就是100G!)。一種方案是在某個時間點將內存數據結構寫入磁盤(雙十一和雙十二大促時一淘數據部的效果平台是每分鍾將數據寫入HBase)然后將內存中的計數器和數據結構清零,但是B樹並不能高效的進行合並,這就使得內存數據落地成了非常大的難題。
另一個需要數據結構合並的場景是查看並集的基數,例如在上面的例子中,如果我想查看Item1和Item2的總UV,是沒有辦法通過這種B樹的結構快速得到的。當然可以為每一種可能的組合維護一棵B樹。不過通過簡單的分析就可以知道這個方案基本不可行。N個元素集合的非空冪集數量為2N−1,因此要為10個鏈接維護1023棵B樹,而隨着鏈接的增加這個數量會以冪指級別增長。
基於bitmap的基數計數
為了克服B樹不能高效合並的問題,一種替代方案是使用bitmap表示集合。也就是使用一個很長的bit數組表示集合,將bit位順序編號,bit為1表示此編號在集合中,為0表示不在集合中。例如“00100110”表示集合 {2,5,6}。bitmap中1的數量就是這個集合的基數。
顯然,與B樹不同bitmap可以高效的進行合並,只需進行按位或(or)運算就可以,而位運算在計算機中的運算效率是很高的。但是bitmap方式也有自己的問題,就是內存使用問題。
很容易發現,bitmap的長度與集合中元素個數無關,而是與基數的上限有關。例如在上面的例子中,假如要計算上限為1億的基數,則需要12.5M字節的bitmap,十個鏈接就需要125M。關鍵在於,這個內存使用與集合元素數量無關,即使一個鏈接僅僅有一個1UV,也要為其分配12.5M字節。
由此可見,雖然bitmap方式易於合並,卻由於內存使用問題而無法廣泛用於大數據場景。
小結
本文重點在於通過電商數據分析中UV計算的例子,說明基數的應用、傳統的基數計數算法及這些算法在大數據面前遇到的問題。實際上目前還沒有發現更好的在大數據場景中准確計算基數的高效算法,因此在不追求絕對准確的情況下,使用概率算法算是一個不錯的解決方案。在后續文章中,我將逐一解讀常用的基數估計概率算法。