前言
眾所周知記憶體快取(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資料結構基類,例如CacheBaseString、CacheBaseSet、CacheBaseSortedSet、CacheBaseList...等
基類中已經實現好了對應資料結構通用的方法,例如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例項才能實現下面那些通用方法
/// <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