還有人不懂布隆過濾器嗎?

Alickx 發表於 2022-01-26

還有人不懂布隆過濾器嗎?

1.介紹

我們在使用快取的時候都會不可避免的考慮到如何應對 快取雪崩快取穿透快取擊穿 ,這裡我們就來講講如何解決快取穿透。

快取穿透是指當有人非法請求不存在的資料的時候,由於資料不存在,所以快取不會生效,請求會直接打到資料庫上,當大量請求集中在該不存在的資料上的時候,會導致資料庫掛掉。

那麼解決方法有好幾個:

  • 當資料庫查詢不到的時候,自動在快取上建立該請求對應的空物件,過期時間較短
  • 使用布隆過濾器,減少資料庫負擔。

那麼布隆過濾器是什麼來的?

布隆過濾器( Bloom Filter )是1970年由布隆提出。主要用於判斷一個元素是否在一個集合中。通過將元素轉化成雜湊函式再經過一系列的計算,最終得出多個下標,然後在長度為n的陣列中該下標的值修改為1。

image-20220126143544446

那麼如何判斷該元素是否在這一個集合中只需要判斷計算得出的下標的值是否為1即可。

當然布隆過濾器也不是完美無缺的,其缺點就是存在誤判刪除困難

優點 缺點
不需要儲存key值,佔用空間少 存在誤判,不能100%判斷元素存在
空間效率和查詢時間遠超一般演算法 刪除困難

布隆過濾器原理:當一個元素被加入集合時,通過 K 個雜湊函式將這個元素對映成一個位陣列(Bit array)中的 K 個點,把它們置為 1 。檢索時,只要看看這些點是不是都是1就知道元素是否在集合中;如果這些點有任何一個 0,則被檢元素一定不在;如果都是1,則被檢元素很可能在(之所以說“可能”是誤差的存在)。

那麼誤差為什麼存在呢?因為當儲存量大的時候,雜湊計算得出的下標有可能會相同,那麼當兩個元素得出的雜湊下標相同時,就無法判斷該元素是否一定存在了。

刪除困難也是如此,因為下標有可能重複,當你對該下標的值歸零的時候,有可能也會對其他元素造成影響。

那麼應對快取穿透,我們只需要在布隆過濾器上判斷該元素是否存在,如果不存在則直接返回,如果判斷存在則查詢快取和資料庫,儘管有誤判率的影響,但是也能夠大大減少資料庫的負擔,同時也能夠阻擋大部分的非法請求。

2.實踐

2.1 Redis實現布隆過濾器

Redis有一系列位運算的命令,如 setbit , getbit 可以設定位陣列的值,這個特性可以很好的實現布隆過濾器,有現成的依賴已經實現布隆過濾器了。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.0</version>
</dependency>

以下是測試程式碼,我們先填入8W的數字進去,然後再迴圈往後2W數字,測試其誤判率

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedisBloomFilter {

    public static void main(String[] args) {

        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        config.useSingleServer().setPassword("123456");
        //建立redis連線
        RedissonClient redissonClient = Redisson.create(config);


        //初始化布隆過濾器並傳入該過濾器自定義命名
        RBloomFilter<Integer> bloomFilter = redissonClient.getBloomFilter("BloomFilter");

        //初始化布隆過濾器引數,設定元素數量和誤判率
        bloomFilter.tryInit(110000,0.1);

        //填充800W數字
        for (int i = 0; i < 80000; i++) {
            bloomFilter.add(i);
        }

        //從8000001開始檢查是否存在,測試誤判率
        double count = 0;
        for (int i = 80001; i < 100000; i++) {
            if (bloomFilter.contains(i)) {
                count++;
            }
        }

        //  count / (1000000-8000001) 就可以得出誤判率
        System.out.println("count=" + count);
        System.out.println("誤判率 = " + count / (100000 - 80001));

    }

}

得出結論:在四捨五入下,誤判率為0.1

image-20220126191617773

2.2 谷歌Guava工具類實現布隆過濾器

新增Guava工具類依賴

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1.1-jre</version>
</dependency>

編寫測試程式碼:

import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class GuavaBloomFilter {
    public static void main(String[] args) {

        //初始化布隆過濾器
        BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),
                                                              110000,0.1);

        //填充資料
        for (int i = 0; i < 80000; i++) {
            bloomFilter.put(i);
        }

        //檢測誤判
        double count = 0;
        for (int i = 80000; i < 100000; i++) {
            if (bloomFilter.mightContain(i)) {
                count++;
            }
        }

        System.out.println("count="+ count);
        System.out.println("誤判率為" + count / (100000-80000));

    }
}

結果:

image-20220126192315056

結果低於設定的誤判率,我猜測可能是兩者底層使用的hash演算法不同導致的,而且在使用過程中可以明顯得出使用Guava工具類的布隆過濾器速度是遠遠快於使用redisson,這可能是因為Guava是直接操作記憶體,而redisson要與Redis互動,在速度上肯定比不過直接操作記憶體的Guava。

2.3 手寫布隆過濾器

我們使用Java一個封裝好的位陣列 BitSetBitSet 提供了大量API,基本的操作包括:

  • 清空陣列的資料
  • 翻轉某一位的資料
  • 設定某一位的資料
  • 獲取某一位的資料
  • 獲取當前的bitSet的位數

寫一個布隆過濾器需要考慮的以下幾點:

  • 位陣列的大小空間需要指定,空間越大,hash衝突的概率越小,誤判率就越低
  • 多個hash函式,我們應該使用多個不同的質數來當種子
  • 實現兩個方法,一個是往過濾器裡新增元素,一個是判斷布隆過濾器是否存在該元素

hash值得出高低位進行異或,然後乘以種子,再對位陣列大小進行取餘數。

import java.util.BitSet;

public class MyBloomFilter {

    // 預設大小
    private static final int DEFAULT_SIZE = Integer.MAX_VALUE;

    // 最小的大小
    private static final int MIN_SIZE = 1000;

    // 大小為預設大小
    private int SIZE = DEFAULT_SIZE;

    // hash函式的種子因子
    private static final int[] HASH_SEEDS = new int[]{3, 5, 7, 11, 13, 17, 19, 23, 29, 31};

    // 位陣列,0/1,表示特徵
    private BitSet bitSet = null;

    // hash函式
    private HashFunction[] hashFunctions = new HashFunction[HASH_SEEDS.length];

    // 無引數初始化
    public MyBloomFilter() {
        // 按照預設大小
        init();
    }

    // 帶引數初始化
    public MyBloomFilter(int size) {
        // 大小初始化小於最小的大小
        if (size >= MIN_SIZE) {
            SIZE = size;
        }
        init();
    }

    private void init() {
        // 初始化位大小
        bitSet = new BitSet(SIZE);
        // 初始化hash函式
        for (int i = 0; i < HASH_SEEDS.length; i++) {
            hashFunctions[i] = new HashFunction(SIZE, HASH_SEEDS[i]);
        }
    }

    // 新增元素,相當於把元素的特徵新增到位陣列
    public void add(Object value) {
        for (HashFunction f : hashFunctions) {
            // 將hash計算出來的位置為true
            bitSet.set(f.hash(value), true);
        }
    }

    // 判斷元素的特徵是否存在於位陣列
    public boolean contains(Object value) {
        boolean result = true;
        for (HashFunction f : hashFunctions) {
            result = result && bitSet.get(f.hash(value));
            // hash函式只要有一個計算出為false,則直接返回
            if (!result) {
                return result;
            }
        }
        return result;
    }

    // hash函式
    public static class HashFunction {
        // 位陣列大小
        private int size;
        // hash種子
        private int seed;

        public HashFunction(int size, int seed) {
            this.size = size;
            this.seed = seed;
        }

        // hash函式
        public int hash(Object value) {
            if (value == null) {
                return 0;
            } else {
                // hash值
                int hash1 = value.hashCode();
                // 高位的hash值
                int hash2 = hash1 >>> 16;
                // 合併hash值(相當於把高低位的特徵結合)
                int combine = hash1 ^ hash1;
                // 相乘再取餘
                return Math.abs(combine * seed) % size;
            }
        }

    }

    public static void main(String[] args) {
        Integer num1 = 12321;
        Integer num2 = 12345;
        MyBloomFilter myBloomFilter = new MyBloomFilter();
        System.out.println(myBloomFilter.contains(num1));
        System.out.println(myBloomFilter.contains(num2));

        myBloomFilter.add(num1);
        myBloomFilter.add(num2);

        System.out.println(myBloomFilter.contains(num1));
        System.out.println(myBloomFilter.contains(num2));

    }
}

手寫程式碼是來自 https://juejin.cn/post/6961681011423838221

通過程式碼可以得出實現一個簡單的布隆過濾器需要一個位陣列,多個雜湊函式,以及對過濾新增元素和判斷元素是否存在的方法。位陣列空間越大,hash碰撞的概率就越小,所以布隆過濾器中誤判率和空間大小是關聯的,誤判率越低,需要的空間就越大

2.4 布隆過濾器的實際應用場景

布隆過濾器的功能很明確,就是判斷元素在集合中是否存在。有一些面試官可能會提問假如現在給你10W資料的集合,那麼我要怎麼快速確定某個資料在集合中是否存在,這個問題就可以使用布隆過濾器來解決,畢竟儘管布隆過濾器存在誤判,但是可以100%確定該資料不存在,相較於其缺點,完全可以接受。

還有一些應用場景:

  • 確定某一個郵箱是否在郵箱黑名單中
  • 在爬蟲中對已經爬取的URL進行去重

解決快取穿透我們可以提前預熱,將資料存入布隆過濾器中,請求進來後,先查詢布隆過濾器是否存在該資料,假如資料不存在則直接返回,如果資料存在則先查詢Redis,Redis不存在再查詢資料庫,假如有新的資料新增,也可以新增資料進布隆過濾器。當然如果有大量資料需要進行更新,那麼最好就是重建布隆過濾器。

3.總結

  • 布隆過濾器是使用一個n長度位元陣列,通過對元素進行多種雜湊,得出多個下標值,在位元陣列中把得出下標的值修改為1,那麼就完成了對元素的儲存
  • 布隆過濾器的誤判率與其位元陣列負相關,誤判率越低,需要的位元陣列就越大
  • 布隆過濾器的優點勝在儲存空間效率高,查詢時間快,缺點為刪除困難,存在誤判
  • Redis易於實現布隆過濾器,Github上也有布隆過濾器模組可以在Redis上安裝,Java中谷歌的Guava工具類也有布隆過濾器的實現
  • 布隆過濾器是解決快取穿透的解決方法之一,通過布隆過濾器可以判斷查詢的元素是否存在

4. 參考文章