翼度科技»论坛 编程开发 .net 查看内容

.NetCore下基于FreeRedis实现的Redis6.0客户端缓存之缓存键条件优雅过滤

8

主题

8

帖子

24

积分

新手上路

Rank: 1

积分
24
前言

众所周知内存缓存(MemoryCache)数据是从内存中获取,性能表现上是最优的,但是内存缓存有一个缺点就是不支持分布式,数据在各个部署节点上各存一份,每份缓存的过期时间不一致,会导致幻读等各种问题,所以我们实现分布式缓存通常会用上Redis
但如果在高并发的情况下读取Redis的缓存,会进行频繁的网络I/O,假如有一些不经常变动的热点缓存,这不就会白白浪费了带宽,并且读到数据以后可能还需要进行反序列化,还影响了CPU性能,造成资源的浪费
从Redis 6.0开始有一个重要特性就是支持客户端缓存(仅支持String类型),效果跟内存缓存是一样的,数据都是从内存中获取,如果服务端缓存数据发送变动,会在极短的时间内通知到所有客户端进行数据同步
在 .NetCore 环境中,我们常用的Redis组件是 StackExchangeRedis 和 CSRedisCore,但是都不支持6.0的客户端缓存这一特性,CSRedisCore 的作者在前两年又重新开发了一个叫 FreeRedis 的组件,并支持了客户端缓存
我们当时为了实现某个对性能有较高要求的产品需求,但不想额外增加硬件上的资源,急需使用上这一特性,在调研后发现了这个组件,经过测试后发现没什么问题就直接用上了
不过我们的主力组件还是CSRedisCore,FreeRedis基本只是用到了客户端缓存,因为当时的版本还不支持异步方法,我记得是今年才加上的
FreeRedis组件介绍原文,有关客户端缓存具体实现原理看看这篇就够了:FreeRedis
目前FreeRedis在我司项目中也已经稳定运行了一年多,这里分享一下我们在项目中的实际用法
 
扩展前

为什么要改造?因为当看过官方的Demo以后,其中让我比较难受的是本地缓存键的过滤条件设置

 
我想到的有三种方式配置这个条件

第一种:在具体实现某个缓存的地方,才设置过滤条件
缺点:
每次都得写一遍有点冗余,而且查看源码可以发现UseClientSideCaching这个方法每次都会实例一个叫ClientSideCachingContext的类,并在里面添加订阅、添加拦截器等一系列操作
这种方式我测试过,虽然每次都调用一下不影响最后客户端缓存效果,但RedisClient中的拦截器是一直在新增的,这上线后不得崩了?
所以意味具体业务实现代码中每次还实现一下不重复调用UseClientSideCaching的特殊逻辑,即使实现了,但每个不重复的Key都会往RedisClient新增一个拦截器,极力不推荐这种方式!

 
第二种:在同一个地方把所有需要进行本地缓存的键一口气设置好过滤条件
缺点:
时间长了以后,这里会写得非常的长,非常的丑陋,而且你并不知道哪些键已经废弃以及对应的业务
当然项目是从头到尾是你一个人负责开发的或需要本地缓存的Key并不多的时候,这种方式其实也够了

 
第三种:所有用到客户端缓存的键约定好一个统一命名前缀,那么过滤条件这里只需要写一个 StartWith(命名前缀) 的条件就行了
缺点:
需要给团队提前培训下这个注意项,但是时间长了以后,大伙完全不知道后面匹配的那么多键对应是什么业务
某些业务可能一口气需要用到了好几个缓存Key组合进行实现,但其中只有一个Key需要本地缓存,那么这个Key的前缀和其他Key的业务命名前缀就不统一了,虽然没什么问题,但是在客户端工具中查看键值时没放在一起,不利于查找
在Key不多且项目参与人数不多的情况下,用这个方式是最简单方便的

 
 三种方式在实现好用程度上排个序: 第三种 > 第二种 > 第一种
 
扩展后

三种方式在我司项目中其实都不好用,我们项目中之前的所有缓存都是一个缓存实现对应一个缓存类,每个缓存类会继承一个对应该缓存用的Redis数据结构基类,例如CacheBaseStringCacheBaseSetCacheBaseSortedSetCacheBaseList...等
基类中已经实现好了对应数据结构通用的方法,例如CacheBaseString中已经实现了Get Set Del Expire这样的通用方法,在派生的缓存类中只要重写基类的抽象方法,设置下Key的命名缓存过期时间,一个缓存实现就结束了,这样便于管理和使用,团队的小伙伴几年来也都习惯了这种用法
所以基于这个要求,我们对FreeRedis的客户端缓存实现进行一下扩展,首先客户端缓存只支持String类型,所以就是再写一个String结构的ClienSideCacheBase就好了,最麻烦的就是如何优雅的统一实现Key的过滤条件
可以发现UseClientSideCaching中KeyFilter是个Lambda Func委托,返回一个布尔值

 
那么我马上想到的就是表达式树,我们在各种高度封装的ORM中经常能看到使用表达式树去组装SQL的Where条件
同样的原理,我们也可以通过在项目启动时通过反射拿到所有派生类,并调用基类中的一个抽象方法,最后合并表达树,返回一个Func给这个KeyFilter
1. 首先我们先设计一下基类

其中核心的两个方法就是 Key的抽象过滤条件的抽象,其中的 FreeRedisService 是已经实现好的一个FreeRedisClient,需要在IOC容器中注入为单例,所以在这基类的构造函数中,必须传入IServiceProvider,从容器拿到FreeRedisService实例才能实现下面那些通用方法
  1.     /// <summary>
  2.     /// Redis6.0客户端缓存实现基类
  3.     /// </summary>
  4.     public abstract class ClienSideCacheBase
  5.     {
  6.         /// <summary>
  7.         /// RedisService
  8.         /// </summary>
  9.         private static FreeRedisService _redisService;
  10.         /// <summary>
  11.         /// 获取RedisKey
  12.         /// </summary>
  13.         /// <returns></returns>
  14.         protected abstract string GetRedisKey();
  15.         /// <summary>
  16.         /// 设置客户端缓存Key过滤条件
  17.         /// </summary>
  18.         /// <returns></returns>
  19.         public abstract Expression<Func<string,bool>> SetCacheKeyFilter();
  20.         /// <summary>
  21.         /// 私有构造函数
  22.         /// </summary>
  23.         private ClienSideCacheBase() { }
  24.         /// <summary>
  25.         /// 构造函数
  26.         /// </summary>
  27.         /// <param name="serviceProvider"></param>
  28.         public ClienSideCacheBase(IServiceProvider serviceProvider)
  29.         {
  30.             _redisService = serviceProvider.GetService<FreeRedisService>();
  31.         }
  32.         /// <summary>
  33.         /// 获取值
  34.         /// </summary>
  35.         /// <typeparam name="T"></typeparam>
  36.         /// <returns></returns>
  37.         public T Get<T>()
  38.         {
  39.             return _redisService.Instance.Get<T>(GetRedisKey());
  40.         }
  41.         /// <summary>
  42.         /// 设置值
  43.         /// </summary>
  44.         /// <typeparam name="T"></typeparam>
  45.         /// <param name="data"></param>
  46.         /// <returns></returns>
  47.         public bool Set<T>(T data)
  48.         {
  49.             _redisService.Instance.Set(GetRedisKey(),data);
  50.             return true;
  51.         }
  52.         /// <summary>
  53.         /// 设置值
  54.         /// </summary>
  55.         /// <typeparam name="T"></typeparam>
  56.         /// <param name="data"></param>
  57.         /// <param name="seconds"></param>
  58.         /// <returns></returns>
  59.         public bool Set<T>(T data,int seconds)
  60.         {
  61.             _redisService.Instance.Set(GetRedisKey(),data,TimeSpan.FromSeconds(seconds));
  62.             return true;
  63.         }
  64.         /// <summary>
  65.         /// 设置值
  66.         /// </summary>
  67.         /// <typeparam name="T"></typeparam>
  68.         /// <param name="data"></param>
  69.         /// <param name="expired"></param>
  70.         /// <returns></returns>
  71.         public bool Set<T>(T data,TimeSpan expired)
  72.         {
  73.             _redisService.Instance.Set(GetRedisKey(),data,expired);
  74.             return true;
  75.         }
  76.         /// <summary>
  77.         /// 设置值
  78.         /// </summary>
  79.         /// <typeparam name="T"></typeparam>
  80.         /// <param name="data"></param>
  81.         /// <param name="expiredAt"></param>
  82.         /// <returns></returns>
  83.         public bool Set<T>(T data,DateTime expiredAt)
  84.         {
  85.             _redisService.Instance.Set(GetRedisKey(),data,TimeSpan.FromSeconds(expiredAt.Subtract(DateTime.Now).TotalSeconds));
  86.             return true;
  87.         }
  88.         /// <summary>
  89.         /// 设置过期时间
  90.         /// </summary>
  91.         /// <returns></returns>
  92.         public bool SetExpire(int seconds)
  93.         {
  94.             return _redisService.Instance.Expire(GetRedisKey(),TimeSpan.FromSeconds(seconds));
  95.         }
  96.         /// <summary>
  97.         /// 设置过期时间
  98.         /// </summary>
  99.         /// <returns></returns>
  100.         public bool SetExpire(TimeSpan expired)
  101.         {
  102.             return _redisService.Instance.Expire(GetRedisKey(),expired);
  103.         }
  104.         /// <summary>
  105.         /// 设置过期时间
  106.         /// </summary>
  107.         /// <returns></returns>
  108.         public bool SetExpireAt(DateTime expiredTime)
  109.         {
  110.             return _redisService.Instance.ExpireAt(GetRedisKey(),expiredTime);
  111.         }
  112.         /// <summary>
  113.         /// 移除缓存
  114.         /// </summary>
  115.         /// <returns></returns>
  116.         public long Remove()
  117.         {
  118.             return _redisService.Instance.Del(GetRedisKey());
  119.         }
  120.         /// <summary>
  121.         /// 缓存是否存在
  122.         /// </summary>
  123.         /// <returns></returns>
  124.         public bool Exists()
  125.         {
  126.             return _redisService.Instance.Exists(GetRedisKey());
  127.         }
  128.     }
复制代码
具体继承用法如下:
  1.     /// <summary>
  2.     /// 实现客户端缓存Demo1
  3.     /// </summary>
  4.     public class ClientSideDemoOneCache : ClienSideCacheBase
  5.     {
  6.         /// <summary>
  7.         /// 构造函数
  8.         /// </summary>
  9.         /// <param name="serviceProvider"></param>
  10.         public ClientSideDemoOneCache(IServiceProvider serviceProvider) : base(serviceProvider) { }
  11.         /// <summary>
  12.         /// 设置Key过滤规则
  13.         /// </summary>
  14.         /// <returns></returns>
  15.         public override Expression<Func<string,bool>> SetCacheKeyFilter()
  16.         {
  17.             return o => o == GetRedisKey();
  18.         }
  19.         /// <summary>
  20.         /// 获取缓存的Key
  21.         /// </summary>
  22.         /// <returns></returns>
  23.         protected override string GetRedisKey()
  24.         {
  25.             return "DemoOneRedisKey";
  26.         }
  27.     }
  28.    
  29.     /// <summary>
  30.     /// 实现客户端缓存Demo2
  31.     /// </summary>
  32.     public class ClientSideDemoTwoCache : ClienSideCacheBase
  33.     {
  34.         /// <summary>
  35.         /// 构造函数
  36.         /// </summary>
  37.         /// <param name="serviceProvider"></param>
  38.         public ClientSideDemoTwoCache(IServiceProvider serviceProvider) : base(serviceProvider) { }
  39.         /// <summary>
  40.         /// 设置Key过滤规则
  41.         /// </summary>
  42.         /// <returns></returns>
  43.         public override Expression<Func<string,bool>> SetCacheKeyFilter()
  44.         {
  45.             return o => o.StartsWith(GetRedisKey());
  46.         }
  47.         /// <summary>
  48.         /// 获取缓存的Key
  49.         /// </summary>
  50.         /// <returns></returns>
  51.         protected override string GetRedisKey()
  52.         {
  53.             return "DemoTwoRedisKey";
  54.         }
  55.     }
复制代码
 
2. FreeRedisService的实现

其中关键代码就是一次性设置好项目中所有本地缓存的过滤条件,FreeRedisService最终会注册为一个单例
  1.     public class FreeRedisService
  2.     {
  3.         /// <summary>
  4.         /// RedisClient
  5.         /// </summary>
  6.         private static RedisClient _redisClient;
  7.         /// <summary>
  8.         /// 初始化配置
  9.         /// </summary>
  10.         private FreeRedisOption _redisOption;
  11.         /// <summary>
  12.         /// 构造函数
  13.         /// </summary>
  14.         public FreeRedisService(FreeRedisOption redisOption)
  15.         {
  16.             if (redisOption == null) {
  17.                 throw new NullReferenceException("初始化配置为空");
  18.             }
  19.             _redisOption = redisOption;
  20.             InitRedisClient();
  21.         }
  22.         /// <summary>
  23.         /// 懒加载Redis客户端
  24.         /// </summary>
  25.         private readonly static Lazy<RedisClient> redisClientLazy = new Lazy<RedisClient>(() => {
  26.             var r = _redisClient;
  27.             r.Serialize = obj => JsonConvert.SerializeObject(obj);
  28.             r.Deserialize = (json,type) => JsonConvert.DeserializeObject(json,type);
  29.             r.Notice += (s,e) => Console.WriteLine(e.Log);
  30.             return r;
  31.         });
  32.         private static readonly object obj = new object();
  33.         /// <summary>
  34.         /// 初始化Redis
  35.         /// </summary>
  36.         /// <returns></returns>
  37.         bool InitRedisClient()
  38.         {
  39.             if (_redisClient == null) {
  40.                 lock (obj) {
  41.                     if (_redisClient == null) {
  42.                         _redisClient = new RedisClient($"{_redisOption.RedisHost}:{_redisOption.RedisPort},password={_redisOption.RedisPassword},defaultDatabase={_redisOption.DefaultIndex},poolsize={_redisOption.Poolsize},ssl=false,writeBuffer=10240,prefix={_redisOption.Prefix},asyncPipeline={_redisOption.asyncPipeline},connectTimeout={_redisOption.ConnectTimeout},abortConnect=false");
  43.                         //设置客户端缓存
  44.                         if (_redisOption.UseClientSideCache) {
  45.                             if (_redisOption.ClientSideCacheKeyFilter == null) {
  46.                                 throw new NullReferenceException("如果开启客户端缓存,必须设置客户端缓存Key过滤条件");
  47.                             }
  48.                             _redisClient.UseClientSideCaching(new ClientSideCachingOptions() {
  49.                                 Capacity = 0,  //本地缓存的容量,0不限制
  50.                                 KeyFilter = _redisOption.ClientSideCacheKeyFilter,  //过滤哪些键能被本地缓存
  51.                                 CheckExpired = (key,dt) => DateTime.Now.Subtract(dt) > TimeSpan.FromSeconds(3)  //检查长期未使用的缓存
  52.                             });
  53.                         }
  54.                         return true;
  55.                     }
  56.                 }
  57.             }
  58.             return _redisClient != null;
  59.         }
  60.         /// <summary>
  61.         /// 获取Client实例
  62.         /// </summary>
  63.         public RedisClient Instance {
  64.             get {
  65.                 if (InitRedisClient()) {
  66.                     return redisClientLazy.Value;
  67.                 }
  68.                 throw new NullReferenceException("Redis不可用");
  69.             }
  70.         }
  71.     }
复制代码
 
3. 反射遍历获取所有过滤条件

我们写一个反射的方法,去遍历所有的缓存派生类,并调用其中重写过的过滤条件抽象方法,最后合并为一个表达式树,Or这个方法是一个自定义扩展方法,具体看Github完整项目
  1.     /// <summary>
  2.     /// 构建Redis客户端缓存Key条件
  3.     /// </summary>
  4.     public class ClientSideCacheKeyBuilder
  5.     {
  6.         /// <summary>
  7.         /// 具体缓存业务实现所在项目程序集
  8.         /// </summary>
  9.         const string DefaultDllName = "Hy.Components.Api";
  10.         /// <summary>
  11.         /// 构建表达式树
  12.         /// </summary>
  13.         /// <param name="serviceProvider">serviceProvider</param>
  14.         /// <param name="dllName">当前类所在的项目dll名</param>
  15.         /// <returns></returns>
  16.         public static Func<string,bool> Build(IServiceProvider serviceProvider,string dllName = DefaultDllName)
  17.         {
  18.             Expression<Func<string,bool>> expression = o => false; //默认false
  19.             var baseClass = typeof(ClienSideCacheBase);
  20.             Assembly ass = Assembly.LoadFrom($"{AppDomain.CurrentDomain.BaseDirectory}{dllName}.dll");
  21.             Type[] types = ass.GetTypes();
  22.             foreach (Type item in types) {
  23.                 if (item.IsInterface || item.IsEnum || item.GetCustomAttribute(typeof(ObsoleteAttribute)) != null) {
  24.                     continue;
  25.                 }
  26.                 //判读基类
  27.                 if (item != null && item.BaseType == baseClass) {
  28.                     var instance = (ClienSideCacheBase)Activator.CreateInstance(item,serviceProvider); //这里参数带入IServiceProvider纯粹为了创建实例不报错
  29.                     var expr = instance.SetCacheKeyFilter();
  30.                     expression = expression.Or(expr); //合并树
  31.                 }
  32.             }
  33.             return expression.Compile();
  34.         }
  35.     }
复制代码
 
4. 将FreeRedis服务在IOC容器中注入

我们在项目启动时,调用上面的Build方法,将返回的Func委托传入到FreeRedisService中即可,这里我是写了一个IServiceCollection的扩展方法
  1.     public static class ServiceCollectionExtensions
  2.     {
  3.         /// <summary>
  4.         /// ServiceInject
  5.         /// </summary>
  6.         /// <param name="services"></param>
  7.         public static void AddRedisService(this IServiceCollection services,IConfiguration configuration)
  8.         {
  9.             var clientCacheKeyFilter = ClientSideCacheKeyBuilder.Build(services.BuildServiceProvider()); //构造过滤条件
  10.             var option = GetRedisOption(configuration,clientCacheKeyFilter); //组装Redis初始配置
  11.             services.AddSingleton(c => new FreeRedisService(option)); //FreeRedis注入为单例
  12.         }
  13.         /// <summary>
  14.         /// 获取配置
  15.         /// </summary>
  16.         /// <param name="configuration"></param>
  17.         /// <param name="clientSideCacheKeyFilter"></param>
  18.         /// <returns></returns>
  19.         static FreeRedisOption GetRedisOption(IConfiguration configuration,Func<string,bool> clientSideCacheKeyFilter = null)
  20.         {
  21.             return new FreeRedisOption() {
  22.                 RedisHost = configuration.GetSection("Redis:RedisHost").Value,
  23.                 RedisPassword = configuration.GetSection("Redis:RedisPassword").Value,
  24.                 RedisPort = Convert.ToInt32(configuration.GetSection("Redis:RedisPort").Value),
  25.                 SyncTimeout = 5000,
  26.                 ConnectTimeout = 15000,
  27.                 DefaultIndex = 0,
  28.                 Poolsize = 5,
  29.                 UseClientSideCache = clientSideCacheKeyFilter != null,
  30.                 ClientSideCacheKeyFilter = clientSideCacheKeyFilter
  31.             };
  32.         }
  33.     }
复制代码
在项目IOC容器中注入,以下为.Net6的Program模板
  1. var builder = WebApplication.CreateBuilder(args);
  2. builder.Services.AddControllers();
  3. builder.Services.AddHealthChecks();
  4. //注入Redis服务
  5. builder.Services.AddRedisService(builder.Configuration);
  6. //可选:注入客户端缓存具体实现类。 如果实现有很多,这里会有一大堆注入代码。在代码中直接实例化类并传入IServiceProvider也一样的
  7. builder.Services.AddSingleton<ClientSideDemoOneCache>();
  8. builder.Services.AddSingleton<ClientSideDemoTwoCache>();
  9. //构建WebApplication
  10. var app = builder.Build();
  11. app.UseAuthorization();
  12. app.MapControllers();
  13. app.UseHealthChecks("/health");
  14. app.Run();
复制代码
 
5. 最后看下我们在业务代码中的具体用法

其中的ClientSideDemoOneCache这个实例,我们可以通过直接实例化并传入IServiceProvider的方式使用,也可以通过构造函数注入,前提是在上面IOC容器中注入过了
  1.     [ApiController]
  2.     [Route("[controller]")]
  3.     public class HomeController : ControllerBase
  4.     {
  5.         private readonly ILogger<HomeController> _logger;
  6.         private readonly IServiceProvider _serviceProvider;
  7.         private readonly ClientSideDemoOneCache _clientSideDemoOneCache;
  8.         public HomeController(ILogger<HomeController> logger,IServiceProvider serviceProvider,ClientSideDemoOneCache clientSideDemoOneCache)
  9.         {
  10.             _logger = logger;
  11.             _serviceProvider = serviceProvider;
  12.             _clientSideDemoOneCache = clientSideDemoOneCache;
  13.         }
  14.         #region 可通过启动不同端口的Api,分别调用以下接口对同一个Key进行操作,测试客户端缓存是否生效以及是否及时同步
  15.         /// <summary>
  16.         /// 测试get
  17.         /// </summary>
  18.         /// <returns></returns>
  19.         [HttpGet, Route("getvalue")]
  20.         public string TestGetValue()
  21.         {
  22.             ClientSideDemoOneCache cacheOne = new ClientSideDemoOneCache(_serviceProvider);
  23.             //cacheOne = _clientSideDemoOneCache; //通过容器拿到实例
  24.             var value = cacheOne.Get<string>();
  25.             return value ?? "缓存空了";
  26.         }
  27.         /// <summary>
  28.         /// 测试set
  29.         /// </summary>
  30.         /// <param name="value"></param>
  31.         /// <returns></returns>
  32.         [HttpGet, Route("setvalue")]
  33.         public string TestSetValue([FromQuery] string value)
  34.         {
  35.             ClientSideDemoOneCache cacheOne = new ClientSideDemoOneCache(_serviceProvider);
  36.             cacheOne.Set(value);
  37.             return "OK";
  38.         }
  39.         /// <summary>
  40.         /// 测试del
  41.         /// </summary>
  42.         /// <returns></returns>
  43.         [HttpGet, Route("delvalue")]
  44.         public string TestDelValue()
  45.         {
  46.             ClientSideDemoOneCache cacheOne = new ClientSideDemoOneCache(_serviceProvider);
  47.             cacheOne.Remove();
  48.             return "OK";
  49.         }
  50.         #endregion
  51.     }
复制代码
 
6. 单机测试

1. 启动项目看一下,先设置一个值,可以看到在Redis中已经添加成功
 
Redis客户端:

 
2. 再获取一下值,成功拿到

 
3. 再次刷新一下,我们看下打印出来的日志,可以发现第一次是从服务端取值,第二次显示从本地取值,说明过滤条件已经生效了

 
 7. 在本机开启两个Api服务,模拟分布式测试

1. 通过2个不同的端口启动两个Api服务,可以看到目前拿到都是同一个值

2. 我们通过其中一个服务修改一下值,发现另外一台马上就变化了

3. 再次刷新一下getvalue接口,看下日志,发现第一次的值222222是从服务端获取,第二次又是从本地获取了

4. 接着我们再通过其中一个服务,删掉这个Key,发现另一个服务马上就获取不到值了

 
以上的完整代码已经放到Github上查看完整代码
 
原创作者:Harry
原文出处:https://www.cnblogs.com/simendancer/articles/17052784.html

来源:https://www.cnblogs.com/simendancer/archive/2023/01/15/17052784.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具