Flink系列(3):以布隆過濾器爲例,從零基礎開始理解並實現實時數據去重問題

目錄

(一)Hash

(二)BitMap

(三)BitSet

(四)BloomFilter


(一)Hash

哈希表是一種基本的數據結構,其思想是利用Hash函數來支持快速的【插入和搜索】,這是哈希表的第一個重要概念。本文從哈希表開始說起,是爲數據去重問題提供最原始的思路。該模塊不涉及任何複雜算法,或者是Java中的實現方法,僅從最簡單的角度進行講解,便於初學者快速理解。

既然說起了【插入和搜索】,那麼哈希表的相關原理就離不開這兩個場景,與插入對應的,是哈希函數;與搜索對應的,是存儲桶。哈希表的原理,即是通過哈希函數,將隨機的數字,固定的映射到一個存儲桶裏。例如當我們插入一個新的鍵值時,會通過哈希函數映射,查詢該鍵值應該分配到哪個存儲桶裏,並進行對應的存儲操作;當我們搜索一個鍵值時,使用相同的哈希函數,找到對應的存儲桶,並只在這個存儲桶裏進行搜索。

如下圖所示,假設哈希函數是y = x % 5:

說到這裏,哈希函數是原始的“分而治之”思想的實踐者,天然的應該應用到對應的高性能場景中。哈希函數的設計並沒有標準方法,最完美的情況下,鍵值和存儲桶之間能做到嚴格的一對一映射。

然而,在大多數情況下,哈希函數並不完美,不可避免的會遇到衝突的問題。例如上文提到的y = x % 5,1987和2這兩個鍵值都被分配到了存儲桶2中。在這裏,哈希表就引入了第二個重要概念【衝突解決】。

衝突解決算法需要解決如下三個問題:

(1)同一個存儲桶下的鍵值應該如何組織?

(2)如果某個存儲桶中的鍵值超過了最大存儲數量,應如何解決?

(3)如何在同一個存儲桶中快速定位目標值?

因此,哈希表需要有一個“N”的概念,即每個存儲桶能夠存儲的最大鍵值。

講到這裏,對於基本的哈希表概念就大致瞭解了,接下來我們實現兩個最簡單的數據結構:HashSet和HashMap。

先說HashSet,實現如下:

class MyHashSet {
    private final int MAX_LEN = 100000;
    private List<Integer>[] set; 
    
    private int getIndex(int key) {
        return key % MAX_LEN;
    }
    
    private int getPos(int key, int index) {
        List<Integer> temp = set[index];
        if (temp == null) {
            return -1;
        }
        for (int i = 0; i < temp.size(); ++i) {
            if (temp.get(i) == key) {
                return i;
            }
        }
        return -1;
    }
    
    public MyHashSet() {
        set = (List<Integer>[])new ArrayList[MAX_LEN];
    }
    
    public void add(int key) {
        int index = getIndex(key);
        int pos = getPos(key, index);
        if (pos < 0) {
            if (set[index] == null) {
                set[index] = new ArrayList<Integer>();
            }
            set[index].add(key);
        }
    }
    
    public void remove(int key) {
        int index = getIndex(key);
        int pos = getPos(key, index);
        if (pos >= 0) {
            set[index].remove(pos);
        }
    }
    
    public boolean contains(int key) {
        int index = getIndex(key);
        int pos = getPos(key, index);
        return pos >= 0;
    }
}

再說HashMap,實現如下:

import javafx.util.Pair;

class MyHashMap {
    private final int MAX_LEN = 100000; 
    private List<Pair<Integer, Integer>>[] map;
    
    private int getIndex(int key) {
        return key % MAX_LEN;
    }
    
    private int getPos(int key, int index) {
        List<Pair<Integer, Integer>> temp = map[index];
        if (temp == null) {
            return -1;
        }
        for (int i = 0; i < temp.size(); ++i) {
            if (temp.get(i).getKey() == key) {
                return i;
            }
        }
        return -1;
    }

    public MyHashMap() {
        map = (List<Pair<Integer, Integer>>[])new ArrayList[MAX_LEN];
    }
    
    public void put(int key, int value) {
        int index = getIndex(key);
        int pos = getPos(key, index);
        if (pos < 0) {
            if (map[index] == null) {
                map[index] = new ArrayList<Pair<Integer, Integer>>();
            }
            map[index].add(new Pair(key, value));
        } else {
            map[index].set(pos, new Pair(key, value));
        }
    }
    
    public int get(int key) {
        int index = getIndex(key);
        int pos = getPos(key, index);
        if (pos < 0) {
            return -1;
        } else {
            return map[index].get(pos).getValue();
        }
    }
    
    public void remove(int key) {
        int index = getIndex(key);
        int pos = getPos(key, index);
        if (pos >= 0) {
            map[index].remove(pos);
        }
    }
}

以上內容很基礎,但對於理解接下來的內容很有幫助。

(二)BitMap

理解了Hash的基本概念,接下來就引入第二個概念:BitMap。簡單講:BitMap就是用一個bit位來標記某個元素對應的鍵值。用哈希表的概念看,就是將每個bit位當作存儲桶,位移作哈希函數,“N”值設定爲1。

例如:我們目前有16個bit位:0000000000000000(標號從0開始),還有一個數字集合{1,2,5,7,11},那麼這16個bit位就可以表示爲:0110010100010000。如下圖所示:

再舉例:一臺32位機器上的自然數總共有4294967295個,如果用一個bit來存放一個整數,1代表存在,0代表不存在,那麼把全部自然數存儲在內存只要4294967295 / (8 * 1024 * 1024) ≈ 512MB,如果存儲在文件中,需要約20G的容量。

以下的實現方式需要自行補充Java位運算的相關知識:

因爲在Java中,最小的數據類型爲byte(8位),因此這裏用byte舉例。這裏我們需要拿到兩個位置:一個是數字在每個byte中的位置,另一個是在byte[]數組中的位置。因此計算公式如下:

所處於數組位置:outerIndex = num >> 3 (相當於除以8取整)

在byte中位置:innerIndex = num & 7 (相當於mod8)

在byte中標記位置:0x01 << innerIndex(位移)

更新byte:bitsMap[outerIndex] | (0x01 << innerIndex) (或運算更新bit位)

相關實現代碼如下:

public class BitMap {

    private static byte[] bitsMap;

    public BitMap(long length) {
        bitsMap = new byte[(byte) (length >> 3) + ((length & 7) > 0 ? 1 : 0)];
    }

    public int get(long num) {
    	byte data = bitsMap[(byte) (num >> 3)];
    	byte innerIndex = (byte) (num & 7);
        return data >> innerIndex & 0x01;
    }

    public void put(long num) {
        byte outerIndex = (byte) (num >> 3);
        byte innerIndex = (byte) (num & 7);
        bitsMap[outerIndex] = (byte) (bitsMap[outerIndex] | (0x01 << innerIndex));
    }
    
    public static void main(String[] args) {
        BitMap bitMap = new BitMap(101);
        bitMap.put(33);
        System.out.println(bitMap.get(0));
        System.out.println(bitMap.get(33));
        System.out.println(bitMap.get(100));
    }
}

結果:0、1、0

是不是與HashMap的實現方式有雷同呢?

(三)BitSet

理解了BitMap,接下來直接看Java.util中的BitSet方法,就容易多了,基本的原理都是相通的。接下來就拿出一部分源碼來看:

public void set(int bitIndex) {
    if (bitIndex < 0)
        throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

    int wordIndex = wordIndex(bitIndex);
    expandTo(wordIndex);

    words[wordIndex] |= (1L << bitIndex); // Restores invariants

    checkInvariants();
}

以Set方法爲例:先是通過wordIndex方法獲得outerIndex的位置,“>> ADDRESS_BITS_PER_WORD”這裏設定的值是6,也就是2的6次方,對應的就是Java中Long的數據類型。再是通過expandTo方法來判斷是否超過了當前words數組,如果存不下,則新增一個Long數組。最後是“1L << bitIndex”更新下標,與BitMap中的位移方式相同。

Get方法的實現方式類似,詳細的實現代碼可以看一下Java源碼。

(四)BloomFilter

布隆過濾器本質上是一種數據結構,通過巧妙的概率型數據結構,以較小的存儲空間,換取高效的插入和去重查詢操作。但布隆過濾器的結果是概率性的,並不準確。

理解了BitSet的概念,我們就可以通過BitSet的數據結構,來實現基本的BloomFilterInMemory方法:

import java.util.BitSet;

public class BloomFilterInMemory {
	
    protected BitSet bloom;

    public BloomFilterInMemory(int size) {
        bloom = new BitSet(size);
    }
    
    public synchronized boolean addElement(int element) {
        boolean added = false;
        if (!getBit(element)) {
            added = true;
            setBit(element, true);
        }
        return added;
    }

    public synchronized void clear() {
        bloom.clear();
    }

    public synchronized boolean contains(int element) {
        if (!getBit(element)) {
            return false;
        }
        return true;
    }

    protected boolean getBit(int index) {
        return bloom.get(index);
    }

    protected void setBit(int index, boolean to) {
        bloom.set(index, to);
    }

    public synchronized boolean isEmpty() {
        return bloom.isEmpty();
    }
}

實時數據去重是一種比較常見的近似場景,通常有以下三種實現方式:

1. 通過布隆過濾器;

2. 通過內嵌數據庫(如RocksDB);

3. 引入外部數據庫(如Redis)。

第一種方式是近似統計,第二、三種方式統計的就相對準確。假如使用場景有很多額外的因素,例如反作弊會對後續數據進行修正,那麼還是推薦通過布隆過濾器的方式來進行統計,只需要去重的指標是整型(字符串去重對哈希函數的設計要求較高)。

實時統計中,我們最常遇到的是對用戶進行去重,假設用戶是登陸狀態,那麼可以獲得用戶的ID,這種ID通常是數據庫自增型的,那麼就很適合布隆過濾器的使用場景。

以上一篇文章的WordCount程序實例,實現一個新的DeduplicateFlatMapFunction即可:

public class DeduplicateFlatMapFunction implements FlatMapFunction<Integer, Tuple2<Integer, Integer>> {
	
	private static final int DEFAULT_SIZE = 1000000;
    private volatile BloomFilterInMemory bloomFilter;

	@Override
	public void flatMap(Integer value, Collector<Tuple2<Integer, Integer>> out) throws Exception {
		if (bloomFilter == null) {
			bloomFilter = new BloomFilterInMemory(DEFAULT_SIZE);
		}
		if (!bloomFilter.contains(value)) {
    	  bloomFilter.addElement(value);
        out.collect(new Tuple2<>(value, 1));
      }
	}
 }

 

發佈了30 篇原創文章 · 獲贊 33 · 訪問量 6757
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章