.Net 開源項目 FreeRedis 實現思路之 – Redis 6.0 客戶端快取技術

寫在開頭

FreeRedis 是一款繼 CSRedisCore 之後重寫的 .NET redis 客戶端開源組件,以 MIT 協議開源託管於 github,目前支援 .NET 5、.NETCore 2.1+、.NETFramework 4.0+、Xamarin,有可能已經支援 AOT 編譯(目前未測試,但會往這個方向走)。

  • 🌈 所有方法名與 redis-cli 保持一致
  • 🌌 支援 Redis 集群(服務端要求 3.2 及以上版本)
  • ⛳ 支援 Redis 哨兵模式
  • 🎣 支援主從分離(Master-Slave)
  • 📡 支援發布訂閱(Pub-Sub)
  • 📃 支援 Redis Lua 腳本
  • 💻 支援管道(Pipeline)
  • 📰 支援事務
  • 🌴 支援 GEO 命令(服務端要求 3.2 及以上版本)
  • 🌲 支援 STREAM 類型命令(服務端要求 5.0 及以上版本)
  • ⚡ 支援本地快取(Client-side-cahing,服務端要求 6.0 及以上版本)
  • 🌳 支援 Redis 6 的 RESP3 協議

github: //github.com/2881099/FreeRedis

了解 Redis

Redis是一個開源的使用C語言編寫、開源、支援網路、可基於記憶體亦可持久化的日誌型、高性能的Key-Value資料庫,並提供多種語言的API。它通常被稱為 數據結構伺服器 ,因為值(value)可以是 字元串(string)、哈希(map)、 列表(list)、集合(sets)、有序集合(sorted sets)、地理位置(Geo)、消息列隊(Streams)等類型。

與其他 key – value 快取產品有以下三個特點:

  • Redis支援數據的持久化,可以將記憶體中的數據保持在磁碟中,重啟的時候可以再次載入進行使用。
  • Redis不僅僅支援簡單的key-value類型的數據,同時還提供list,set,zset,hash等數據結構的存儲。
  • Redis支援數據的備份,即master-slave模式的數據備份。

優勢:

  • 性能極高 – Redis能讀的速度是110000次/s,寫的速度是81000次/s 。
  • 豐富的數據類型 – Redis支援二進位案例的 Strings, Lists, Hashes, Sets, Ordered Sets, Geo, Streams 數據類型操作。
  • 原子 – Redis的所有操作都是原子性的,同時Redis還支援對幾個操作全並後的原子性執行。
  • 豐富的特性 – Redis還支援 publish/subscribe, 通知, key 過期等等特性。

本文看點

Redis 6.0 是一個可期的版本,增加了 RESP3.0 協議,ACL 許可權控制,從原有的單執行緒改為多執行緒(性能提升2-3倍)等諸多更新。今天向大家介紹他的另一個重要特性:客戶端快取技術,講解如何落設計在 .NET 中。

為什麼需要客戶端快取?

我們都知道,使用 Redis 進行數據的快取的主要目的是減少對 MySQL 等資料庫的訪問,提供更快的訪問速度,畢竟 《Redis in Action》中提到的, Redis 的性能大致是普通關係型資料庫的 10 ~ 100 倍。

所以,如下圖所示,Redis 用來存儲熱點數據,Redis 未命中,再去訪問資料庫,這樣可以應付大多數情況下的性能要求。

但是,Redis 也有其性能上限,並且訪問 Redis 必然有一定的網路 I/O 以及序列化反序列化損耗。所以,往往會引入進程快取,將最熱的數據存儲在本地,進一步加快訪問速度。

如上圖所示,Guava Cache 等進程快取作為一級快取,Redis 作為二級快取:

  • 先去 Guava Cache 中查詢數據,如果命中則直接返回。
  • Guava Cache 中未命中,則再去 Redis 中查詢,如果命中則返回數據,並在 Guava Cache 中設置此數據。
  • Redis 也未命中的話,只有去 MySQL 中查詢,然後依次將數據設置到 Redis 和 Guava Cache 中。

只使用 Redis 分散式快取時,遇到數據更新時,應用程式更新完 MySQL 中的數據,可以直接將 Redis 中對應快取失效掉,保持數據的一致性。

而進程內快取的數據一致性比分散式的快取面臨更大的挑戰。數據更新的時候,如何通知其他進程也更新自己的快取呢?

如果按照分散式快取的思路,我們可以設置極短的快取失效時間,這樣不必實現複雜的通知機制。

但是不同進程內的數據依然會面臨不一致的問題,並且不同進程快取失效時間不統一,同一個請求到了不同的進程,可能出現反覆幻讀的情況。

落地分析

如上當 key 失效的時候,Redis 6.0 提供了三種模式通知客戶端,普通模式、廣播模式、轉發模式。

1、普通模式

普通模式依賴 RESP3.0 協議,需要在連接成功時使用 hello 命令開啟 RESP3.0 模式。

hello 3

client tracking on

落地在 .NET 之中時,我們必然是使用連接池技術,那麼每個連接都必須使用以上的兩個命令,此時每個連接是一個循環讀的操作,如下:

while (true)
{
    var msg = await redisSocket.ReceiveAsync(); //等待 key 失效的通知
}

本來我們可以比較簡單的這樣執行命令:

await redisSocket.SendAsync("GET key1");
await redisSocket.ReceiveAsync(); //讀取響應的結果

可以看出來,兩段程式碼同時讀,會導致讀取的結果錯亂。如何解決還需要三思,而我們 PASS 了這種模式。


2、廣播模式

廣播模式和普通模式差不多,都需要依賴 RESP3.0 協議。這種方式下 Redis 服務端不再消耗過多記憶體存儲資訊,而是發送更多的失效消息給客戶端。

與普通模式必須獲取一次鍵的規則不同,廣播模式下,只要鍵被修改或刪除,符合規則的客戶端都會收到失效消息,而且是可以多次獲取的

與普通模式相比,雖然少存儲了一些數據,但是由於需要對前綴規則進行匹配,會消耗一定的 CPU 資源,所以注意別使用過長的前綴。

廣播模式和普通模式一樣,需要解決命令同時讀取的問題(請見上面的兩段程式碼)。


3、轉發模式

Redis 為了兼容 RESP2 協議提供了轉發(Redirect)模式,不再使用 RESP3 原生支援 PUSH 消息,而是將消息通過 Pub/Sub 通知給另外一個客戶端,具體流程如下圖所示。

public void Start()
{
    //訂閱 __redis__:invalidate
    _sub = _cli.Subscribe("__redis__:invalidate", InValidate) as IPubSubSubscriber;

    //攔截快取
    _cli.Interceptors.Add(() => new MemoryCacheAop(this));

    //當網路斷開的時候,清空本地快取
    _cli.Unavailable += (_, e) =>
    {
        lock (_dictLock) _dictSort.Clear(); 
        _dict.Clear();
    };
    _cli.Connected += (_, e) =>
    {
        //最關鍵的一個命令,否則 __redis__:invalidate 無法收到訂閱消息
        e.Client.ClientTracking(true, _sub.RedisSocket.ClientId, null, false, false, false, false);
    };
}

void InValidate(string chan, object msg)
{
    var keys = msg as object[];
    foreach (var key in keys) //移除本地快取
        RemoveCache(string.Concat(key));
}

MemoryCacheAop 是 FreeRedis 已經實現好的攔截器,主要實現攔截命令執行,獲取本地記憶體。完整程式碼://github.com/2881099/FreeRedis/blob/master/src/FreeRedis/ClientSideCaching.cs

測試功能

static Lazy<RedisClient> _cliLazy = new Lazy<RedisClient>(() =>
{
    var r = new RedisClient("192.168.164.10:6379,database=1"); //redis 6.0
    r.Serialize = obj => JsonConvert.SerializeObject(obj);
    r.Deserialize = (json, type) => JsonConvert.DeserializeObject(json, type);
    r.Notice += (s, e) => Console.WriteLine(e.Log);
    return r;
});
static RedisClient cli => _cliLazy.Value;

static void Main(string[] args)
{
    cli.UseClientSideCaching(new ClientSideCachingOptions
    {
        //本地快取的容量
        Capacity = 3,
        //過濾哪些鍵能被本地快取
        KeyFilter = key => key.StartsWith("Interceptor"),
        //檢查長期未使用的快取
        CheckExpired = (key, dt) => DateTime.Now.Subtract(dt) > TimeSpan.FromSeconds(2)
    });

    cli.Set("Interceptor01", "123123"); //redis-server

    var val1 = cli.Get("Interceptor01"); //redis-server
    var val2 = cli.Get("Interceptor01"); //本地
    var val3 = cli.Get("Interceptor01"); //斷點等3秒,redis-server

    cli.Set("Interceptor01", "234567"); //redis-server
    var val4 = cli.Get("Interceptor01"); //redis-server
    var val5 = cli.Get("Interceptor01"); //本地

    var val6 = cli.MGet("Interceptor01", "Interceptor02", "Interceptor03"); //redis-server
    var val7 = cli.MGet("Interceptor01", "Interceptor02", "Interceptor03"); //本地
    var val8 = cli.MGet("Interceptor01", "Interceptor02", "Interceptor03"); //本地

    cli.MSet("Interceptor01", "Interceptor01Value", "Interceptor02", "Interceptor02Value", "Interceptor03", "Interceptor03Value");  //redis-server
    var val9 = cli.MGet("Interceptor01", "Interceptor02", "Interceptor03");  //redis-server
    var val10 = cli.MGet("Interceptor01", "Interceptor02", "Interceptor03");  //本地

    //以下 KeyFilter 返回 false,從而不使用本地快取
    cli.Set("123Interceptor01", "123123"); //redis-server

    var val11 = cli.Get("123Interceptor01"); //redis-server
    var val12 = cli.Get("123Interceptor01"); //redis-server
    var val23 = cli.Get("123Interceptor01"); //redis-server
    Console.ReadKey();
}

cli.Notice 事件在控制台輸出內容:

Not connected 代表沒有經過 redis-server


192.168.164.10:6379   > CLIENT TRACKING ON REDIRECT 46
FreeRedis.RedisResult
(0ms)

192.168.164.10:6379   > SET Interceptor01 123123
OK
(24ms)

192.168.164.10:6379   > GET Interceptor01
123123
(2ms)

Not connected         > GET Interceptor01
123123
(0ms)

192.168.164.10:6379   > GET Interceptor01
123123
(0ms)

192.168.164.10:6379   > SET Interceptor01 234567
OK
(0ms)

192.168.164.10:6379   > GET Interceptor01
234567
(0ms)

Not connected         > GET Interceptor01
234567
(0ms)

192.168.164.10:6379   > MGET Interceptor01 Interceptor02 Interceptor03
[234567, Interceptor02Value, Interceptor03Value]
(0ms)

Not connected         > MGET Interceptor01 Interceptor02 Interceptor03
[234567, Interceptor02Value, Interceptor03Value]
(0ms)

Not connected         > MGET Interceptor01 Interceptor02 Interceptor03
[234567, Interceptor02Value, Interceptor03Value]
(0ms)

192.168.164.10:6379   > MSET Interceptor01 Interceptor01Value Interceptor02 Interceptor02Value Interceptor03 Interceptor03Value
False
(3ms)

192.168.164.10:6379   > MGET Interceptor01 Interceptor02 Interceptor03
[Interceptor01Value, Interceptor02Value, Interceptor03Value]
(1ms)

Not connected         > MGET Interceptor01 Interceptor02 Interceptor03
[Interceptor01Value, Interceptor02Value, Interceptor03Value]
(0ms)

192.168.164.10:6379   > SET 123Interceptor01 123123
OK
(0ms)

192.168.164.10:6379   > GET 123Interceptor01
123123
(0ms)

192.168.164.10:6379   > GET 123Interceptor01
123123
(0ms)

192.168.164.10:6379   > GET 123Interceptor01
123123
(0ms)

寫在最後

FreeRedis 是一款繼 CSRedisCore 之後重寫的 .NET redis 客戶端開源組件,以 MIT 協議開源託管於 github,目前支援 .NET 5、.NETCore 2.1+、.NETFramework 4.0+、Xamarin,有可能已經支援 AOT 編譯(目前未測試,但會往這個方向走)。

github: //github.com/2881099/FreeRedis

謝謝支援!!