還有人不懂布隆過濾器嗎?
1.介紹
我們在使用緩存的時候都會不可避免的考慮到如何應對 緩存雪崩
, 緩存穿透
和 緩存擊穿
,這里我們就來講講如何解決緩存穿透。
緩存穿透是指當有人非法請求不存在的數據的時候,由於數據不存在,所以緩存不會生效,請求會直接打到數據庫上,當大量請求集中在該不存在的數據上的時候,會導致數據庫掛掉。
那么解決方法有好幾個:
- 當數據庫查詢不到的時候,自動在緩存上創建該請求對應的空對象,過期時間較短
- 使用布隆過濾器,減少數據庫負擔。
那么布隆過濾器是什么來的?
布隆過濾器( Bloom Filter )是1970年由布隆提出。主要用於判斷一個元素是否在一個集合中。通過將元素轉化成哈希函數再經過一系列的計算,最終得出多個下標,然后在長度為n的數組中該下標的值修改為1。
那么如何判斷該元素是否在這一個集合中只需要判斷計算得出的下標的值是否為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
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));
}
}
結果:
結果低於設置的誤判率,我猜測可能是兩者底層使用的hash算法不同導致的,而且在使用過程中可以明顯得出使用Guava工具類的布隆過濾器速度是遠遠快於使用redisson,這可能是因為Guava是直接操作內存,而redisson要與Redis交互,在速度上肯定比不過直接操作內存的Guava。
2.3 手寫布隆過濾器
我們使用Java一個封裝好的位數組 BitSet
。BitSet
提供了大量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工具類也有布隆過濾器的實現
- 布隆過濾器是解決緩存穿透的解決方法之一,通過布隆過濾器可以判斷查詢的元素是否存在