大資料去重(data deduplication)方案

阿凡盧發表於2021-02-09
資料去重(data deduplication)是大資料領域司空見慣的問題了。除了統計UV等傳統用法之外,去重的意義更在於消除不可靠資料來源產生的髒資料——即重複上報資料或重複投遞資料的影響,使計算產生的結果更加準確。

介紹下經常使用的去重方案:

一、布隆過濾器(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會產生非常大的壓力。

 

參考:

高效壓縮點陣圖RoaringBitmap的原理與應用

談談三種海量資料實時去重方案

Flink基於RoaringBitmap的精確去重方案

 

相關文章