設計一個緩存系統,不得不要考慮的問題就是:緩存穿透、緩存擊穿與失效時的雪崩效應。
一.什么樣的數據適合緩存?
分析一個數據是否適合緩存,我們要從訪問頻率、讀寫比例、數據一致性等要求去分析. 
二.什么是緩存擊穿
在高並發下,多線程同時查詢同一個資源,如果緩存中沒有這個資源,那么這些線程都會去數據庫查找,對數據庫造成極大壓力,緩存失去存在的意義.打個比方,數據庫是人,緩存是防彈衣,子彈是線程,本來防彈衣是防止子彈打到人身上的,但是當防彈衣里面沒有防彈的物質時,子彈就會穿過它打到人身上.
三.緩存擊穿的解決辦法
方案一
后台刷新
后台定義一個job(定時任務)專門主動更新緩存數據.比如,一個緩存中的數據過期時間是30分鍾,那么job每隔29分鍾定時刷新數據(將從數據庫中查到的數據更新到緩存中).
- 這種方案比較容易理解,但會增加系統復雜度。比較適合那些 key 相對固定,cache 粒度較大的業務,key 比較分散的則不太適合,實現起來也比較復雜。
方案二
檢查更新
將緩存key的過期時間(絕對時間)一起保存到緩存中(可以拼接,可以添加新字段,可以采用單獨的key保存..不管用什么方式,只要兩者建立好關聯關系就行).在每次執行get操作后,都將get出來的緩存過期時間與當前系統時間做一個對比,如果緩存過期時間-當前系統時間<=1分鍾(自定義的一個值),則主動更新緩存.這樣就能保證緩存中的數據始終是最新的(和方案一一樣,讓數據不過期.)
- 這種方案在特殊情況下也會有問題。假設緩存過期時間是12:00,而 11:59
到 12:00這 1 分鍾時間里恰好沒有 get 請求過來,又恰好請求都在 11:30 分的時
候高並發過來,那就悲劇了。這種情況比較極端,但並不是沒有可能。因為“高
並發”也可能是階段性在某個時間點爆發。
方案三
分級緩存
采用 L1 (一級緩存)和 L2(二級緩存) 緩存方式,L1 緩存失效時間短,L2 緩存失效時間長。 請求優先從 L1 緩存獲取數據,如果 L1緩存未命中則加鎖,只有 1 個線程獲取到鎖,這個線程再從數據庫中讀取數據並將數據再更新到到 L1 緩存和 L2 緩存中,而其他線程依舊從 L2 緩存獲取數據並返回。
- 這種方式,主要是通過避免緩存同時失效並結合鎖機制實現。所以,當數據更
新時,只能淘汰 L1 緩存,不能同時將 L1 和 L2 中的緩存同時淘汰。L2 緩存中
可能會存在臟數據,需要業務能夠容忍這種短時間的不一致。而且,這種方案
可能會造成額外的緩存空間浪費。
方案四
加鎖
方法1
-
// 方法1:
-
public synchronized List<String> getData01() {
-
List<String> result = new ArrayList<String>();
-
// 從緩存讀取數據
-
result = getDataFromCache();
-
if (result.isEmpty()) {
-
// 從數據庫查詢數據
-
result = getDataFromDB();
-
// 將查詢到的數據寫入緩存
-
setDataToCache(result);
-
}
-
return result;
-
}
- 這種方式確實能夠防止緩存失效時高並發到數據庫,但是緩存沒有失效的時候,在從緩存中拿數據時需要排隊取鎖,這必然會大大的降低了系統的吞吐量.
方法2
-
// 方法2:
-
static Object lock = new Object();
-
public List<String> getData02() {
-
List<String> result = new ArrayList<String>();
-
// 從緩存讀取數據
-
result = getDataFromCache();
-
if (result.isEmpty()) {
-
synchronized ( lock) {
-
// 從數據庫查詢數據
-
result = getDataFromDB();
-
// 將查詢到的數據寫入緩存
-
setDataToCache(result);
-
}
-
}
-
return result;
-
}
- 這個方法在緩存命中的時候,系統的吞吐量不會受影響,但是當緩存失效時,請求還是會打到數據庫,只不過不是高並發而是阻塞而已.但是,這樣會造成用戶體驗不佳,並且還給數據庫帶來額外壓力.
方法3
-
//方法3
-
public List<String> getData03() {
-
List<String> result = new ArrayList<String>();
-
// 從緩存讀取數據
-
result = getDataFromCache();
-
if (result.isEmpty()) {
-
synchronized (lock) {
-
//雙重判斷,第二個以及之后的請求不必去找數據庫,直接命中緩存
-
// 查詢緩存
-
result = getDataFromCache();
-
if (result.isEmpty()) {
-
// 從數據庫查詢數據
-
result = getDataFromDB();
-
// 將查詢到的數據寫入緩存
-
setDataToCache(result);
-
}
-
}
-
}
-
return result;
-
}
雙重判斷雖然能夠阻止高並發請求打到數據庫,但是第二個以及之后的請求在命中緩存時,還是排隊進行的.比如,當30個請求一起並發過來,在雙重判斷時,第一個請求去數據庫查詢並更新緩存數據,剩下的29個請求則是依次排隊取緩存中取數據.請求排在后面的用戶的體驗會不爽.
方法4
-
static Lock reenLock = new ReentrantLock();
-
public List<String> getData04() throws InterruptedException {
-
List<String> result = new ArrayList<String>();
-
// 從緩存讀取數據
-
result = getDataFromCache();
-
if (result.isEmpty()) {
-
if (reenLock.tryLock()) {
-
try {
-
System. out.println("我拿到鎖了,從DB獲取數據庫后寫入緩存");
-
// 從數據庫查詢數據
-
result = getDataFromDB();
-
// 將查詢到的數據寫入緩存
-
setDataToCache(result);
-
} finally {
-
reenLock.unlock(); // 釋放鎖
-
}
-
} else {
-
result = getDataFromCache(); // 先查一下緩存
-
if (result.isEmpty()) {
-
System. out.println("我沒拿到鎖,緩存也沒數據,先小憩一下");
-
Thread.sleep( 100);// 小憩一會兒
-
return getData04();// 重試
-
}
-
}
-
}
-
return result;
-
}
- 最后使用互斥鎖的方式來實現,可以有效避免前面幾種問題.
緩存穿透
緩存穿透是指查詢一個一定不存在的數據,由於緩存是不命中時被動寫的,並且出於容錯考慮,如果從存儲層查不到數據則不寫入緩存,這將導致這個不存在的數據每次請求都要到存儲層去查詢,失去了緩存的意義。在流量大時,可能DB就掛掉了,要是有人利用不存在的key頻繁攻擊我們的應用,這就是漏洞。
解決方案
有很多種方法可以有效地解決緩存穿透問題,最常見的則是采用布隆過濾器,將所有可能存在的數據哈希到一個足夠大的bitmap中,一個一定不存在的數據會被 這個bitmap攔截掉,從而避免了對底層存儲系統的查詢壓力。另外也有一個更為簡單粗暴的方法(我們采用的就是這種),如果一個查詢返回的數據為空(不管是數 據不存在,還是系統故障),我們仍然把這個空結果進行緩存,但它的過期時間會很短,最長不超過五分鍾。
緩存雪崩
緩存雪崩是指在我們設置緩存時采用了相同的過期時間,導致緩存在某一時刻同時失效,請求全部轉發到DB,DB瞬時壓力過重雪崩。
解決方案
緩存失效時的雪崩效應對底層系統的沖擊非常可怕。大多數系統設計者考慮用加鎖或者隊列的方式保證緩存的單線 程(進程)寫,從而避免失效時大量的並發請求落到底層存儲系統上。這里分享一個簡單方案就時講緩存失效時間分散開,比如我們可以在原有的失效時間基礎上增加一個隨機值,比如1-5分鍾隨機,這樣每一個緩存的過期時間的重復率就會降低,就很難引發集體失效的事件。
緩存擊穿
對於一些設置了過期時間的key,如果這些key可能會在某些時間點被超高並發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮一個問題:緩存被“擊穿”的問題,這個和緩存雪崩的區別在於這里針對某一key緩存,前者則是很多key。
緩存在某個時間點過期的時候,恰好在這個時間點對這個Key有大量的並發請求過來,這些請求發現緩存過期一般都會從后端DB加載數據並回設到緩存,這個時候大並發的請求可能會瞬間把后端DB壓垮。
解決方案
1.使用互斥鎖(mutex key)
業界比較常用的做法,是使用mutex。簡單地來說,就是在緩存失效的時候(判斷拿出來的值為空),不是立即去load db,而是先使用緩存工具的某些帶成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一個mutex key,當操作返回成功時,再進行load db的操作並回設緩存;否則,就重試整個get緩存的方法。
SETNX,是「SET if Not eXists」的縮寫,也就是只有不存在的時候才設置,可以利用它來實現鎖的效果。在redis2.6.1之前版本未實現setnx的過期時間,所以這里給出兩種版本代碼參考:
-
//2.6.1前單機版本鎖
-
String get(String key) {
-
String value = redis.get(key);
-
if (value == null) {
-
if (redis.setnx(key_mutex, "1")) {
-
// 3 min timeout to avoid mutex holder crash
-
redis.expire(key_mutex, 3 * 60)
-
value = db.get(key);
-
redis.set(key, value);
-
redis.delete(key_mutex);
-
} else {
-
//其他線程休息50毫秒后重試
-
Thread.sleep( 50);
-
get(key);
-
}
-
}
-
}
最新版本代碼:
-
public String get(key) {
-
String value = redis.get(key);
-
if (value == null) { //代表緩存值過期
-
//設置3min的超時,防止del操作失敗的時候,下次緩存過期一直不能load db
-
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表設置成功
-
value = db.get(key);
-
redis.set(key, value, expire_secs);
-
redis.del(key_mutex);
-
} else { //這個時候代表同時候的其他線程已經load db並回設到緩存了,這時候重試獲取緩存值即可
-
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. "提前"使用互斥鎖(mutex key):
在value內部設置1個超時值(timeout1), timeout1比實際的memcache timeout(timeout2)小。當從cache讀取到timeout1發現它已經過期時候,馬上延長timeout1並重新設置到cache。然后再從數據庫加載數據並設置到cache中。偽代碼如下:
-
v = memcache.get(key);
-
if (v == null) {
-
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();
-
}
-
} else {
-
if (v.timeout <= now()) {
-
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
-
// extend the timeout for other threads
-
v.timeout += 3 * 60 * 1000;
-
memcache.set(key, v, KEY_TIMEOUT * 2);
-
-
// load the latest value from db
-
v = db.get(key);
-
v.timeout = KEY_TIMEOUT;
-
memcache.set(key, value, KEY_TIMEOUT * 2);
-
memcache.delete(key_mutex);
-
} else {
-
sleep( 50);
-
retry();
-
}
-
}
-
}
3. "永遠不過期":
這里的“永遠不過期”包含兩層意思:
(1) 從redis上看,確實沒有設置過期時間,這就保證了,不會出現熱點key過期問題,也就是“物理”不過期。
(2) 從功能上看,如果不過期,那不就成靜態的了嗎?所以我們把過期時間存在key對應的value里,如果發現要過期了,通過一個后台的異步線程進行緩存的構建,也就是“邏輯”過期
從實戰看,這種方法對於性能非常友好,唯一不足的就是構建緩存時候,其余線程(非構建緩存的線程)可能訪問的是老數據,但是對於一般的互聯網功能來說這個還是可以忍受。
-
String get(final String key) {
-
V v = redis.get(key);
-
String value = v.getValue();
-
long timeout = v.getTimeout();
-
if (v.timeout <= System.currentTimeMillis()) {
-
// 異步更新后台異常執行
-
threadPool.execute( new Runnable() {
-
public void run() {
-
String keyMutex = "mutex:" + key;
-
if (redis.setnx(keyMutex, "1")) {
-
// 3 min timeout to avoid mutex holder crash
-
redis.expire(keyMutex, 3 * 60);
-
String dbValue = db.get(key);
-
redis.set(key, dbValue);
-
redis.delete(keyMutex);
-
}
-
}
-
});
-
}
-
return value;
-
}
4. 資源保護:
采用netflix的hystrix,可以做資源的隔離保護主線程池,如果把這個應用到緩存的構建也未嘗不可。
四種解決方案:沒有最佳只有最合適
| 解決方案 | 優點 | 缺點 |
| 簡單分布式互斥鎖(mutex key) | 1. 思路簡單 2. 保證一致性 |
1. 代碼復雜度增大 2. 存在死鎖的風險 3. 存在線程池阻塞的風險 |
| “提前”使用互斥鎖 | 1. 保證一致性 | 同上 |
| 不過期(本文) | 1. 異步構建緩存,不會阻塞線程池 |
1. 不保證一致性。 2. 代碼復雜度增大(每個value都要維護一個timekey)。 3. 占用一定的內存空間(每個value都要維護一個timekey)。 |
| 資源隔離組件hystrix(本文) | 1. hystrix技術成熟,有效保證后端。 2. hystrix監控強大。
|
1. 部分訪問存在降級策略。 |
總結
針對業務系統,永遠都是具體情況具體分析,沒有最好,只有最合適。
最后,對於緩存系統常見的緩存滿了和數據丟失問題,需要根據具體業務分析,通常我們采用LRU策略處理溢出,Redis的RDB和AOF持久化策略來保證一定情況下的數據安全。
參考文檔: https://blog.csdn.net/zeb_perfect/article/details/54135506

