概述
如今許多互聯網應用系統都重度依賴緩存來提高讀操作的性能,對於這些系統來說如何正確地使用緩存至關重要。本文從緩存讀取這個視角來討論緩存架構設計上的一些思路。重點關注如何防止緩存雪崩。
1. 緩存讀操作
引入緩存後,讀數據的流程如下:
- (1)先讀緩存,如果緩存中有數據(hit),則返回緩存中的結果;
- (2)如果緩存中沒有數據(miss),則回源到database獲取,然後把結果寫入緩存再返回。
2. 緩存雪崩
在正常情況下,一旦miss就去查DB是沒有問題的。但是如果大量緩存集中在某一時間段失效,將導致所有請求都去訪問後端的DB,DB壓力會很大,甚至被壓垮,造成雪崩。
- 場景一
電商系統的某個大促活動的首頁,首頁有很多新上架的商品。活動開始前,技術團隊對緩存做了預熱,由於是腳本化預熱,這些商品的Cache數據幾乎都是同時創建好,並且過期時間都設置爲5分鐘。這就會導致這大量的商品數據在5分鐘後集中失效。
- 場景二
cache系統剛上線(或者剛從崩潰中恢復過來),沒有對cache進行預熱。cache中什麼也沒有,這時瞬時大流量過來也會產生雪崩。
3. 解決思路
3.1 cache過期時間均勻分佈
針對上面的場景一,可以對cache的過期時間做一個均勻分佈的處理。比如1-5分鐘內,隨機分佈。
3.2 排斥鎖
針對場景二,可以考慮使用排斥鎖(mutex)。即第一個線程過來讀取cache,發現沒有,就去訪問DB。後續線程再過來就需要等待第一個線程讀取DB成功,cache裏的value變得可用,後續線程返回新的value。僞代碼如下:
public Object getCacheValue(String key, int expiredTime) {
Object cacheValue = cache.get(key);
if (cacheValue != null) {
return cacheValue;
} else {
try {
if (DistributeLock.lock(key)) {
cacheValue = cache.get(key);
if (cacheValue != null) { // double check
return cacheValue;
} else {
cacheValue = GetValueFromDB(); // 讀數據庫
cache.set(key, cacheValue, expiredTime);
}
}
} finally {
DistributeLock.unlock(key);
}
return cacheValue;
}
}
方案細節:
-
使用了分佈式鎖,這當然是考慮到在分佈式環境下,讀請求會落到集羣中的不同應用服務機器上。分佈式鎖可以選用zookeeper或基於redis的setnx這類原子性操作來實現。
-
加鎖時需要用到經典的double-check lock。
-
本方案雖然能夠減輕DB壓力,防止雪崩。但由於用到了加鎖排隊,吞吐率是不高的。僅適用於併發量不大的場景。
3.3 緩存過期標記+異步刷新
排斥鎖方案對緩存過期是零容忍的:cache一旦過期,後續所有讀操作就必須返回新的value。如果我們稍微放寬點限制:在cache過期時間T到達後,允許短時間內部分讀請求返回舊值,我們就能提出兼顧吞吐率的方案。實際上既然用了cache,系統就默許了容忍cache和DB的數據短時間的不一致。
限制放寬後,下面我們提出一個優化思路。時間T到達後,cache中的key和value不會被清掉,而只是被標記爲過期(邏輯上過期,物理上不過期),然後程序異步去刷新cache。而後續部分讀線程在前面的線程刷新cache成功之前,暫時獲取cache中舊的value返回。一旦cache刷新成功,後續所有線程就能直接獲取cache中新的value。可以看到,這個思路很大程度上減少了排斥鎖的使用(雖然並沒有完全消除排斥鎖)。
下面先看下僞代碼:
public Object getCacheValue(String key, int expiredTime) {
final String signKey = "sign:" + key;
Object cacheValue = cache.get(key);
if (!isExpired(signKey, false)) { // 緩存標記未過期
return cacheValue;
} else {
// 緩存標記signKey已過期,異步更新緩存key
THREAD_POOL.execute(() -> {
try {
if (DistributeLock.lock(key)) {
if (isExpired(signKey, true)) { // double-check
Object cacheValue = GetValueFromDB(); // 讀數據庫
if (cacheValue != null) {
cache.set(key, cacheValue); // 設置緩存
setSign(signKey, expiredTime); // 設置緩存標記
}
}
}
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
} finally {
DistributeLock.unlock(key);
}
});
return cacheValue;
}
}
// 判斷緩存標記是否過期
private boolean isExpired(String signKey, boolean prolongTime) {
Object time = cache.get(signKey);
if (null == time || Long.valueOf(time) < System.currentTimeMillis()) {
if (prolongTime) {
// 將過期時間後延一分鐘,防止同一時間過期多次而出現多次重載
this.setSign(signKey, 1 * 60);
}
return true;
}
return false;
}
// 設置signKey的過期時間
private void setSign(String key, int expiredSeconds) {
DateTime dateTime = new DateTime();
dateTime = dateTime.plusSeconds(expiredSeconds);// 當前時間延後expiredSeconds秒
cache.set(key, String.valueOf(dateTime.getMillis()));
}
方案細節
- signKey:既然存放數據的cache不會被清掉,那麼就通過別的key也就是代碼中的signKey來標記過期。signKey的過期時間一到,就代表實際key邏輯過期。
- 異步刷新cache時也用到了排斥鎖,這是因爲同一時間多個讀線程進來都發現signKey已過期,就都要去異步刷新cache,所以這裏有必要加上排斥鎖。但注意到isExpired方法中(35-41行),signKey一旦過期,馬上把過期時間延後1分鐘,這是爲了讓後續進來的線程先返回舊的value。這樣只有極少一部分讀線程去刷新cache。因此需要加排斥鎖的線程也並不多。
4.小結
本文討論了防止緩存雪崩的三個方案:
- cache過期時間均勻分佈
- 排斥鎖
- 緩存過期標記+異步刷新