缓存是高并发服务端程序中最重要也是最常用的优化技术,ASP.NET Core中提供了内存缓存、分布式缓存和响应缓存三种机制。上一章节我们学习Session的使用时已经配置过分布式缓存了,其中服务端Session信息其实就存储在分布式缓存的实现中。这篇笔记我们继续深入学习缓存相关的概念和用法。
ASP.NET Core中,内存缓存是基于接口IMemoryCache
的实现,使用内存缓存时我们需要先注册对应的服务。
// 注册内存缓存
builder.Services.AddMemoryCache();
下面例子代码中我们实现了一个常见的功能,查询一条数据,如果数据在缓存中不存在就查询数据库并将其加入缓存,如果缓存存在则直接返回缓存。
using DemoWebAPI.Model;
using DemoWebAPI.Repository;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/Student")]
public class StudentController : ControllerBase
{
private IMemoryCache _memoryCache;
private StudentRepository _studentRepository;
public StudentController(IMemoryCache memoryCache, StudentRepository studentRepository)
{
_memoryCache = memoryCache;
_studentRepository = studentRepository;
}
[HttpGet("GetStudentById")]
public ActionResult<ApiResult> GetStudentById(long id)
{
Student? student = _memoryCache.GetOrCreate<Student?>("student" + id, (cacheEntry) =>
{
cacheEntry.SetAbsoluteExpiration(TimeSpan.FromSeconds(10));
return _studentRepository.GetStudentById(id);
});
return ApiResult.Success(student);
}
}
控制器代码中,我们通过依赖注入的方式注入了IMemoryCache
的实现和一个StudentRepository
实例,后者用于具体调用数据库查询数据。我们访问缓存时调用了IMemoryCache
的GetOrCreate
方法,它就能够实现自动判断是否取缓存以及自动创建缓存功能,方法中,第1个参数是缓存的Key,不同的数据应该使用不同的Key以避免数据冲突,第2个参数是当缓存中不存在数据时查询数据库的逻辑,IMemoryCache
的实现会自动调用该函数并将查询结果加入缓存。cacheEntry
是一个配置对象,它实现了ICacheEntry
接口,它主要用于配置缓存的过期时间,我们这里配置缓存的过期时间为10秒。
对于“查询数据,缓存存在则走缓存,缓存不存在则查数据库并加入缓存”这种逻辑,我们通常建议直接使用GetOrCreate()
方法,它是一个完整的原子操作且不存在缓存穿透问题。除此之外,我们也可以分别调用读取缓存和写入缓存的方法。
Set()
方法写入缓存的例子如下。
Student tom = new Student() { Id = 1, Name = "汤姆" };
_memoryCache.Set<Student?>("tom", tom, TimeSpan.FromSeconds(60));
Get()
方法读取缓存的例子如下。
Student? tom = _memoryCache.Get<Student?>("tom");
另一种读取缓存的写法是使用TryGetValue()
方法,它能额外返回缓存是否读取成功,如果缓存存在并被读取了则返回true
。
Student? tom = null;
bool isExist = _memoryCache.TryGetValue<Student?>("tom", out tom);
缓存通常不能是永久有效的,这就涉及到缓存的更新策略。一种策略是不指定缓存过期时间,当数据更新时就将缓存失效,这种方式实时性很好,但实现相对复杂;另一种方式就是设置缓存的过期时间。内存缓存的过期时间由两个参数指定:
SlidingExpiration 滑动过期时间:滑动过期时间可以理解为带续期过期时间,只要缓存还未过期,读取缓存就会造成缓存的超时时间重置。
AbsoluteExpiration 绝对过期时间:绝对过期时间比较容易理解,就是缓存在固定时间后过期。
一般来说,我们指定一个绝对过期时间就足够了,对于类似Session这种使用场景才可能需要滑动过期时间这种续期实现。此外滑动过期时间通常不可以单独使用,它必须配合绝对过期时间使用,否则可能出现一个缓存永远也无法过期的情况。
下面例子代码中,我们设置了缓存的续期时间限制为30秒,最长持续180秒。
Student tom = new Student() { Id = 1, Name = "汤姆" };
_memoryCache.Set<Student?>("tom", tom, new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(30))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(180))
);
IMemoryCache
实现了同步和异步两种操作方式,前面我们调用的都是同步方法,在高并发服务中,我们也可以使用异步方法。下面例子代码是前面演示GetOrCreate()
用法的异步版本,它的功能和之前完全相同。
using DemoWebAPI.Model;
using DemoWebAPI.Repository;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/Student")]
public class StudentController : ControllerBase
{
private IMemoryCache _memoryCache;
private StudentRepository _studentRepository;
public StudentController(IMemoryCache memoryCache, StudentRepository studentRepository)
{
_memoryCache = memoryCache;
_studentRepository = studentRepository;
}
[HttpGet("GetStudentById")]
public async Task<ActionResult<ApiResult>> GetStudentById(long id)
{
Student? student = await _memoryCache.GetOrCreateAsync<Student?>("student" + id, (cacheEntry) =>
{
cacheEntry.SetAbsoluteExpiration(TimeSpan.FromSeconds(10));
return _studentRepository.GetStudentByIdAsync(id);
});
return ApiResult.Success(student);
}
}
ASP.NET Core中默认的内存缓存底层是使用ConcurrentDictionary
实现的,也就是说缓存的内容在进程内存中,它的性能显而易见的要比分布式缓存更高(毕竟省去了网络读写和序列化、反序列化的开销),但它仅支持单机使用,不适用于分布式环境。此外这个实现其实也比较简陋,它没有Overflow to Disk机制、缓存淘汰策略等高级功能,因此主要适用于小规模数据的单机高并发存取。
使用分布式缓存前我们需要注册分布式缓存服务,不过这里有一个比较奇葩的假“分布式”缓存实现DistributedMemoryCache
,它实现的分布式缓存的接口IDistributedCache
但仍是使用内存实现的单机内存缓存。
builder.Services.AddDistributedMemoryCache();
这个DistributedMemoryCache
是基于内存的单机缓存,虽然名字中包含“distributed”,但它不是分布式的,这个名字的含义仅仅是表明它兼容分布式缓存的统一接口,不过即使是单机程序,在某些预料到未来需要扩展的场景下使用这个缓存实现也是明智的,因为IDistributedCache
接口保证它的操作方式和各种分布式缓存实现是完全一致的,如果后续需要分布式扩展,我们也可以迅速切换到分布式缓存实现上。
如果希望使用“真”分布式缓存,我们可以集成Redis实现,这需要安装Microsoft.Extensions.Caching.StackExchangeRedis
包,然后配置StackExchangeRedisCache
缓存服务。
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 6.0.36
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
ASP.NET Core中,对于分布式缓存其API和内存缓存是有一定区别的。首先,分布式缓存存取的值是byte[]
或string
类型,毕竟分布式缓存通常是需要基于网络通信实现的,我们必须将存储的数据对象序列化为某种字节流才能传输,而string
类型值则是IDistributedCache
规定的一种便捷方法,它底层其实也是会转为字节数组的。此外,分布式缓存不提供方便的GetOrCreate()
方法,官方的解释是分布式缓存IDistributedCache
接口无法保证底层实现的GetOrCreate()
方法具有原子性,因此没在接口层面提供该方法,我们需要自行实现类似的逻辑。除了这两点,其它就和内存缓存的使用方法类似了。
下面例子中,我们使用分布式缓存实现了非常经典的“查询数据,缓存存在则走缓存,缓存不存在则查数据库并加入缓存”逻辑。
using DemoWebAPI.Model;
using DemoWebAPI.Repository;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/Student")]
public class StudentController : ControllerBase
{
private IDistributedCache _distributedCache;
private StudentRepository _studentRepository;
public StudentController(IDistributedCache distributedCache, StudentRepository studentRepository)
{
_distributedCache = distributedCache;
_studentRepository = studentRepository;
}
[HttpGet("GetStudentById")]
public ActionResult<ApiResult> GetStudentById(long id)
{
// 查询缓存
string studentJson = _distributedCache.GetString("student" + id);
Student? student;
if (studentJson == null)
{
// 缓存不存在,读取数据库
student = _studentRepository.GetStudentById(id);
// 写入缓存
_distributedCache.SetString("student" + id, JsonSerializer.Serialize(student),
new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(60))
);
}
else
{
student = JsonSerializer.Deserialize<Student?>(studentJson);
}
return ApiResult.Success(student);
}
}
我们这里直接使用了分布式缓存的string
类型值相关操作,对于Student
对象我们采用JSON格式存储到缓存中。
注意,由于没有GetOrCreate()
方法,我们使用IDistributedCache
分布式缓存时要注意缓存穿透问题。缓存穿透是指查询的数据既不在缓存中,也不在数据库中,导致每次查询都直接访问数据库。
上面代码中,当缓存不存在且数据库查询返回也是null
时,student
变量的值为null
,JSON序列化方法JsonSerializer.Serialize(null)
会返回"null"
字符串而不是空类型存入缓存,当再次查询缓存时,if ("null" == null)
判断会进入else
分支,并执行JsonSerializer.Deserialize<Student?>("null")
,它的返回结果是空值,因此该段逻辑是正确的,不具有缓存穿透问题。但如果你使用其它序列化方式,则要尤其注意实现的正确性。
这部分用法和内存缓存一致,这里就不重复介绍了。
分布式缓存适用于分布式系统,由于网络传输和序列化、反序列化机制的存在,分布式缓存必然性能较内存缓存更低,但分布式缓存能支撑大规模的分布式集群,通过横向扩容,分布式系统能承载远高于单机的数据吞吐量,这是内存缓存所不能实现的。
此外,ASP.NET Core的Session机制也是基于分布式缓存接口IDistributedCache
实现的,要想使用Session,我们就必须注册分布式缓存服务而不是内存缓存服务,即使是单机程序,我们也需要注册DistributedMemoryCache
这个奇葩的“假”分布式缓存实现,基于分布式缓存实现也使得ASP.NET Core的Session天然具有分布式一致性,不需要我们再做其它的额外处理了。
ASP.NET Core的响应缓存主要用于高并发的MVC工程中对服务端渲染页面或静态文件的缓存策略控制,注意它仅对GET和DELETE请求生效。响应缓存在WebAPI工程中其实很少使用,在这里是个相对鸡肋的功能,仅作了解。
我们编写如下控制器代码,其中控制器方法DemoAction
标注了[ResponseCache]
注解,注解属性指定了Duration
值为60
。
using DemoWebAPI.Model;
using Microsoft.AspNetCore.Mvc;
namespace DemoWebAPI.Controllers;
[ApiController]
[Route("api/Demo")]
public class DemoController : ControllerBase
{
[ResponseCache(Duration = 60)]
[HttpGet("DemoAction")]
public ActionResult<ApiResult> DemoAction()
{
Student tom = new Student() { Id = 1, Name = "汤姆" };
return ApiResult.Success(tom);
}
}
当我们使用浏览器访问该控制器方法对应的接口时,接口会返回一个特殊的缓存控制响应头。
Cache-Control: public,max-age=60
这个响应头会指示浏览器将该结果缓存60秒。缓存控制响应头的效果依赖于客户端实现,如果我们的浏览器在开发者工具中禁用了缓存,或是浏览器本身不支持Cache-Control
,那么这个缓存实现是无效的。这个响应头其实我们也可以手动添加,效果和[ResponseCache]
注解是一样的。
想要启用响应缓存服务端部分的实现需要配置对应的中间件。
app.UseResponseCaching();
我们还是在控制器方法上添加[ResponseCache]
注解,此时它就又自动具备了服务端缓存的功能(即缓存策略响应头和服务端缓存同时具备)。我们可以尝试在控制器方法内设置断点查看效果,如果缓存有效,控制器内的代码是不会执行的。
对于服务端响应缓存,我们还可以指定VaryByQueryKeys
属性,它能实现根据请求的GET参数进行判断,对于同样参数的请求返回有效期内的缓存。
[ResponseCache(Duration = 60, VaryByQueryKeys = new string[] { "id" })]