介紹下經常使用的去重方案:
一、布隆過濾器(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
3、Redis Bloom Filter:https://oss.redis.com/redisbloom/,基於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會產生非常大的壓力。
參考: