.NetCore下基於FreeRedis實現的Redis6.0使用者端快取之快取鍵條件優雅過濾

2023-01-15 06:00:30

前言

眾所周知記憶體快取(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結構的ClientSideCacheBase就好了,最麻煩的就是如何優雅的統一實現Key的過濾條件

可以發現UseClientSideCaching中KeyFilter是個Lambda Func委託,返回一個布林值

 

那麼我馬上想到的就是表示式樹,我們在各種高度封裝的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中已經新增成功

 

Redis使用者端:

 

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

 

3. 再次重新整理一下,我們看下列印出來的紀錄檔,可以發現第一次是從伺服器端取值,第二次顯示從本地取值,說明過濾條件已經生效了

 

 7. 在本機開啟兩個Api服務,模擬分散式測試

1. 通過2個不同的埠啟動兩個Api服務,可以看到目前拿到都是同一個值

2. 我們通過其中一個服務修改一下值,發現另外一邊馬上就變化了

3. 再次重新整理一下getvalue介面,看下紀錄檔,發現第一次的值222222是從伺服器端獲取,第二次又是從本地獲取了

4. 接著我們再通過其中一個服務,刪掉這個Key,發現另一邊馬上就獲取不到值了

 

以上的完整程式碼已經放到Github上檢視完整程式碼

 

原創作者:Harry

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