缓存是提高应用程序性能最简单的解决方案。 它将数据暂时存储在访问速度更快的位置,这样当请求相同时我们就不必从数据源中获取数据。ASP.NET Core 提供了多种类型的缓存,比如 IMemoryCache、IDistributedCache 以及.NET 9 中即将推出的 HybridCache 。那么,这篇文章我们将学习如何在 ASP.NET Core 应用程序中实现缓存。
一、缓存是怎么提高应用程序性能的?
这一小节,我们来看一下缓存时如何提高我们应用程序的性能的。首先,缓存数据的访问速度要比从数据源中检索更快,这是因为缓存通常存储在内存中。其次,缓存中存储的是经常访问的数据,这样就减少了对数据源查询的次数,从而减轻了数据源服务器压力。接着,渲染网页或处理 API 响应会消耗大量 CPU 资源,缓存请求的结果可减少对相同请求对CPU资源的消耗,尤其是重复性很高且CPU密集型的请求。然后,通过减少后端系统的负载,缓存可让我们的应用程序处理更多并发请求。最后,分布式缓存解决方案可跨多台服务器扩展缓存,从而进一步提高性能和弹性。
二、ASP.NET Core 中的缓存抽象
ASP.NET Core 为使用缓存提供了两个接口:
- IMemoryCache:将数据存储在 Web 服务器的内存中。虽然使用简单,但不适合分布式场景。
- IDistributedCache: 它允许我们将缓存数据存储在类似 Redis 的分布式缓存中。
下面,我们一起来看一下如何使用他们。
要使用它们就必须先把它们注入到我们的项目中,代码如下:
csharp
builder.Services.AddMemoryCache();
builder.Services.AddDistributedMemoryCache();
上面的代码段中我们将 IMemoryCache
和 IDistributedCache
都注入了进来,其中
AddDistributedMemoryCache
方法是将 IDistributedCache
配置为只存储在项目宿主机的内存中,而不存储到分布式缓存中。
我们再来看一下如何使用 IMemoryCache
来实现数据的缓存和查询。
csharp
app.MapGet(
"products/{id}",
(int id, IMemoryCache cache, TestDbContext context) =>
{
if (!cache.TryGetValue(id, out Product product))
{
product = context.Products.Find(id);
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
.SetSlidingExpiration(TimeSpan.FromMinutes(2));
cache.Set(id, product, cacheEntryOptions);
}
return Results.Ok(product);
});
在上面的代码中,我们将首先检查缓存值是否存在,如果存在直接返回缓存的数据。 反之从数据库中获取该值,缓存它,并通过SetAbsoluteExpiration
方法设置过期时间,通过SetSlidingExpiration
设置滑动过期时间,最后返回值。
上面的代码所展示的是Cache-Aside 模式 ,Cache-Aside 模式是最常见的缓存策略,从代码中我们不难看出它的工作愿意:在缓存中查找请求的数据,如果缓存中没有数据,则从源获取,将获取的数据存储在缓存中供后续请求使用。
下面我们利用 Cache-Aside 模式为 IDistributedCache
实现一个扩展:
csharp
public static class DistributedCacheExtensions
{
public static DistributedCacheEntryOptions DefaultExpiration => new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
};
public static async Task<T> GetOrCreateAsync<T>(
this IDistributedCache cache,
string key,
Func<Task<T>> factory,
DistributedCacheEntryOptions? cacheOptions = null)
{
var cachedData = await cache.GetStringAsync(key);
if (cachedData is not null)
{
return JsonSerializer.Deserialize<T>(cachedData);
}
var data = await factory();
await cache.SetStringAsync(
key,
JsonSerializer.Serialize(data),
cacheOptions ?? DefaultExpiration);
return data;
}
}
在上面的代码中,我们使用 JsonSerializer
来进行 JSON 字符串的序列化。并且 SetStringAsync
方法还接受一个类型为 DistributedCacheEntryOptions
的参数来控制缓存的过期。以下代码是如何使用它的例子:
csharp
app.MapGet(
"products/{id}",
(int id, IDistributedCache cache, AppDbContext context) =>
{
var product = cache.GetOrCreateAsync($"products-{id}", async () =>
{
var productFromDb = await context.Products.FindAsync(id);
return productFromDb;
});
return Results.Ok(product);
});
Tip:
IDistributedCache
接口的AddDistributedMemoryCache
方法是微软为我们提供的在开发和测试阶段使用的临时缓存方案,不可用于生产环境, 并且IDistributedCache
的使用较为复杂,因此在这里不进行详细讲解,详细讲解请关注后续文章。
这一小节讲了这么多其实都是讲的内存缓存,在讲分布式缓存前我们先来简单看一下内存缓存的优缺点:
- 优点:速度极快、实施简单、无外部依赖性
- 缺点:服务器重启时会丢失缓存数据、仅限于单个服务器的内存、缓存数据不会在应用程序的多个实例中共享
四、使用 Redis 实现分布式缓存
Redis是一种流行的高效内存数据存储,一般作为高性能分布式缓存使用。 如果在 ASP.NET Core 应用程序中使用 Redis,我们可以使用 StackExchange.Redis 库和微软官方的 Microsoft.Extensions.Caching.StackExchangeRedis 库。在这里我们不讲 StackExchange.Redis 库如何与Asp.net Core 项目集成,我们只讲解 Microsoft.Extensions.Caching.StackExchangeRedis 库如何与Asp.net Core 项目集成。
注入 Microsoft.Extensions.Caching.StackExchangeRedis 有两种方法:一种是配置 Redis 连接字符串并注入,另一种是将 IConnectionMultiplexer
注册为服务,然后在 ConnectionMultiplexerFactory
函数中使用它。下面我们分别来看一下如何实现这两种方式。
- 配置 Redis 连接字符串并注入
csharp
string redisConnectionString= builder.Configuration.GetConnectionString("RedisConnectionString");
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConnectionString;
});
- IConnectionMultiplexer 方式
csharp
string redisConnectionString = builder.Configuration.GetConnectionString("RedisConnectionString");
IConnectionMultiplexer connectionMultiplexer =
ConnectionMultiplexer.Connect(redisConnectionString);
builder.Services.AddSingleton(connectionMultiplexer);
builder.Services.AddStackExchangeRedisCache(options =>
{
options.ConnectionMultiplexerFactory =
() => Task.FromResult(connectionMultiplexer);
});
五、缓存踩踏
ASP.NET Core 中的内存缓存实现容易受到竞争条件的影响,这可能会导致缓存踩踏事件。 缓存踩踏事件会在并发请求遇到缓存未命中并试图从数据源获取数据时发生。这种情况下所有的请求全都从数据源查询数据,导致数据源负载过大,应用程序性能降低。
那么如何解决这个问题呢?锁定是缓存踩踏问题的一种解决方案。.NET 提供了许多锁定和并发控制选项,最常用的锁定语句是 lock
语句和 Semaphore
或 SemaphoreSlim
类。
下面是我们来看看如何使用 SemaphoreSlim (其他方式类似)在获取数据前引入锁定(其他方式类似):
csharp
public static class DistributedCacheExtensions
{
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
// 忽略参数
public static async Task<T> GetOrCreateAsync<T>(...)
{
// 从缓存中获取数据,如果存在则返回
More code
// 缓存失败逻辑
try
{
await Semaphore.WaitAsync();
var data = await factory();
await cache.SetStringAsync(
key,
JsonSerializer.Serialize(data),
cacheOptions ?? DefaultExpiration);
}
finally
{
Semaphore.Release();
}
return data;
}
}
在上面代码中,SemaphoreSlim
用于限制并发访问,这里设置为 1,表示同一时刻只允许一个线程进入临界区。通过这种方式避免了多个线程同时进行缓存更新操作,从而防止缓存踩踏问题。GetOrCreateAsync
方法的主要作用是尝试从缓存中获取数据。如果缓存中存在数据,则直接返回。如果缓存中不存在数据,则通过工厂方法获取数据,并将数据序列化后存入缓存中。
Tip:缓存穿透和缓存踩踏的区别
缓存穿透和缓存踩踏是缓存系统中常见的两种问题,它们会影响系统的性能和稳定性。
缓存穿透是指用户请求的数据不在缓存中,也不在数据库中,因此每次请求都必须查询数据库,导致缓存失效。这种情况通常发生在用户请求不存在的资源时,例如查询一个数据库中不存在的 ID。这种情况下,由于缓存中没有相应的数据,系统会不断地访问数据库,给数据库带来很大的压力。
解决方法:
- 缓存空结果: 对于查询不存在的数据,可以将空结果缓存起来,并设置一个较短的过期时间,以避免频繁查询数据库。
- 使用布隆过滤器: 在访问缓存前,通过布隆过滤器判断请求的数据是否存在,以减少无效的数据库查询。
缓存踩踏是指在高并发场景下,当缓存过期时,多个请求同时去加载数据库数据并写入缓存,导致数据库瞬时负载过高。由于多个请求同时发现缓存过期,它们会同时请求数据库,从而造成数据库的高并发压力。
解决方法:
- 使用互斥锁(Mutex): 在加载数据时,使用互斥锁保证只有一个请求能加载数据,其它请求等待或使用旧数据。
- 提前更新缓存: 在缓存即将过期时,提前更新缓存,确保不会有大量请求同时去访问数据库。
- 缓存过期时间加随机值: 在缓存过期时间上加一个随机值,避免大量缓存同时过期。
这两种问题的解决方法主要是为了减少对数据库的频繁访问,提高系统的响应速度和稳定性。
六、总结
我们可以选择 IMemoryCache 用于内存缓存,也可以选择IDistributedCache 用于分布式缓存,但是要注意以下的使用原则:
- 使用 IMemoryCache 进行简单的内存缓存
- 实施缓存预留模式,以尽量减少数据库命中率
- 考虑将 Redis 作为高性能分布式缓存实现
- 使用 IDistributedCache 跨多个应用程序共享缓存数据