写在开头

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: https://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 未命中,再去访问数据库,这样可以应付大多数情况下的性能要求。

.Net 开源项目 FreeRedis 实现思路之 - Redis 6.0 客户端缓存技术-LMLPHP

但是,Redis 也有其性能上限,并且访问 Redis 必然有一定的网络 I/O 以及序列化反序列化损耗。所以,往往会引入进程缓存,将最热的数据存储在本地,进一步加快访问速度。

.Net 开源项目 FreeRedis 实现思路之 - Redis 6.0 客户端缓存技术-LMLPHP

如上图所示,Guava Cache 等进程缓存作为一级缓存,Redis 作为二级缓存:

  • 先去 Guava Cache 中查询数据,如果命中则直接返回。
  • Guava Cache 中未命中,则再去 Redis 中查询,如果命中则返回数据,并在 Guava Cache 中设置此数据。
  • Redis 也未命中的话,只有去 MySQL 中查询,然后依次将数据设置到 Redis 和 Guava Cache 中。

只使用 Redis 分布式缓存时,遇到数据更新时,应用程序更新完 MySQL 中的数据,可以直接将 Redis 中对应缓存失效掉,保持数据的一致性。

而进程内缓存的数据一致性比分布式的缓存面临更大的挑战。数据更新的时候,如何通知其他进程也更新自己的缓存呢?

如果按照分布式缓存的思路,我们可以设置极短的缓存失效时间,这样不必实现复杂的通知机制。

但是不同进程内的数据依然会面临不一致的问题,并且不同进程缓存失效时间不统一,同一个请求到了不同的进程,可能出现反复幻读的情况。

.Net 开源项目 FreeRedis 实现思路之 - Redis 6.0 客户端缓存技术-LMLPHP

落地分析

如上当 key 失效的时候,Redis 6.0 提供了三种模式通知客户端,普通模式、广播模式、转发模式。

1、普通模式

普通模式依赖 RESP3.0 协议,需要在连接成功时使用 hello 命令开启 RESP3.0 模式。

落地在 .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 通知给另外一个客户端,具体流程如下图所示。

.Net 开源项目 FreeRedis 实现思路之 - Redis 6.0 客户端缓存技术-LMLPHP

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 已经实现好的拦截器,主要实现拦截命令执行,获取本地内存。完整代码:https://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 事件在控制台输出内容:


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: https://github.com/2881099/FreeRedis

谢谢支持!!

11-25 11:41