1. 程式人生 > >如何在 1 秒內做到大資料精準去重?

如何在 1 秒內做到大資料精準去重?

去重計數在企業日常分析中應用廣泛,如使用者留存、銷售統計、廣告營銷等。海量資料下的去重計數十分消耗資源,動輒幾分鐘,甚至幾小時,Apache Kylin 如何做到秒級的低延遲精確去重呢?

什麼是去重計數

去重計數是資料分析中的常用分析函式,指查詢某列中不同值的個數,在 SQL 中的函式是 count(distinct col)。它與 count(col) 函式的區別在於有一個 distinct 描述符,意思是去掉重複值,因此稱為去重計數。

去重計數使用廣泛,例如:在網站/app 使用統計中,PV/UV 是最常用的指標,其中 UV(unique visitor,獨立訪問使用者)就是去重後的數字,即同一個使用者的所有訪問記錄只計入一次。對於網站/app 所有者,PV (page view)代表的使用量的高低,UV 代表使用者的多少,兩個數字都很重要;只有結合兩個數字一起,才能更加準確地瞭解網站/app的使用者、用量增長情況。

圖 1:PV/UV 統計

 

大資料上去重運算的難點與挑戰

去重運算因為涉及到數值的比較,因此它的計算要比單純的 PV 計數要略複雜。當資料量不大的時候,單機執行的效能或許還能忍受。但是當資料量漸長的時候,所花的時間越來越長,依靠單節點處理難以滿足,此時就需要依靠分散式框架如 MapReduce 或 Spark 等並行處理,把大資料分而治之。

學習過 MapReduce 的朋友,一定對它的 WordCount 範例非常瞭解。下圖解釋了使用MapReduce 進行並行詞語出現次數統計的過程:

圖 2:WordCount 過程示例

試想,如果你的網站/app,訪問使用者數較大,如一千萬,訪問記錄一億(假設一個人平均點選 10 次)。假如每個使用者的 ID 已經用 int 表示了,那麼一次簡單的去重運算,需要 shuffle 的資料量就是:1億*4位元組 = 400 MB = 3200 Mb。以內網千兆網 1000 Mbps 來計算,至少也需要 3 秒的傳輸;再加上磁碟讀寫、排序、序列化、反序列化操作,這樣的一個計數運算最終的時間基本在 10 秒以上。現實中情況可能更加複雜:

  • 使用者識別符號可能是 email、uuid、身份證、手機號等更長的字元,空間佔用更大;
  • 去重之前需要某些條件過濾,佔用更多計算資源,如查詢過去 2 天在某幾個地區的 UV;
  • 訪問記錄多(行為日誌往往在數十億以上);
  • 網路和磁碟 IO 忙,查詢效能會出現抖動。

總之,大資料上的去重計數是一個耗資源的計算,做到秒級的低延遲響應十分困難;如果這方面查詢較頻繁,必定需要優化資料結構和演算法。

 

大資料去重演算法

事實上,研究人員早就意識到了這裡存在優化空間,開發了多種演算法和資料結構。最著名的當屬 HyperLogLog 和 Bitmap 兩種。前不久,Kyligence工程師在 2019 年 4 月的北京 Kylin Meetup 上做了分享,並撰寫了技術文章,感興趣的同學請參考文末的“參考閱讀”

【1】【2】

這兩種演算法結構的共同點是,以非常精湊的結構儲存去重集合的特徵(或完整集合),這樣不但可以回答去重數,還可以用於後續合併運算(如昨天和今天的去重)。相比較於每次都從原始值上做去重,它的儲存和計算效率可以大大提高。

但這兩種演算法也有明顯的差異點:

1) HyperLogLog,以下簡稱 HLL,它的空間複雜度非常低(log(log(n)) ,故而得名 HLL),幾乎不隨儲存集合的大小而變化;根據精度的不同,一個 HLL 佔用的空間從 1KB 到 64KB 不等。而 Bitmap 因為需要為每一個不同的 id 用一個 bit 位表示,所以它儲存的集合越大,所佔用空間也越大;儲存 1 億內數字的原始 bitmap,空間佔用約為 12MB。可以看到,Bitmap 的空間要比 HLL 大約一兩個數量級。

2)HLL 支援各種資料型別作為輸入,使用方便;Bitmap 只支援 int/long 型別的數字作為輸入,因此如果原始值是 string 等型別的話,使用者需要自己提前進行到 int/long 的對映。

3)HLL 之所以支援各種資料型別,是因為其採用了雜湊函式,將輸入值對映成一個二進位制位元組,然後對這個二進位制位元組進行分桶以及再判斷其首個1出現的最後位置,來估計目前桶中有多少個不同的值。由於使用了雜湊函式,以及使用概率估計的方式,因此 HLL 演算法的結果註定是非精確的;儘管 HLL 採用了多種糾正方式來減小誤差,但無法改變結果非精確的事實,即便最高精度,理論誤差也超過了 1%。

4)Bitmap 忠實地為每個 id 使用一個 bit 位來代表其出現(1)或不出現(0);所以只要能保證不同的使用者被對映成不同的 id 值,那麼 Bitmap 的結果就是精確的。

綜合看下來,這兩個演算法都有各自明顯的優劣:HLL 各種好,但是不精確;Bitmap 雖然佔用空間比 HLL多,但能保證精確。

 

為什麼精確去重如此重要

其實,Kylin 在最開始的時候只支援 HLL 演算法,因為 HLL 在大資料領域被普遍使用:無奈資料量大效能要求高呀,那就損失點精度吧。如果有人問起有誤差的事情該怎麼回答呢,當時我們的說法是:在幾千萬上億的結果上,你還在意那 1% 的誤差嗎?

然而,使用者不是這麼想的,在一些場景下,有誤差的結果是難以被接受的。

例如:在渠道導流或廣告投放方面,費用的結算按導流或點選使用者數來統計的。有誤差的數字,對於業務雙方來說都是難以接受的:購買方擔心多付錢,服務方擔心少收錢。更何況,HLL 的誤差率也是有機率的,也就是說,可能 99% 的情況下它的錯誤範圍在 1% 內,剩下 1% 的情況下誤差有多大就不好說了,萬一大不少,豈不要造成生產事故了?

此外,如果 UV 結果還要做乘除法,那麼這個誤差率會進一步放大。例如使用者增長率=今天使用者數/昨天使用者數;如果分子的數字偏大,分母的數字偏小,那麼最終的誤差就更大了,並且你還不知道誤差是多少。一億使用者基數下,1% 的誤差就是一百萬使用者,對於流量比較平穩的網站/app,這點誤差率足以將實際運營效果直接掩蓋,從而失去指導業務的意義。所以,如果你不想某天半夜被老闆或業務方叫起來查資料,那還是想想辦法一次解決準確性這個難題吧,以確保日後睡個安穩覺。

所以,沒過多久,我們便意識到,僅有近似演算法是不夠的,Kylin 需要支援精確的去重,否則在重要場景中將失去機會。

 

Kylin 精確去重是如何做到的

如果讀者對 Kylin 有一定了解的話會知道,Kylin 會按照使用者指定的維度、度量對資料進行預計算,將計算出來的度量值,以維度的值為索引儲存在 Cube (預設 HBase 表)中,例如每天的銷售記錄數、銷售額等。

圖3:OLAP Cube

對於 count distinct 度量,只儲存一個數字是不夠的,因為使用者的查詢可能需要遍歷許多單元格然後再做合併,單純的 int 數字不能再做去重合並。因此,過去Kylin 會將 HLL 物件整個序列化後,儲存在 Cube 中維度值所對應的 cell 中。查詢時,將其反序列化,交給 SQL 執行器做合併運算(通過 Kylin 的聚合函式),最後返回結果時,再從 HLL 物件中獲取去重數。同樣的道理,只要把 HLL替換成 Bitmap,理論上就可以實現精確的去重計數的儲存和查詢。

思路清楚了,但這裡依然面臨兩個挑戰:

1)Bitmap 空間佔用大

如前面提到的,Bitmap 的空間佔用相比於 HLL 是比較大的,但是相比於儲存原始值的集合來說,它又是最小的。一個儲存最大基數是1億的 Bitmap,大約需要(1億/8) 個位元組,也就是 12MB,當維度多、基數高的時候,可想而知,這個 Cube 構建出來會佔用很大儲存。

調研以後,Kylin 引入了帶壓縮的 Bitmap 實現:Roaring Bitmap。Roaring Bitmap 把一個 32 位的 Integer 劃分為高 16 位和低 16 位,取高 16 位找到該條資料所對應的 key,每個 key 都有自己的一個 Container。把剩餘的低 16 位放入該 Container 中。依據不同的場景,有 3 種不同的 Container,分別是 Array Container、Bitmap Container 和 Run Container,它們分別通過不同的壓縮方法來壓縮。實踐證明,Roaring Bitmap 可以顯著減小 Bitmap 的儲存空間和記憶體佔用。

2)Bitmap 只接受 int/long 型別作為輸入

前面提到過,Bitmap 只接受int/long(或可以直接 cast 成這兩種的型別)為輸入值。因此當去重列的型別不是這兩個的時候,使用者需要做一個 1:1 的對映,方能利用 Bitmap 進行去重,這樣使用的難度大大提高了。

比較巧的是,Kylin 預設會對維度構建資料字典(dictionary),然後通過字典將 string 等值 1:1 對映成 int 值,這樣在後續 Cube 運算和儲存時,使用 int 值代替 string 值,可以大大減少空間佔用。讓 Kylin 對去重列也用字典先進行編碼,豈不就可以支援 Bitmap 了?

基本可行,但是 Kylin 維度字典不是完全適用去重。主要原因是:

a) 維度字典是保序的(order preserving),因此構建後不能再追加修改;

b) 維度字典是對應於每一個 segment 來建立的,當構建下一個 segment 的時候,會重新建立另一個字典。這樣會導致同一個 string 值在兩個 segment 中可能會被編碼成不同的 int 值;或者不同的 string 值,在不同的 cube segment 中可能被編碼成相同的 int 值,那麼用在 bitmap 的話,會造成跨 segment 的去重合並後的數值錯誤,所以行不通。

因此,Kylin 需要引入一個可以被追加的、保證在所有segment 中做到唯一對映的字典;因為只是為了回答去重數,它不需要支援反向對映,為了跟普通字典相區分,我們稱之為全域性字典(Global Dictionary)(程式碼中稱為 AppendTrieDictionary),意思是它服務於所有 segment 的(當然也可以服務多個 cube)。跟普通字典相比,全域性字典放棄了保序性,也不再支援雙向對映(從 int 再解碼回原始 string 值),它是完全為非 int 數值的精確去重而準備的,在使用中請注意區分。更多關於全域性字典的介紹,請參考文末的“參考閱讀”文章【3】【4】。

在解決了上述挑戰之後,Kylin 就可以對海量資料集,根據使用者建立的模型進行 Cube 計算,各維度組合、各維度值組合下的去重集合以 Bitmap 形式儲存下來:

圖 4:含 Bitmap 的 Cube 構建示例

查詢時基於 Bitmap 進行後續運算,如:

select count(distinct 使用者ID) from access_log where 日期 = ‘2019/09/09’

圖 5:含Bitmap 的查詢示例

工作還不是到這裡就結束了,在多年的實踐中,Kylin 社群開發者們不斷完善 Kylin 的精確去重能力,使得其越來越健壯和完善,其中的一些重要改進包括:

1. 使用Segment Global Dictionary

前面提到,為了確保跨 segment 的合併時,同一值可以被始終對映成一個 int,所以開發了全域性字典(Global dictionary),它是可以增長的。那麼隨著資料的增加,這個全域性字典會逐漸變大,載入它會變得吃力;雖然全域性字典內部做了分頁處理,不用一次全部載入到記憶體,但是如果記憶體不足的話,依然會影響效率。而有些場景中,業務只看按天的去重結果,不做跨天的去重合並,這樣一來,維護全域性的對映也就沒必要了,而只需要維護一個 segment 級別的字典對映(segment 往往按天構建),就能夠滿足需求。這樣的區域性全域性字典相比於正常全域性字典會更小,更易於載入到記憶體中處理;當構建完成後,它甚至可以被刪除以節省空間;缺點是,這樣的 cube 的 segment 將不支援合併,因此在使用的時候需要略加註意。

2. 切分小字典提速 Cube 構建

剛提到,全域性字典變大以後,在構建的時候,會載入其中的某些頁到記憶體,記憶體不夠的時候再載出;如果輸入資料比較亂序,分佈在全域性字典的很多頁,那麼這種載入載出會消耗大量時間。所以,Kylin 引入一種優化策略,在進行編碼之前,先翻出每一個 Mapper 資料中的去重列的 distinct 值,然後用此值去全域性字典中查詢對應的 int 值,然後生成一個僅供當前 mapper 使用的小字典;在 Cube 構建的時候,使用此小字典而非大字典給當前 Mapper 來使用,從而減少字典頁換入換出操作,提高構建效能。

3. 使用 Hive/Spark 分散式構建外部全域性字典

使用全域性字典也存在一些侷限,例如:

1)字典的構建在任務節點單機上完成,存在效能瓶頸,當有多個去重任務並行執行時,造成任務等待;

2)全域性字典不能用於解碼,也不能被其它大資料應用直接使用,導致資料資產浪費。

因此,社群貢獻者提出將全域性字典外接成一張 Hive 表(兩個列,一個是原始值,一個是編碼的int值),利用 Hive/Spark 進行分散式的生成和追加,並在 Cube 構建的時候可以做分散式對映,使 Kylin 任務節點的負載得以減輕,同時外部全域性字典可以很容易地被複用,成為企業的資料資產。目前這個功能已經開發完成,將在 Kylin 3.0 中正式釋出,敬請期待。

4. Bitmap 數值直接返回

Kylin 儲存 Bitmap 是為了 UV 值的二次計算;然而有的查詢是比較簡單的,Cube 預計算已經一步到位了,Bitmap 不會參與二次計算,這種情況下各個HBase 節點就不需要將 Bitmap 傳輸給Kylin,而只要把結果值返回就可以,從而大大減少各個 HBase 節點到Kylin查詢節點的網路傳輸。Kylin 會自動判斷查詢是否精準匹配預計算結果,決定是否使用此優化。

 

為什麼 Kylin 是唯一能做到秒級去重的引擎

我們知道,Apache Kylin 是為數不多的能夠在超大資料集上做到亞秒級低延遲的 OLAP 分析引擎;Apache Kylin 基於獨特的預計算思想,將整個過程分為離線的 Cube 構建過程和線上的 Cube 查詢兩個階段,且這兩步可以分在兩個獨立叢集,互不影響。雖然 Cube 構建會花費一定的時間,但帶來的是後續查詢的大大提速,對於頻繁需要進行檢索/查詢的場景來說,一次構建多次受益,是非常值得的。而沒有預計算的引擎,每次都需要從原始資料開始計算,不但佔用大量計算資源,而且在效能、併發和效率方面都難以滿足業務使用者的苛刻要求。

圖 6:Apache Kylin 架構圖

引入 Bitmap 和全域性字典後,Kylin 實現了秒級的精確去重查詢,在大資料領域可以說是唯一的通用型方案(這句話來自某大型網際網路使用者)。有讀者可能會問,大資料分析引擎這麼多,Spark SQL,Presto,ClickHouse,Phoenix,Redshift等等,難道它們做不到嗎?其實沒有什麼是做不到,只是受架構的限制,沒有預先的資料準備,要想做到快速的精確去重,需要投入大量的計算資源,例如資料都預熱在記憶體中、節點之間使用萬兆網連線等等,但這恰恰是多數使用者無法承擔的。此外,隨著資料量和併發的增長,效能和穩定性往往會出現顯著下降,造成使用者體驗急劇下降,也就失去了可行性。當然,如果使用者對效能和併發的要求不高,使用頻率也不高,那麼這些技術都是可以滿足的。

 

總結

Kylin 既支援非精確去重,也支援精確去重,使用者可以根據自己的場景要求選擇合適的去重演算法。Kylin 精確去重相比於其它技術的優勢在於:

  • 資料離線自動生成壓縮 Bitmap,查詢時沒有資料 shuffle 和落盤,保證了低延遲的同時 100% 準確;
  • UV 值可二次合併,滿足靈活查詢的需要;
  • 查詢使用標準 SQL 的標準函式,無縫相容已有系統;
  • 既支援整數型別,也支援 string 等型別;
  • 使用簡單,無需程式設計開發;
  • 基於 Kylin 的 UDAF,Bitmap 還可以做交集(intersect)運算,實現留存、漏斗等分析功能;
  • 已經在 eBay、美團點評、滴滴、丁香園、Vivo、華為、滿幫集團等大型使用者生產環境平穩使用數年。

Apache Kylin 精確去重功能,是 Kylin 社群開發者們在各種複雜情況下不斷研究和努力的成果,凝結了許多人的汗水和智慧,在此向孫業銳、高大月、康凱森、鍾陽紅、靳國衛等同學表示感謝!引入 Bitmap 後,Kylin 的能力大大加強,使用場景得到豐富,這部分內容我們將在下次文章中為您分享。

 

Q:想體驗 Kylin 秒級精確去重?

A:Kylin 官網文件中有操作指南哦:https://kylin.apache.org/docs/tutorial/create_cube.html

 

參考閱讀

【1】陶加濤 《大資料分析常用去重演算法分析『HyperLogLog 篇』》https://kyligence.io/zh/blog/count-distinct-hyperloglog/

【2】陶加濤 《大資料分析常用去重演算法分析『Bitmap 篇』》https://kyligence.io/zh/blog/count-distinct-bitmap/

【3】康凱森,《Apache Kylin 精確去重和全域性字典權威指南》, https://blog.bcmeng.com/post/kylin-distinct-count-global-dict.html

【4】孫業銳,賀小橋《Apache Kylin精確計數與全域性字典揭祕》, https://hexiaoqiao.github.io/blog/2016/11/27/exact-count-and-global-dictionary-of-apache-kylin/

 

瞭解更多大資料資訊,點選進入Kyligen