那些年我們一起追過的緩存寫法(一)


    本篇主要介紹下樓主平常項目中,緩存使用經驗和遇到過的問題。

閱讀目錄:

  1. 基本寫法
  2. 緩存雪崩
  3. 全局鎖,實例鎖
  4. 字符串鎖
  5. 緩存穿透
  6. 再談緩存雪崩
  7. 總結

基本寫法

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

 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);
        }
    }

 簡單讀取:

    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;
        }    

在項目中,有不少這樣寫法,這樣寫並沒有錯,但在並發量上來后就容易出問題。

 緩存雪崩

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

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

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

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

全局鎖,實例鎖

  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;
        }

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

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

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

字符串鎖

既然鎖對象不行,利用字符串的特性,直接鎖緩存的key呢

    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;
        }

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

         具體請參考之前的博文 c#語言-多線程中的鎖系統(一)。 

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

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

緩存穿透

先舉個簡單例子:一般網站經常會緩存用戶搜索的結果,如果數據庫查詢不到,是不會做緩存的。但如果頻繁查這個空關鍵字,會導致每次請求都直接查詢數據庫了。

例子就是緩存穿透,請求繞過緩存直接查數據庫,這也是經常提的緩存命中率問題。

  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;
        }

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

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

再談緩存雪崩

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

假設在高並發下,緩存重建期間key是鎖着的,這是過來1000個請求999個都在阻塞的。導致的結果是用戶等待超時,這是非常不優化的體驗。

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

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

  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;
        }

從代碼中看出,我們多使用了一個緩存標記key,並使用雙檢鎖校驗保證后面邏輯不會多次執行。

緩存標記key: 緩存標記key只是一個記錄實際key過期時間的標記,它的緩存值可以是任意值,比如1。 它主要用來在實際key過期后,觸發通知另外的線程在后台去更新實際key的緩存。

實際key:  它的過期時間會延長1倍,例:本來5分鍾,現在設置為10分鍾。 這樣做的目的是,當緩存標記key過期后,實際緩存還能以臟數據返回給調用端,直到另外的線程在后台更新完成后,才會返回新緩存。

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

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

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

總結

文中說的阻塞其他函數指的是,並發情況下鎖同一對象,比如一個函數鎖A對象,另外的函數就必須等待A對象的鎖釋放后才能再次進鎖。

關於更新緩存,可以單開一個線程去專門跑緩存更新,圖方便的話扔線程池里面即可。

實際項目中,緩存層框架的封裝往往要復雜的多,如果並發量比較小,這樣寫反而會增加代碼的復雜度,具體要根據實際情況來取舍。    

系列目錄:

那些年我們一起追過的緩存寫法(一)

那些年我們一起追過的緩存寫法(二) 

那些年我們一起追過的緩存寫法(三) 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM