一、緩存穿透
項目中的熱點數據我們一般會放在 redis 中,在數據庫前面加了一層緩存,減少數據庫的訪問,提升性能。但如果,請求的 key 在 redis 中並不存在,那請求還是會抵達數據庫,這就叫緩存穿透。
我們無法避免緩存穿透,因爲數據庫中的數據要全部放到 redis 中不太現實,也不可能保證數據庫數據和 redis 中的數據做到實時同步。但我們可以避免高頻的緩存穿透。
避免高頻緩存穿透的辦法:
做好參數檢驗,對於一些非法參數直接擋掉,比如 id 爲負數的請求直接擋掉;
緩存無效的 key,比如某次請求的 key 在數據庫中不存在,那就將其緩存到 redis 並設置過期時間,但是這種辦法不好,假如黑客每次請求都用不同的 key,那 redis 中的無用數據就會很多;
使用布隆過濾器;
二、布隆過濾器
1. 過濾器的作用:
上面說了,如果大量不存在的 key 請求過來,還是會直接請求到數據庫,如果我們能在請求數據庫之前判斷這個 key 在數據庫到底存不存在,不存在就直接返回相關錯誤信息,那就可以解決緩存穿透的問題。
如何在不請求數據庫的前提下判斷這個 key 在數據庫中存不存在呢?這就需要用到過濾器。難不成又要將數據庫的所有數據緩存到過濾器中嗎?當然不是,如果這樣,那和將所有 key 緩存到 redis 就沒啥區別了。接下來看看布隆過濾器是怎麼做的。
2. 布隆過濾器原理:
布隆過濾器使用了布隆算法來存儲數據,明確一點,布隆算法存儲的數據不是 100% 準確的,即布隆過濾器認爲這個 key 存在,實際上它也有可能不存在,如果它認爲這個key 不存在,那麼它一定不存在。布隆算法是通過一定的錯誤率來換取空間的。
布隆算法通過 bit 數組 來標識 key 是否存在。怎麼做的呢?key 經過 hash 函數的運算,得到一個數組的下標,然後將對應下標的值改成1,1就表示該 key 存在。這個 hash 函數要滿足的條件有:
對 key 計算的結果必須在 [0, bitArray.length - 1] 之間;
計算出來的結果分佈要足夠散列;
因爲要進行 hash 計算,所有布隆算法的錯誤率是由於 hash 碰撞導致的。所以降低 hash 碰撞的概率就可以降低錯誤率。怎麼降低 hash 碰撞的概率呢?兩種辦法:
加大數組的長度:數組長度更長,hash 碰撞的概率自然更小;
增加 hash 函數的個數:假如 key 爲 10 的數據,第一個 hash 函數計算出來的下標是 1,第二個 hash 函數計算出來的是 4,第三個 hash 函數計算出來的是 10,那麼就要 1,4,10 這三個下標所對應的值都得是 1,纔會認爲 key 存在,故而也可以減少誤判的情況。
3. 爲什麼要用 bit 數組:
因爲節省空間。1k = 1024byte = 1024 * 8 bit = 8192bit,即長度爲8192的bit數組只需要1kb的空間。
4. 怎麼用?
業界大佬和民間大神已經造了很多輪子了,這裏主要說三種,具體用法大家看一下相關 api 即會了。
可以使用 guava 中的布隆過濾器;
使用 hutools 工具包中的布隆過濾器;
redis 有 bitMap,也可以用作布隆過濾器,推薦使用 redisson 構造布隆過濾器;
三、hutools 中的布隆過濾器源碼分析
這裏帶大家分析一下 hutools 中的布隆過濾器源碼,看看人家怎麼實現的。用法如下:
public static void main(String[] args) {
BitMapBloomFilter bloomFilter = new BitMapBloomFilter(5);
bloomFilter.add("aa");
bloomFilter.add("bb");
bloomFilter.add("cc");
bloomFilter.add("dd");
System.out.println(bloomFilter.contains("aa"));
}
1. 構造方法:
首先來看 new BitMapBloomFilter 的時候做了什麼。
public BitMapBloomFilter(int m) {
long mNum = NumberUtil.div(String.valueOf(m), String.valueOf(5)).longValue();
long size = mNum * 1024 * 1024 * 8;
filters = new BloomFilter[]{
new DefaultFilter(size),
new ELFFilter(size),
new JSFilter(size),
new PJWFilter(size),
new SDBMFilter(size)
};
}
用傳進來的 m 計算得到一個 size,然後創建了一個 BloomFilter 數組。這個數組有五個不同實現的對象,可以簡單地理解爲 hutools 中的布隆過濾器用了五個 hash 函數去計算 key 對應的索引。注意:如果傳進來的 m 小於 5,那麼 size 就是 0,調用 hash 的時候就會報錯,因爲 hash 函數中用這個 size 做除數了,如下:
@Override
public long hash(String str) {
return HashUtil.javaDefaultHash(str) % size;
}
2. add 方法:
@Override
public boolean add(String str) {
boolean flag = false;
for (BloomFilter filter : filters) {
flag |= filter.add(str);
}
return flag;
}
這裏就是遍歷上面構造的五個對象,也即分別調用那五個對象的 add 方法。再看看這裏調用的那個 add 方法:
@Override
public boolean add(String str) {
final long hash = Math.abs(hash(str));
if (bm.contains(hash)) {
return false;
}
bm.add(hash);
return true;
}
這裏首先計算 hash 值,這裏的 hash 就是那五個對象的 hash函數,計算出了 hash 值後,判斷是否已經存在了,存在了就直接返回 false,否則就進行 add 操作。這個 contains 等會兒再看,先看看這個 add 方法。它的實現有兩個,如圖:
默認用的是 IntMap 中的 add 方法,再去看看它的實現:
@Override
public void add(long i) {
int r = (int) (i / BitMap.MACHINE32);
int c = (int) (i % BitMap.MACHINE32);
ints[r] = (int) (ints[r] | (1 << c));
}
這裏是不是有點兒懵逼呢?首先看看這個 ints 數組是啥:
private final int[] ints;
它竟然是個 int 數組,說好的 bit 數組呢?
先來回顧一下,一個 int 佔 4 個 byte,而一個 byte 是 32 bit。所以,一個長度爲 10 的 int 數組,其實就可以存放 320 bit數據。這裏正是用 int 數組來表示 bit。明白了這個之後,再來看上面的代碼。首先讓 i 除以 32,然後再讓 i 對 32 求餘,最後再做了一堆計算就完事了。不懂沒關係,舉個例子就秒懂了。
假如有一個 int 數組:int[] ints = new int[10],那麼它可以存放 32 * 10 = 320 bit 數據。
現在我想將第 66 位的 bit 值改成 1,第 66 位索引其實是 65,那麼做法如下:
int r = 65 / 32 = 2; int c = 65 % 32 = 1;
1<<1 = 2,二進制就是0000……0010,10 前面是 30 個 0,ints[2] 是0,二進制就是 32 個 0;
它們做與運算,結果就是還是 2,二進制就是 0000……0010。
然後讓把 0000……0010 賦值給 ints[2]。爲什麼這樣就表示把第 66 個 bit 值改成 1 了呢?
ints[0] 和 ints[1] 都是 0 對不對,也即 ints[0] 和 ints[1] 中都有 32 個 0,加起來就是 64 個 0。
也就是前 64 bit 都是 0。ints[2] 存的是 2,二進制是 0000……0010,這個二進制第一位是 0,第二位是 1……
所以 ints[2] 中的第一位是 0, 第二位是 1,後面的 30 位都是0。32 + 32 + 2 = 66,所以第 66 位就變成了 1。
3. contains 方法:
@Override
public boolean contains(String str) {
return bm.contains(Math.abs(hash(str)));
}
再點進去看這個 contains 方法:
@Override
public boolean contains(long i) {
int r = (int) (i / BitMap.MACHINE32);
int c = (int) (i % BitMap.MACHINE32);
return ((int) ((ints[r] >>> c)) & 1) == 1;
}
還是上面的例子,r 還是 2,c 還是 1,ints[2] = 2,2>>>1 結果是 1,
1 & 1 結果是 1,所以返回true,也就是說,如果傳進來的 i 還是 65 的話,那就返回 true,因爲剛纔已經 add 過了。