上周在工作中遇到了一個問題場景,即查詢商品的配件資訊時(商品:配件為1:N的關系),如若商品并未配置配件資訊,則查資料庫為空,且不會加入緩存,這就會導緻,下次在查詢同樣商品的配件時,由于緩存未命中,則仍舊會查底層資料庫,是以緩存就一直未起到應有的作用,當并發流量大時,會很容易把DB打垮。
緩存穿透問題
緩存穿透是指查詢一個根本不存在的資料,緩存層和存儲層都不會命中,通常出于容錯的考慮,如果從存儲層查不到資料則不寫入緩存層。
一般對于未命中的資料我們是按照如下方式進行處理的:
1.緩存層不命中。
2.存儲層不命中,不将空結果寫回緩存。
3.傳回空結果。
/**
* 緩存穿透問題:
* 在資料庫層沒有查到資料,未存入緩存,
* 則下次查詢同樣的資料時,還會查庫。
*
* @param id
* @return
*/
private Object getObjectById(Integer id) {
// 從緩存中擷取資料
Object cacheValue = cache.get(id);
if (cacheValue != null) {
return cacheValue;
}
// 從資料庫中擷取
Object storageValue = storage.get(id);
// 如果這裡按照id查詢DB為空,那麼便會出現緩存穿透
if (storageValue != null) {
cache.set(id, storageValue);
}
return storageValue;
}
緩存穿透将導緻不存在的資料每次請求都要到存儲層去查詢,失去了緩存保護後端存儲的意義。
緩存穿透問題可能會使後端存儲負載加大,由于很多後端存儲不具備高并發性,甚至可能造成後端存儲宕掉。
方案一:緩存空對象
/**
* 緩存空對象:
* 此種方式存在漏洞,不經過判斷就直接将Null對象存入到緩存中,
* 如果惡意制造不存在的id那麼,緩存中的鍵值就會很多,惡意攻擊時,很可能會被打爆,是以需設定較短的過期時間。
*
* @param id
* @return
*/
public Object getObjectInclNullById(Integer id) {
// 從緩存中擷取資料
Object cacheValue = cache.get(id);
// 緩存為空
if (cacheValue != null) {
// 從資料庫中擷取
Object storageValue = storage.get(key);
// 緩存空對象
cache.set(key, storageValue);
// 如果存儲資料為空,需要設定一個過期時間(300秒)
if (storageValue == null) {
// 必須設定過期時間,否則有被攻擊的風險
cache.expire(key, 60 * 5);
}
return storageValue;
}
return cacheValue;
}
緩存空對象會有一個必須考慮的問題:
空值做了緩存,意味着緩存層中存了更多的鍵,需要更多的記憶體空間(如果是攻擊,問題更嚴重),比較有效的方法是針對這類資料設定一個較短的過期時間,讓其自動剔除。
方案二:布隆過濾器攔截
布隆過濾器介紹
概念:
布隆過濾器(英語:Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進制向量和一系列随機映射函數。布隆過濾器可以用于檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的算法,缺點是有一定的誤識别率和删除困難。
如果想判斷一個元素是不是在一個集合裡,一般想到的是将集合中所有元素儲存起來,然後通過比較确定。連結清單、樹、散清單(又叫哈希表,Hash table)等等資料結構都是這種思路。但是随着集合中元素的增加,我們需要的存儲空間越來越大。同時檢索速度也越來越慢,上述三種結構的檢索時間複雜度分别為 O(n),O(log n),O(n/k)
布隆過濾器的原理是,當一個元素被加入集合時,通過K個散列函數将這個元素映射成一個位數組中的K個點,把它們置為1。檢索時,我們隻要看看這些點是不是都是1就(大約)知道集合中有沒有它了:如果這些點有任何一個0,則被檢元素一定不在;如果都是1,則被檢元素很可能在。這就是布隆過濾器的基本思想。
示例:
google guava包下有對布隆過濾器的封裝,BloomFilter。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class BloomFilterTest {
// 初始化一個能夠容納10000個元素且容錯率為0.01布隆過濾器
private static final BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
/**
* 初始化布隆過濾器
*/
private static void initLegalIdsBloomFilter() {
// 初始化10000個合法Id并加入到過濾器中
for (int legalId = 0; legalId < 10000; legalId++) {
bloomFilter.put(legalId);
}
}
/**
* id是否合法有效,即是否在過濾器中
*
* @param id
* @return
*/
public static boolean validateIdInBloomFilter(Integer id) {
return bloomFilter.mightContain(id);
}
public static void main(String[] args) {
// 初始化過濾器
initLegalIdsBloomFilter();
// 誤判個數
int errorNum=0;
// 驗證從10000個非法id是否有效
for (int id = 10000; id < 20000; id++) {
if (validateIdInBloomFilter(id)){
// 誤判數
errorNum++;
}
}
System.out.println("judge error num is : " + errorNum);
}
}
布隆過濾器攔截
設定過期時間,讓其自動過期失效,這種在很多時候不是最佳的實踐方案。
我們可以提前将真實正确的商品Id,在添加完成之後便加入到過濾器當中,每次再進行查詢時,先确認要查詢的Id是否在過濾器當中,如果不在,則說明Id為非法Id,則不需要進行後續的查詢步驟了。
/**
* 防緩存穿透的:布隆過濾器
*
* @param id
* @return
*/
public Object getObjectByBloom(Integer id) {
// 判斷是否為合法id
if (!bloomFilter.mightContain(id)) {
// 非法id,則不允許繼續查庫
return null;
} else {
// 從緩存中擷取資料
Object cacheValue = cache.get(id);
// 緩存為空
if (cacheValue == null) {
// 從資料庫中擷取
Object storageValue = storage.get(id);
// 緩存空對象
cache.set(id, storageValue);
}
return cacheValue;
}
}
參考書籍:《Redis開發與運維》
作者:翎野君