第四十二节:再探缓存重点概念、内存缓存、分布式缓存、相关封装剖析

一. 缓存重点概念

 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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章