前言 眾所周知記憶體緩存(MemoryCache)數據是從記憶體中獲取,性能表現上是最優的,但是記憶體緩存有一個缺點就是不支持分散式,數據在各個部署節點上各存一份,每份緩存的過期時間不一致,會導致幻讀等各種問題,所以我們實現分散式緩存通常會用上Redis 但如果在高併發的情況下讀取Redis的緩存,會進行 ...
前言
眾所周知記憶體緩存(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結構的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