第四十二節:再探緩存重點概念、內存緩存、分佈式緩存、相關封裝剖析

一. 緩存重點概念

 1. 緩存命中

  指可以直接通過緩存獲取到需要的數據. 

 2. 緩存命中率

  從緩存中拿到數據的次數/查詢的總次數,緩存的命中率越高則表示使用緩存的收益越高,應用的性能越好(響應時間越短、吞吐量越高),抗併發的能力越強.

 3. 緩存穿透

  業務請求中數據緩存中沒有,DB中也沒有,導致類似請求直接跨過緩存,反覆在DB中查詢,與此同時緩存也不會得到更新。

 4. 緩存雪崩

  同一時刻,大量的緩存同時過期失效。

 5. 緩存擊穿

  特指某熱點Key扛着大量的併發請求,當key失效的一瞬間,大量的QPS打到DB上,導致系統癱瘓。 

(更多概念和詳細解決方案詳見redis章節:https://www.cnblogs.com/yaopengfei/p/13878124.html)

 

二. 客戶端緩存

1. 說明

  RFC7324是HTTP協議中對緩存進行控制的規範,其中重要的是cache-control這個響應報文頭。服務器如果返回cache-control:max-age=60,則表示服務器指示瀏覽器端“可以緩存這個響應內容60秒”。

  我們只要給需要進行緩存控制的控制器的操作方法添加ResponseCacheAttribute這個Attribute,ASP.NET Core會自動添加cache-control報文頭

2. 測試

  訪問 http://localhost:5164/swagger/index.html, 多次調用DemoApi/GetNowTime, 發現第一次從緩存中獲取當前時間,然後後續15s內,觀察請求都是從disk cache,即本地緩存中獲取的,且內容值是一樣的. 詳見運行結果圖

PS: 另外還有服務端響應緩存,用法:app.MapControllers()之前加上app.UseResponseCaching()。請確保app.UseCors()寫到app.UseResponseCaching()之前 【很雞肋,很少使用】

代碼分享:

        /// <summary>
        /// 測試客戶端緩存
        /// </summary>
        /// <returns></returns>
        [ResponseCache(Duration = 15)]
        [HttpGet]
        public DateTime GetNowTime()
        {
            return DateTime.Now;
        }

運行結果測試:

 

三. 內存緩存

1.含義

 (1)把緩存數據放到應用程序的內存。內存緩存中保存的是一系列的鍵值對,就像Dictionary類型一樣。

 (2)內存緩存的數據保存在當前運行的網站程序的內存中,是和進程相關的。因爲在Web服務器中,多個不同網站是運行在不同的進程中的,因此不同網站的內存緩存是不會互相干擾的, 而且網站重啓後,內存緩存中的所有數據也就都被清空了。

2.用法

【推薦使用 Microsoft.Extensions.Caching.Memory/IMemoryCache 而非 System.Runtime.Caching/MemoryCache】

  (1) 在program中添加如下代碼:builder.Services.AddMemoryCache()

  (2). 控制器中注入IMemoryCache接口,常用用的接口方法有:TryGetValue、Remove、Set、Get、GetOrCreate、GetOrCreateAsync

  這裏重點使用:GetOrCreateAsync 用法,表示:如果緩存中有則從緩存中讀取,如果沒有則存入緩存

測試:

  寫法1:利用Get和Set方法實現

  寫法2:利用TryGetValue和set方法實現, 其中TryGetValue返回true 或 false,true表示對應的key在緩存中存在(是以key爲依據判斷的)

  寫法3:利用GetOrCreate方法實現,其中GetOrCreate有則從緩存中讀取,沒有則執行回調業務(從DB中查詢→寫入緩存並返回) 【簡潔,省略了if判空和set寫入,推薦】

代碼分享:

        private readonly IMemoryCache _memoryCache;
        public DemoApiController(IMemoryCache memoryCache)
        {
            this._memoryCache = memoryCache;
        }
        /// <summary>
        /// 測試內存緩存
        /// </summary>
        /// <returns></returns>
        [HttpPost]
        public dynamic TestMemoryCache()
        {
            //寫法1:
            //var goodsNum = _memoryCache.Get("goodsNum");
            //if (goodsNum==null)
            //{
            //    //從數據庫中查詢
            //    goodsNum = DbHelp.GetNum();
            //    //寫入緩存
            //    _memoryCache.Set("goodsNum", goodsNum);      
            //}
            //Console.WriteLine("數量爲:" + goodsNum);
            //return goodsNum;

            //寫法2
            //string myTime;
            //if (!_memoryCache.TryGetValue("mySpecialTime",out myTime))
            //{
            //    //從數據庫中查詢
            //    myTime = DbHelp.GetTime();
            //    //寫入緩存
            //    _memoryCache.Set("mySpecialTime", myTime);
            //}
            //Console.WriteLine("時間爲:" + myTime);
            //return myTime;

            //寫法3
            string myTime = _memoryCache.GetOrCreate("mySpecialTime", (cacheEnty) =>
             {
                 //將數據庫中查詢的結果寫入緩存
                 return DbHelp.GetTime();
             });
            Console.WriteLine("時間爲:" + myTime);
            return myTime;
        }

3. 緩存過期時間

  (1) 默認寫法是永不過期的, 除非重啓項目

  (2) 絕對過期時間: 兩種時間寫法

  (3) 滑動過期時間

  (4).混用:滑動和絕對同時存在,滑動和絕對都生效

場景1:

  絕對時間要長於滑動

  比如:絕對10分鐘,滑動1分鐘, 第一次訪問後,如果一直不訪問,則1分鐘緩存失效,這1分鐘期間可以不斷續命滑動,但是隻要過了10分鐘,肯定失效。

場景2:

  絕對時間是時間點

  比如:絕對時間2022-09-01:12:00:00,滑動爲1分鐘,第一次訪問後,如果一直不訪問,則1分鐘緩存失效,在這1分鐘期間,可以不斷續命滑動,但是無論如何續命,只要過了 2022-09-01:12:00:00,緩存一定失效

代碼分享:

/// <summary>
        /// 03-測試內存緩存-過期時間
        /// </summary>
        /// <returns></returns>
        [HttpPost]
        public String TestMemoryCacheTimeOut()
        {
            //1.絕對過期時間
            //string myTime = _memoryCache.GetOrCreate<string>("mySpecialTime", (cacheEnty) =>
            // {
            //    //配置過期時間(寫入緩存後,15s過期)
            //    cacheEnty.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(15);

            //    //或者直接具體到某個時間點過期
            //    //cacheEnty.AbsoluteExpiration = new DateTimeOffset(DateTime.Parse("2022-07-16 16:33:10")); 

            //    //將數據庫中查詢的結果寫入緩存
            //    return DbHelp.GetTime();
            // });


            //2. 滑動過期時間
            //string myTime = _memoryCache.GetOrCreate<string>("mySpecialTime", (cacheEnty) =>
            //{
            //    //配置過期時間(每次調用緩存續命10s)
            //    cacheEnty.SlidingExpiration = TimeSpan.FromSeconds(10);

            //    //將數據庫中查詢的結果寫入緩存
            //    return DbHelp.GetTime();
            //});


            //3. 絕對和滑動混合使用
            string myTime = _memoryCache.GetOrCreate<string>("mySpecialTime", (cacheEnty) =>
            {
                //配置過期時間(每次調用緩存續命10s)
                cacheEnty.SlidingExpiration = TimeSpan.FromSeconds(10);

                //配置過期時間(寫入緩存後,20s過期)
                //cacheEnty.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(20);

                //或者直接具體到某個時間點過期
                cacheEnty.AbsoluteExpiration = new DateTimeOffset(DateTime.Parse("2022-05-19 7:02:00"));


                //將數據庫中查詢的結果寫入緩存
                return DbHelp.GetTime();
            });


            Console.WriteLine("時間爲:" + myTime);
            return myTime;
        }

4. 設置大小

  如果使用 SetSize、Size 和 SizeLimit 限制緩存大小,建議設置單例類

  詳見:https://docs.microsoft.com/zh-cn/aspnet/core/performance/caching/memory?view=aspnetcore-6.0

5. 緩存穿透的解決方案

   cache null策略:DB查詢的結果即使爲null,也給緩存的value設置爲null,同時可以設置一個較短的過期時間,這樣就避免不存在的數據跨過緩存直接打到DB上。

   分析我們前面 DemoApi中TestMemoryCache中,從數據庫中查詢的數據,並沒有判空直接存入到緩存中了,這就是cache null策略,實際場景結合業務添加一個過期時間, 或者DB中的數據更新了刪除緩存。

   這裏還是推薦使用GetOrCreateAsync方法即可,因爲它會把null值也當成合法的緩存值

   測試代碼省略

6. 緩存雪崩的解決方案

   設置不同的緩存失效時間,比如可以在緩存基礎過期時間後面加個隨機數,這樣就避免同一時刻緩存大量過期失效

   詳見後面的代碼封裝即可

 

四. 分佈式緩存

(這裏基於redis進行配置,實際上可以直接用redis相關程序集進行處理的)

1. 說明

 分佈式緩存是由多個應用服務器共享的緩存,通常作爲訪問它的應用服務器的外部服務進行維護, Asp.NET Core中提供了統一的分佈式緩存服務器的操作接口IDistributedCache,用法和內存緩存類似。

 分佈式緩存和內存緩存的區別:緩存值的類型爲byte[],需要我們進行類型轉換,也提供了一些按照string類型存取緩存值的擴展方法,比如:GetString、SetString

2. 用法

(1). 啓動Redis服務器

(2). 安裝程序集 【Microsoft.Extensions.Caching.StackExchangeRedis】

(3). 在Program中註冊Redis服務配置,其中Configuration是redis的鏈接字符串,InstanceName表示給所有存入redis的key加個前綴(可以不配置哦)

    builder.Services.AddStackExchangeRedisCache(options =>
    {
        options.Configuration = "localhost";
        options.InstanceName = "ypf_";
    });

(4). 在控制器中注入 IDistributedCache接口, 使用即可

代碼詳見:05-DistributeCacheDemo/Test1Controller/GetMyTime

        public Test1Controller(IDistributedCache distributeCache)
        {
            this._distributeCache = distributeCache;
        }
        /// <summary>
        /// 測試基於Redis的分佈式緩存
        /// </summary>
        [HttpPost]
        public String GetMyTime()
        {
            string myTime = _distributeCache.GetString("mySpecialTime");
            if (string.IsNullOrEmpty(myTime))
            {
                //從數據庫中查詢
                myTime = DbHelp.GetTime();
                //配置過期時間
                var opt = new DistributedCacheEntryOptions
                {
                    //絕對過期時間
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(15)
                };
                //存入redis緩存中
                _distributeCache.SetString("mySpecialTime", myTime, opt);
            }
            Console.WriteLine("時間爲:" + myTime);
            return myTime;
        }

補充:(如何配置密碼呢? 參考:【119.45.143.22:6379,password=123456,defaultDatabase=0】)

 

五. 相關封裝剖析

1. 老楊的內存緩存框架

(1).需求:

  A. IQueryable、IEnumerable等類型可能存在着延遲加載的問題,如果把這兩種類型的變量指向的對象保存到緩存中,在我們把它們取出來再去執行的時候,如果它們延遲加載時候需要的對象已經被釋放的話,就會執行失敗。因此緩存禁止這兩種類型。

  B. 實現隨機緩存過期時間

  源碼:https://github.com/yangzhongke/NETBookMaterials/blob/main/%E6%9C%80%E5%90%8E%E5%A4%A7%E9%A1%B9%E7%9B%AE%E4%BB%A3%E7%A0%81/YouZack-VNext/Zack.ASPNETCore/MemoryCacheHelper.cs

(2). 源碼剖析

   重點理解一下GetOrCreate方法中委託的使用, 詳見 Utils/MemoryCacheHelper類

接口代碼:

 public interface IMemoryCacheHelper
    {
        /// <summary>
        /// 從緩存中獲取數據,如果緩存中沒有數據,則調用valueFactory獲取數據。
        /// 可以用AOP+Attribute的方式來修飾到Service接口中實現緩存,更加優美,但是沒有這種方式更靈活。
        /// 默認最長的緩存過期時間是expireSeconds秒,當然也可以在領域事件的Handler中調用Update更新緩存,或者調用Remove刪除緩存。
        /// 因爲IMemoryCache會把null當成合法的值,因此不會有緩存穿透的問題,但是還是建議用我這裏封裝的ICacheHelper,原因如下:
        /// 1)可以切換別的實現類,比如可以保存到MemCached、Redis等地方。這樣可以隔離變化。
        /// 2)IMemoryCache的valueFactory用起來麻煩,還要單獨聲明一個ICacheEntry參數,大部分時間用不到這個參數。
        /// 3)這裏把expireSeconds加上了一個隨機偏差,這樣可以避免短時間內同樣的請求集中過期導致“緩存雪崩”的問題
        /// 4)這裏加入了緩存數據的類型不能是IEnumerable、IQueryable等類型的限制
        /// </summary>
        /// <typeparam name="TResult">緩存的值的類型</typeparam>
        /// <param name="cacheKey">緩存的key</param>
        /// <param name="valueFactory">提供數據的委託</param>
        /// <param name="expireSeconds">緩存過期秒數的最大值,實際緩存時間是在[expireSeconds,expireSeconds*2)之間,這樣可以一定程度上避免大批key集中過期導致的“緩存雪崩”的問題</param>
        /// <returns></returns>
        TResult? GetOrCreate<TResult>(string cacheKey, Func<ICacheEntry, TResult?> valueFactory, int expireSeconds = 60);

        Task<TResult?> GetOrCreateAsync<TResult>(string cacheKey, Func<ICacheEntry, Task<TResult?>> valueFactory, int expireSeconds = 60);

        /// <summary>
        /// 刪除緩存的值
        /// </summary>
        /// <param name="cacheKey"></param>
        void Remove(string cacheKey);
    }
View Code

類代碼:

/// <summary>
    /// 用ASP.NET的IMemoryCache實現的內存緩存
    /// </summary>
    public class MemoryCacheHelper : IMemoryCacheHelper
    {
        private readonly IMemoryCache memoryCache;
        public MemoryCacheHelper(IMemoryCache memoryCache)
        {
            this.memoryCache = memoryCache;
        }

        private static void ValidateValueType<TResult>()
        {
            //因爲IEnumerable、IQueryable等有延遲執行的問題,造成麻煩,因此禁止用這些類型
            Type typeResult = typeof(TResult);
            if (typeResult.IsGenericType)//如果是IEnumerable<String>這樣的泛型類型,則把String這樣的具體類型信息去掉,再比較
            {
                typeResult = typeResult.GetGenericTypeDefinition();
            }
            //注意用相等比較,不要用IsAssignableTo
            if (typeResult == typeof(IEnumerable<>) || typeResult == typeof(IEnumerable)
                || typeResult == typeof(IAsyncEnumerable<TResult>)
                || typeResult == typeof(IQueryable<TResult>) || typeResult == typeof(IQueryable))
            {
                throw new InvalidOperationException($"TResult of {typeResult} is not allowed, please use List<T> or T[] instead.");
            }
        }

        private static void InitCacheEntry(ICacheEntry entry, int baseExpireSeconds)
        {
            //過期時間.Random.Shared 是.NET6新增的
            double sec = Random.Shared.NextDouble(baseExpireSeconds, baseExpireSeconds * 2);
            TimeSpan expiration = TimeSpan.FromSeconds(sec);
            entry.AbsoluteExpirationRelativeToNow = expiration;
        }

        public TResult? GetOrCreate<TResult>(string cacheKey, Func<ICacheEntry, TResult?> valueFactory, int baseExpireSeconds = 60)
        {
            ValidateValueType<TResult>();
            //因爲IMemoryCache保存的是一個CacheEntry,所以null值也認爲是合法的,因此返回null不會有“緩存穿透”的問題
            //不調用系統內置的CacheExtensions.GetOrCreate,而是直接用GetOrCreate的代碼,這樣免得包裝一次委託
            if (!memoryCache.TryGetValue(cacheKey, out TResult result))
            {
                using ICacheEntry entry = memoryCache.CreateEntry(cacheKey);
                InitCacheEntry(entry, baseExpireSeconds);
                result = valueFactory(entry)!;
                entry.Value = result;
            }
            return result;
        }

        public async Task<TResult?> GetOrCreateAsync<TResult>(string cacheKey, Func<ICacheEntry, Task<TResult?>> valueFactory, int baseExpireSeconds = 60)
        {
            ValidateValueType<TResult>();
            if (!memoryCache.TryGetValue(cacheKey, out TResult result))
            {
                using ICacheEntry entry = memoryCache.CreateEntry(cacheKey);
                InitCacheEntry(entry, baseExpireSeconds);
                result = (await valueFactory(entry))!;
                entry.Value = result;
            }
            return result;
        }

        public void Remove(string cacheKey)
        {
            memoryCache.Remove(cacheKey);
        }
    }
View Code

(3). 補充1個方法:

  A..Net6中新增的生成隨機數的方法,解決了舊版本高併發下的問題  Random.Shared.Next(1,100);

  B. 另外默認的NextDouble方法只能生成0-1取件,這裏自己擴展一個可以指定大小範圍的NextDouble方法

 public static class RandomExtensions
    {
        /// <summary>
        ///  擴展一個可以指定大小範圍生成隨機數的方法
        /// </summary>
        /// <param name="random"></param>
        /// <param name="minValue">The inclusive lower bound of the random number returned.</param>
        /// <param name="maxValue">The exclusive upper bound of the random number returned. maxValue must be greater than or equal to minValue.</param>
        /// <returns></returns>
        public static double NextDouble(this Random random, double minValue, double maxValue)
        {
            if (minValue >= maxValue)
            {
                throw new ArgumentOutOfRangeException(nameof(minValue), "minValue cannot be bigger than maxValue");
            }
            //https://stackoverflow.com/questions/65900931/c-sharp-random-number-between-double-minvalue-and-double-maxvalue
            double x = random.NextDouble();
            return x * maxValue + (1 - x) * minValue;
        }
    }

(4). 測試

  A. 註冊成單例模式 builder.Services.AddScoped<IMemoryCacheHelper, MemoryCacheHelper>();

  B. 通過[FromServices] IMemoryCacheHelper cacheHelp 注入使用即可

        /// <summary>
        /// 測試內存緩存封裝類
        /// </summary>
        /// <returns></returns>
        [HttpPost]
        public string? TestMemoryCacheHelper([FromServices] IMemoryCacheHelper cacheHelp)
        {
            string? myTime = cacheHelp.GetOrCreate<String>("mySpecialTime", (cacheEnty) =>
            {
                //將數據庫中查詢的結果寫入緩存
                return DbHelp.GetTime();
            }, 50);
            Console.WriteLine("時間爲:" + myTime);
            return myTime;
        }

2. 老楊的分佈式緩存框架

(1).需求:

  A. 解決緩存穿透、緩存雪崩等問題。

  B. 自動地進行其他類型的轉換。

 源碼:https://github.com/yangzhongke/NETBookMaterials/tree/main/%E6%9C%80%E5%90%8E%E5%A4%A7%E9%A1%B9%E7%9B%AE%E4%BB%A3%E7%A0%81/YouZack-VNext/Zack.ASPNETCore/DistributedCacheHelper.cs

(2). 源碼剖析

   詳見 DistributedCacheHelper 方法,詳見 Utils/DistributedCacheHelper類

接口代碼:

  public interface IDistributedCacheHelper
    {
        TResult? GetOrCreate<TResult>(string cacheKey, Func<DistributedCacheEntryOptions, TResult?> valueFactory, int expireSeconds = 60);

        Task<TResult?> GetOrCreateAsync<TResult>(string cacheKey, Func<DistributedCacheEntryOptions, Task<TResult?>> valueFactory, int expireSeconds = 60);

        void Remove(string cacheKey);
        Task RemoveAsync(string cacheKey);
    }
View Code

類代碼:

  public class DistributedCacheHelper : IDistributedCacheHelper
    {
        private readonly IDistributedCache distCache;

        public DistributedCacheHelper(IDistributedCache distCache)
        {
            this.distCache = distCache;
        }

        private static DistributedCacheEntryOptions CreateOptions(int baseExpireSeconds)
        {
            //過期時間.Random.Shared 是.NET6新增的
            double sec = Random.Shared.NextDouble(baseExpireSeconds, baseExpireSeconds * 2);
            TimeSpan expiration = TimeSpan.FromSeconds(sec);
            DistributedCacheEntryOptions options = new()
            {
                AbsoluteExpirationRelativeToNow = expiration
            };
            return options;
        }

        public TResult? GetOrCreate<TResult>(string cacheKey, Func<DistributedCacheEntryOptions, TResult?> valueFactory, int expireSeconds = 60)
        {
            string jsonStr = distCache.GetString(cacheKey);
            //緩存中不存在
            if (string.IsNullOrEmpty(jsonStr))
            {
                var options = CreateOptions(expireSeconds);
                TResult? result = valueFactory(options);//如果數據源中也沒有查到,可能會返回null
                //null會被json序列化爲字符串"null",所以可以防範“緩存穿透”
                string jsonOfResult = JsonSerializer.Serialize(result, typeof(TResult));
                distCache.SetString(cacheKey, jsonOfResult, options);
                return result;
            }
            else
            {
                //"null"會被反序列化爲null
                //TResult如果是引用類型,就有爲null的可能性;如果TResult是值類型
                //在寫入的時候肯定寫入的是0、1之類的值,反序列化出來不會是null
                //所以如果obj這裏爲null,那麼存進去的時候一定是引用類型
                distCache.Refresh(cacheKey);//刷新,以便於滑動過期時間延期
                return JsonSerializer.Deserialize<TResult>(jsonStr)!;
            }
        }

        public async Task<TResult?> GetOrCreateAsync<TResult>(string cacheKey, Func<DistributedCacheEntryOptions, Task<TResult?>> valueFactory, int expireSeconds = 60)
        {
            string jsonStr = await distCache.GetStringAsync(cacheKey);
            if (string.IsNullOrEmpty(jsonStr))
            {
                var options = CreateOptions(expireSeconds);
                TResult? result = await valueFactory(options);
                string jsonOfResult = JsonSerializer.Serialize(result, typeof(TResult));
                await distCache.SetStringAsync(cacheKey, jsonOfResult, options);
                return result;
            }
            else
            {
                await distCache.RefreshAsync(cacheKey);
                return JsonSerializer.Deserialize<TResult>(jsonStr)!;
            }
        }

        public void Remove(string cacheKey)
        {
            distCache.Remove(cacheKey);
        }

        public Task RemoveAsync(string cacheKey)
        {
            return distCache.RemoveAsync(cacheKey);
        }
    }
View Code

(3). 測試

  A. 註冊成單例模式 builder.Services.AddScoped<IDistributedCacheHelper, DistributedCacheHelper>();

  B. 通過[FromServices] IDistributedCacheHelper cacheHelp 注入使用即可

        /// <summary>
        ///  02-測試分佈式緩存封裝類
        /// </summary>
        /// <returns></returns>
        [HttpPost]
        public string? TestDistributedCacheHelper([FromServices] IDistributedCacheHelper cacheHelp)
        {
            string? myTime = cacheHelp.GetOrCreate<String>("mySpecialTime", (cacheEnty) =>
            {
                //將數據庫中查詢的結果寫入緩存
                return DbHelp.GetTime();
            }, 50);
            Console.WriteLine("時間爲:" + myTime);
            return myTime;
        }

 

 

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鵬飛)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 聲     明1 : 如有錯誤,歡迎討論,請勿謾罵^_^。
  • 聲     明2 : 原創博客請在轉載時保留原文鏈接或在文章開頭加上本人博客地址,否則保留追究法律責任的權利。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章