淺談布隆過濾器Bloom Filter


先從一道面試題開始:

給A,B兩個文件,各存放50億條URL,每條URL占用64字節,內存限制是4G,讓你找出A,B文件共同的URL。

這個問題的本質在於判斷一個元素是否在一個集合中。哈希表以O(1)的時間復雜度來查詢元素,但付出了空間的代價。在這個大數據問題中,就算哈希表有100%的空間利用率,也至少需要50億*64Byte的空間,4G肯定是遠遠不夠的。

當然我們可能想到使用位圖,每個URL取整數哈希值,置於位圖相應的位置上。4G大概有320億個bit,看上去是可行的。但位圖適合對海量的、取值分布很均勻的集合去重。位圖的空間復雜度是隨集合內最大元素增大而線性增大的。要設計沖突率很低的哈希函數,勢必要增加哈希值的取值范圍,假如哈希值最大取到了264,位圖大概需要23億G的空間。4G的位圖最大值是320億左右,為50億條URL設計沖突率很低、最大值為320億的哈希函數比較困難。

題目的一個解決思路是將文件切割成可以放入4G空間的小文件,重點在於A與B兩個文件切割后的小文件要一一對應。

分別切割A與B文件,根據hash(URL) % k值將URL划分到k個不同的文件中,如A1,A2,...,Ak和B1,B2,...,Bk,同時可以保存hash值避免重復運算。這樣Bn小文件與A文件共同的URL肯定會分到對應的An小文件中。讀取An到一個哈希表中,再遍歷Bn,判斷是否有重復的URL。

另一個解決思路就是使用Bloom Filter布隆過濾器了。

Bloom Filter簡介

布隆過濾器(Bloom-Filter)是1970年由Bloom提出的。它可以用於檢索一個元素是否在一個集合中。

布隆過濾器其實是位圖的一種擴展,不同的是要使用多個哈希函數。它包括一個很長的二進制向量(位圖)和一系列隨機映射函數。

首先建立一個m位的位圖,然后對於每一個加入的元素,使用k個哈希函數求k個哈希值映射到位圖的k個位置,然后將這k個位置的bit全設置為1。下圖是k=3的布隆過濾器:

檢索時,我們只要檢索這些k個位是不是都是1就可以了:如果這些位有任何一個0,則被檢元素一定不在;如果都是1,則被檢元素很可能在。

可以看出布隆過濾器在時間和空間上的效率比較高,但也有缺點:

  • 存在誤判。布隆過濾器可以100%確定一個元素不在集合之中,但不能100%確定一個元素在集合之中。當k個位都為1時,也有可能是其它的元素將這些bit置為1的。
  • 刪除困難。一個放入容器的元素映射到位圖的k個位置上是1,刪除的時候不能簡單的直接全部置為0,可能會影響其他元素的判斷。

Bloom Filter實現

要實現一個布隆過濾器,我們需要預估要存儲的數據量為n,期望的誤判率為P,然后計算位圖的大小m,哈希函數的個數k,並選擇哈希函數。

求位圖大小m公式:

哈希函數數目k公式:

Python中已經有實現布隆過濾器的包:pybloom

安裝

pip install pybloom

簡單的看一下實現:

class BloomFilter(object):
    FILE_FMT = b'<dQQQQ'

    def __init__(self, capacity, error_rate=0.001):
        """Implements a space-efficient probabilistic data structure
        capacity
            this BloomFilter must be able to store at least *capacity* elements
            while maintaining no more than *error_rate* chance of false
            positives
        error_rate
            the error_rate of the filter returning false positives. This
            determines the filters capacity. Inserting more than capacity
            elements greatly increases the chance of false positives.
        >>> b = BloomFilter(capacity=100000, error_rate=0.001)
        >>> b.add("test")
        False
        >>> "test" in b
        True
        """
        if not (0 < error_rate < 1):
            raise ValueError("Error_Rate must be between 0 and 1.")
        if not capacity > 0:
            raise ValueError("Capacity must be > 0")
        # given M = num_bits, k = num_slices, P = error_rate, n = capacity
        #       k = log2(1/P)
        # solving for m = bits_per_slice
        # n ~= M * ((ln(2) ** 2) / abs(ln(P)))
        # n ~= (k * m) * ((ln(2) ** 2) / abs(ln(P)))
        # m ~= n * abs(ln(P)) / (k * (ln(2) ** 2))
        num_slices = int(math.ceil(math.log(1.0 / error_rate, 2)))
        bits_per_slice = int(math.ceil(
            (capacity * abs(math.log(error_rate))) /
            (num_slices * (math.log(2) ** 2))))
        self._setup(error_rate, num_slices, bits_per_slice, capacity, 0)
        self.bitarray = bitarray.bitarray(self.num_bits, endian='little')
        self.bitarray.setall(False)

    def _setup(self, error_rate, num_slices, bits_per_slice, capacity, count):
        self.error_rate = error_rate
        self.num_slices = num_slices
        self.bits_per_slice = bits_per_slice
        self.capacity = capacity
        self.num_bits = num_slices * bits_per_slice
        self.count = count
        self.make_hashes = make_hashfuncs(self.num_slices, self.bits_per_slice)

    def __contains__(self, key):
        """Tests a key's membership in this bloom filter.
        >>> b = BloomFilter(capacity=100)
        >>> b.add("hello")
        False
        >>> "hello" in b
        True
        """
        bits_per_slice = self.bits_per_slice
        bitarray = self.bitarray
        hashes = self.make_hashes(key)
        offset = 0
        for k in hashes:
            if not bitarray[offset + k]:
                return False
            offset += bits_per_slice
        return True

計算公式基本一致。

算法將位圖分成了k段(代碼中的num_slices,也就是哈希函數的數量k),每段長度為代碼中的bits_per_slice,每個哈希函數只負責將對應的段中的bit置為1:

        for k in hashes:
            if not skip_check and found_all_bits and not bitarray[offset + k]:
                found_all_bits = False
            self.bitarray[offset + k] = True
            offset += bits_per_slice

當期望誤判率為0.001時,m與n的比率大概是14:

>>> import math
>>> abs(math.log(0.001))/(math.log(2)**2)
14.37758756605116

當期望誤判率為0.05時,m與n的比率大概是6:

>>> import math
>>> abs(math.log(0.05))/(math.log(2)**2)
6.235224229572683

上述題目中,m最大為320億,n為50億,誤判率大概為0.04,在可以接受的范圍:

>>> math.e**-((320/50.0)*(math.log(2)**2))
0.04619428041606246

應用

布隆過濾器一般用於在大數據量的集合中判定某元素是否存在:

1. 緩存穿透

緩存穿透,是指查詢一個數據庫中不一定存在的數據。正常情況下,查詢先進行緩存查詢,如果key不存在或者key已經過期,再對數據庫進行查詢,並將查詢到的對象放進緩存。如果每次都查詢一個數據庫中不存在的key,由於緩存中沒有數據,每次都會去查詢數據庫,很可能會對數據庫造成影響。

緩存穿透的一種解決辦法是為不存在的key緩存一個空值,直接在緩存層返回。這樣做的弊端就是緩存太多空值占用了太多額外的空間,這點可以通過給緩存層空值設立一個較短的過期時間來解決。

另一種解決辦法就是使用布隆過濾器,查詢一個key時,先使用布隆過濾器進行過濾,如果判斷請求查詢key值存在,則繼續查詢數據庫;如果判斷請求查詢不存在,直接丟棄。

2. 爬蟲

在網絡爬蟲中,用於URL去重策略。

3. 垃圾郵件地址過濾

由於垃圾郵件發送者可以不停地注冊新地址,垃圾郵件的Email地址是一個巨量的集合。使用哈希表存貯幾十億個郵件地址可能需要上百GB的內存,而布隆過濾器只需要哈希表1/8到1/4的大小就能解決問題。布隆過濾器決不會漏掉任何一個在黑名單中的可疑地址。至於誤判問題,常見的補救辦法是在建立一個小的白名單,存儲那些可能被誤判的清白郵件地址。

4. Google的BigTable

Google的BigTable也使用了布隆過濾器,以減少不存在的行或列在磁盤上的I/O。

5. Summary Cache

Summary Cache是一種用於代理服務器Proxy之間共享Cache的協議。可以使用布隆過濾器構建Summary Cache,每一個Cache的網頁由URL唯一標識,因此Proxy的Cache內容可以表示為一個URL列表。進而我們可以將URL列表這個集合用布隆過濾器表示。

擴展

要實現刪除元素,可以采用Counting Bloom Filter。它將標准布隆過濾器位圖的每一位擴展為一個小的計數器(Counter),插入元素時將對應的k個Counter的值分別加1,刪除元素時則分別減1:

代價就是多了幾倍的存儲空間。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM