ASP.NET WEB API 缓存处理

在Web Api中,假设有用户频繁的对一个并不经常更新数据的接口发起请求,为了减少不必要的资源开销,可通过缓存进行优化:

一、 客户端缓存

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CacheLearnController : ControllerBase
    {
        // 当用户十秒内多次向该接口发起请求,浏览器会从缓存中获取
        [ResponseCache(Duration = 10)]
        [HttpGet]

        public DateTime Now()
        {
            return DateTime.Now;
        }
    }
}

我们通过装饰器[ResponseCache(Duration = 10)]来设定接口内容缓存10秒,用户在十秒内频繁刷新接口拿到的时间都是相同的,这是一个客户端缓存方式,不同的用户进行上述操作他们会有不同的缓存结果;

不过这也需要一个前提,浏览器=>网络,设置未勾选“禁用缓存”。

二、 服务器端缓存

开启服务器端缓存,需要在 Program.cs 中增加 app.UseResponseCaching(); 也有需要注意的事项,注释在代码中了。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

// 启用服务器缓存需要在 app.MapControllers() 前加上 app.UseResponseCaching()
// 如果启用了 app.UseCors 跨域,需要 app.UseCors 写到 app.UseResponseCaching 前面

app.UseResponseCaching();  // 启用服务器端缓存
app.MapControllers();

app.Run();

服务器端缓存意味着在一定时间范围内,无论是哪个客户端进行的请求,在缓存未更新前,都是相同的。

无论是客户端缓存或是服务器端缓存,都会受到浏览器“禁用缓存”设置的影响,由请求时头部报文中的参数决定,比如下面的两种图,当浏览器勾选了“禁用缓存”,请求标头中会多出Cache-Control: no-cache。

三、 缓存的绝对过期时间

通过 IMemoryCache Interface设置缓存的绝对过期时间,完整的示例代码如下:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        private readonly IMemoryCache memoryCache;

        private readonly ILogger<ProjectTodoController> logger;

        public ProjectTodoController(IMemoryCache memoryCache, ILogger<ProjectTodoController> logger)
        {
            this.memoryCache = memoryCache;
            this.logger = logger;
        }

        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            
            using ProjectTodoContext ctx = new ProjectTodoContext();

            logger.LogInformation("开始");  // 用于在控制台输出内存缓存的作用

            // 二合一的方法,从缓存取数据,没有数据会从数据库取,并且保存进缓存
            var items = await memoryCache.GetOrCreateAsync("AllProjects", async (e) =>
            {
                // 用于在控制台输出内存缓存的作用
                logger.LogInformation("缓存里没有找到,从数据库拿");

                // 缓存有效期(绝对过期时间)
                // 设置内存缓存过期时间为 10 秒
                e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);

                return await ctx.Projects.Select(x => new ProjectDTO(x)).ToListAsync();
            });
            // 用于在控制台输出内存缓存的作用
            logger.LogInformation($"{items}");

            return Ok(items);
        }
}

示例代码中设置了获取所有Project的API接口采用了绝对过期时间10秒,当一个用户在某时间访问了该接口,余下的十秒时间内,任何对该接口的访问都获得相同的内容。

四、 缓存滑动过期时间

内存缓存也可以使用滑动过期时间,当缓存内容在设定的时间内没有被访问,到达设定有效期时间之后缓存失效,当缓存内容在设定时间内被访问了,它将重置延续有效期。

应用场景如某些登录鉴权的状态,如Token失效等。

// 滑动过期时间,当缓存内容在设定时间内没有被访问,到达设定有效期时间之后缓存失效
// 当缓存内容在设定时间内又被访问,它将继续延长有效期
// 某些软件的 Token 采用的滑动过期时间
e.SlidingExpiration = TimeSpan.FromSeconds(10);

五、 绝对过期与滑动过期共用

当两种方法混用时,需要注意绝对过期时间要大于滑动过期时间。

// 两种过期时间可以混用
// 可以将绝对过期时间设置的比滑动过期时间长,这样在绝对过期时间的范围内,滑动过期的方式是有效的
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
e.SlidingExpiration = TimeSpan.FromSeconds(10);

六、 内存缓存存取的一个简单示范(缓存穿透)

当客户端访问了一个不存在的id,依照下列代码,程序会先从缓存中查找,查找结果为 Null,程序会从数据库查找,数据库查找的结果为 Null 并赋值给 b,最终这个 null值又被设置到缓存中,判断的条件成立之后,实际每次通过不存在的id进行访问时都会触发数据库的查询,这就是典型的缓存穿透。

string cacheKey = "Book" + id;  // 缓存键
Book? b = memCache.Get<Book?>(cacheKey);
if(b == null)
{
  b = await dbCtx.Books.FindAsync(id);
  memCache.Set(cacheKey, b);
}

缓存穿透的解决方案:

  1. “查不到”也当成一个数据放入缓存。
  2. 使用GetOrCreateAsync方法即可,该方法会将 null 值当成合法的缓存值。
string cacheKey = "Book" + id;
var book = await memCache.GetOrCreateAsync(cacheKey, async(e)=>{
  var b = await dbCtx.Books.FindAsync(id);
  return b;
});

七、 缓存雪崩

缓存项的集中过期引起缓存雪崩,固定的缓存过期时间在周期性的访问中存在波动,比如刚好缓存过期时又出现了大量的请求而产生的突发性数据库压力。

在基础的过期时间之上,再加上一个随机的过期时间来应对和解决缓存雪崩问题。

// 随机过期时间来应对缓存雪崩
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));

八、 分布式缓存(Redis)

Redis 是一个键值对数据库,它有丰富的数据类型,可以用来保存列表、字典、集合等数据类型,也可以用作消息队列、缓存服务器;

首先,在项目中通过NuGet安装Microsoft.Extensions.Caching.StackExchangeRedis,并在Program.cs中增加:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";  // 使用的本地Redis
    options.InstanceName = "mack_";  // key 前缀
});

然后在需要进行缓存的controller中写入如下代码:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using System.Diagnostics.Eventing.Reader;
using System.Runtime.CompilerServices;
using System.Text.Json;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        private readonly IDistributedCache distributedCache;

        public ProjectTodoController(IDistributedCache distributedCache)
        {
            this.distributedCache = distributedCache;
        }

        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            
            using ProjectTodoContext ctx = new ProjectTodoContext();

            List<ProjectDTO> projects;
            var opt = new DistributedCacheEntryOptions();
            opt.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));
            string? s = await distributedCache.GetStringAsync("AllProjects");

            if (s == null)
            {
                Console.WriteLine("从数据库里取数据");
                projects = await ctx.Projects.Select(x => new ProjectDTO(x)).ToListAsync();
                Console.WriteLine("数据写进 Redis");
                await distributedCache.SetStringAsync("AllProjects", JsonSerializer.Serialize(projects), opt);
                Console.WriteLine($"数据失效时间为 {opt}");
            }
            else
            {
                Console.WriteLine("从 Redis 中取数据");
                projects = JsonSerializer.Deserialize<List<ProjectDTO>>(s)!;
            }
            if(projects == null)
            {
                return NotFound();
            }
            else
            {
                Console.WriteLine("返回数据");
                return Ok(projects);
            }
        }
    }
}

这段代码先从Redis缓存中去获取所有的Project,当缓存中的Project不存在时再去数据库去取,也设定了缓存的失效时间,这里采用了一个随机范围,避免缓存雪崩,当取出数据为null时也会被赋值给Redis的键,代码判断为null成立调用NotFound()函数,从而避免了的缓存穿透,不过本段代码如果取不到数据返回的是一个空[],并不会触发NotFound()。

由于快发机是Windows环境,在对Redis版本没有过高要求的前提下,博主去https://github.com/tporadowski/redis/releases 下载安装了可运行在Windows系统上的Redis安装包,为了查看缓存的内容,通过https://github.com/uglide/RedisDesktopManager/releases/download/0.9.3/redis-desktop-manager-0.9.3.817.exe下载了Redis桌面管理工具。

下图是Redis分区缓存的所有的项目,键的前缀以及键组合命名:mack_AllProjects,absexp 的 value 是缓存的失效时间,失效时间一到,数据自动清除。

结合代码为了模拟出代码工作节点,通过控制台打印,可以看到: