.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: 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 未命中,再去访问数据库,这样可以应付大多数情况下的性能要求。
但是,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 已经实现好的拦截器,主要实现拦截命令执行,获取本地内存。完整代码: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 事件在控制台输出内容:
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: https://github.com/2881099/FreeRedis
谢谢支持!!