ASP.NET Core中如何使用缓存

此文只是从中摘录整理下自己感兴趣的部分,以便备忘和方便查找回顾,详见:

缓存

缓存就是指将热点数据或信息的存放于内存中,以便加快数据的读写访问,提高效率。

因为缓存位于内存中,而内存的读取速度要比磁盘快的多。因此能够很快响应用户请求。特别针对一些热点数据,优势尤为明显。

缓存(Caching)是最常用于提高应用程序的性能的一种手段。合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。

用户对某些数据的请求量特别大,例如新闻、商品等热点信息,之前模式下都是从数据库直接获取信息,易增加数据库IO压力,久而久之,数据库成为了整个系统的瓶颈。而若将热点数据通过缓存技术来获取,而不再直接访问数据库,就可以减少数据库IO压力。

缓存的适用场景

  • 对于数据实时性要求不高

    对于一些经常访问但是很少改变的数据,读明显多于写,使用缓存就很有必要。比如一些网站配置项。

  • 对于性能要求高(请求的响应时间越短越好)

    比如一些秒杀活动场景。

ASP.NET Core 与 缓存

ASP.NET Core 提供了两个独立的缓存框架,一个是针对本地内存的缓存,另一个是针对分布式存储的缓存

针对本地内存的缓存,可以在不经过序列化的情况下,直接将对象存储在当前应用程序进程的内存中。

分布式缓存则需要将对象序列化成字节数组,并存储到一个独立的中心数据库(Redis/Sql Server)中。

此外,还借助一个中间件,实现了响应缓存,即按照HTTP缓存规范,对整个响应内容实施缓存。

序列化

序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式(json/xml等)的过程。

在序列化期间,对象将其当前状态写入到临时或持久性存储区,以后就可以通过从存储区中读取或反序列化对象的状态,重新创建该对象。

通常的序列化过程有:对象到 JSON 字符串,对象到 XML 字符串,对象到字节数组。

缓存数据通常采用字典类型的存储结构,并通过提供的key来定位目标缓存条目。

缓存的有效期和刷新方面,有必要先了解几个过期策略:

两种针对时间的过期策略:

  • 绝对时间过期策略:不论缓存对象最近使用的频率如何,对应的 ICacheEntry 对象总是在指定的时间点之后过期。注意:当 AbsoluteExpiration AbsoluteExpirationRelativeToNow 都设置值的时候,绝对过期时间取距离当前时间近的那个设置。
  • 滑动过期:如果在指定的时间内没有读取过该缓存条目,缓存将会过期。反之,针对缓存的每一次使用都会将过期时间延长,指定的这个时间段(SlidingExpiration)就是后延的时长。

如果同时设置了绝对过期时间为1小时后,且滑动过期时间为5分钟,则新添加的缓存条目的过期时间为:5分钟。即多久后过期采用的算法为:Min(AbsoluteExpiration - Now , SlidingExpiration)

利用 IChangeToken 对象发送通知的过期策略

假设内存的数据来自于一个物理文件,那么最理想的的方式是让缓存在文件被修改之前永不过期,显然此场景下利用这种过期策略比较好。

1、将数据缓存在内存中

即将数据缓存在当前应用程序进程的内存中,这种针对本地内存的缓存,可以在不经过序列化的情况下,直接将对象存储在当前应用程序进程的内存中。这种方法可以获得最高的性能优势。
参考:https://learn.microsoft.com/zh-cn/aspnet/core/performance/caching/memory?view=aspnetcore-6.0

代码中使用步骤示例:

添加 Nuget 引用:Microsoft.Extensions.Caching.Memory

在 startup.cs 服务配置里面加上:services.AddMemoryCache();

上面将服务添加到依赖注入容器后,就可以在程序中使用了:

//HomeController
private readonly IMemoryCache memoryCache;//本地内存缓存
public HomeController(IMemoryCache cache)
{
this.memoryCache = cache;
}
public string TestCache()
{
if (!memoryCache.TryGetValue<DateTime>("CurrentTime",out var time))
{
memoryCache.Set("CurrentTime", time = DateTime.Now);
//如果想设置过期时间(绝对过期时间)。
//memoryCache.Set("UpdateTime", time = DateTime.Now, DateTime.Now.AddSeconds(10));
//如果想设置过期时间。滑动时间
//memoryCache.Set("",time=DateTime.Now,new MemoryCacheEntryOptions() { SlidingExpiration=new TimeSpan(0,0,10)});
}
return $"缓存时间:{time}(,当前时间:{DateTime.Now})";
}

注意:这种内存中的缓存数据,对于该应用来说,相当于全局变量

2、对数据进行分布式缓存

由于采用集中式存储,必然涉及网络传输或者持久化存储,所以在ASP.NET Core中,分布式缓存只支持字节数组这一种数据类型,应用程序需要自行解决针对缓存对象的序列化和反序列化问题。

分布式缓存的读和写实现在通过 IDistributedCache 接口表示的服务中。分布式缓存的操作除了设置、提取和移除,还包括刷新缓存。所谓的刷新是针对滑动时间过期策略而言的,每次刷新都会将对应缓存条目的最后访问时间设置为当前时间。

基于 Redis 数据库的分布式缓存具体实现在 RedisCache 类型上,它是对 IDistributedCache 接口的实现。

RedisCacheOptions 对象承载了与目标Redis数据库相关的配置选项。

设置分布式缓存条目时, DistributedCacheEntryOptions 对象用来决定设置的缓存条目何时过期。对象里同样的有绝对过期时间和滑动过期时间属性。

注意:Redis 是实现分布式缓存最好的选择

2.1、基于 Redis 的分布式缓存

首先请确保已经正常安装并启动了 Redis,并在项目中添加Nuget包:Microsoft.Extensions.Caching.Redis

关于这个组件工具的文档说明,参考:https://stackexchange.github.io/StackExchange.Redis/

//startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddDistributedRedisCache(option =>
{
option.Configuration = "localhost";
option.InstanceName = "Demo_";//每个rediskey前面加上的字符串,用于区分不同应用系统。
});
services.AddControllersWithViews();
}
//HomeController.cs
private readonly IDistributedCache distributedCache;//Redis
public HomeController(IDistributedCache distributedCache)
{
this.distributedCache = distributedCache;
}
public async Task<string> TestRedisCache()
{
var time = await distributedCache.GetStringAsync("CurrentTime");
if (null==time)
{
time = DateTime.Now.ToString();
await distributedCache.SetAsync("CurrentTime", Encoding.UTF8.GetBytes(time));
//设置带有过期时间的
//redisCache.SetString("str", DateTime.Now.Ticks.ToString(),new DistributedCacheEntryOptions() { AbsoluteExpiration=DateTime.Now.AddSeconds(10)});
}
return $"缓存时间:{time}(,当前时间:{DateTime.Now})";
}

option.InstanceName = "Demo_"; 这句,表示当多个应用共享同一个Redis数据库时,缓存数据可以利用该属性值进行区分不同应用的数据。当缓存数据被保存到Redis数据库中的时候,Key 自动在前面加上 InstanceName 的值,作为最终保存到Redis的Key。

可以额外了解下:StackExchange.Redis 。它是 .NET 领域知名的 Redis 客户端框架。

2.2、基于 SQL Server 的分布式缓存

需要依赖 Nuget 包:Microsoft.Extensions.Caching.SqlServer

首先得有一个已经可用的 SQL server 数据库。存放数据的缓存表可以通过 cmd.exe 执行以下命令创建:

dotnet sql-cache create "server=.;database=MyTest;uid=sa;pwd=你的密码;" dbo AspnetCache

image-20211125161817468

建立的表结构如下:

image-20211125161908069

在应用程序中的编码:

//startup.cs:
public void ConfigureServices(IServiceCollection services)
{
services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = "server=.;database=MyTest;uid=sa;pwd=你的密码;";
options.SchemaName = "dbo";
options.TableName = "AdpnetCache";
});
services.AddControllersWithViews();
}
//HomeController.cs
private readonly IDistributedCache distributedCache;//SqlServer
public HomeController(IDistributedCache distributedCache)
{
this.distributedCache = distributedCache;
}
public async Task<string> TestRedisCache()
{
var time = await distributedCache.GetStringAsync("CurrentTime");
if (null==time)
{
time = DateTime.Now.ToString();
await distributedCache.SetAsync("CurrentTime", Encoding.UTF8.GetBytes(time));
}
return $"缓存时间:{time}(,当前时间:{DateTime.Now})";
}

这种情况下的缓存实现在 SqlServerCache 类型中,该类型由 Nuget 包:Microsoft.Extensions.Caching.SqlServer 提供。相关缓存配置信息由: SqlServerCacheOptions 对象提供。其属性:

  • ConnectionString :SQL数据库连接字符串
  • SchemaName TableName :分别表示缓存数据表的Schema与表名称

与基于Redis数据库的分布式缓存相比,针对SQL Server数据库的分布式缓存与其差异最大的就是如何删除过期缓存条目。

3、缓存整个HTTP响应

上面的两种缓存,都要求利用注册的服务对象以手动方式存储和提取具体的缓存数据。响应缓存(Response Caching)则不再基于某个具体的缓存数据,而是将服务端生成的HTTP响应的内容予以缓存。响应缓存是http规范家族中的一个重要成员。

HTTP/1.1 Caching (缓存规范)

只针对方法为 GET 或 HEAD 的请求,这样的请求旨在获取URL所指向的资源或者描述资源的元数据。

缓存会根据一定的规则在本地存储一份原始服务器提供的响应副本,并赋予它一个保质期,保质期内的副本可以直接用来作为后续匹配请求的响应,所以缓存能够避免客户端与原始服务器之间不必要的网络交互。

私有缓存和共享缓存

私有缓存是客户端自己的缓存仅自己用,比如每个用户的浏览器上的缓存。

共享缓存是一份缓存可多人使用。这种类型的缓存一般部署在一个私有网络的代理服务器上,称为缓存代理服务器。

在响应报文中,使用 Cache-Control 报头区分私有缓存和共享缓存:Cache-Control: public|private

使用场景

  • 静态页面,几乎不怎么变的

如何在程序中使用

在 startup.cs 中:

  • ConfigureServices 方法里面加上:services.AddResponseCaching(); //需用到内存缓存

  • Configure 方法里加上:app.UseResponseCaching();

然后就可以在程序中(比如 HomeController)使用了:

//HomeController.cs
/// <summary>
/// 把响应内容缓存到客户端,以便减少与服务器的网络交互。
/// </summary>
/// <returns></returns>
public DateTimeOffset Response( [FromHeader(Name = "X-UTC")] string? utcHeader,
[FromQuery(Name = "utc")] string? utcQuery)
{
var response = HttpContext.Response;
response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true,
MaxAge = TimeSpan.FromSeconds(3600)
};
//让缓存的key不仅包含请求的路径(不含?之后的部分),还应该包括查询字符串“utc"和请求报头“X-UTC”的值。
var feature = HttpContext.Features.Get<IResponseCachingFeature>()!;
feature.VaryByQueryKeys = new string[] { "utc" };
response.Headers.Vary = "X-UTC";
return Parse(utcHeader) ?? Parse(utcQuery) ?? false
? DateTimeOffset.UtcNow : DateTimeOffset.Now;
}
static bool? Parse(string? value)
=> value == null
? null
: string.Compare(value, "1", true) == 0 || string.Compare(value, "true", true) == 0;

测试方法:用浏览器打开多个相同URL的标签页,发现内容都一样。即除了首次访问服务器外,其它都是从本地磁盘缓存读取的响应。

屏蔽缓存

响应缓存通过复用已经生成的响应内容来提升性能,但不意味任何请求都适合以缓存的内容予以回复。请求携带的一些爆头会屏蔽响应缓存。更加准确的说法是客户端请求携带的一些报头。“提醒”服务端当前场景需要返回实时内容,例如携带authorization报头的请求,在默认情况下将不会使用缓存的内容予以回复。另外使用“Cache-Control:no-cache”或“Pragma:no-cache”的请求报头(冒号左边是key右边是value),则服务端会返回实时的响应内容。

ResponseCachingMiddleware与 http-cache

ASP.NET Core 的缓存响应是利用 ResponseCachingMiddleware 中间件实现的,该中间件按照 HTTP 规范来操作缓存。该规范只针对方法为GET或HEAD的请求。如果将资源的提供者和消费者称为目标服务器与客户端,那么所谓的缓存是位于这两者之间的一个HTTP处理部件。缓存会根据一定的规则在本地存储一份服务器提供的响应副本,并赋予他一个保质期。保质期内的副本可以直接用来作为后续请求的响应,所以缓存能够避免客户端与目标服务器之间不必要的网络交互。(其实请求还是来到了服务器的,只不过经过响应缓存中间件处理后就可能直接返回缓存内容了)

私有缓存和共享缓存。注意理解下,这里不过多摘录。响应报文以如下形式采用catche-control报头,区分私有缓存和共享缓存:Cche-Control:public|private

ResponseCachingMiddleware 中间件在默认情况下并不会将携带的查询字符串作为缓存键的组成部分。为了提供用于存储响应内容荷载的请求报头名称。目标服务器在生成最初响应时会将它们存储在一个名为vary的报头中。

到缓存决定存储该响应副本,学会提取响应的vary包头提供的所有请求报头名称,并将对应的值作为存储该响应副本对应key的组成部分。对于后续指向同一个url的请求,只有在他们具有一致的报头值的情况下,对应的响应副本才会被选择。

缓存的再验证,新鲜度检查,保质期,Max-Age/Expire

注意:响应缓存其实是把响应相关信息在server 内存中缓存的,请求还是到服务端了的,只不过对于可用的缓存,响应缓存中间件直接处理,取出响应内容返回给客户端了。

关于ResponseCachingMiddleware 这个中间件,最详细的解释,请参考《ASP.NET Core 6 框架揭秘》第22章。

4、针对内存缓存的 .NET CORE 框架实现

ICacheEntry

使用内存缓存时,我们提供的数据对象在真正缓存之前需要被封装成一个 ICacheEntry 对象,该对象表示真正保存在内存中的一个缓存条目。

一个 ICacheEntry 对象除了封装指定的缓存数据,还承载着一些其他的控制信息,这些信息决定缓存何时失效,以及在内存压力较大时,是否应该被移除。

其有几个属性:

  • Key:缓存键
  • Value:缓存数据
  • Size:缓存的容量
  • AbsoluteExpiration:绝对过期时间,直接设置具体的过期时间点。
  • AbsoluteExpirationRelativeToNow:绝对过期时间,在当前时间加上该属性值的时间后的时间点为绝对过期时间。
  • SlidingExpiration:滑动过期时间
  • ExpirationTokens:一个 IChangeToken 对象的集合。一个 IChangeToken 对象一般与某个需要被监控的对象绑定,并在监控对象发生变化的情况下对外发出通知。
  • Priority:表示缓存条目的重要性。
  • PostEvictionCallbacks:表示当条目删除时,可以执行的回调。

参考:ICacheEntry 接口 (Microsoft.Extensions.Caching.Memory) | Microsoft Learn

当我们调用 IMemoryCache 接口的 TryGetValue 方法通过指定的 Key 试图获取对应的缓存数据时,该方法会进行过期检查,过期的内存条目会直接从缓存字典中移除,此时该方法会返回 FALSE 。

基于内存的缓存具有两种过期策略:一种是基于时间的(绝对时间或者滑动时间)的过期策略,另一种是利用 IChangeToken 对象来发送过期通知。

ICacheEntry 还有许多扩展方法用来设置相关属性

两种针对时间的过期策略

针对绝对时间和滑动时间的过期策略

  • 绝对时间过期策略:不论缓存对象最近使用的频率如何,对应的 ICacheEntry 对象总是在指定的时间点之后过期。注意:当 AbsoluteExpiration 和 AbsoluteExpirationRelativeToNow 都设置值的时候,绝对过期时间取距离当前时间近的那个设置。
  • 滑动过期:如果在指定的时间内没有读取过该缓存条目,缓存将会过期。反之,针对缓存的每一次使用都会将过期时间延长,指定的这个时间段(SlidingExpiration)就是后延的时长。

如果同时设置了绝对过期时间为1小时后,且滑动过期时间为5分钟,则新添加的缓存条目的过期时间为:5分钟。即多久后过期采用的算法为:Min(AbsoluteExpiration - Now , SlidingExpiration)

利用 IChangeToken 对象发送通知的过期策略

假设内存的数据来自于一个物理文件,那么最理想的的方式是让缓存在文件被修改之前永不过期,显然此场景下利用这种过期策略比较好。

内存占用垃圾回收

虽然基于内存的缓存具有最好的性能,但是内存占用过多内存压力较大是不合理也不应该的。因此基于内存的缓存采用了一种“内存压缩”的机制,即根据预定义的策略,删除那些“重要性(Priority)低” 的 ICacheEntry 对象。

Priority 属性的类型是 CacheItemPriority ,定义如下:

public enum CacheItemPriority
{
Low,
Normal,
High,
NeverRemove
}

内存压缩只针对 NeverRemove 以外的优先级的 ICacheEntry 对象。

当条目删除时,可以执行一个删除回调,即往 ICacheEntry 的 PostEvictionCallbacks 属性里添加一个 PostEvictionCallbackRegistration 对象,该对象的 EvictionCallback 属性是一个 PostEvictionDelegate 对象,PostEvictionDelegate 定义如下:

public delegate void PostEvictionDelegate(object key, object value, EvictionReason reason, object state);

MemoryCacheEntryOptions

一个 ICacheEntry 对象除了封装指定的缓存数据,还承载着一些其它的控制信息,这些信息决定缓存何时失效,以及在内存压力较大是是否应该被移除。这些控制信息最终作为配置选项通过一个 MemoryCacheEntryOptions 对象表示,对象中的的属性和 ICacheEntry 的类似。

由于 IMemoryCache 对象最终存储的是 ICacheEntry 对象,所以一个 MemoryCacheEntryOptions 对象承载的缓存配置选项最终需要应用到对应的 ICacheEntry 对象上,这个过程可以通过调用 ICacheEntry 对象的 SetOptions扩展方法来完成。

IMemoryCache

基于内存缓存的读写最终落在了通过 IMemoryCache 接口表示的服务对象上,其默认实现类型是:MemoryCache。

该对象有个方法:ICacheEntry CreateEntry(object key); 。且对象有许多扩展方法用于创建 ICacheEntry 对象,扩展方法内部就是调用 CreateEntry 方法。

image-20220104143933086

MemoryCacheOptions

可以在服务注册的时候使用这个对象。

它有几个属性:

  • Clock:用于时间同步设置。由于绝对过期的计算是基于某个具体的时间点,所以时间的同步,即客户端与服务器的时间同步,以及集群中多台服务器之间的时间同步,很重要。该属性帮我们设置和返回这个同步时钟。
  • ExpirationScanFrequency :表示两次扫描所有缓存条目以确定他们是否过期的时间间隔,默认1分钟。
  • SizeLimit:表示缓存的最大容量。没有显式设置意味着对容量没有限制,设置了,则提供的每个 ICacheEntry 的 Size 属性必须被赋值。另外注意,当缓存总容量超出限制时,针对新的缓存的设置会失败。
  • CompactionPercentage :表示每次实施缓存压缩时移除的缓存容量占当前总容量的百分比,默认0.05。

以上都是必要的知识前提,下面正式介绍 MemoryCache。

MemoryCache

一个 MemoryCache 对象根据提供的 MemoryCacheOptions 对象(构造函数参数)创建。

它直接利用一个 ConcurrentDictionary< object , CacheEntry> 对象(System.Collections.Concurrent)来保存添加的内存条目,所以添加、删除和获取都是基于这个字典对象的操作而已,且默认支持多线程并发,应用程序无需自行解决线程同步问题。

缓存设置流程如图:

true
false
开始
设置过期和最近访问时间戳
过期检验
过期 ?
执行逐出回调
设置新的缓存条目

5、ASP.NET Core 的分布式缓存实现

分布式缓存的读和写实现在通过 IDistributedCache 接口表示的服务中。分布式缓存的操作除了设置、提取和移除,还包括刷新缓存。所谓的刷新是针对滑动时间过期策略而言的,每次刷新都会将对应缓存条目的最后访问时间设置为当前时间。

ASP.NET Core 中针对Redis数据库的访问借助一个名为 StackExchange.Redis 的框架来完成。

DistributedCacheEntryOptions

这个类用来设置缓存条目何时过期。


更新于:2023-5-20

posted @   AI大胜  阅读(979)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示