介紹下經常使用的去重方案:
一、布隆過濾器(BloomFilter)
基本原理:
BloomFilter是由一個長度為m位元的位陣列(bit array)與k個雜湊函式(hash function)組成的資料結構。位陣列均初始化為0,所有雜湊函式都可以分別把輸入資料儘量均勻地雜湊。當要插入一個元素時,將其資料分別輸入k個雜湊函式,產生k個雜湊值。以雜湊值作為位陣列中的下標,將所有k個對應的位元置為1。當要查詢(即判斷是否存在)一個元素時,同樣將其資料輸入雜湊函式,然後檢查對應的k個位元。如果有任意一個位元為0,表明該元素一定不在集合中。如果所有位元均為1,表明該集合有(較大的)可能性在集合中。為什麼不是一定在集合中呢?因為一個位元被置為1有可能會受到其他元素的影響,這就是所謂“假陽性”(false positive)。相對地,“假陰性”(false negative)在BloomFilter中是絕不會出現的。
實現參考:
1、Guava中的布隆過濾器:com.google.common.hash.BloomFilter類
2、開源java實現:https://github.com/Baqend/Orestes-Bloomfilter
Redis Bloom Filter擴充套件:
基於redis做儲存後端的BloomFilter實現,可以將bit位儲存在redis中,防止計算任務在重啟後,當前狀態丟失的問題。
二、HyperLogLog(HLL)
HyperLogLog是去重計數的利器,能夠以很小的精確度誤差作為trade-off大幅減少記憶體空間佔用,在不要求100%準確的計數場景下常用。
HLL基本原理:
- HyperLogLog,以下簡稱 HLL,它的空間複雜度非常低(log(log(n)) ,故而得名 HLL),幾乎不隨儲存集合的大小而變化;根據精度的不同,一個 HLL 佔用的空間從 1KB 到 64KB 不等。而 Bitmap 因為需要為每一個不同的 id 用一個 bit 位表示,所以它儲存的集合越大,所佔用空間也越大;儲存 1 億內數字的原始 bitmap,空間佔用約為 12MB。可以看到,Bitmap 的空間要比 HLL 大約一兩個數量級。
- HLL 支援各種資料型別作為輸入,使用方便;Bitmap 只支援 int/long 型別的數字作為輸入,因此如果原始值是 string 等型別的話,使用者需要自己提前進行到 int/long 的對映。
- HLL 之所以支援各種資料型別,是因為其採用了雜湊函式,將輸入值對映成一個二進位制位元組,然後對這個二進位制位元組進行分桶以及再判斷其首個1出現的最後位置,來估計目前桶中有多少個不同的值。由於使用了雜湊函式,以及使用概率估計的方式,因此 HLL 演算法的結果註定是非精確的;儘管 HLL 採用了多種糾正方式來減小誤差,但無法改變結果非精確的事實,即便最高精度,理論誤差也超過了 1%。
在用Flink做實時計算的過程中,可以用HLL去重計數,比如統計UV。
實現參考:
https://github.com/aggregateknowledge/java-hll
結合Flink,下面的聚合函式即可實現從WindowedStream按天、分key統計PV和UV。
WindowedStream<AnalyticsAccessLogRecord, Tuple, TimeWindow> windowedStream = watermarkedStream .keyBy("siteId") .window(TumblingEventTimeWindows.of(Time.days(1))) .trigger(ContinuousEventTimeTrigger.of(Time.seconds(10))); windowedStream.aggregate(new AggregateFunction<AnalyticsAccessLogRecord, Tuple2<Long, HLL>, Tuple2<Long, Long>>() { private static final long serialVersionUID = 1L; @Override public Tuple2<Long, HLL> createAccumulator() { return new Tuple2<>(0L, new HLL(14, 6)); } @Override public Tuple2<Long, HLL> add(AnalyticsAccessLogRecord record, Tuple2<Long, HLL> acc) { acc.f0++; acc.f1.addRaw(record.getUserId()); return acc; } @Override public Tuple2<Long, Long> getResult(Tuple2<Long, HLL> acc) { return new Tuple2<>(acc.f0, acc.f1.cardinality()); } @Override public Tuple2<Long, HLL> merge(Tuple2<Long, HLL> acc1, Tuple2<Long, HLL> acc2) { acc1.f0 += acc2.f0; acc1.f1.union(acc2.f1); return acc1; } });
三、Roaring Bitmap
布隆過濾器和HyperLogLog,雖然它們節省空間並且效率高,但也付出了一定的代價,即:
- 只能插入元素,不能刪除元素;
- 不保證100%準確,總是存在誤差。
這兩個缺點可以說是所有概率性資料結構(probabilistic data structure)做出的trade-off,畢竟魚與熊掌不可兼得。
如果一定追求100%準確,普通的點陣圖法顯然不合適,應該採用壓縮點陣圖(Roaring Bitmap)。
將32位無符號整數按照高16位分桶,即最多可能有216=65536個桶,稱為container。儲存資料時,按照資料的高16位找到container(找不到就會新建一個),再將低16位放入container中。也就是說,一個RBM就是很多container的集合。依據不同的場景,有 3 種不同的 Container,分別是 Array Container、Bitmap Container 和 Run Container,它們分別通過不同的壓縮方法來壓縮。實踐證明,Roaring Bitmap 可以顯著減小 Bitmap 的儲存空間和記憶體佔用。
實現參考:
https://github.com/RoaringBitmap/RoaringBitmap
使用限制:
- 對去重的欄位只能用整型:int或者long型別,如果要對字串去重,需要構建一個字串和整型的對映。
- 對於無法有效壓榨的欄位(如隨機生成的),佔用記憶體較大。
四、外部儲存去重
利用外部K-V資料庫(Redis、HBase之類)儲存需要去重的鍵。由於外部儲存對記憶體和磁碟佔用同樣敏感,所以也得設定相應的TTL,以及對大的鍵進行壓縮。另外,外部K-V儲存畢竟是獨立於應用之外的,一旦計算任務出現問題重啟,外部儲存的狀態和內部狀態的一致性(是否需要同步)也是要注意的。
外部儲存去重,比如Elasticsearch的 _id 就可以做“去重”功能,但是這種去重的只能針對少量低概率的資料,對全量資料去重是不合適的,因為對ES會產生非常大的壓力。
參考: