1、緩存穿透
緩存穿透是指查詢一個(gè)不存在的數(shù)據(jù),由于緩存不命中,而將請(qǐng)求全部打到數(shù)據(jù)庫(kù)上的情況。緩存起不到作用,請(qǐng)求每次都會(huì)走到數(shù)據(jù)庫(kù),流量大時(shí)數(shù)據(jù)庫(kù)可能會(huì)被打掛。此時(shí)緩存就好像被“穿透”了一樣,起不到任何作用。
一般來(lái)說(shuō),少量的緩存穿透對(duì)系統(tǒng)的傷害不大,而且不可避免,原因有以下幾點(diǎn):
- 緩存系統(tǒng)的容量是有限的,不可能存儲(chǔ)系統(tǒng)所有的數(shù)據(jù),那么在查詢未緩存數(shù)據(jù)的時(shí)候就會(huì)發(fā)生緩存穿透。
- 另一方面就是基于“二八原則”,我們通常只會(huì)緩存常用的那 20% 的熱點(diǎn)數(shù)據(jù)。
但是如果我們系統(tǒng)被人惡意攻擊,那么很有可能查詢的值是偽造的,必然大概率不存在我們的系統(tǒng)中,這樣無(wú)論查詢多少次,在緩存中一直不存在,這樣緩存穿透就一直存在。比如用一個(gè)不存在的用戶id獲取用戶信息,不論緩存還是數(shù)據(jù)庫(kù)都沒(méi)有,若黑客利用此漏洞進(jìn)行攻擊可能壓垮數(shù)據(jù)庫(kù)。
下圖是一個(gè)比較典型的cache-storage架構(gòu),cache(例如memcache, redis等等) + storage(例如mysql, hbase等等)架構(gòu),查一個(gè)壓根就不存在的值, 如果不做兼容,永遠(yuǎn)會(huì)查詢storage:

基于存在這種大量緩存穿透的可能性,所以我們就需要從根源上解決緩存穿透的問(wèn)題,目前一般有兩種方案:緩存空值和使用布隆過(guò)濾器。
1.1 緩存空值

如上圖所示,當(dāng)?shù)?步MISS后,仍然將空對(duì)象保留到Cache中(可能是保留幾分鐘或者一段時(shí)間,具體問(wèn)題具體分析),下次新的Request(同一個(gè)key)將會(huì)從Cache中獲取到數(shù)據(jù),保護(hù)了后端的Storage。
這種方案適用于數(shù)據(jù)命中不高,數(shù)據(jù)頻繁變化的場(chǎng)景。
下面是一段偽代碼:
Object nullValue = new Object();
try {
//從數(shù)據(jù)庫(kù)中查詢數(shù)據(jù)
Object valueFromDB = getFromDB(uid);
if (valueFromDB == null) {
//如果從數(shù)據(jù)庫(kù)中查詢到空值,就把空值寫(xiě)入緩存,設(shè)置較短的超時(shí)時(shí)間
cache.set(uid, nullValue, 10);
} else {
cache.set(uid, valueFromDB, 1000);
}
} catch (Exception e) {
cache.set(uid, nullValue, 10);
}
這種方式也存在弊端,因?yàn)樵诰彺嫦到y(tǒng)中存了大量的空值,浪費(fèi)緩存的存儲(chǔ)空間,可能會(huì)逐出真實(shí)有效的信息反而會(huì)造成緩存命中率的下降。
1.2 布隆過(guò)濾器
1970年布隆提出了一種過(guò)濾器的算法,用來(lái)判斷一個(gè)元素是否在一個(gè)集合中。布隆過(guò)濾器底層是一個(gè)超級(jí)大的 bit 數(shù)組,默認(rèn)值都是 0 ,一個(gè)元素通過(guò)多個(gè)hash函數(shù)映射到這個(gè) bit 數(shù)組上,并且將 0 改成 1。
相比于傳統(tǒng)的 List、Set、Map 等數(shù)據(jù)結(jié)構(gòu),布隆過(guò)濾器是一個(gè)bit數(shù)組, 它更高效、占用空間更少。

如果我們要映射一個(gè)值到布隆過(guò)濾器中,我們需要使用多個(gè)不同的哈希函數(shù)生成多個(gè)哈希值,并對(duì)每個(gè)生成的哈希值指向的 bit 位置 1,例如針對(duì)值 “zhangsan” 和三個(gè)不同的哈希函數(shù)分別生成了哈希值 1、4、7:

我們現(xiàn)在再存一個(gè)值 “l(fā)isi”,如果哈希函數(shù)返回 4、5、8 的話,圖繼續(xù)變?yōu)椋?/p>

當(dāng)我們想要判斷布隆過(guò)濾器是否記錄了某個(gè)數(shù)據(jù)時(shí),布隆過(guò)濾器會(huì)先對(duì)該數(shù)據(jù)進(jìn)行同樣的哈希處理, 比如 “wangwu”的哈希函數(shù)返回了 2、5、8三個(gè)值,結(jié)果我們發(fā)現(xiàn) 2 這個(gè) bit 位上的值為 0,說(shuō)明沒(méi)有任何一個(gè)值映射到這個(gè) bit 位上,因此我們可以很確定地說(shuō) “wangwu” 這個(gè)數(shù)據(jù)一定不存在。
但是同時(shí)我們會(huì)發(fā)現(xiàn),4 這個(gè) bit 位由于”zhangsan”和”lisi”的哈希函數(shù)都返回了這個(gè) bit 位,因此它被覆蓋了。那么隨著布隆過(guò)濾器保存的數(shù)據(jù)不斷增多,重復(fù)的概率就會(huì)不斷增大,所以當(dāng)我們過(guò)濾某個(gè)數(shù)據(jù)時(shí),如果發(fā)現(xiàn)其三個(gè)哈希值都在過(guò)濾器中進(jìn)行了記錄,那么也只能說(shuō)明過(guò)濾器中可能包含了該數(shù)據(jù),并不能絕對(duì)肯定,因?yàn)楣E鲎部赡軐?dǎo)致誤判。
也就是說(shuō)布隆過(guò)濾器可以做到以下兩點(diǎn):
- 某個(gè)值一定不存在
- 某個(gè)可能存在
利用布隆過(guò)濾器的這個(gè)特點(diǎn)可以解決緩存穿透的問(wèn)題,在服務(wù)啟動(dòng)的時(shí)候先把數(shù)據(jù)的查詢條件,例如數(shù)據(jù)的 ID 映射到布隆過(guò)濾器上,當(dāng)然如果新增數(shù)據(jù)時(shí),除了寫(xiě)入到數(shù)據(jù)庫(kù)中之外,也需要將數(shù)據(jù)的ID存入到布隆過(guò)濾器中。
我們?cè)诓樵兡硹l數(shù)據(jù)時(shí),先判斷這個(gè)查詢的 ID 是否存在布隆過(guò)濾器中,如果不存在就直接返回空值,而不需要繼續(xù)查詢數(shù)據(jù)庫(kù)和緩存,存在布隆過(guò)濾器中才繼續(xù)查詢數(shù)據(jù)庫(kù)和緩存,這樣就解決緩存穿透的問(wèn)題。

2、緩存擊穿
緩存擊穿是指某一個(gè)熱點(diǎn) key,在緩存過(guò)期的一瞬間,同時(shí)有大量的請(qǐng)求打進(jìn)來(lái),由于此時(shí)緩存過(guò)期了,所以請(qǐng)求最終都會(huì)走到數(shù)據(jù)庫(kù),造成瞬時(shí)數(shù)據(jù)庫(kù)請(qǐng)求量大、壓力驟增,甚至可能打垮數(shù)據(jù)庫(kù)。
注意與緩存穿透的區(qū)別:
- 導(dǎo)致緩存穿透的數(shù)據(jù)在數(shù)據(jù)庫(kù)不存在,在緩存也不存在
- 導(dǎo)致緩存擊穿的數(shù)據(jù)在數(shù)據(jù)庫(kù)存在,而緩存不存在,一般是熱點(diǎn)數(shù)據(jù)的緩存時(shí)間到期了
緩存擊穿的解決方案有兩種:使用互斥鎖或設(shè)置熱點(diǎn)數(shù)據(jù)永遠(yuǎn)不過(guò)期
2.1 互斥鎖
業(yè)界比較常用的做法是使用mutex。簡(jiǎn)單地來(lái)說(shuō),就是在緩存失效的時(shí)候(判斷拿出來(lái)的值為空),只有第一個(gè)請(qǐng)求線程能拿到鎖并執(zhí)行數(shù)據(jù)庫(kù)查詢操作,其他的線程拿不到鎖就阻塞等著,等到第一個(gè)線程將數(shù)據(jù)寫(xiě)入緩存后,直接走緩存。
可以使用緩存工具的某些帶成功操作返回值的操作(比如Redis的SETNX或者M(jìn)emcache的ADD)去set一個(gè)mutex key。
Redis的SETNX是SET if Not eXists的縮寫(xiě),也就是只有不存在的時(shí)候才設(shè)置,可以利用它來(lái)實(shí)現(xiàn)鎖的效果:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表緩存值過(guò)期
//設(shè)置3min的超時(shí),防止del操作失敗的時(shí)候,下次緩存過(guò)期一直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表設(shè)置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //這個(gè)時(shí)候代表同時(shí)候的其他線程已經(jīng)load db并回設(shè)到緩存了,這時(shí)候重試獲取緩存值即可
sleep(50);
get(key); //重試
}
} else {
return value;
}
}
memcache代碼:
if (memcache.get(key) == null) {
// 3 min timeout to avoid mutex holder crash
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
}
2.2 設(shè)置熱點(diǎn)數(shù)據(jù)永遠(yuǎn)不過(guò)期
直接將緩存設(shè)置為不過(guò)期,然后由定時(shí)任務(wù)去異步加載數(shù)據(jù),更新緩存。
這種方式適用于比較極端的場(chǎng)景,例如流量特別大的場(chǎng)景,使用時(shí)需要考慮業(yè)務(wù)能接受數(shù)據(jù)不一致的時(shí)間,還有就是異常情況的處理。
3、緩存雪崩
緩存雪崩是指在某一個(gè)時(shí)刻,緩存大面積地失效(比如采用了相同的過(guò)期時(shí)間),從而導(dǎo)致所有請(qǐng)求都會(huì)去查數(shù)據(jù)庫(kù),導(dǎo)致數(shù)據(jù)庫(kù)、CPU和內(nèi)存負(fù)載過(guò)高,甚至宕機(jī)。
緩存雪崩其實(shí)有點(diǎn)像“升級(jí)版的緩存擊穿”,緩存擊穿是一個(gè)熱點(diǎn) key,緩存雪崩是一組熱點(diǎn) key。
解決方案有以下幾種。
3.1 加鎖/隊(duì)列
一般并發(fā)量不是特別多的時(shí)候,使用最多的解決方案是加鎖排隊(duì),加鎖排隊(duì)只是為了減輕數(shù)據(jù)庫(kù)的壓力,并沒(méi)有提高系統(tǒng)吞吐量。假設(shè)在高并發(fā)下,緩存重建期間key是鎖著的,這是過(guò)來(lái)1000個(gè)請(qǐng)求999個(gè)都在阻塞的。同樣會(huì)導(dǎo)致用戶等待超時(shí),這是個(gè)治標(biāo)不治本的方法。
3.2 為key設(shè)置不同的緩存失效時(shí)間
在緩存的時(shí)候給過(guò)期時(shí)間加上一個(gè)隨機(jī)值,將緩存失效時(shí)間分散開(kāi)。
3.3 雙緩存
- 主緩存:有效期按照經(jīng)驗(yàn)值設(shè)置,主要讀取的緩存,主緩存失效后從數(shù)據(jù)庫(kù)加載最新值。
- 備份緩存:有效期長(zhǎng),獲取鎖失敗時(shí)讀取的緩存,主緩存更新時(shí)需要同步更新備份緩存。
其實(shí)就是緩存降級(jí)策略。
3.4 數(shù)據(jù)預(yù)熱
數(shù)據(jù)預(yù)熱的含義就是在正式部署之前,我先把可能的數(shù)據(jù)先預(yù)先訪問(wèn)一遍,這樣部分可能大量訪問(wèn)的數(shù)據(jù)就會(huì)加載到緩存中。在即將發(fā)生大并發(fā)訪問(wèn)前(比如雙十一零點(diǎn)之前)手動(dòng)觸發(fā)加載緩存不同的key,設(shè)置不同的過(guò)期時(shí)間,讓緩存失效的時(shí)間點(diǎn)盡量均勻。