6

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

 2 years ago
source link: https://www.cnblogs.com/simendancer/p/17052784.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

众所周知内存缓存(MemoryCache)数据是从内存中获取,性能表现上是最优的,但是内存缓存有一个缺点就是不支持分布式,数据在各个部署节点上各存一份,每份缓存的过期时间不一致,会导致幻读等各种问题,所以我们实现分布式缓存通常会用上Redis

但如果在高并发的情况下读取Redis的缓存,会进行频繁的网络I/O,假如有一些不经常变动的热点缓存,这不就会白白浪费了带宽,并且读到数据以后可能还需要进行反序列化,还影响了CPU性能,造成资源的浪费

从Redis 6.0开始有一个重要特性就是支持客户端缓存(仅支持String类型),效果跟内存缓存是一样的,数据都是从内存中获取,如果服务端缓存数据发送变动,会在极短的时间内通知到所有客户端进行数据同步

在 .NetCore 环境中,我们常用的Redis组件是 StackExchangeRedis 和 CSRedisCore,但是都不支持6.0的客户端缓存这一特性,CSRedisCore 的作者在前两年又重新开发了一个叫 FreeRedis 的组件,并支持了客户端缓存

我们当时为了实现某个对性能有较高要求的产品需求,但不想额外增加硬件上的资源,急需使用上这一特性,在调研后发现了这个组件,经过测试后发现没什么问题就直接用上了

不过我们的主力组件还是CSRedisCore,FreeRedis基本只是用到了客户端缓存,因为当时的版本还不支持异步方法,我记得是今年才加上的

FreeRedis组件介绍原文,有关客户端缓存具体实现原理看看这篇就够了:FreeRedis

目前FreeRedis在我司项目中也已经稳定运行了一年多,这里分享一下我们在项目中的实际用法

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

546142-20230114230041415-998724208.png

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

第一种:在具体实现某个缓存的地方,才设置过滤条件

每次都得写一遍有点冗余,而且查看源码可以发现UseClientSideCaching这个方法每次都会实例一个叫ClientSideCachingContext的类,并在里面添加订阅、添加拦截器等一系列操作

这种方式我测试过,虽然每次都调用一下不影响最后客户端缓存效果,但RedisClient中的拦截器是一直在新增的,这上线后不得崩了?

所以意味具体业务实现代码中每次还实现一下不重复调用UseClientSideCaching的特殊逻辑,即使实现了,但每个不重复的Key都会往RedisClient新增一个拦截器,极力不推荐这种方式!

546142-20230114231505369-353294083.png

第二种:在同一个地方把所有需要进行本地缓存的键一口气设置好过滤条件

时间长了以后,这里会写得非常的长,非常的丑陋,而且你并不知道哪些键已经废弃以及对应的业务

当然项目是从头到尾是你一个人负责开发的或需要本地缓存的Key并不多的时候,这种方式其实也够了

546142-20230114231305264-444867777.png

第三种:所有用到客户端缓存的键约定好一个统一命名前缀,那么过滤条件这里只需要写一个 StartWith(命名前缀) 的条件就行了

需要给团队提前培训下这个注意项,但是时间长了以后,大伙完全不知道后面匹配的那么多键对应是什么业务

某些业务可能一口气需要用到了好几个缓存Key组合进行实现,但其中只有一个Key需要本地缓存,那么这个Key的前缀和其他Key的业务命名前缀就不统一了,虽然没什么问题,但是在客户端工具中查看键值时没放在一起,不利于查找

在Key不多且项目参与人数不多的情况下,用这个方式是最简单方便的

546142-20230114232730815-2017852730.png

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

扩展后

三种方式在我司项目中其实都不好用,我们项目中之前的所有缓存都是一个缓存实现对应一个缓存类,每个缓存类会继承一个对应该缓存用的Redis数据结构基类,例如CacheBaseStringCacheBaseSetCacheBaseSortedSetCacheBaseList...等

基类中已经实现好了对应数据结构通用的方法,例如CacheBaseString中已经实现了Get Set Del Expire这样的通用方法,在派生的缓存类中只要重写基类的抽象方法,设置下Key的命名缓存过期时间,一个缓存实现就结束了,这样便于管理和使用,团队的小伙伴几年来也都习惯了这种用法

所以基于这个要求,我们对FreeRedis的客户端缓存实现进行一下扩展,首先客户端缓存只支持String类型,所以就是再写一个String结构的ClientSideCacheBase就好了,最麻烦的就是如何优雅的统一实现Key的过滤条件

可以发现UseClientSideCaching中KeyFilter是个Lambda Func委托,返回一个布尔值

546142-20230115010317459-1551617218.png

那么我马上想到的就是表达式树,我们在各种高度封装的ORM中经常能看到使用表达式树去组装SQL的Where条件

同样的原理,我们也可以通过在项目启动时通过反射拿到所有派生类,并调用基类中的一个抽象方法,最后合并表达树,返回一个Func给这个KeyFilter

1. 首先我们先设计一下基类

其中核心的两个方法就是 Key的抽象过滤条件的抽象,其中的 FreeRedisService 是已经实现好的一个FreeRedisClient,需要在IOC容器中注入为单例,所以在这基类的构造函数中,必须传入IServiceProvider,从容器拿到FreeRedisService实例才能实现下面那些通用方法

    /// <summary>
    /// Redis6.0客户端缓存实现基类
    /// </summary>
    public abstract class ClienSideCacheBase
    {
        /// <summary>
        /// RedisService
        /// </summary>
        private static FreeRedisService _redisService;

        /// <summary>
        /// 获取RedisKey
        /// </summary>
        /// <returns></returns>
        protected abstract string GetRedisKey();

        /// <summary>
        /// 设置客户端缓存Key过滤条件
        /// </summary>
        /// <returns></returns>
        public abstract Expression<Func<string,bool>> SetCacheKeyFilter();

        /// <summary>
        /// 私有构造函数
        /// </summary>
        private ClienSideCacheBase() { }

        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="serviceProvider"></param>
        public ClienSideCacheBase(IServiceProvider serviceProvider)
        {
            _redisService = serviceProvider.GetService<FreeRedisService>();
        }

        /// <summary>
        /// 获取值
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <returns></returns>
        public T Get<T>()
        {
            return _redisService.Instance.Get<T>(GetRedisKey());
        }

        /// <summary>
        /// 设置值
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="data"></param>
        /// <returns></returns>
        public bool Set<T>(T data)
        {
            _redisService.Instance.Set(GetRedisKey(),data);
            return true;
        }

        /// <summary>
        /// 设置值
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="data"></param>
        /// <param name="seconds"></param>
        /// <returns></returns>
        public bool Set<T>(T data,int seconds)
        {
            _redisService.Instance.Set(GetRedisKey(),data,TimeSpan.FromSeconds(seconds));
            return true;
        }

        /// <summary>
        /// 设置值
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="data"></param>
        /// <param name="expired"></param>
        /// <returns></returns>
        public bool Set<T>(T data,TimeSpan expired)
        {
            _redisService.Instance.Set(GetRedisKey(),data,expired);
            return true;
        }

        /// <summary>
        /// 设置值
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="data"></param>
        /// <param name="expiredAt"></param>
        /// <returns></returns>
        public bool Set<T>(T data,DateTime expiredAt)
        {
            _redisService.Instance.Set(GetRedisKey(),data,TimeSpan.FromSeconds(expiredAt.Subtract(DateTime.Now).TotalSeconds));
            return true;
        }

        /// <summary>
        /// 设置过期时间
        /// </summary>
        /// <returns></returns>
        public bool SetExpire(int seconds)
        {
            return _redisService.Instance.Expire(GetRedisKey(),TimeSpan.FromSeconds(seconds));
        }

        /// <summary>
        /// 设置过期时间
        /// </summary>
        /// <returns></returns>
        public bool SetExpire(TimeSpan expired)
        {
            return _redisService.Instance.Expire(GetRedisKey(),expired);
        }

        /// <summary>
        /// 设置过期时间
        /// </summary>
        /// <returns></returns>
        public bool SetExpireAt(DateTime expiredTime)
        {
            return _redisService.Instance.ExpireAt(GetRedisKey(),expiredTime);
        }

        /// <summary>
        /// 移除缓存
        /// </summary>
        /// <returns></returns>
        public long Remove()
        {
            return _redisService.Instance.Del(GetRedisKey());
        }

        /// <summary>
        /// 缓存是否存在
        /// </summary>
        /// <returns></returns>
        public bool Exists()
        {
            return _redisService.Instance.Exists(GetRedisKey());
        }
    }

具体继承用法如下:

    /// <summary>
    /// 实现客户端缓存Demo1
    /// </summary>
    public class ClientSideDemoOneCache : ClienSideCacheBase
    {
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="serviceProvider"></param>
        public ClientSideDemoOneCache(IServiceProvider serviceProvider) : base(serviceProvider) { }

        /// <summary>
        /// 设置Key过滤规则
        /// </summary>
        /// <returns></returns>
        public override Expression<Func<string,bool>> SetCacheKeyFilter()
        {
            return o => o == GetRedisKey();
        }

        /// <summary>
        /// 获取缓存的Key
        /// </summary>
        /// <returns></returns>
        protected override string GetRedisKey()
        {
            return "DemoOneRedisKey";
        }
    }
    
    /// <summary>
    /// 实现客户端缓存Demo2
    /// </summary>
    public class ClientSideDemoTwoCache : ClienSideCacheBase
    {
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="serviceProvider"></param>
        public ClientSideDemoTwoCache(IServiceProvider serviceProvider) : base(serviceProvider) { }

        /// <summary>
        /// 设置Key过滤规则
        /// </summary>
        /// <returns></returns>
        public override Expression<Func<string,bool>> SetCacheKeyFilter()
        {
            return o => o.StartsWith(GetRedisKey());
        }

        /// <summary>
        /// 获取缓存的Key
        /// </summary>
        /// <returns></returns>
        protected override string GetRedisKey()
        {
            return "DemoTwoRedisKey";
        }
    }

2. FreeRedisService的实现

其中关键代码就是一次性设置好项目中所有本地缓存的过滤条件,FreeRedisService最终会注册为一个单例

    public class FreeRedisService
    {
        /// <summary>
        /// RedisClient
        /// </summary>
        private static RedisClient _redisClient;

        /// <summary>
        /// 初始化配置
        /// </summary>
        private FreeRedisOption _redisOption;

        /// <summary>
        /// 构造函数
        /// </summary>
        public FreeRedisService(FreeRedisOption redisOption)
        {
            if (redisOption == null) {
                throw new NullReferenceException("初始化配置为空");
            }
            _redisOption = redisOption;
            InitRedisClient();
        }

        /// <summary>
        /// 懒加载Redis客户端
        /// </summary>
        private readonly static Lazy<RedisClient> redisClientLazy = new Lazy<RedisClient>(() => {
            var r = _redisClient;
            r.Serialize = obj => JsonConvert.SerializeObject(obj);
            r.Deserialize = (json,type) => JsonConvert.DeserializeObject(json,type);
            r.Notice += (s,e) => Console.WriteLine(e.Log);
            return r;
        });

        private static readonly object obj = new object();

        /// <summary>
        /// 初始化Redis
        /// </summary>
        /// <returns></returns>
        bool InitRedisClient()
        {
            if (_redisClient == null) {
                lock (obj) {
                    if (_redisClient == null) {
                        _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");
                        //设置客户端缓存
                        if (_redisOption.UseClientSideCache) {
                            if (_redisOption.ClientSideCacheKeyFilter == null) {
                                throw new NullReferenceException("如果开启客户端缓存,必须设置客户端缓存Key过滤条件");
                            }
                            _redisClient.UseClientSideCaching(new ClientSideCachingOptions() {
                                Capacity = 0,  //本地缓存的容量,0不限制
                                KeyFilter = _redisOption.ClientSideCacheKeyFilter,  //过滤哪些键能被本地缓存
                                CheckExpired = (key,dt) => DateTime.Now.Subtract(dt) > TimeSpan.FromSeconds(3)  //检查长期未使用的缓存
                            });
                        }
                        return true;
                    }
                }
            }
            return _redisClient != null;
        }

        /// <summary>
        /// 获取Client实例
        /// </summary>
        public RedisClient Instance {
            get {
                if (InitRedisClient()) {
                    return redisClientLazy.Value;
                }
                throw new NullReferenceException("Redis不可用");
            }
        }
    }

3. 反射遍历获取所有过滤条件

我们写一个反射的方法,去遍历所有的缓存派生类,并调用其中重写过的过滤条件抽象方法,最后合并为一个表达式树,Or这个方法是一个自定义扩展方法,具体看Github完整项目

    /// <summary>
    /// 构建Redis客户端缓存Key条件
    /// </summary>
    public class ClientSideCacheKeyBuilder
    {
        /// <summary>
        /// 具体缓存业务实现所在项目程序集
        /// </summary>
        const string DefaultDllName = "Hy.Components.Api";

        /// <summary>
        /// 构建表达式树
        /// </summary>
        /// <param name="serviceProvider">serviceProvider</param>
        /// <param name="dllName">当前类所在的项目dll名</param>
        /// <returns></returns>
        public static Func<string,bool> Build(IServiceProvider serviceProvider,string dllName = DefaultDllName)
        {
            Expression<Func<string,bool>> expression = o => false; //默认false
            var baseClass = typeof(ClienSideCacheBase);
            Assembly ass = Assembly.LoadFrom($"{AppDomain.CurrentDomain.BaseDirectory}{dllName}.dll");
            Type[] types = ass.GetTypes();
            foreach (Type item in types) {
                if (item.IsInterface || item.IsEnum || item.GetCustomAttribute(typeof(ObsoleteAttribute)) != null) {
                    continue;
                }
                //判读基类
                if (item != null && item.BaseType == baseClass) {
                    var instance = (ClienSideCacheBase)Activator.CreateInstance(item,serviceProvider); //这里参数带入IServiceProvider纯粹为了创建实例不报错
                    var expr = instance.SetCacheKeyFilter();
                    expression = expression.Or(expr); //合并树
                }
            }
            return expression.Compile();
        }
    }

4. 将FreeRedis服务在IOC容器中注入

我们在项目启动时,调用上面的Build方法,将返回的Func委托传入到FreeRedisService中即可,这里我是写了一个IServiceCollection的扩展方法

    public static class ServiceCollectionExtensions
    {
        /// <summary>
        /// ServiceInject
        /// </summary>
        /// <param name="services"></param>
        public static void AddRedisService(this IServiceCollection services,IConfiguration configuration)
        {
            var clientCacheKeyFilter = ClientSideCacheKeyBuilder.Build(services.BuildServiceProvider()); //构造过滤条件
            var option = GetRedisOption(configuration,clientCacheKeyFilter); //组装Redis初始配置
            services.AddSingleton(c => new FreeRedisService(option)); //FreeRedis注入为单例
        }

        /// <summary>
        /// 获取配置
        /// </summary>
        /// <param name="configuration"></param>
        /// <param name="clientSideCacheKeyFilter"></param>
        /// <returns></returns>
        static FreeRedisOption GetRedisOption(IConfiguration configuration,Func<string,bool> clientSideCacheKeyFilter = null)
        {
            return new FreeRedisOption() {
                RedisHost = configuration.GetSection("Redis:RedisHost").Value,
                RedisPassword = configuration.GetSection("Redis:RedisPassword").Value,
                RedisPort = Convert.ToInt32(configuration.GetSection("Redis:RedisPort").Value),
                SyncTimeout = 5000,
                ConnectTimeout = 15000,
                DefaultIndex = 0,
                Poolsize = 5,
                UseClientSideCache = clientSideCacheKeyFilter != null,
                ClientSideCacheKeyFilter = clientSideCacheKeyFilter
            };
        }
    }

在项目IOC容器中注入,以下为.Net6的Program模板

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddHealthChecks();

//注入Redis服务
builder.Services.AddRedisService(builder.Configuration);

//可选:注入客户端缓存具体实现类。 如果实现有很多,这里会有一大堆注入代码。在代码中直接实例化类并传入IServiceProvider也一样的
builder.Services.AddSingleton<ClientSideDemoOneCache>();
builder.Services.AddSingleton<ClientSideDemoTwoCache>();

//构建WebApplication
var app = builder.Build();

app.UseAuthorization();

app.MapControllers();

app.UseHealthChecks("/health");

app.Run();

5. 最后看下我们在业务代码中的具体用法

其中的ClientSideDemoOneCache这个实例,我们可以通过直接实例化并传入IServiceProvider的方式使用,也可以通过构造函数注入,前提是在上面IOC容器中注入过了

    [ApiController]
    [Route("[controller]")]
    public class HomeController : ControllerBase
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IServiceProvider _serviceProvider;
        private readonly ClientSideDemoOneCache _clientSideDemoOneCache;

        public HomeController(ILogger<HomeController> logger,IServiceProvider serviceProvider,ClientSideDemoOneCache clientSideDemoOneCache)
        {
            _logger = logger;
            _serviceProvider = serviceProvider;
            _clientSideDemoOneCache = clientSideDemoOneCache;
        }

        #region 可通过启动不同端口的Api,分别调用以下接口对同一个Key进行操作,测试客户端缓存是否生效以及是否及时同步

        /// <summary>
        /// 测试get
        /// </summary>
        /// <returns></returns>
        [HttpGet, Route("getvalue")]
        public string TestGetValue()
        {
            ClientSideDemoOneCache cacheOne = new ClientSideDemoOneCache(_serviceProvider);
            //cacheOne = _clientSideDemoOneCache; //通过容器拿到实例
            var value = cacheOne.Get<string>();
            return value ?? "缓存空了";
        }

        /// <summary>
        /// 测试set
        /// </summary>
        /// <param name="value"></param>
        /// <returns></returns>
        [HttpGet, Route("setvalue")]
        public string TestSetValue([FromQuery] string value)
        {
            ClientSideDemoOneCache cacheOne = new ClientSideDemoOneCache(_serviceProvider);
            cacheOne.Set(value);
            return "OK";
        }

        /// <summary>
        /// 测试del
        /// </summary>
        /// <returns></returns>
        [HttpGet, Route("delvalue")]
        public string TestDelValue()
        {
            ClientSideDemoOneCache cacheOne = new ClientSideDemoOneCache(_serviceProvider);
            cacheOne.Remove();
            return "OK";
        }

        #endregion
    }

6. 单机测试

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

546142-20230115015719895-546098264.png

 

Redis客户端:

546142-20230115015759376-1579837431.png

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

546142-20230115015845319-881028107.png

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

546142-20230115020101704-38548995.png

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

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

546142-20230115021318643-595795150.png

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

546142-20230115021401081-248930921.png

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

546142-20230115021516382-1861241688.png

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

546142-20230115021755591-1707897827.png

以上的完整代码已经放到Github上查看完整代码

原创作者:Harry

原文出处:https://www.cnblogs.com/simendancer/articles/17052784.html


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK