一 前言
假如有一個15億使用者的系統,每天有幾億使用者訪問系統,要如何快速判斷是否為系統中的使用者呢?
- 方法一,將15億使用者儲存在資料庫中,每次使用者訪問系統,都到資料庫進行查詢判斷,準確性高,但是查詢速度會比較慢。
- 方法二,將15億使用者快取在Redis記憶體中,每次使用者訪問系統,都到Redis中進行查詢判斷,準確性高,查詢速度也快,但是佔用記憶體極大。即使只儲存使用者ID,一個使用者ID一個字元,則15億*8位元組=12GB,對於一些記憶體空間有限的伺服器來說相對浪費。
還有對於網站爬蟲的專案,我們都知道世界上的網站數量及其之多,每當我們爬一個新的網站url時,如何快速判斷是否爬蟲過了呢?還有垃圾郵箱的過濾,廣告電話的過濾等等。如果還是用上面2種方法,顯然不是最好的解決方案。
再者,查詢是一個系統最高頻的操作,當查詢一個資料,首先會先到快取查詢(例如Redis),如果快取沒命中,於是到持久層資料庫(mongo,mysql等)查詢,發現也沒有此資料,於是本此查詢失敗。如果使用者很多的時候,並且快取都沒命中,進而全部請求了持久層資料庫,這就給資料庫帶來很大壓力,嚴重可能拖垮資料庫。俗稱快取穿透
。
可能大家也聽到另一個詞叫快取擊穿
,它是指一個熱點key,不停著扛著高併發,突然這個key失效了,在失效的瞬間,大量的請求快取就沒命中,全部請求到資料庫。
對於以上這些以及類似的場景,如何高效的解決呢?針對此,布隆過濾器應運而生了。
二 布隆過濾器
布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進位制向量和一系列隨機對映函式。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的演算法要好的多,缺點是有一定的誤識別率和刪除困難。
二進位制向量,簡單理解就是一個二進位制陣列。這個陣列裡面存放的值要麼是0,要麼是1。
對映函式,它可以將一個元素對映成一個位陣列(Bit array)中的一個點。所以通過這個點,就能判斷集合中是否有此元素。
基本思想
- 當一個元素被加入集合時,通過K個雜湊函式將這個元素對映到一個位陣列中的K個點,把它們置為1。
- 檢索某個元素時,再通過這K個雜湊函式將這個元素對映,看看這些位置是不是都是1就能知道集合中這個元素存不存在。如果這些位置有任何一個0,則該元素
一定
不存在;如果都是1,則被檢元素很可能
存在。
Bloom Filter跟單個雜湊函式對映不同,Bloom Filter使用了k個雜湊函式,每個元素跟k個bit對應。從而降低了衝突的概率。
優點
- 二進位制組成的陣列,記憶體佔用空間少,並且插入和查詢速度很快,常數級別。
- Hash函式相互之間沒有必然聯絡,方便由硬體並行實現。
- 只儲存0和1,不需要儲存元素本身,在某些對保密要求非常嚴格的場合有優勢。
缺點
- 存在誤差率。隨著存入的元素數量增加,誤算率隨之增加。(比如現實中你是否遇到正常郵件也被放入垃圾郵件目錄,正常簡訊被攔截)可以增加一個小的白名單,儲存那些可能被誤判的元素。
- 刪除困難。一個元素對映到bit陣列的k個位置上是1,刪除的時候不能簡單的直接置為0,可能會影響其他元素的判斷。因為其他元素的對映也有可能在相同的位置置為1。可以採用
Counting Bloom Filter
解決。
三 Redis實現
在Redis中,有一種資料結構叫點陣圖,即bitmap
。以下是一些常用的操作命令。
在Redis命令中,SETBIT key offset value
,此命令表示將key對應的值的二進位制陣列,從左向右起,offset下標的二進位制數字設定為value。
鍵k1對應的值為keke,對應ASCII碼為107 101 107 101,對應的二進位制為 0110 1011,0110 0101,0110 1011,0110 0101。將下標5的位置設定為1,所以變成 0110 1111,0110 0101,0110 1011,0110 0101。即 oeke。
GETBIT key offset
命令,它用來獲取指定下標的值。
還有一個比較常用的命令,BITCOUNT key [start end]
,用來獲取點陣圖中指定範圍值為1的個數。注意,start和end指定的是位元組的個數,而不是位陣列下標。
Redisson
是用於在Java程式中操作Redis的庫,利用Redisson我們可以在程式中輕鬆地使用Redis。Redisson這個客戶端工具實現了布隆過濾器,其底層就是通過bitmap這種資料結構來實現的。
Redis 4.0提供了外掛功能之後,Redis就提供了布隆過濾器功能。布隆過濾器作為一個外掛載入到了Redis Server之中,給Redis提供了強大的布隆去重功能。此文就不細講了,大家感興趣地可到官方檢視詳細文件介紹。它又如下常用命令:
- bf.add:新增元素
- bf.madd:批量新增元素
- bf.exists:檢索元素是否存在
- bf.mexists:檢索多個元素是否存在
- bf.reserve:自定義布隆過濾器,設定key,error_rate和initial_size
下面演示是在本地單節點Redis實現的,如果資料量很大,並且誤差率又很低的情況下,那單節點記憶體可能會不足。當然,在叢集Redis中,也是可以通過Redisson實現分散式布隆過濾器的。
引入依賴
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
程式碼測試
package com.nobody;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
/**
* @Description
* @Author Mr.nobody
* @Date 2021/3/6
* @Version 1.0
*/
public class RedissonDemo {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// config.useSingleServer().setPassword("123456");
RedissonClient redissonClient = Redisson.create(config);
// 獲取一個redis key為users的布隆過濾器
RBloomFilter<Integer> bloomFilter = redissonClient.getBloomFilter("users");
// 假設元素個數為10萬
int size = 100000;
// 進行初始化,預計元素為10萬,誤差率為1%
bloomFilter.tryInit(size, 0.01);
// 將1至100000這十萬個數對映到布隆過濾器中
for (int i = 1; i <= size; i++) {
bloomFilter.add(i);
}
// 檢查已在過濾器中的值,是否有匹配不上的
for (int i = 1; i <= size; i++) {
if (!bloomFilter.contains(i)) {
System.out.println("存在不匹配的值:" + i);
}
}
// 檢查不在過濾器中的1000個值,是否有匹配上的
int matchCount = 0;
for (int i = size + 1; i <= size + 1000; i++) {
if (bloomFilter.contains(i)) {
matchCount++;
}
}
System.out.println("誤判個數:" + matchCount);
}
}
結果存在的10萬個元素都匹配上了;不存在布隆過濾器中的1千個元素,有23個誤判。
誤判個數:23
四 Guava實現
布隆過濾器有許多實現與優化,Guava中就提供了一種實現。Google Guava提供的布隆過濾器的位陣列是儲存在JVM記憶體中,故是單機版的,並且最大位長為int型別的最大值。
- 使用布隆過濾器時,重要關注點是預估資料量n以及期望的誤判率fpp。
- 實現布隆過濾器時,重要關注點是hash函式的選取以及bit陣列的大小。
Bit陣列大小選擇
根據預估資料量n以及誤判率fpp,bit陣列大小的m的計算方式:
Guava中原始碼實現如下:
@VisibleForTesting
static long optimalNumOfBits(long n, double p) {
if (p == 0) {
p = Double.MIN_VALUE;
}
return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
雜湊函式選擇
雜湊函式的個數的選擇也是挺講究的,雜湊函式的選擇影響著效能的好壞,而且一個好的雜湊函式能近似等概率的將元素對映到各個Bit。如何選擇構造k個函式呢,一種簡單的方法是選擇一個雜湊函式,然後送入k個不同的引數。
雜湊函式的個數k,可以根據預估資料量n和bit陣列長度m計算而來:
Guava中原始碼實現如下:
@VisibleForTesting
static int optimalNumOfHashFunctions(long n, long m) {
// (m / n) * log(2), but avoid truncation due to division!
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
引入依賴
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
程式碼測試
package com.nobody;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
/**
* @Description
* @Author Mr.nobody
* @Date 2021/3/6
* @Version 1.0
*/
public class GuavaDemo {
public static void main(String[] args) {
// 假設元素個數為10萬
int size = 100000;
// 預計元素為10萬,誤差率為1%
BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.01);
// 將1至100000這十萬個數對映到布隆過濾器中
for (int i = 1; i <= size; i++) {
bloomFilter.put(i);
}
// 檢查已在過濾器中的值,是否有匹配不上的
for (int i = 1; i <= size; i++) {
if (!bloomFilter.mightContain(i)) {
System.out.println("存在不匹配的值:" + i);
}
}
// 檢查不在過濾器中的1000個值,是否有匹配上的
int matchCount = 0;
for (int i = size + 1; i <= size + 1000; i++) {
if (bloomFilter.mightContain(i)) {
matchCount++;
}
}
System.out.println("誤判個數:" + matchCount);
}
}
結果存在的10萬個元素都匹配上了;不存在布隆過濾器中的1千個元素,有10個誤判。
誤判個數:10
當fpp的值改為為0.001,即降低誤差率時,誤判個數為0個。
誤判個數:0
分析結果可知,誤判率確實跟我們傳入的容錯率差不多,而且在布隆過濾器中的元素都匹配到了。
原始碼分析
通過debug建立布隆過濾器的方法,當預計元素為10萬個,fpp的值為0.01時,需要位數958505個,hash函式個數為7個。
當預計元素為10萬個,fpp的值為0.001時,需要位數1437758個,hash函式個數為10個。
得出結論
- 容錯率越大,所需空間和時間越小,容錯率越小,所需空間和時間越大。
- 理論上存10萬個數,一個int是4位元組,即32位,需要320萬位。如果使用HashMap儲存,按HashMap50%的儲存效率,需要640萬位。而布隆過濾器即使容錯率fpp為0.001,也才需要1437758位,可以看出BloomFilter的儲存空間很小。
五 擴充套件知識點
假如有一臺伺服器,記憶體只有4GB,磁碟上有2個大檔案,檔案A儲存100億個URL,檔案B儲存100億個URL。請問如何模糊
找出兩個檔案的URL交集?如何精緻
找出兩個檔案的URL交集。
模糊交集:
藉助布隆過濾器思想,先將一個檔案的URL通過hash函式對映到bit陣列中,這樣大大減少了記憶體儲存,再讀取另一個檔案URL,去bit陣列中進行匹配。
精緻交集:
對大檔案進行hash拆分成小檔案,例如拆分成1000個小檔案(如果伺服器記憶體更小,則可以拆分更多個更小的檔案),比如檔案A拆分為A1,A2,A3...An,檔案B拆分為B1,B2,B3...Bn。而且通過相同的hash函式,相同的URL一定被對映到相同下標的小檔案中,例如A檔案的www.baidu.com被對映到A1中,那B檔案的www.baidu.com也一定被對映到B1檔案中。最後再通過求相同下標的小檔案(例如A1和B1)(A2和B2)的交集即可。
歡迎關注微信公眾號:「Java之言」技術文章持續更新,請持續關注......
- 第一時間學習最新技術文章
- 領取最新技術學習資料視訊
- 最新網際網路資訊和麵試經驗