第四十二节:再探缓存重点概念、内存缓存、分布式缓存、相关封装剖析
一. 缓存重点概念
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)
//{
// //从数据库中查询(不判空,直接存入缓存,这就是cache null 策略)
// goodsNum = DbHelp.GetNum();
// //写入缓存
// _memoryCache.Set("goodsNum", goodsNum);
//}
//Console.WriteLine("数量为:" + goodsNum);
//return goodsNum;
//写法2
//string myTime;
//if (!_memoryCache.TryGetValue("mySpecialTime",out myTime))
//{
// //从数据库中查询(不判空,直接存入缓存,这就是cache null 策略)
// 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); }
类代码:
/// <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); } }
(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); }
类代码:
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); } }
(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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。