Redis(5)——億級數據過濾和布隆過濾器


一、布隆過濾器簡介

上一次 我們學會了使用 HyperLogLog 來對大數據進行一個估算,它非常有價值,可以解決很多精確度不高的統計需求。但是如果我們想知道某一個值是不是已經在 HyperLogLog 結構里面了,它就無能為力了,它只提供了 pfaddpfcount 方法,沒有提供類似於 contains 的這種方法。

就舉一個場景吧,比如你 刷抖音

你有 刷到過重復的推薦內容 嗎?這么多的推薦內容要推薦給這么多的用戶,它是怎么保證每個用戶在看推薦內容時,保證不會出現之前已經看過的推薦視頻呢?也就是說,抖音是如何實現 推送去重 的呢?

你會想到服務器 記錄 了用戶看過的 所有歷史記錄,當推薦系統推薦短視頻時會從每個用戶的歷史記錄里進行 篩選,過濾掉那些已經存在的記錄。問題是當 用戶量很大,每個用戶看過的短視頻又很多的情況下,這種方式,推薦系統的去重工作 在性能上跟的上么?

實際上,如果歷史記錄存儲在關系數據庫里,去重就需要頻繁地對數據庫進行 exists 查詢,當系統並發量很高時,數據庫是很難抗住壓力的。

你可能又想到了 緩存,但是這么多用戶這么多的歷史記錄,如果全部緩存起來,那得需要 浪費多大的空間 啊.. (可能老板看一眼賬單,看一眼你..) 並且這個存儲空間會隨着時間呈線性增長,就算你用緩存撐得住一個月,但是又能繼續撐多久呢?不緩存性能又跟不上,咋辦呢?

如上圖所示,布隆過濾器(Bloom Filter) 就是這樣一種專門用來解決去重問題的高級數據結構。但是跟 HyperLogLog 一樣,它也一樣有那么一點點不精確,也存在一定的誤判概率,但它能在解決去重的同時,在 空間上能節省 90% 以上,也是非常值得的。

布隆過濾器是什么

布隆過濾器(Bloom Filter) 是 1970 年由布隆提出的。它 實際上 是一個很長的二進制向量和一系列隨機映射函數 (下面詳細說),實際上你也可以把它 簡單理解 為一個不怎么精確的 set 結構,當你使用它的 contains 方法判斷某個對象是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要參數設置的合理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。

當布隆過濾器說某個值存在時,這個值 可能不存在;當它說不存在時,那么 一定不存在。打個比方,當它說不認識你時,那就是真的不認識,但是當它說認識你的時候,可能是因為你長得像它認識的另外一個朋友 (臉長得有些相似),所以誤判認識你。

布隆過濾器的使用場景

基於上述的功能,我們大致可以把布隆過濾器用於以下的場景之中:

  • 大數據判斷是否存在:這就可以實現出上述的去重功能,如果你的服務器內存足夠大的話,那么使用 HashMap 可能是一個不錯的解決方案,理論上時間復雜度可以達到 O(1 的級別,但是當數據量起來之后,還是只能考慮布隆過濾器。
  • 解決緩存穿透:我們經常會把一些熱點數據放在 Redis 中當作緩存,例如產品詳情。 通常一個請求過來之后我們會先查詢緩存,而不用直接讀取數據庫,這是提升性能最簡單也是最普遍的做法,但是 如果一直請求一個不存在的緩存,那么此時一定不存在緩存,那就會有 大量請求直接打到數據庫 上,造成 緩存穿透,布隆過濾器也可以用來解決此類問題。
  • 爬蟲/ 郵箱等系統的過濾:平時不知道你有沒有注意到有一些正常的郵件也會被放進垃圾郵件目錄中,這就是使用布隆過濾器 誤判 導致的。

二、布隆過濾器原理解析

布隆過濾器 本質上 是由長度為 m 的位向量或位列表(僅包含 01 位值的列表)組成,最初所有的值均設置為 0,所以我們先來創建一個稍微長一些的位向量用作展示:

當我們向布隆過濾器中添加數據時,會使用 多個 hash 函數對 key 進行運算,算得一個證書索引值,然后對位數組長度進行取模運算得到一個位置,每個 hash 函數都會算得一個不同的位置。再把位數組的這幾個位置都置為 1 就完成了 add 操作,例如,我們添加一個 wmyskxz

向布隆過濾器查查詢 key 是否存在時,跟 add 操作一樣,會把這個 key 通過相同的多個 hash 函數進行運算,查看 對應的位置 是否 1只要有一個位為 0,那么說明布隆過濾器中這個 key 不存在。如果這幾個位置都是 1,並不能說明這個 key 一定存在,只能說極有可能存在,因為這些位置的 1 可能是因為其他的 key 存在導致的。

就比如我們在 add 了一定的數據之后,查詢一個 不存在key

很明顯,1/3/5 這幾個位置的 1 是因為上面第一次添加的 wmyskxz 而導致的,所以這里就存在 誤判。幸運的是,布隆過濾器有一個可以預判誤判率的公式,比較復雜,感興趣的朋友可以自行去閱讀,比較燒腦.. 只需要記住以下幾點就好了:

  • 使用時 不要讓實際元素數量遠大於初始化數量
  • 當實際元素數量超過初始化數量時,應該對布隆過濾器進行 重建,重新分配一個 size 更大的過濾器,再將所有的歷史元素批量 add 進行;

三、布隆過濾器的使用

Redis 官方 提供的布隆過濾器到了 Redis 4.0 提供了插件功能之后才正式登場。布隆過濾器作為一個插件加載到 Redis Server 中,給 Redis 提供了強大的布隆去重功能。下面我們來體驗一下 Redis 4.0 的布隆過濾器,為了省去繁瑣安裝過程,我們直接用
Docker 吧。

> docker pull redislabs/rebloom # 拉取鏡像
> docker run -p6379:6379 redislabs/rebloom # 運行容器
> redis-cli # 連接容器中的 redis 服務

如果上面三條指令執行沒有問題,下面就可以體驗布隆過濾器了。

布隆過濾器的基本用法

布隆過濾器有兩個基本指令,bf.add 添加元素,bf.exists 查詢元素是否存在,它的用法和 set 集合的 saddsismember 差不多。注意 bf.add 只能一次添加一個元素,如果想要一次添加多個,就需要用到 bf.madd 指令。同樣如果需要一次查詢多個元素是否存在,就需要用到 bf.mexists 指令。

127.0.0.1:6379> bf.add codehole user1
(integer) 1
127.0.0.1:6379> bf.add codehole user2
(integer) 1
127.0.0.1:6379> bf.add codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user1
(integer) 1
127.0.0.1:6379> bf.exists codehole user2
(integer) 1
127.0.0.1:6379> bf.exists codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user4
(integer) 0
127.0.0.1:6379> bf.madd codehole user4 user5 user6
1) (integer) 1
2) (integer) 1
3) (integer) 1
127.0.0.1:6379> bf.mexists codehole user4 user5 user6 user7
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 0

上面使用的布隆過過濾器只是默認參數的布隆過濾器,它在我們第一次 add 的時候自動創建。Redis 也提供了可以自定義參數的布隆過濾器,只需要在 add 之前使用 bf.reserve 指令顯式創建就好了。如果對應的 key 已經存在,bf.reserve 會報錯。

bf.reserve 有三個參數,分別是 keyerror_rate (錯誤率)initial_size

  • error_rate 越低,需要的空間越大,對於不需要過於精確的場合,設置稍大一些也沒有關系,比如上面說的推送系統,只會讓一小部分的內容被過濾掉,整體的觀看體驗還是不會受到很大影響的;
  • initial_size 表示預計放入的元素數量,當實際數量超過這個值時,誤判率就會提升,所以需要提前設置一個較大的數值避免超出導致誤判率升高;

如果不適用 bf.reserve,默認的 error_rate0.01,默認的 initial_size100

四、布隆過濾器代碼實現

自己簡單模擬實現

根據上面的基礎理論,我們很容易就可以自己實現一個用於 簡單模擬 的布隆過濾器數據結構:

public static class BloomFilter {

    private byte[] data;

    public BloomFilter(int initSize) {
        this.data = new byte[initSize * 2]; // 默認創建大小 * 2 的空間
    }

    public void add(int key) {
        int location1 = Math.abs(hash1(key) % data.length);
        int location2 = Math.abs(hash2(key) % data.length);
        int location3 = Math.abs(hash3(key) % data.length);

        data[location1] = data[location2] = data[location3] = 1;
    }

    public boolean contains(int key) {
        int location1 = Math.abs(hash1(key) % data.length);
        int location2 = Math.abs(hash2(key) % data.length);
        int location3 = Math.abs(hash3(key) % data.length);

        return data[location1] * data[location2] * data[location3] == 1;
    }

    private int hash1(Integer key) {
        return key.hashCode();
    }

    private int hash2(Integer key) {
        int hashCode = key.hashCode();
        return hashCode ^ (hashCode >>> 3);
    }

    private int hash3(Integer key) {
        int hashCode = key.hashCode();
        return hashCode ^ (hashCode >>> 16);
    }
}

這里很簡單,內部僅維護了一個 byte 類型的 data 數組,實際上 byte 仍然占有一個字節之多,可以優化成 bit 來代替,這里也僅僅是用於方便模擬。另外我也創建了三個不同的 hash 函數,其實也就是借鑒 HashMap 哈希抖動的辦法,分別使用自身的 hash 和右移不同位數相異或的結果。並且提供了基礎的 addcontains 方法。

下面我們來簡單測試一下這個布隆過濾器的效果如何:

public static void main(String[] args) {
    Random random = new Random();
    // 假設我們的數據有 1 百萬
    int size = 1_000_000;
    // 用一個數據結構保存一下所有實際存在的值
    LinkedList<Integer> existentNumbers = new LinkedList<>();
    BloomFilter bloomFilter = new BloomFilter(size);

    for (int i = 0; i < size; i++) {
        int randomKey = random.nextInt();
        existentNumbers.add(randomKey);
        bloomFilter.add(randomKey);
    }

    // 驗證已存在的數是否都存在
    AtomicInteger count = new AtomicInteger();
    AtomicInteger finalCount = count;
    existentNumbers.forEach(number -> {
        if (bloomFilter.contains(number)) {
            finalCount.incrementAndGet();
        }
    });
    System.out.printf("實際的數據量: %d, 判斷存在的數據量: %d \n", size, count.get());

    // 驗證10個不存在的數
    count = new AtomicInteger();
    while (count.get() < 10) {
        int key = random.nextInt();
        if (existentNumbers.contains(key)) {
            continue;
        } else {
            // 這里一定是不存在的數
            System.out.println(bloomFilter.contains(key));
            count.incrementAndGet();
        }
    }
}

輸出如下:

實際的數據量: 1000000, 判斷存在的數據量: 1000000 
false
true
false
true
true
true
false
false
true
false

這就是前面說到的,當布隆過濾器說某個值 存在時,這個值 可能不存在,當它說某個值 不存在時,那就 肯定不存在,並且還有一定的誤判率...

手動實現參考

當然上面的版本特別 low,不過主體思想是不差的,這里也給出一個好一些的版本用作自己實現測試的參考:

import java.util.BitSet;

public class MyBloomFilter {

    /**
     * 位數組的大小
     */
    private static final int DEFAULT_SIZE = 2 << 24;
    /**
     * 通過這個數組可以創建 6 個不同的哈希函數
     */
    private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134};

    /**
     * 位數組。數組中的元素只能是 0 或者 1
     */
    private BitSet bits = new BitSet(DEFAULT_SIZE);

    /**
     * 存放包含 hash 函數的類的數組
     */
    private SimpleHash[] func = new SimpleHash[SEEDS.length];

    /**
     * 初始化多個包含 hash 函數的類的數組,每個類中的 hash 函數都不一樣
     */
    public MyBloomFilter() {
        // 初始化多個不同的 Hash 函數
        for (int i = 0; i < SEEDS.length; i++) {
            func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]);
        }
    }

    /**
     * 添加元素到位數組
     */
    public void add(Object value) {
        for (SimpleHash f : func) {
            bits.set(f.hash(value), true);
        }
    }

    /**
     * 判斷指定元素是否存在於位數組
     */
    public boolean contains(Object value) {
        boolean ret = true;
        for (SimpleHash f : func) {
            ret = ret && bits.get(f.hash(value));
        }
        return ret;
    }

    /**
     * 靜態內部類。用於 hash 操作!
     */
    public static class SimpleHash {

        private int cap;
        private int seed;

        public SimpleHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }

        /**
         * 計算 hash 值
         */
        public int hash(Object value) {
            int h;
            return (value == null) ? 0 : Math.abs(seed * (cap - 1) & ((h = value.hashCode()) ^ (h >>> 16)));
        }

    }
}

使用 Google 開源的 Guava 中自帶的布隆過濾器

自己實現的目的主要是為了讓自己搞懂布隆過濾器的原理,Guava 中布隆過濾器的實現算是比較權威的,所以實際項目中我們不需要手動實現一個布隆過濾器。

首先我們需要在項目中引入 Guava 的依賴:

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

實際使用如下:

我們創建了一個最多存放 最多 1500 個整數的布隆過濾器,並且我們可以容忍誤判的概率為百分之(0.01)

// 創建布隆過濾器對象
BloomFilter<Integer> filter = BloomFilter.create(
        Funnels.integerFunnel(),
        1500,
        0.01);
// 判斷指定元素是否存在
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));
// 將元素添加進布隆過濾器
filter.put(1);
filter.put(2);
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));

在我們的示例中,當 mightContain() 方法返回 true 時,我們可以 99% 確定該元素在過濾器中,當過濾器返回 false 時,我們可以 100% 確定該元素不存在於過濾器中。

Guava 提供的布隆過濾器的實現還是很不錯的 (想要詳細了解的可以看一下它的源碼實現),但是它有一個重大的缺陷就是只能單機使用 (另外,容量擴展也不容易),而現在互聯網一般都是分布式的場景。為了解決這個問題,我們就需要用到 Redis 中的布隆過濾器了。

相關閱讀

  1. Redis(1)——5種基本數據結構 - https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/
  2. Redis(2)——跳躍表 - https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/
  3. Redis(3)——分布式鎖深入探究 - https://www.wmyskxz.com/2020/03/01/redis-3/
  4. Reids(4)——神奇的HyperLoglog解決統計問題 - https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/

參考資料

  1. 《Redis 深度歷險》 - 錢文品/ 著 - https://book.douban.com/subject/30386804/
  2. 5 分鍾搞懂布隆過濾器,億級數據過濾算法你值得擁有! - https://juejin.im/post/5de1e37c5188256e8e43adfc
  3. 【原創】不了解布隆過濾器?一文給你整的明明白白! - https://github.com/Snailclimb/JavaGuide/blob/master/docs/dataStructures-algorithms/data-structure/bloom-filter.md
  • 本文已收錄至我的 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 個人公眾號 :wmyskxz,個人獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

非常感謝各位人才能 看到這里,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支持和認可,就是我創作的最大動力,我們下篇文章見!


免責聲明!

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



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