透過C#單一實例實作Redis快取伺服器之存取與管理

Redis的高速與效能常常是快取伺服器的首選,這篇文章將透過程式碼來展示如何設計與存取這個快取資料庫。

  1. 首先,你必須要架設好一台Redis伺服器,且Listen於預設的6379 Port

  2. 選擇連線的程式庫:若以效能來考量ServiceStack.Redis的連線程式庫最好(目前版本是V8),但是他從V4版本後每小時只給你6000次存取,除非花錢否則只能降轉V3版本(如果你是屬於不在乎版權的人,網路上倒是有很多人分享暗黑版本)。考量到V3已經是上古時期的產物,只好將眼光投向效能稍差一些但完全免費的StackExchange.Redis,喜歡的人請自己去nuget拿(目前版本是V2.7.33)。

  3. 接下來你必須知道,在網站伺服器的世界裡面,併發要求(Concurrent Request)是絕對必須承認的環境,但又基於我們不可能讓網站後台或Redis伺服器無限制的開啟傳送、接收連線需求(將會讓TCP Ports耗盡),這時候就是單一實例(Singleton Pattern)的應用場景啦!類別程式碼如下:

public class Redis
{ //伺服器位址
  private static string _cRedisServer => $"x.x.x.x:6379";
  //伺服器密碼
  private static string _cRedisAuth => $"SlashviewPassword";
  //延遲建立:Redis連線
  private static StackExchange.Redis.ConnectionMultiplexer _oConn => _oLazyConn.Value;
  private static readonly System.Lazy<StackExchange.Redis.ConnectionMultiplexer> _oLazyConn = new System.Lazy<StackExchange.Redis.ConnectionMultiplexer>(() => {
    var oOptions = new StackExchange.Redis.ConfigurationOptions
    {
      EndPoints = { _cRedisServer },
      Password = _cRedisAuth,
      AbortOnConnectFail = false,
      AllowAdmin = true,
    };
    return StackExchange.Redis.ConnectionMultiplexer.Connect(oOptions);
  });

  //單一實例Singleton:取得伺服器
  private static StackExchange.Redis.IServer GetServer()
  {
    try
    { return _oConn.GetServer(_cRedisServer); }
    catch (System.Exception oEx)
    { throw new System.Exception($"伺服器連線失敗/{oEx.Message}"); }
  }

  //單一實例Singleton:取得資料庫
  private static StackExchange.Redis.IDatabase GetDatabase()
  {
    try
    {
      if (!_oConn.IsConnected)
      { throw new System.Exception($"連接資訊:{_oConn.Configuration}"); }
      return _oConn.GetDatabase();
    }
    catch (System.Exception oEx)
    { throw new System.Exception($"資料庫連線失敗/{oEx.Message}"); }
  }

  /// <summary>
  /// 管理方法:清除全部快取
  /// </summary>
  public static void Clear()
  {
    try
    {
      var oDB = GetServer();
      oDB.FlushDatabase();  //全部清除快取
      oDB.MemoryPurge();    //立即釋放資源
    }
    catch (System.Exception oEx)
    {
      throw new System.Exception($"Clear失敗");
    }
  }

  /// <summary>
  /// 使用方法:設定快取(字串)
  /// </summary>
  /// <param name="cKey">鍵名</param>
  /// <param name="cValue">值</param>
  /// <param name="iSecond">過期秒數</param>
  public static void Set(string cKey, string cValue, int? iSecond = null) => Set<string>(cKey, cValue, iSecond);

  /// <summary>
  /// 使用方法:設定快取(泛型)
  /// </summary>
  /// <typeparam name="T">泛型型別</typeparam>
  /// <param name="cKey">鍵名</param>
  /// <param name="tValue">值</param>
  /// <param name="iSecond">過期秒數</param>
  public static void Set<T>(string cKey, T tValue, int? iSecond = null)
  {
    try
    {
      var oDB = GetDatabase();
      if (tValue is string)
      { oDB.StringSet(cKey, tValue as string, iSecond == null ? null : System.TimeSpan.FromSeconds(iSecond.Value)); }
      else
      { oDB.StringSet(cKey, Newtonsoft.Json.JsonConvert.SerializeObject(tValue), iSecond == null ? null : System.TimeSpan.FromSeconds(iSecond.Value)); }
    }
    catch (System.Exception oEx)
    {
      throw new System.Exception($"Set失敗");
    }
  }

  /// <summary>
  /// 使用方法:取得快取(字串)
  /// </summary>
  /// <param name="cKey">鍵名</param>
  /// <returns>值</returns>
  public static string? Get(string cKey) => Get<string>(cKey);

  /// <summary>
  /// 使用方法:取得快取(泛型)
  /// </summary>
  /// <typeparam name="T">泛型型別</typeparam>
  /// <param name="cKey">鍵名</param>
  /// <returns>值</returns>
  public static T? Get<T>(string cKey)
  {
    try
    {
      var oDB = GetDatabase();
      if (!oDB.KeyExists(cKey))
      { return default(T); }
      var oValue = oDB.StringGet(cKey);
      if (oValue.IsNull || oValue.IsNullOrEmpty)
      { return default(T); }
      if (typeof(T) == typeof(string))
      { return (T)System.Convert.ChangeType(oValue, typeof(T)); }
      else
      { return Newtonsoft.Json.JsonConvert.DeserializeObject<T>(oValue); }
    }
    catch (System.Exception oEx)
    {
      throw new System.Exception($"Get失敗");
    }
  }

  /// <summary>
  /// 使用方法:刪除單一快取
  /// </summary>
  /// <param name="cKey">鍵名</param>
  public static void Delete(string cKey)
  {
    try
    {
      var oDB = GetDatabase();
      oDB.KeyDelete(cKey);
    }
    catch (System.Exception oEx)
    {
      throw new System.Exception($"Delete失敗");
    }

  }
}

考量在實際運作場景上對於程式設計師的便利性,因此在撰寫這個類別我最後採用的思路就是除了字串之外,其他無論是三小類型一律序列化伺候就對了。缺點除了效率會損失一些(存入取出都需要JSON.NET起來幫我們序列與反序列化)之外,撰寫時期的思考可以降至為零,不必再思考現在這個POCO、DTO Collections要用哪個特別的方法存取,或者是否有在Class加上[Serializable]。對付一般應用場景我覺得是很夠用了,如果最終遇到千萬筆導致讀寫效能過低時,頂多再手動調用StackExchange.Redis原始類別解決即可。

  1. 實際調用範例

文字型別的快取存取:

Redis.Set("testString", "這是一個文字字串 / Test string");
Console.WriteLine(Redis.Get("testString"));

//輸出:
這是一個文字字串 / Test string

其他資料集合型別的快取存取

//DTO
class MyData
{
  public string cName { get; set; }
  public int iMoney { get; set; }
}

//調用端
var oData = new System.Collections.Generic.List<MyData>();
for (int i = 0; i < 10; i++)
{ oData.Add(new MyData() { cName = i.ToString(), iMoney = i }); }

Redis.Set<System.Collections.Generic.List<MyData>>("testObject", oData);

foreach (var oItem in Redis.Get<System.Collections.Generic.List<MyData>>("testObject"))
{ Console.WriteLine($"Name: {oItem.cName} - ${oItem.iMoney}"); }

//輸出:
Name: 0 - $0
Name: 1 - $1
Name: 2 - $2
Name: 3 - $3
Name: 4 - $4
Name: 5 - $5
Name: 6 - $6
Name: 7 - $7
Name: 8 - $8
Name: 9 - $9

實際測試產生百萬筆資料等級的資料物件,讀寫本機Redis效率非常驚人(包含序列反序列化,寫入大約2秒多讀取大約1秒多)就完成,如果只是一般的常數等級的讀寫效率都是1ms上下就完成了,有這方面喜好的人可以自行嘗試看看。如果你試圖不顧一切透過set指令將Redis伺服器灌爆,那麼當到達記憶體極限後Linux會將Redis自動重啟(記憶體資料當然也一併洗掉),此時比較倒楣的是重啟過程中那些執行緒會觸發Exception,後續的執行緒起來讀寫是沒有問題的。

另外必須提醒一點就是數值的處理必須注意,貌似Redis只有實際支援到INT32等級的數據,若要更高位數的處理可能需要特別轉換,或乾脆直接先轉型成字串再取回自行手動轉型也可以,更複雜的狀況例如decimal、float就更未知了,因為那不是我本篇文章關心的重點,所以有用到的人請特別注意一下數值處理便是。

最後要提醒的是,本篇文章的類別是使用簡單的思維將所有的物件都序列化成文字送入Redis儲存,若要追求極致的效能Redis有針對各式物件、各種Collections進行特別的處理,若你的網站需要追求極致的效能,那麼應該針對特別的運用環境進行針對性的調用指令設計,而不是採用本文提供的粗糙做法。

相關連結

CSharp SingletonPattern Redis Cache Server Request Response Set Get