Bloom Filter 與機率性資料結構

當你需要檢查一個元素是否在一個集合中,最簡單的方式是用 HashSet。但如果集合中有數十億個元素呢?HashSet 會佔用大量記憶體。

Bloom Filter 是一個極度節省記憶體的機率性資料結構。它可以告訴你:

  • 一定不在(100% 正確)
  • 可能在(有機率誤判)

這種取捨在許多大數據場景中非常值得——用微小的誤判率換取 90% 以上的記憶體節省。

實作 Bloom Filter

import math
import mmh3
from bitarray import bitarray

class BloomFilter:
    def __init__(self, n, fp_prob):
        """
        n: 預計插入的元素數量
        fp_prob: 期望的誤判率 (False Positive Probability)
        """
        self.fp_prob = fp_prob
        self.size = self._get_size(n, fp_prob)
        self.hash_count = self._get_hash_count(self.size, n)
        self.bit_array = bitarray(self.size)
        self.bit_array.setall(0)
    
    def _get_size(self, n, p):
        """計算需要的 bit 數"""
        m = -(n * math.log(p)) / (math.log(2) ** 2)
        return int(m)
    
    def _get_hash_count(self, m, n):
        """計算需要的 hash 函數數量"""
        k = (m / n) * math.log(2)
        return int(k)
    
    def add(self, item):
        """加入元素"""
        for i in range(self.hash_count):
            digest = mmh3.hash(item, i) % self.size
            self.bit_array[digest] = True
    
    def check(self, item):
        """檢查元素是否存在"""
        for i in range(self.hash_count):
            digest = mmh3.hash(item, i) % self.size
            if not self.bit_array[digest]:
                return False  # 一定不在
        return True  # 可能在

# 使用範例
bf = BloomFilter(1000000, 0.01)  # 100 萬個元素,1% 誤判率
print(f"bit 陣列大小: {bf.size/1024/1024:.2f} MB")
print(f"hash 函數數: {bf.hash_count}")

# 加入一些元素
for i in range(10000):
    bf.add(f"user_{i}_email@gmail.com")

# 測試
print(f"\n已存在的 email: {bf.check('user_123_email@gmail.com')}")
print(f"不存在的 email: {bf.check('fake_email@gmail.com')}")

# 測試誤判率
test_size = 10000
false_positives = 0
for i in range(test_size):
    if bf.check(f"not_in_set_{i}@test.com"):
        false_positives += 1
print(f"\n誤判率測試: {false_positives/test_size:.4%} (理論值: 1%)")

記憶體比較

| 資料結構 | 100萬項目 | 記憶體 | 備註 | |---------|----------|--------|------| | HashSet | 100萬字串 | ~50 MB | 100%準確 | | Bloom Filter (1% FP) | 100萬項目 | ~1.2 MB | 節省97.6% | | Bloom Filter (0.1% FP) | 100萬項目 | ~1.8 MB | 節省96.4% | | Bloom Filter (0.01% FP) | 100萬項目 | ~2.4 MB | 節省95.2% |

實際應用場景

1. 快取過濾(防止快取穿透)

當網頁伺服器收到某個 key 的請求時,先檢查 Bloom Filter。如果 filter 說該 key 不存在,直接跳過快取查詢 — 這可以防止快取穿透導致資料庫被擊垮。

2. 爬蟲 URL 去重

搜尋引擎爬蟲使用 Bloom Filter 來追蹤已訪問過的 URL。面對數十億的 URL,Bloom Filter 可比 HashSet 節省數 TB 的 RAM。

3. 垃圾郵件檢測

郵件服務維護已知垃圾郵件發送者地址的 Bloom Filter。如果某發送者「絕對不在」filter 中,郵件可快速送達;如果「可能在」,則觸發更詳細的檢查。

4. 資料庫查詢優化

Apache Cassandra 使用 Bloom Filter 快速判斷某行是否存在於 SSTable 中,避免不必要的磁碟 I/O。PostgreSQL 也有類似的 visibility map 機制。

5. 區塊鏈輕錢包

以太坊輕錢包使用 Bloom Filter 來檢查某筆交易或日誌是否存在於區塊中,而無需下載完整區塊。

時間複雜度

| 操作 | 時間複雜度 | |------|-----------| | add(item) | O(k) — k 個 hash 函數 | | check(item) | O(k) — k 個 hash 函數 | | 空間 | O(m) — m 位元的陣列 |

與 HashSet(需要 O(n) 記憶體且可能需重新調整大小)不同,Bloom Filter 的記憶體使用量是可預測且固定的,無論實際元素數量如何(雖然隨著元素增加,誤判率會上升)。

限制

| 限制 | 說明 | |------|------| | 無法刪除元素 | 標準 BF 不支援刪除(可使用 Counting BF) | | 誤判(False Positive) | 省記憶體的代價,無法完全消除 | | 誤判率會增長 | 當加入的數量超過預期的 n,誤判率會上升 | | 無法列舉 | 不能列出已儲存的所有元素 |

總結

  • 節省空間的機率性資料結構
  • 100% 召回率 — 保證沒有 false negative
  • 可設定誤判率(在記憶體與準確度之間取捨)
  • 使用 k 個獨立 hash 函數將元素映射到 bit 位置
  • 廣泛應用於資料庫、快取、分散式系統與區塊鏈

真實世界的工程抉擇:何時該用 Bloom Filter?

你的 API 每秒收到 10,000 次請求,需要檢查請求的 IP 是否在黑名單中。
黑名單有 500 萬個 IP。

選項 A:HashSet(100% 準確)
  記憶體: 500萬 × ~40 bytes ≈ 200 MB
  查詢速度: O(1),但 200 MB 可能超出快取上限
  結果: 每次查詢都要 RAM 存取,延遲 ~100ns

選項 B:Bloom Filter(1% 誤判)
  記憶體: ~6 MB(節省 97%)
  查詢速度: O(k),但全部在 CPU 快取內
  結果: 查詢延遲 ~10ns(快 10 倍!)
  代價: 1% 的 IP 會被誤判為黑名單(接到客服投訴時再用 HashSet 二次確認)

這就是 Bloom Filter 的典型應用場景——用 1% 的誤判率換取 97% 的記憶體節省和 10 倍的查詢速度。在系統設計面試中,這正是面試官想聽到的權衡取捨。

Bloom Filter 的著名使用者

  • Cassandra:每個 SSTable 都有一個 Bloom Filter,跳過不包含查詢資料的檔案
  • PostgreSQL:Visibility Map 類似 Bloom Filter 的概念
  • Google Bigtable:每個 SSTable 使用 Bloom Filter 減少磁碟查找
  • Redis:Redis Stack 提供原生 Bloom Filter 資料結構(BF.ADDBF.EXISTS
  • Ethereum:輕錢用戶端使用 Bloom Filter 檢查交易日誌
  • Medium:使用 Bloom Filter 向使用者推薦已讀過的內容
  • GitHub:使用 BF 檢查貢獻者是否已簽署 CLA

何時不該用 Bloom Filter

  • ✅ 當你必須保證 100% 準確時(銀行交易、醫療記錄)
  • ✅ 當資料量很小,HashSet 完全吃得消時
  • ✅ 當你需要列出所有儲存的元素時(BF 無法列舉)
  • ✅ 當你需要刪除元素時(Counting BF 可解決但更複雜)

下章預告:HyperLogLog——另一種機率性資料結構

如果你覺得 Bloom Filter 用 1.2 MB 取代 50 MB 已經很神奇,那下一章的 HyperLogLog 會讓你更驚訝——它可以用 1.5 KB 的記憶體統計任意大小的集合中有多少個不同元素(例如今天的獨立訪客數 UV)。

Bloom Filter 回答的是「這個元素在不在集合中?」,HyperLogLog 回答的是「這個集合有多大?」。兩者都是大數據場景下的必備工具,但解決的是完全不同的問題。

準備好迎接下一章的基數估計魔法了嗎?

會員專屬免費教學

本章節為註冊會員專屬的免費開放內容!請先登入或註冊會員,即可立即解鎖閱讀。

立即登入 / 註冊