P7-4 性能优化“万金油”:缓存

性能优化“万金油”:缓存


什么是缓存

  • 缓存是系统优化中简单又有效的工具,只要简单几行代码或者几个简单的配置,我们就可以利用缓存让系统的性能得到极大的提升
    • 类似于数据库中的索引等简单有效的优化功能
  • 缓存是一个用来保存数据的区域,从缓存区域中读取数据的速度比从数据源读取数据的速度快很多。
    • 1.从数据源获取数据
      • 获取数据之后,我们可以把数据保存到缓存中
      • 数据源获取
    • 2.下次在需要获取同样数据的时候可以直接从缓存中获取
      • 从缓存中获取

缓存的概念

  • 1、缓存命中
    • 如果从缓存中获取了要获取的数据,就叫作“缓存命中”
  • 2、缓存命中率
    • 多次请求中,命中的请求占全部请求的百分比叫作“命中率”
  • 3、缓存数据不一致
    • 如果数据源中的数据保存到缓存后,发生了变化,就会导致“缓存数据不一致”

多级缓存

  • 在Web开发中,存在着多级缓存,比如在浏览器存在“浏览器端缓存”,在网关节点服务器中也可能存在“节点缓存”,在Web服务器上也有可能存在“服务器端缓存”,如下图所示

    • 对于用户发出的请求,只要在任何一个节点上命中缓存,请求就会直接返回,而不会继续向后传递
    • 多级缓存
  • ASP.NET Core中的响应缓存就是遵守RFC 7234规范的缓存控制机制,它可以对浏览器缓存、CDN节点缓存、服务器端缓存进行统一的控制

  • RFC 7234规范对缓存有一定的局限性,因此有时候我们需要进行更加个性化的服务器端缓存控制

    • ASP.NET Core不仅提供了把Web服务器的内存用作缓存的内存缓存,还提供了把Redis、数据库等用作缓存的分布式缓存

客户端响应缓存

  • 1、RFC7324是HTTP协议中对缓存进行控制的规范,其中重要的是cache-control这个响应报文头。

    • 服务器如果返回cache-control:max-age=60,则表示服务器指示浏览器端“可以缓存这个响应内容60秒”。
  • 2、我们只要给需要进行缓存控制的控制器的操作方法添加ResponseCacheAttribute这个Attribute,ASP.NET Core会自动添加cache-control报文头。

  • 3、验证:编写一个返回当前时间的Action方法。分别加和不加ResponseCacheAttribute看区别。也F12看看Network。

  • 4、ResponseCache设置只是通过cache-control响应报文头来控制客户端缓存。

    • 如果客户端不支持/不启用缓存,这个设置也是不生效的,毕竟是否使用缓存、如何使用缓存都是由客户端决定的,cache-control响应报文头只是一个“建议”而已
    • 停用缓存后,你即使代码里面加了,也不会对客户端产生作用
    • 选择
  • 缓存命中

    • 缓存命中
  • 控制

    • 响应头

服务器端响应缓存

  • 1、如果ASP.NET Core中安装了“响应缓存中间件” ,那么ASP.NET Core不仅会继续根据[ResponseCache]设置来生成cache-control响应报文头来设置客户端缓存,而且服务器端也会按照[ResponseCache]的设置来对响应进行服务器端缓存。和客户端端缓存的区别?来自多个不同客户端的相同请求。
  • 2、“响应缓存中间件”的好处:对于来自不同客户端的相同请求或者不支持客户端缓存的客户端,能降低服务器端的压力。
  • 3、用法:app.MapControllers()之前加上app.UseResponseCaching()。请确保app.UseCors()写到app.UseResponseCaching()之前。

服务器端响应缓存很鸡肋

  • 1、无法解决恶意请求给服务器带来的压力。
  • 2、服务器端响应缓存还有很多限制,包括但不限于:响应状态码为200的GET或者HEAD响应才可能被缓存;报文头中不能含有Authorization、Set-Cookie等。
  • 3、不怪它自带的这种服务器端响应缓存鸡肋,因为它只是严格的遵守RFC 7234规范的缓存控制机制而已
  • 4、杨中科老师的建议是:不建议启用“响应缓存中间件”。
    • 对于需要进行客户端响应缓存处理的操作方法,我们简单标注ResponseCache即可
    • 如果还需要再服务器端进行缓存处理,建议开发人员采用ASP.NET Core提供的内存缓存、分布式缓存等机制。

内存缓存

  • 1、把缓存数据放到应用程序的内存。内存缓存中保存的是一系列的键值对,就像Dictionary类型一样。
  • 2、内存缓存的数据保存在当前运行的网站程序的内存中,是和进程相关的。
    • 因为在Web服务器中,多个不同网站是运行在不同的进程中的,因此不同网站的内存缓存是不会互相干扰的,而且网站重启后,内存缓存中的所有数据也就都被清空了。
  • 3、对于MVC项目,框架会自动地注入内存缓存服务;但是对于Web API来说,没有自动注入内存缓存服务,这时就需要我们手动去注入了~

用法

  • 1、启用:builder.Services.AddMemoryCache()
    • 内存服务
  • 2、注入IMemoryCache接口,查看接口的方法:TryGetValue、Remove、Set、GetOrCreate、GetOrCreateAsync
  • 3、用GetOrCreateAsync讲解
1
2
3
4
5
6
7
8
9
10
11
public async Task<Book[]> GetBooks()
{
logger.LogInformation("开始执行GetBooks");
var items = await memCache.GetOrCreateAsync("AllBooks", async (e) =>
{
logger.LogInformation("从数据库中读取数据");
return await dbCtx.Books.ToArrayAsync();
});
logger.LogInformation("把数据返回给调用者");
return items;
}

缓存的过期时间策略

  • 1、上面学习的例子中的缓存是不会过期,除非应用程序重启或者通过调用Remove等方法进行删除。
    • 这种情况下,如果数据库中的数据发生变化,而缓存中保存的还是旧数据,就会出现缓存数据和数据库中的数据不一致的情况
  • 2、解决方法:
    • 在数据改变的时候调用Remove或者Set来删除或者修改缓存(优点:及时);
    • 过期时间(只要过期时间比较短,缓存数据不一致的情况也不会持续很长时间。)
      • 当过期时间到来的时候,对应的缓存数据会从缓存中被清楚
  • 3、两种过期时间策略:绝对过期时间、滑动过期时间。
    • 绝对过期时间指的是自设置缓存之后的指定时间后,缓存项被清除
    • 滑动过期时间指的是自设置缓存之后的指定时间后,如果对应的缓存数据没有被访问,则缓存项被清除;而如果在指定的时间内,对应的缓存数据被访问了一次,则缓存项的过期时间会自动续期

绝对过期时间

  • 1、GetOrCreateAsync()方法的回调方法中有一个ICacheEntry类型的参数,通过ICacheEntry对当前的缓存项做设置。
  • 2、AbsoluteExpirationRelativeToNow用来设定缓存项的绝对过期时间。
  • 3、测试演示。
1
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);//缓存有效期10秒---绝对过期时间

滑动过期时间

  • 1、 ICacheEntry的SlidingExpiration属性用来设定缓存项的滑动过期时间。
  • 2、测试演示。
1
e.SlidingExpiration = TimeSpan.FromSeconds(10);//滑动过期时间

两种过期时间混用

  • 使用滑动过期时间策略,如果一个缓存项一直被频繁访问,那么这个缓存项就会一直被续期而不过期。可以对一个缓存项同时设定滑动过期时间和绝对过期时间,并且把绝对过期时间设定的比滑动过期时间长,这样缓存项的内容会在绝对过期时间内随着访问被滑动续期,但是一旦超过了绝对过期时间,缓存项就会被删除。
1
2
3
//混合使用
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);//绝对过期时间
e.SlidingExpiration = TimeSpan.FromSeconds(10);//滑动过期时间

内存缓存的是与非

  • 无论用哪种过期时间策略,程序中都会存在缓存数据不一致的情况。
    • 对于有些系统,这种数据不一致的情况是可以接受的。比如部分系统(博客等)无所谓
    • 但有些系统,这种延时是无法接受的,比如:金融,电商等。
      • 对于这种无法接受缓存延时的系统,如果对应的从数据源获取数据的频率不高的话,可以不用缓存;如果我们需要用缓存提升性能的话,可以通过其他机制获取数据源改变的消息,再通过代码调用IMemoryCache的Set方法更新缓存。

缓存穿透问题的规避

  • 在使用内存缓存的时候,如果处理不当,我们容易遇到“缓存穿透”的问题
    • 比如下面这段代码是可以正常执行的,但是存在一个缺陷
      • 对于大部分正常请求,客户端发送的ID都是存在的图书ID,因此可以被保存到缓存从而去降低数据库的压力;
      • 但如果有恶意访问者使用不存在的图书ID来发送大量的请求,这样的请求会一直执行查询数据库的代码,导致数据库就会承受非常大的压力,甚至可能会导致数据库服务器的崩溃,这种问题就叫作缓存穿透
  • 缓存穿透是由于“查询不到的数据用null表示”导致的,因此解决的思路也很简单,就是我们把“查不到”也当成数据放入缓存即可
    • 所以在日常开发中,我们建议使用GetOrCreateAsync方法即可解决这个问题
    • 因为这个方法会把null也当成合法的缓存值,这样就可以轻松规避缓存穿透的问题了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//不适当的缓存代码
public async Task<ActionResult<Book?>> GetBookById(long id)
{
string cacheKey = "Book" + id;//缓存键
Book? b = memoryCache.Get<Book?>(cacheKey);
if (b == null)//如果缓存中没有数据
{
//查询数据库,然后写入缓存
b = await dbCtx.Books.FindAsync(id);
memoryCache.Set(cacheKey, b);
}
if (b==null)
{
return NotFound("找不到这本书");

}
else
{
return b;
}
}

缓存雪崩问题的规避

缓存雪崩是什么?

  • 在使用缓存的时候,有时会有在很短时间内,程序把一大批数据从数据源加入缓存的情况。
    • 比如为了提升网站的运行速度,我们会对数据进行“预热”,也就是在网站启动的时候把一部分数据从数据库中读取出来并加入缓存当中。**(比如双11秒杀商品的时候)**
    • 如果这些数据设置的过期时间都相同,那么到了过期时间,缓存就会爆发性的集中过期,从而导致大量的数据库请求去获取数据,然后再次存到缓存中去。
    • 这个过程对数据库服务器来说,就会出现周期性的压力,这种陡增的压力甚至会把数据库服务器“压垮”(崩溃),当数据库服务器从崩溃中恢复后,这些压力就会再次的压过来,从而造成数据库服务器反复崩溃和恢复,这就是数据库服务器中的雪崩

解决雪崩

  • 在写缓存时,在基础过期时间之上,添加一个随机的过期时间
    • 这样缓存项的过期时间就会均匀地分布在一个时间段内,就不会出现缓存集中一个时间点全部过期的情况了
1
2
//new Random().Next();//.NET 6之前都是这样,很麻烦~
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));//过期时间随机

缓存数据混乱问题的规避

  • 在使用服务器端缓存的时候,如果处理不当,程序有可能造成缓存数据混乱等严重的问题。
    • 下面这段代码是用来获取当前登录用户详细信息的一段,其中存在数据泄漏的问题
    • 解析
      • 用“UserInfo”作为缓存键。当A用户访问代码的时候,把A用户的信息数据写到了缓存中;但当B用户访问代码的时候,网站会直接把缓存中的A用户信息数据返回给B用户,所以这就造成了B用户看到了A用户信息的数据泄漏问题
  • 解决这种问题的核心是要合理设置缓存的ID
    • 比如“UserInfo”+“userId” 这样组合作为缓存键就可以避免这样的问题发生
      1
      2
      3
      4
      5
      6
      7
      public User GetUserInfo()
      {
      Guid userid = ...;//获取当前用户ID
      return memoryCache.GetOrCreate("UserInfo", (e) => {
      return ctx.User.Find(userId);
      });
      }

案例:封装内存缓存操作的帮助类

1
2
3
4
5
6
7
8
9
10
11
12
13
public async Task<ActionResult<Book?>> Test2(long id)
{
var b = await memoryCacheHelper.GetOrCreateAsync("Book" + id, async (e) => { return await MyDbContext.GetByIdAsync(id); },10);
if (b==null)
{
return NotFound("不存在");
}
else
{
return b;

}
}

分布式缓存

为什么需要用分布式缓存?

  • 内存缓存
    • 优点是访问效率高、部署和运维简单,通常能满足大多数需求
      • 由于内存缓存把缓存数据保存在Web应用的内存中,因此数据的读写速度是非常快的
    • 缺点是不能跨集群节点来复用这些缓存的内容,所以当集群节点数量过多时,会导致数据库服务器压力仍然很大
      • 在分布式系统中,这些缓存数据是不能被共享的,因此集群中每个节点中的Web应用都要加载一份数据到自己的内存数据中去
      • 如果集群节点数量不多的话,这样的重复查询不会对数据库服务器造成太大压力,各个Web应用维护自己的内存缓存即可
      • 但是如果集群节点的数量非常多的话,这样的重复查询就有可能会把数据库服务器“压垮”。
        • 同时,如果缓存的数据量很大的话,它们占用的内存空间也会比较大,这样每台服务器都需要配置比较大的内存,也会间接性的增加服务器的硬件成本
    • 除非必要,否则不需要使用分布式缓存
  • 分布式缓存
    • 前提条件:由于节点数量太大,造成数据库压力非常大,才会引入分布式缓存
      • 分布式缓存只是在内存缓存无法满足需求的情况下,咱们才采用分布式缓存~

分布式缓存服务器

  • 当内存缓存不能满足需求的情况下,我们就需要把缓存数据保存到专门的缓存服务器中,所有的Web应用都通过缓存服务器进行缓存数据的写入和获取,这样的缓存服务器就叫作分布式缓存服务器
    • 由于缓存数据被保存到一台公共的服务器中,因此我们就可以实现集群中的所有服务器共享一份缓存,从而避免各个服务器重复加载数据到本地内存缓存的问题
    • 分布式
  • 1、常用的分布式缓存服务器有Redis、Memcached等。
  • 2、.NET Core中提供了统一的分布式缓存服务器的操作接口IDistributedCache,用法和内存缓存类似。
  • 3、分布式缓存和内存缓存的区别:缓存值的类型为byte[],需要我们进行类型转换,也提供了一些按照string类型存取缓存值的扩展方法。

用什么做缓存服务器

  • 1、用SQLServer做缓存性能并不好。
  • 2、Memcached是缓存专用,性能非常高,但是集群、高可用等方面比较弱,而且有“缓存键的最大长度为250字节”等限制。可以安装EnyimMemcachedCore这个第三方NuGet包。
  • 3、Redis不局限于缓存,Redis做缓存服务器比Memcached性能稍差,但是Redis的高可用、集群等方便非常强大,适合在数据量大、高可用性等场合使用。

分布式缓存用法

  • 1、NuGet安装Microsoft.Extensions.Caching.StackExchangeRedis
    • Install-Package Microsoft.Extensions.Caching.StackExchangeRedis
  • 2、
1
2
3
4
5
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost";
options.InstanceName = "Zane_";//避免混乱,连接Redis
});
  • 3、用时间显示测试用法。
    • Redis
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      [HttpGet]
      public String Now()//测试Redis
      {
      string? s = distCache.GetString("Now");
      if (s == null)
      {
      s = DateTime.Now.ToString();
      var opt = new DistributedCacheEntryOptions();
      opt.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
      distCache.SetString("Now", s, opt);
      }
      return s;
      }

案例:封装分布式缓存操作的帮助类

  • 分布式缓存同样有缓存穿透、缓存雪崩等问题,而且IDistributedCache方法中的缓存值只支持byte[]和string类型,需要开发人员进行其他类型的转换。
    • Redis没有缓存穿透的问题,因为它也把null当成一个值缓存了起来
  • 为了简化开发的工作量,杨中科老师封装了一个IDistributedCacheHelper的帮助类
    • 使用

缓存方式的选择

  • .NET中的缓存分为客户端响应缓存服务器端响应缓存内存缓存分布式缓存等。
    • 缓存可以极大地提升系统的性能,在进行系统设计的时候,*我们要根据系统的特点选择合适的缓存方式
  • 客户端响应缓存能够充分利用客户端的缓存机制,它不仅可以降低服务器端的压力,也能够提升客户端的操作响应速度并且降低客户端的网络流量,
    • 但是我们需要合理设置缓存相关参数,以避免客户端无法及时刷新到最新数据的问题
  • 服务器端响应缓存能够让我们几乎不需要编写额外的代码就轻松地降低服务器的压力。
    • 但是由于服务器端响应缓存的启用条件比较苛刻,因此要根据项目的情况觉得是否使用它
  • 内存环境/分布式缓存
    • 内存缓存能够降低数据库以及后端服务器的压力,而且内存缓存的存取速度非常快
    • 分布式缓存能够让集群中的多台服务器共享同一份缓存,从而降低数据源的压力。
    • 选择
      • 如果集群节点的数量不多,并且数据库服务器的压力不大的话,推荐使用内存缓存,比较内存的读写速度比网络快很多;如果集群节点太多造成数据库服务器的压力很大的话,可以采用分布式缓存
      • 无论是使用内存缓存还是分布式缓存,我们都要合理地设计缓存键,以免出现数据混乱。

总结

  • 上述的缓存方式并不是互斥的,我们在项目中可以组合使用它们。

P7-4 性能优化“万金油”:缓存
http://example.com/2024/10/07/Net Core2022教程/第7章:ASP.NET Core 基础组件/P7-4 性能优化“万金油”:缓存/
Author
John Doe
Posted on
October 7, 2024
Licensed under