大數據分析常用去重算法分析

去重分析在企業日常分析中的使用頻率非常高,如何在大數據場景下快速地進行去重分析一直是一大難點。在近期的 Apache Kylin 沙龍上, Kyligence 大數據研發工程師陶加濤爲大家揭開了大數據分析常用去重算法的神祕面紗。

首先,請大家思考一個問題:在大數據處理領域中,什麼環節是你最不希望見到的?以我的觀點來看,shuffle 是我最不願意見到的環節,因爲一旦出現了非常多的 shuffle,就會佔用大量的磁盤和網絡 IO,從而導致任務進行得非常緩慢。而今天我們所討論的去重分析,就是一個會產生非常多 shuffle 的場景,先來看以下場景:

我們有一張商品訪問表,表上有 item 和 user_id 兩個列,我們希望求商品的 UV,這是去重非常典型的一個場景。我們的數據是存儲在分佈式平臺上的,分別在數據節點 1 和 2 上。

我們從物理執行層面上想一下這句 SQL 背後會發生什麼故事:首先分佈式計算框架啓動任務, 從兩個節點上去拿數據, 因爲 SQL group by 了 item 列, 所以需要以 item 爲 key 對兩個表中的原始數據進行一次 shuffle。我們來看看需要 shuffle 哪些數據:因爲 select/group by了 item,所以 item 需要 shuffle 。但是,user_id 我們只需要它的一個統計值,能不能不 shuffle 整個 user_id 的原始值呢?

如果只是簡單的求 count 的話, 每個數據節點分別求出對應 item 的 user_id 的 count, 然後只要 shuffle 這個 count 就行了,因爲count 只是一個數字, 所以 shuffle 的量非常小。但是由於分析的指標是 count distinct,我們不能簡單相加兩個節點user_id 的 count distinct 值,我們只有得到一個 key 對應的所有 user_id 才能統計出正確的 count distinct值,而這些值原先可能分佈在不同的節點上,所以我們只能通過 shuffle 把這些值收集到同一個節點上再做去重。而當 user_id 這一列的數據量非常大的時候,需要 shuffle 的數據量也會非常大。我們其實最後只需要一個 count 值,那麼有辦法可以不 shuffle 整個列的原始值嗎?我下面要介紹的兩種算法就提供了這樣的一種思路,使用更少的信息位,同樣能夠求出該列不重複元素的個數(基數)。

精確算法: Bitmap

第一種要介紹的算法是一種精確的去重算法,主要利用了 Bitmap 的原理。Bitmap 也稱之爲 Bitset,它本質上是定義了一個很大的 bit 數組,每個元素對應到 bit 數組的其中一位。例如有一個集合[2,3,5,8]對應的 Bitmap 數組是[001101001],集合中的 2 對應到數組 index 爲 2 的位置,3 對應到 index 爲 3 的位置,下同,得到的這樣一個數組,我們就稱之爲 Bitmap。很直觀的,數組中 1 的數量就是集合的基數。追本溯源,我們的目的是用更小的存儲去表示更多的信息,而在計算機最小的信息單位是 bit,如果能夠用一個 bit 來表示集合中的一個元素,比起原始元素,可以節省非常多的存儲。

這就是最基礎的 Bitmap,我們可以把 Bitmap 想象成一個容器,我們知道一個 Integer 是32位的,如果一個 Bitmap 可以存放最多 Integer.MAX_VALUE 個值,那麼這個 Bitmap 最少需要 32 的長度。一個 32 位長度的 Bitmap 佔用的空間是512 M (2^32/8/1024/1024),這種 Bitmap 存在着非常明顯的問題:這種 Bitmap 中不論只有 1 個元素或者有 40 億個元素,它都需要佔據 512 M 的空間。回到剛纔求 UV 的場景,不是每一個商品都會有那麼多的訪問,一些爆款可能會有上億的訪問,但是一些比較冷門的商品可能只有幾個用戶瀏覽,如果都用這種 Bitmap,它們佔用的空間都是一樣大的,這顯然是不可接受的。

升級版 Bitmap: Roaring Bitmap

對於上節說的問題,有一種設計的非常的精巧 Bitmap,叫做 Roaring Bitmap,能夠很好地解決上面說的這個問題。我們還是以存放 Integer 值的 Bitmap 來舉例,Roaring Bitmap 把一個 32 位的 Integer 劃分爲高 16 位和低 16 位,取高 16 位找到該條數據所對應的 key,每個 key 都有自己的一個 Container。我們把剩餘的低 16 位放入該 Container 中。依據不同的場景,有 3 種不同的 Container,分別是 Array Container、Bitmap Container 和 Run Container,下文將一一介紹。

首先第一種,是 Roaring Bitmap 初始化時默認的 Container,叫做 Array Container。Array Container 適合存放稀疏的數據,Array Container 內部的數據結構是一個 short array,這個 array 是有序的,方便查找。數組初始容量爲 4,數組最大容量爲 4096。超過最大容量 4096 時,會轉換爲 Bitmap Container。這邊舉例來說明數據放入一個 Array Container 的過程:有 0xFFFF0000 和 0xFFFF0001 兩個數需要放到 Bitmap 中, 它們的前 16 位都是 FFFF,所以他們是同一個 key,它們的後 16 位存放在同一個 Container 中; 它們的後 16 位分別是 0 和 1, 在 Array Container 的數組中分別保存 0 和 1 就可以了,相較於原始的 Bitmap 需要佔用 512M 內存來存儲這兩個數,這種存放實際只佔用了 2+4=6 個字節(key 佔 2 Bytes,兩個 value 佔 4 Bytes,不考慮數組的初始容量)。

第二種 Container 是 Bitmap Container,其原理就是上文說的 Bitmap。它的數據結構是一個 long 的數組,數組容量固定爲 1024,和上文的 Array Container 不同,Array Container 是一個動態擴容的數組。這邊推導下 1024 這個值:由於每個 Container 還需處理剩餘的後 16 位數據,使用 Bitmap 來存儲需要 8192 Bytes(2^16/8), 而一個 long 值佔 8 個 Bytes,所以一共需要 1024(8192/8)個 long 值。所以一個 Bitmap container 固定佔用內存 8 KB(1024 * 8 Byte)。當 Array Container 中元素到 4096 個時,也恰好佔用 8 k(4096*2Bytes)的空間,正好等於 Bitmap 所佔用的 8 KB。而當你存放的元素個數超過 4096 的時候,Array Container 的大小佔用還是會線性的增長,但是 Bitmap Container 的內存空間並不會增長,始終還是佔用 8 K,所以當 Array Container 超過最大容量(DEFAULT_MAX_SIZE)會轉換爲 Bitmap Container。

我們自己在 Kylin 中實踐使用 Roaring Bitmap 時,我們發現 Array Container 隨着數據量的增加會不停地 resize 自己的數組,而 Java 數組的 resize 其實非常消耗性能,因爲它會不停地申請新的內存,同時老的內存在複製完成前也不會釋放,導致內存佔用變高,所以我們建議把 DEFAULT_MAX_SIZE 調得低一點,調成 1024 或者 2048,減少 Array Container 後期 reszie 數組的次數和開銷。

最後一種 Container 叫做Run Container,這種 Container 適用於存放連續的數據。比如說 1 到 100,一共 100 個數,這種類型的數據稱爲連續的數據。這邊的Run指的是Run Length Encoding(RLE),它對連續數據有比較好的壓縮效果。原理是對於連續出現的數字, 只記錄初始數字和後續數量。例如: 對於 [11, 12, 13, 14, 15, 21, 22],會被記錄爲 11, 4, 21, 1。很顯然,該 Container 的存儲佔用與數據的分佈緊密相關。最好情況是如果數據是連續分佈的,就算是存放 65536 個元素,也只會佔用 2 個 short。而最壞的情況就是當數據全部不連續的時候,會佔用 128 KB 內存。

總結:用一張圖來總結3種 Container 所佔的存儲空間,可以看到元素個數達到 4096 之前,選用 Array Container 的收益是最好的,當元素個數超過了 4096 時,Array Container 所佔用的空間還是線性的增長,而 Bitmap Container 的存儲佔用則與數據量無關,這個時候 Bitmap Container 的收益就會更好。而 Run Container 佔用的存儲大小完全看數據的連續性, 因此只能畫出一個上下限範圍 [4 Bytes, 128 KB]。

在 Kylin 中的應用

我們再來看一下Bitmap 在 Kylin 中的應用,Kylin 中編輯 measure 的時候,可以選擇 Count Distinct,且Return Type 選爲 Precisely,點保存就可以了。但是事情沒有那麼簡單,剛纔上文在講 Bitmap 時,一直都有一個前提,放入的值都是數值類型,但是如果不是數值類型的值,它們不能夠直接放入 Bitmap,這時需要構建一個全區字典,做一個值到數值的映射,然後再放入 Bitmap 中。

在 Kylin 中構建全局字典,當列的基數非常高的時候,全局字典會成爲一個性能的瓶頸。針對這種情況,社區也一直在努力做優化,這邊簡單介紹幾種優化的策略,更詳細的優化策略可以見文末的參考鏈接。

1)當一個列的值完全被另外一個列包含,而另一個列有全局字典,可以複用另一個列的全局字典。

2)當精確去重指標不需要跨 Segment 聚合的時候,可以使用這個列的 Segment 字典代替(這個列需要字典編碼)。在 Kylin 中,Segment 就相當於時間分片的概念。當不會發生跨 Segments 的分析時,這個列的 Segment 字典就可以代替這個全局字典。

3)如果你的 cube 包含很多的精確去重指標,可以考慮將這些指標放到不同的列族上。不止是精確去重,像一些複雜 measure,我們都建議使用多個列族去存儲,可以提升查詢的性能。

雖然Roaring Bitmap這種算法能大大地減少存儲開銷,但是隨着數據量的增大,它依然面臨着存儲上的壓力。下面我們將要介紹的 HyperLogLog(下稱 HLL)是一種非精確的去重算法,它的特點是具有非常優異的空間複雜度(幾乎可以達到常數級別)。

HLL 算法需要完整遍歷所有元素一次,而非多次或採樣;該算法只能計算集合中有多少個不重複的元素,不能給出每個元素的出現次數或是判斷一個元素是否之前出現過;多個使用 HLL 統計出的基數值可以融合。

HLL 算法有着非常優異的空間複雜度,可以看到它的空間佔用隨着基數值的增長並沒有變化。HLL 後面不同的數字代表着不同的精度,數字越大,精度越高,佔用的空間也越大,可以認爲 HLL 的空間佔用只和精度成正相關。

HLL算法原理感性認知

HLL 算法的原理會涉及到比較多的數學知識,這邊對這些數學原理和證明不會展開。舉一個生活中的例子來幫助大家理解HLL算法的原理:比如你在進行一個實驗,內容是不停地拋硬幣,記錄你連續拋到正面的次數(這是數學中的伯努利過程,感興趣同學可以自行研究下);如果你最多的連拋正面記錄是3次,那可以想象你並沒有做這個實驗太多次,如果你最長的連拋正面記錄是 20 次,那你可能進行了這個實驗上千次。

一種理論上存在的情況是,你非常幸運,第一次進行這個實驗就連拋了 20 次正面,我們也會認爲你進行了很多次這個實驗纔得到了這個記錄,這就會導致錯誤的預估;改進的方式是請 10 位同學進行這項實驗,這樣就可以觀察到更多的樣本數據,降低出現上述情況的概率。這就是 HLL 算法的核心思想。

HLL算法具體實現

HLL 會通過一個 hash 函數來求出集合中所有元素的 hash 值(二進制表示的 hash 值,就可以理解爲一串拋硬幣正反面結果的序列),得到一個 hash 值的集合,然後找出該 hash 值集合中,第一個 1 出現的最晚的位置。例如有集合爲 [010, 100, 001], 集合中元素的第一個 1 出現的位置分別爲 2, 1, 3,可以得到裏面最大的值爲 3,故該集合中第一個1出現的最晚的位置爲 3。因爲每個位置上出現1的概率都是 1/2,所以我們可以做一個簡單的推斷,該集合中有 8 個不重複的元素。

可以看到這種簡單的推斷計算出來集合的基數值是有較大的偏差的,那如何來減少偏差呢?正如我上面的例子裏說的一樣,HLL 通過多次的進行試驗來減少誤差。那它是如何進行多次的實驗的呢?這裏 HLL 使用了分桶的思想,上文中我們一直有提到一個精度的概念,比如說 HLL(10),這個 10 代表的就是取該元素對應 Hash 值二進制的後 10 位,計算出記錄對應的桶,桶中會記錄一個數字,代表對應到該桶的 hash 值的第一個 1 出現的最晚的位置。如上圖,該 hash 值的後 10 位的 hash 值是 0000001001,轉成 10 進制是 9,對應第 9 號桶,而該 hash 值第一個 1 出現的位置是第 6 位,比原先 9 號桶中的數字大,故把 9 號桶中的數字更新爲 6。可以看到桶的個數越多,HLL 算法的精度就越高,HLL(10) 有 1024(210) 個桶,HLL(16)有 65536(216) 個桶。同樣的,桶的個數越多,所佔用的空間也會越大。

剛纔的例子我們省略了一些細節,爲了讓大家不至於迷失在細節中而忽視了重點,真實的 HLL 算法的完整描述見上圖,這邊的重點是計算桶中平均數時使用調和平均數。調和平均數的優點是可以過濾掉不健康的統計值,使用算術平均值容易受到極值的影響(想想你和馬雲的平均工資),而調和平均數的結果會傾向於集合中比較小的元素。HLL 論文中還有更多的細節和參數,這邊就不一一細舉,感興趣的同學可以自己閱讀下論文。

HLL評估

HLL 的誤差分佈服從正態分佈,它的空間複雜度: O(m log2log2N), N 爲基數, m 爲桶個數。這邊給大家推導一下它的空間複雜度,我有 264 個的不重複元素(Long. MAX_VALUE),表達爲二進制一個數是 64 位,這是第一重 log2, 那麼第一個1最晚可能出現在第 64 位。64 需要 6 個 bit (26=64) 就可以存儲,這是第二重 log2。如果精度爲 10,則會有 1024 個桶,所以最外面還要乘以桶的個數。由於需要完整的遍歷元素一遍,所以它的時間複雜度是一個線性的時間複雜度。

在Kylin中的應用

在 Kylin 中使用 HLL 非常簡單,在編輯度量的頁面選擇 COUNT DISTINCT,Return Type 選爲非 Precisely 的其他選項,大家根據自己的需求選擇不同的精度就可以愉快地使用了。

總結

我們回到最開始的去重場景,看看使用了 Bitmap 和 HLL 會給我們帶來什麼增益:無優化 case 下,每個 item 對應的 user_id 就可以看成存儲原始值的一個集合;在使用 Bitmap 優化的case 下,每個 item 對應的 user_id 就可以看成一個 Bitmap 實例,同理 HLL就是一個 HLL 的實例,Bitmap/HLL 實例佔用的空間都會比直接存儲原始值的集合要小,這就達到了我們開始提的減少 shuffle 數據量的需求。

Q&A

Q1:您好,問一下關於精確去重的問題, 我選擇了非精確去重,最後的誤差率有時候會比界面上提示的值要高一些,這是爲什麼?

A1:首先 HLL 的誤差分佈服從正態分佈,也就是說是在99%的情況下是這個誤差,同時 HLL 對於基數比較低的情況,誤差會偏高。如果你的基數比較低的話,我推薦使用精確去重。

Q2:我想要了解一下 Bitmap 在 Kylin 中,它最終落盤在 HBase 裏面是什麼樣子的?

A2:在 HBase 中存儲的當然都是 Bytes。這個問題其實就是 Bitmap 的序列化的形式,Roaring Bitmap提供了序列化和反序列化的實現,你也可以寫自己的序列化/反序列化的實現。

Q3:Roaring Bitmap 裏這些 container 要我們自己手動的指定嗎?

A3:不需要,Roaring Bitmap 會自動選擇使用哪個 Container。

作者簡介:陶加濤,Kyligence 大數據研發工程師,主要負責 Kyligence Enterprise 存儲與查詢計算部分。GitHub ID: https://github.com/aaaaaaron。wechat:245915794。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章