在AspNetCore 中 使用Redis实现分布式缓存 (转载)
文章概念描述
分布式缓存描述:
分布式缓存重点是在分布式上,相信大家接触过的分布式有很多中,像分布式开发,分布式部署,分布式锁、事物、系统 等有很多。使我们对分布式本身就有一个很明确的认识,分布式就是有多个应用程序组成,可能分布在不同的服务器上,最终都是在为web端提供服务。
分布式缓存有以下几点优点:
- 所有的Web服务器上的缓存数据都是相同的,不会因为应用程序不同,服务器的不同导致缓存数据的不一样。
- 缓存的是独立的不受Web服务器的重新启动或被删除添加的影响,也就是说这些Web的改变不到导致缓存数据的改变。
传统的单体应用架构因为用户的访问量的不高,缓存的存在大多数都是存储用户的信息,以及一些页面,大多数的操作都是直接和DB进行读写交互,这种架构简单,也称为简单架构,
传统的OA项目比如ERP,SCM,CRM等系统因为用户量不大也是因为大多数公司业务的原因,单体应用架构还是很常用的架构,但是有些系统随着用户量的增加,业务的扩张扩展,导致DB的瓶颈的出现。
以下我所了解到的关于这种情况的处理有以下两种
(1):当用户访问量不大,但是读写的数据量很大的时候,我们一般采取的是,对DB进行读写分离、一主多从、对硬件进行升级的方式来解决DB瓶颈的问题。
这样的缺点也同样纯在:
1、用户量大的时候怎么办?,
2、对于性能的提升有限,
3、性价比不高。提升一点性能就需要花费很多代价,(打个比方,现在的I/O吞吐量是0.9的需要提升到1.0,我们在增加机器配置的情况下这个价格确实很可观的)
(2):当用户访问量也增加的时候,我们就需要引入缓存了来解决了,一张图描述缓存的大致的作用。
缓存主要针对的是不经常发生改变的并且访问量很大的数据,DB数据库可以理解为只作为数据固化的或者只用来读取经常发生改变的数据,上图中我没有画SET的操作,就是想特意说明一下,缓存的存在可以作为一个临时的数据库,我们可以通过定时的任务的方式去同步缓存和数据库中的数据,这样做的好处是可以转移数据库的压力到缓存中。
缓存的出现解决了数据库压力的问题,但是当以下情况发生的时候,缓存就不在起到作用了,缓存穿透、缓存击穿、缓存雪崩这三种情况。
缓存穿透:我们的程序中用缓存的时候一般采取的是先去缓存中查询我们想要的缓存数据,如果缓存中不存在我们想要的数据的话,缓存就失去了做用(缓存失效)我们就是需要伸手向DB库去要数据,这个时候这种动作过多数据库就崩溃了,这种情况需要我们去预防了。比如说:我们向缓存获取一个用户信息,但是故意去输入一个缓存中不存在的用户信息,这样就避过了缓存,把压力重新转移到数据上面了。对于这种问题我们可以采取,把第一次访问的数据进行缓存,因为缓存查不到用户信息,数据库也查询不到用户信息,这个时候避免重复的访问我们把这个请求缓存起来,把压力重新转向缓存中,有人会有疑问了,当访问的参数有上万个都是不重复的参数并且都是可以躲避缓存的怎么办,我们同样把数据存起来设置一个较短过期时间清理缓存。
缓存击穿:事情是这样的,对于一些设置了过期时间的缓存KEY,在过期的时候,程序被高并发的访问了(缓存失效),这个时候使用互斥锁来解决问题,
互斥锁原理:通俗的描述就是,一万个用户访问了,但是只有一个用户可以拿到访问数据库的权限,当这个用户拿到这个权限之后重新创建缓存,这个时候剩下的访问者因为没有拿到权限,就原地等待着去访问缓存。
永不过期:有人就会想了,我不设置过期时间不就行了吗?可以,但是这样做也是有缺点的,我们需要定期的取更新缓存,这个时候缓存中的数据比较延迟。
缓存雪崩:是指多种缓存设置了同一时间过期,这个时候大批量的数据访问来了,(缓存失效)数据库DB的压力又上来了。解决方法在设置过期时间的时候在过期时间的基础上增加一个随机数尽可能的保证缓存不会大面积的同事失效。
项目准备
1、首先安装Redis,可以参考这里
2、然后下载安装:客户端工具:RedisDesktopManager(方便管理)
3、在我们的项目Nuget中 引用 Microsoft.Extensions.Caching.Redis。注意,Microsoft.Extensions.Caching.Redis会被逐渐淘汰,微软发布了新的Nuget包Microsoft.Extensions.Caching.StackExchangeRedis来操作Redis,新的.NET Core项目推荐引用这个新的Nuget包,它的配置和老的Microsoft.Extensions.Caching.Redis稍有不同,本文末尾会提到。
为此我们新建一个ASP.NET Core MVC项目,在项目Startup类的ConfigureServices方法中先注册Redis服务:
public void ConfigureServices(IServiceCollection services) { //将Redis分布式缓存服务添加到服务中 services.AddDistributedRedisCache(options => { //用于连接Redis的配置 Configuration.GetConnectionString("RedisConnectionString")读取配置信息的串 options.Configuration = "localhost";// Configuration.GetConnectionString("RedisConnectionString"); //Redis实例名DemoInstance options.InstanceName = "DemoInstance"; }); services.AddMvc(); }
也可以在上面注册Redis服务的时候,指定Redis服务器的IP地址、端口号和登录密码:
public void ConfigureServices(IServiceCollection services) { //将Redis分布式缓存服务添加到服务中 services.AddDistributedRedisCache(options => { //用于连接Redis的配置 Configuration.GetConnectionString("RedisConnectionString")读取配置信息的串 options.Configuration = "192.168.1.105:6380,password=1qaz@WSX3edc$RFV";//指定Redis服务器的IP地址、端口号和登录密码 //Redis实例名DemoInstance options.InstanceName = "DemoInstance"; }); services.AddMvc(); }
后面我们会解释上面options.InstanceName设置的Redis实例名DemoInstance是用来做什么的
此外还可以在services.AddDistributedRedisCache方法中指定Redis服务器的超时时间,如果调用后面介绍的IDistributedCache接口中的方法,对Redis服务器进行的操作超时了,会抛出RedisConnectionException和RedisTimeoutException异常,所以下面我们在注册Redis服务的时候,指定了三个超时时间:
public void ConfigureServices(IServiceCollection services) { //将Redis分布式缓存服务添加到服务中 services.AddDistributedRedisCache(options => { options.ConfigurationOptions = new StackExchange.Redis.ConfigurationOptions() { Password = "1qaz@WSX3edc$RFV", ConnectTimeout = 5000,//设置建立连接到Redis服务器的超时时间为5000毫秒 SyncTimeout = 5000,//设置对Redis服务器进行同步操作的超时时间为5000毫秒 ResponseTimeout = 5000//设置对Redis服务器进行操作的响应超时时间为5000毫秒 }; options.ConfigurationOptions.EndPoints.Add("192.168.1.105:6380"); options.InstanceName = "DemoInstance"; }); services.AddMvc(); }
其中ConnectTimeout是建立连接到Redis服务器的超时时间,而SyncTimeout和ResponseTimeout是对Redis服务器进行数据操作的超时时间。注意上面我们使用了options.ConfigurationOptions属性来设置Redis服务器的IP地址、端口号和登录密码。
如果你使用的Redis服务是在公网上的,那么强烈建议使用SSL安全加密来传输Redis数据,这样数据就是加密后在公网上传输的,更安全。
我们可以在Startup类的ConfigureServices方法中配置使用SSL来传输Redis数据。
我们可以通过Redis连接字符串来配置SSL:
public void ConfigureServices(IServiceCollection services) { //将Redis分布式缓存服务添加到服务中 services.AddDistributedRedisCache(options => { //用于连接Redis的配置 Configuration.GetConnectionString("RedisConnectionString")读取配置信息的串 options.Configuration = "192.168.1.105:6380,password=1qaz@WSX3edc$RFV,ssl=True,abortConnect=False"; //指定Redis服务器的IP地址、端口号和登录密码,并且启用SSL安全加密传输Redis数据 //Redis实例名DemoInstance options.InstanceName = "DemoInstance"; }); services.AddMvc(); }
注意连接字符串中的"ssl=True"就是设置Redis启用SSL安全加密传输数据。
我们也可以通过options.ConfigurationOptions属性来配置SSL:
public void ConfigureServices(IServiceCollection services) { //将Redis分布式缓存服务添加到服务中 services.AddDistributedRedisCache(options => { options.ConfigurationOptions = new StackExchange.Redis.ConfigurationOptions() { Password = "1qaz@WSX3edc$RFV", ConnectTimeout = 5000,//设置建立连接到Redis服务器的超时时间为5000毫秒 SyncTimeout = 5000,//设置对Redis服务器进行同步操作的超时时间为5000毫秒 ResponseTimeout = 5000,//设置对Redis服务器进行操作的响应超时时间为5000毫秒 Ssl = true//设置启用SSL安全加密传输Redis数据 //SslProtocols = System.Security.Authentication.SslProtocols.Tls//还可以通过SslProtocols属性指定SSL具体用到的是什么协议,不过这个属性不是必须的 }; options.ConfigurationOptions.EndPoints.Add("192.168.1.105:6380"); options.InstanceName = "DemoInstance"; }); services.AddMvc(); }
这次我们是通过options.ConfigurationOptions属性,设置ConfigurationOptions类的Ssl属性为true,来启用SSL安全加密来传输Redis数据。
IDistributedCache 接口
在项目中引用:using Microsoft.Extensions.Caching.Distributed; 使用IDistributedCache
IDistributedCache接口包含同步和异步方法。 接口允许在分布式缓存实现中添加、检索和删除项。 IDistributedCache接口包含以下方法:
Get、 GetAsync
采用字符串键并以byte[]形式检索缓存项(如果在缓存中找到)。如果传入Get方法的键在Redis中不存在,那么Get方法会返回null。
Set、SetAsync
使用字符串键向缓存添加或更改项(byte[]形式)。
Refresh、RefreshAsync
根据键刷新缓存中的项,并重置其滑动过期超时值(如果有)。如果传入Refresh方法的键在Redis中不存在,Refresh方法不会报错,只是什么都不会发生而已,但是如果传入Refresh方法的参数为null,则会抛出异常。
Remove、RemoveAsync
根据键删除缓存项。如果传入Remove方法的键在Redis中不存在,Remove方法不会报错,只是什么都不会发生而已,但是如果传入Remove方法的参数为null,则会抛出异常。
如上所述,由于IDistributedCache接口的Set和Get方法,是通过byte[]字节数组来向Redis存取数据的,所以从某种意义上来说不是很方便,下面我封装了一个RedisCache类,可以向Redis中存取任何类型的数据。
其中用到了Json.NET Nuget包,来做Json格式的序列化和反序列化:
using Microsoft.Extensions.Caching.Distributed; using Newtonsoft.Json; using System.Text; namespace AspNetCoreRedis.Assembly { /// <summary> /// RedisCache缓存操作类 /// </summary> public class RedisCache { protected IDistributedCache cache; /// <summary> /// 通过IDistributedCache来构造RedisCache缓存操作类 /// </summary> /// <param name="cache">IDistributedCache对象</param> public RedisCache(IDistributedCache cache) { this.cache = cache; } /// <summary> /// 添加或更改Redis的键值,并设置缓存的过期策略 /// </summary> /// <param name="key">缓存键</param> /// <param name="value">缓存值</param> /// <param name="distributedCacheEntryOptions">设置Redis缓存的过期策略,可以用其设置缓存的绝对过期时间(AbsoluteExpiration或AbsoluteExpirationRelativeToNow),也可以设置缓存的滑动过期时间(SlidingExpiration)</param> public void Set(string key, object value, DistributedCacheEntryOptions distributedCacheEntryOptions) { //通过Json.NET序列化缓存对象为Json字符串 //调用JsonConvert.SerializeObject方法时,设置ReferenceLoopHandling属性为ReferenceLoopHandling.Ignore,来避免Json.NET序列化对象时,因为对象的循环引用而抛出异常 //设置TypeNameHandling属性为TypeNameHandling.All,这样Json.NET序列化对象后的Json字符串中,会包含序列化的类型,这样可以保证Json.NET在反序列化对象时,去读取Json字符串中的序列化类型,从而得到和序列化时相同的对象类型 var stringObject = JsonConvert.SerializeObject(value, new JsonSerializerSettings() { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, TypeNameHandling = TypeNameHandling.All }); var bytesObject = Encoding.UTF8.GetBytes(stringObject);//将Json字符串通过UTF-8编码,序列化为字节数组 cache.Set(key, bytesObject, distributedCacheEntryOptions);//将字节数组存入Redis Refresh(key);//刷新Redis } /// <summary> /// 查询键值是否在Redis中存在 /// </summary> /// <param name="key">缓存键</param> /// <returns>true:存在,false:不存在</returns> public bool Exist(string key) { var bytesObject = cache.Get(key);//从Redis中获取键值key的字节数组,如果没获取到,那么会返回null if (bytesObject == null) { return false; } return true; } /// <summary> /// 从Redis中获取键值 /// </summary> /// <typeparam name="T">缓存的类型</typeparam> /// <param name="key">缓存键</param> /// <param name="isExisted">是否获取到键值,true:获取到了,false:键值不存在</param> /// <returns>缓存的对象</returns> public T Get<T>(string key, out bool isExisted) { var bytesObject = cache.Get(key);//从Redis中获取键值key的字节数组,如果没获取到,那么会返回null if (bytesObject == null) { isExisted = false; return default(T); } var stringObject = Encoding.UTF8.GetString(bytesObject);//通过UTF-8编码,将字节数组反序列化为Json字符串 isExisted = true; //通过Json.NET反序列化Json字符串为对象 //调用JsonConvert.DeserializeObject方法时,也设置TypeNameHandling属性为TypeNameHandling.All,这样可以保证Json.NET在反序列化对象时,去读取Json字符串中的序列化类型,从而得到和序列化时相同的对象类型 return JsonConvert.DeserializeObject<T>(stringObject, new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }); } /// <summary> /// 从Redis中删除键值,如果键值在Redis中不存在,该方法不会报错,只是什么都不会发生 /// </summary> /// <param name="key">缓存键</param> public void Remove(string key) { cache.Remove(key);//如果键值在Redis中不存在,IDistributedCache.Remove方法不会报错,但是如果传入的参数key为null,则会抛出异常 } /// <summary> /// 从Redis中刷新键值 /// </summary> /// <param name="key">缓存键</param> public void Refresh(string key) { cache.Refresh(key); } } }
使用测试
然后我们在ASP.NET Core MVC项目中,新建一个CacheController,然后在其Index方法中来测试RedisCache类的相关方法:
public class CacheController : Controller { protected RedisCache redisCache; //由于我们前面在Startup类的ConfigureServices方法中调用了services.AddDistributedRedisCache来注册Redis服务,所以ASP.NET Core MVC会自动依赖注入下面的IDistributedCache cache参数 public CacheController(IDistributedCache cache) { redisCache = new RedisCache(cache); } public IActionResult Index() { bool isExisted; isExisted = redisCache.Exist("abc");//查询键值"abc"是否存在 redisCache.Remove("abc");//删除不存在的键值"abc",不会报错 string key = "Key01";//定义缓存键"Key01" string value = "This is a demo key !";//定义缓存值 redisCache.Set(key, value, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });//设置键值"Key01"到Redis,使用绝对过期时间,AbsoluteExpirationRelativeToNow设置为当前系统时间10分钟后过期 //也可以通过AbsoluteExpiration属性来设置绝对过期时间为一个具体的DateTimeOffset时间点 //redisCache.Set(key, value, new DistributedCacheEntryOptions() //{ // AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(10) //});//设置键值"Key01"到Redis,使用绝对过期时间,AbsoluteExpiration设置为当前系统时间10分钟后过期 var getVaue = redisCache.Get<string>(key, out isExisted);//从Redis获取键值"Key01",可以看到getVaue的值为"This is a demo key !" value = "This is a demo key again !";//更改缓存值 redisCache.Set(key, value, new DistributedCacheEntryOptions() { SlidingExpiration = TimeSpan.FromMinutes(10) });//将更改后的键值"Key01"再次缓存到Redis,这次使用滑动过期时间,SlidingExpiration设置为10分钟 getVaue = redisCache.Get<string>(key, out isExisted);//再次从Redis获取键值"Key01",可以看到getVaue的值为"This is a demo key again !" //下面我们来演示如何将键值"Key01"在Redis中设置为永不过期 redisCache.Remove(key);//首先要从Redis中删除键值"Key01",因为.NET Core无法将一个有绝对过期时间或滑动过期时间的键值在Redis中设置为永不过期,我们只能将键值"Key01"先从Redis中删除,然后重新添加键值"Key01"到Redis redisCache.Set(key, value, new DistributedCacheEntryOptions() { AbsoluteExpiration = null, AbsoluteExpirationRelativeToNow = null, SlidingExpiration = null });//在添加键值"Key01"到Redis时,只要将DistributedCacheEntryOptions类的AbsoluteExpiration、AbsoluteExpirationRelativeToNow、SlidingExpiration三个属性都设置为null,那么键值"Key01"在Redis中就没有过期时间,也就是永不过期。 //这时可以在redis-cli中,使用指令:"ttl <键名称>",查看到键值"Key01"的剩余时间为-1,表示没有过期时间,永不过期。 redisCache.Set(key, value, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) });//虽然.NET Core无法将Redis中有绝对过期时间或滑动过期时间的键值设置为永不过期,但是却可以将一个永不过期的键值设置为有绝对过期时间或滑动过期时间,取消永不过期,只需调用Set方法,将DistributedCacheEntryOptions类的AbsoluteExpiration、AbsoluteExpirationRelativeToNow、SlidingExpiration三个属性之一赋值即可。 redisCache.Remove(key);//从Redis中删除键值"Key01" return View(); } }
我们可以使用"ttl"指令在redis-cli中,查看键值在Redis中还有多久过期,格式如下:
ttl <键名称>
该指令会返回一个整数,表示键值还有多少秒到过期时间,此外"ttl"指令还会返回两个负数,表示:
- -1, 表示键值没有过期时间,即该键值在Redis中永不过期。
- -2, 表示键值在Redis中不存在。
"ttl"指令的运行效果如下所示:
注意,如果DistributedCacheEntryOptions类的AbsoluteExpirationRelativeToNow、AbsoluteExpiration、SlidingExpiration属性,使得Redis缓存的过期时间小于当前系统时间,那么IDistributedCache接口的Set方法会抛出ArgumentOutOfRangeException异常,如下代码所示:
public class CacheController : Controller { protected RedisCache redisCache; public CacheController(IDistributedCache cache) { redisCache = new RedisCache(cache); } public IActionResult Index() { string key = "Key01";//定义缓存键"Key01" string value = "This is a demo key !";//定义缓存值 try { redisCache.Set(key, value, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(-1) }); } catch (ArgumentOutOfRangeException) { //因为DistributedCacheEntryOptions的AbsoluteExpirationRelativeToNow属性,导致Redis缓存的过期时间小于当前系统时间,IDistributedCache.Set方法抛出ArgumentOutOfRangeException异常 } try { redisCache.Set(key, value, new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(-1) }); } catch (ArgumentOutOfRangeException) { //因为DistributedCacheEntryOptions的AbsoluteExpiration属性,导致Redis缓存的过期时间小于当前系统时间,IDistributedCache.Set方法抛出ArgumentOutOfRangeException异常 } try { redisCache.Set(key, value, new DistributedCacheEntryOptions() { SlidingExpiration = TimeSpan.FromMinutes(-1) }); } catch (ArgumentOutOfRangeException) { //因为DistributedCacheEntryOptions的SlidingExpiration属性,导致Redis缓存的过期时间小于当前系统时间,IDistributedCache.Set方法抛出ArgumentOutOfRangeException异常 } return View(); } }
前面我们在项目的Startup类ConfigureServices方法中,调用services.AddDistributedRedisCache注册Redis服务的时候,有设置options.InstanceName = "DemoInstance",那么这个InstanceName到底有什么用呢?
当我们在上面的CacheController中调用Index方法的下面代码后:
string key = "Key01";//定义缓存键"Key01" string value = "This is a demo key !";//定义缓存值 redisCache.Set(key, value, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });//设置键值"Key01"到Redis,使用绝对过期时间,AbsoluteExpirationRelativeToNow设置为当前系统时间10分钟后过期
我们使用redis-cli登录到Redis服务器中,使用Keys *指令查看当前Redis服务中存储的所有键时,可以看到结果如下:
可以看到虽然我们代码中向Redis存入的键是"Key01",但是实际上在Redis服务中存储的键是"DemoInstanceKey01",所以实际上真正存入Redis服务中的键是“InstanceName+键”这种组合键,因此我们可以通过设置不同的InstanceName来为不同的Application在Redis中做数据隔离,这就是InstanceName的作用
使用新的Microsoft.Extensions.Caching.StackExchangeRedis包
根据微软最新的规划,本文前面讨论的Microsoft.Extensions.Caching.Redis这个Nuget包会被逐渐淘汰,不再更新。微软开发了一个新的Nuget包Microsoft.Extensions.Caching.StackExchangeRedis来在.NET Core中支持对Redis的操作。
这个新的Nuget包适用本文介绍的所有方法,除了在ASP.NET Core的Startup类中,在ConfigureServices方法中对其进行配置时稍有不同,我们使用Microsoft.Extensions.Caching.StackExchangeRedis包后,要调用新的AddStackExchangeRedisCache方法对Redis进行配置:
// This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddStackExchangeRedisCache(options => { options.InstanceName = "DemoInstance"; options.ConfigurationOptions = new StackExchange.Redis.ConfigurationOptions() { Password = "1qaz@WSX3edc$RFV", ConnectTimeout = 10000, SyncTimeout = 10000, Ssl = true, SslProtocols = System.Security.Authentication.SslProtocols.Tls //ResponseTimeout = 10000 //注意,ResponseTimeout属性在Microsoft.Extensions.Caching.StackExchangeRedis中已经被弃用 }; options.ConfigurationOptions.EndPoints.Add("192.168.1.105:6380"); }); services.AddControllersWithViews(); }
这个新的配置方法,和本文前面介绍的配置项大同小异,使用起来还是很方便。
关于Microsoft.Extensions.Caching.Redis和Microsoft.Extensions.Caching.StackExchangeRedis这两个Nuget包的区别,可以参考这篇帖子: