淺談快取寫法(一):快取的雪崩和穿透

Java_老男孩發表於2019-04-25

基本寫法

為了方便演示,這裡使用Runtime.Cache做快取容器,並定義個簡單操作類。如下:

<pre class="brush:csharp;gutter:true;" 
         style="margin: 0px; 
         padding: 0px; 
         white-space: pre-wrap; 
         overflow-wrap: break-word;"> 
public class CacheHelper
    {
        public static object Get(string cacheKey)
        {
	return HttpRuntime.Cache[cacheKey];
}
public static void Add(string cacheKey, object obj, int cacheMinute)
        {
	HttpRuntime.Cache.Insert(cacheKey, obj, null, DateTime.Now.AddMinutes(cacheMinute),
		                Cache.NoSlidingExpiration, CacheItemPriority.Normal, null);
}
}
</pre>
複製程式碼

簡單讀取:

<pre class="brush:csharp;
	gutter:true;
	" style="margin:0px;
	padding:0px;
	white-space:pre-wrap;
	overflow-wrap:break-word;
	">    
public object GetMemberSigninDays1() {
	const int cacheTime = 5;
	const string cacheKey = "mushroomsir";
	var cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null)
	                return cacheValue;
	cacheValue = "395";
	//這裡一般是 sql查詢資料。 例:395 簽到天數
	CacheHelper.Add(cacheKey,cacheValue,cacheTime);
	return cacheValue;
}
</pre>
複製程式碼

在專案中,有不少這樣寫法,這樣寫並沒有錯,但在併發量上來後就容易出問題。

快取雪崩

快取雪崩是由於快取失效(過期),新快取未到期間。

這個中間時間內,所有請求都去查詢資料庫,而對資料庫CPU和記憶體造成巨大壓力,前端連線數不夠、查詢阻塞。

這個中間時間並沒有那麼短,比如sql查詢1秒,加上傳輸解析0.5秒。  就是說1.5秒內所有使用者查詢,都是直接查詢資料庫的。

碰到這種情況,使用最多的解決方案就是加鎖排隊。

全域性鎖,例項鎖

<pre style="margin:0px;
	padding:0px;
	white-space:pre-wrap;
	overflow-wrap:break-word;
	font-family:&quot;
	Courier New&quot;
	!important;
	font-size:12px !important;
	">  
public static object obj1 = new object();
	public object GetMemberSigninDays2() {
	const int cacheTime = 5;
	const string cacheKey = "mushroomsir";
	var cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null) return cacheValue;
	//lock (obj1) //全域性鎖 // {
	// cacheValue = CacheHelper.Get(cacheKey);
	// if (cacheValue != null) // return cacheValue;
	// cacheValue = "395";
	//這裡一般是 sql查詢資料。 例:395 簽到天數 // CacheHelper.Add(cacheKey,cacheValue,cacheTime);
	//
}
lock (this) {
	cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null) return cacheValue;
	cacheValue = "395";
	//這裡一般是 sql查詢資料。 例:395 簽到天數
		CacheHelper.Add(cacheKey,cacheValue,cacheTime);
}
return cacheValue;}</pre>
複製程式碼

第一種:lock (obj1)  是全域性鎖可以滿足,但要為每個函式都宣告一個obj,不然在A、B函式都鎖obj1時,必然會讓其中一個阻塞。

第二種:lock (this)  這個鎖當前例項,對其他例項無效,那這個鎖就沒什麼效果了,當然使用單例模式的物件可以鎖。

在當前例項中:A函式鎖當前例項,其他也鎖當前例項的函式的讀寫,也被阻塞,這種做法也不可取。

字串鎖

既然鎖物件不行,利用字串的特性,直接鎖快取的key呢

<pre style="margin:0px;
	padding:0px;
	white-space:pre-wrap;
	overflow-wrap:break-word;
	font-family:&quot;
	Courier New&quot;
	!important;
	font-size:12px !important;
	">    
public object GetMemberSigninDays3() {
	const int cacheTime = 5;
	const string cacheKey = "mushroomsir";
	var cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null) return cacheValue;
	const string lockKey = cacheKey + "n(*≧▽≦*)n";
	//lock (cacheKey) // {
	// cacheValue = CacheHelper.Get(cacheKey);
	// if (cacheValue != null) // return cacheValue;
	// cacheValue = "395";
	//這裡一般是 sql查詢資料。 例:395 簽到天數 // CacheHelper.Add(cacheKey,cacheValue,cacheTime);
	//
}
lock (lockKey) {
	cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null) return cacheValue;
	cacheValue = "395";
	//這裡一般是 sql查詢資料。 例:395 簽到天數
		CacheHelper.Add(cacheKey,cacheValue,cacheTime);
}
return cacheValue;}
</pre>
複製程式碼

第一種:lock (cacheName)  有問題,因為字串也是共享的,會阻塞其他使用這個字串的操作行為。

因為字串被公共語言執行庫 (CLR)暫留,這意味著整個程式中任何給定字串都只有一個例項,所以才會用下面第二種方法。

第二種:lock (lockKey)  可以滿足。其目的就是為了保證鎖的粒度最小並且全域性唯一性,只鎖當前快取的查詢行為。

快取穿透

先舉個簡單例子:一般網站經常會快取使用者搜尋的結果,如果資料庫查詢不到,是不會做快取的。但如果頻繁查這個空關鍵字,會導致每次請求都直接查詢資料庫了

例子就是快取穿透,請求繞過快取直接查資料庫,這也是經常提的快取命中率問題。

<pre style="margin:0px;
	padding:0px;
	white-space:pre-wrap;
	overflow-wrap:break-word;
	font-family:&quot;
	Courier New&quot;
	!important;
	font-size:12px !important;
	">  
public object GetMemberSigninDays4() {
	const int cacheTime = 5;
	const string cacheKey = "mushroomsir";
	var cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null) return cacheValue;
	const string lockKey = cacheKey + "n(*≧▽≦*)n";
	lock (lockKey) {
	cacheValue = CacheHelper.Get(cacheKey);
	if (cacheValue != null) return cacheValue;
	cacheValue = null;
	//資料庫查詢不到,為空。 //if (cacheValue2 == null) // {
	// return null;
	//一般為空,不做快取 //
}
if (cacheValue == null) {
	cacheValue = string.Empty;
	//如果發現為空,我設定個預設值,也快取起來。
}
CacheHelper.Add(cacheKey,cacheValue,cacheTime);}return cacheValue;}</pre>
複製程式碼

如果把查詢不到的空結果,也給快取起來,這樣下次同樣的請求就可以直接返回null了,即可以避免當查詢的值為空時引起的快取穿透。

可以單獨設定個快取區域儲存空值,對要查詢的key進行預先校驗,然後再放行給後面的正常快取處理邏輯。

再談快取雪崩

前面不是用加鎖排隊方式就解決了嗎?其實加鎖排隊只是為了減輕資料庫的壓力,本質上並沒有提高系統吞吐量。

假設在高併發下,快取重建期間key是鎖著的,這是過來1000個請求999個都在阻塞的。導致的結果是使用者等待超時,這是非常不優化的體驗。

這種行為本質上是把多執行緒的Web伺服器,在此時給變成單執行緒處理了,會導致大量的阻塞。對於系統資源也是一種浪費,因快取重建而阻塞的執行緒本可以處理更多請求的。

這裡提出一種解決方案是:

<pre style="margin:0px;
	padding:0px;
	white-space:pre-wrap;
	overflow-wrap:break-word;
	font-family:&quot;
	Courier New&quot;
	!important;
	font-size:12px !important;
	">  
public object GetMemberSigninDays5() {
	const int cacheTime = 5;
	const string cacheKey = "mushroomsir";
	//快取標記。
	const string cacheSign = cacheKey + "_Sign";
	var sign = CacheHelper.Get(cacheSign);
	//獲取快取值
	var cacheValue = CacheHelper.Get(cacheKey);
	if (sign != null) return cacheValue;
	//未過期,直接返回。
	lock (cacheSign) {
	sign = CacheHelper.Get(cacheSign);
	if (sign != null) return cacheValue;
	CacheHelper.Add(cacheSign,"1",cacheTime);
	ThreadPool.QueueUserWorkItem((arg) => {
	cacheValue = "395";
	//這裡一般是 sql查詢資料。 例:395 簽到天數
		CacheHelper.Add(cacheKey,cacheValue,cacheTime*2);
	//日期設快取時間的2倍,用於髒讀。
}
);}return cacheValue;}
</pre>
複製程式碼

從程式碼中看出,我們多使用了一個快取標記key,並使用雙檢鎖校驗保證後面邏輯不會多次執行。

快取標記key: 快取標記key只是一個記錄實際key過期時間的標記,它的快取值可以是任意值,比如1。 它主要用來在實際key過期後,觸發通知另外的執行緒在後臺去更新實際key的快取。

實際key:  它的過期時間會延長1倍,例:本來5分鐘,現在設定為10分鐘。 這樣做的目的是,當快取標記key過期後,實際快取還能以髒資料返回給呼叫端,直到另外的執行緒在後臺更新完成後,才會返回新快取。

關於實際key的過期時間延長1倍,還是2、3倍都是可以的。只要大於正常快取過期時間,並且能保證在延長的時間內足夠拉取資料即可。

還一個好處就是,如果突然db掛了,髒資料的存在可以保證前端系統不會拿不到資料。

這樣做後,就可以一定程度上提高系統吞吐量。

總結

文中說的阻塞其他函式指的是,併發情況下鎖同一物件,比如一個函式鎖A物件,另外的函式就必須等待A物件的鎖釋放後才能再次進鎖。

關於更新快取,可以單開一個執行緒去專門跑快取更新,圖方便的話扔執行緒池裡面即可。

實際專案中,快取層框架的封裝往往要複雜的多,如果併發量比較小,這樣寫反而會增加程式碼的複雜度,具體要根據實際情況來取捨。

相關文章