众所周知,计算机科学中只有两个难题:缓存失效和命名。这篇文章,正如你从它的名字中猜到的,是关于第一个问题:缓存失效问题。我写这篇文章是因为Redis 6中的一个新特性使客户端更容易管理本地缓存。这个小项目的源文件可以在rsacsc-py库中找到。
前言
我不打算强调缓存本身的重要性。实际上,我们在计算机工程的各个方面都在缓存数据,以减少随后访问数据所需的时间。我们使用多级缓存,并将继续这样做,直到有人把功能改为了使用即时通讯的即时更新。
Redis是一个远程服务器,意味着它存储的任何数据都可以通过网络访问。从Redis客户端的角度来看,网络延迟通常是总延迟的最大贡献者。通过缓存以前的响应来避免重复调用可以减轻这种情况。
Redis使用键值对数据存储模型,它将一个唯一的标识符映射到每个数据结构和其中的数据。因为数据访问总是按键名称进行的,所以Redis客户端很容易将其各自的数据存储在本地以用于缓存。但这带来了使缓存失效的难题。
服务器辅助客户端缓存是Redis版本6中新增的一项功能。它旨在通过让服务器发送失效通知来帮助管理本地缓存。服务器追踪客户端访问的键,并在这些键发生改变时通知客户端。
因为Redis服务器辅助的客户端缓存(简称RSACSC)在第一个候选版本中确实有些不成熟,所以我想将其用于实际的测试。我们的想法是通过事实的证明,以便更好地了解现有的功能和欠缺。
设计说明
我倾向于用Python进行原型设计,一个简短的非正式调查显示这种方法可行。我想到了三个部分:
连接到Redis以读取数据
使用缓存将数据存储到本地
一个可以将所有事情连接起来的管理器
为了建立连接,我选择了redis-py。它提供了Redis类,它是一个直截了当的简单客户端,Python的特性使得扩展它变得很容易。
缓存组件的需求是基本的,所以我乐意于采用Python OrderedDict中的LRU缓存。
将常规连接转换为缓存的连接
在这个实验中,我选择实现一个Redis命令的缓存,即GET。前提是让客户机使用read-through模式:即尝试从本地缓存读取数据,并在未命中时查询Redis。对Redis客户端进行子类化并重写其get()方法,可以得到以下结果:
新的Redis类初始化并不新奇。它首先调用基类__init__() ,然后设置几个属性。最后调用Redis的客户端跟踪命令,通过该命令,服务器可以为连接提供协助。
类的get()方法是神奇发生的地方。我们尝试从连接管理器可用的缓存中按名称读取键的值。在发生KeyError或缓存未命中的情况下,我们将请求基类get()来获取数据并将其存储在缓存中。
在客户端追踪键
一旦Redis客户机设置了被追踪,服务器就会维护客户机从中读取的键的记录。Redis没有跟踪每个单独的键,而是对键的名称使用散列函数,将它们分配给插槽。具体来说,它使用键名的CRC64摘要的24个最低有效位,从而产生大约1600万个可能的插槽。
这减少了追踪多个客户端的多个键所需的服务器资源。因此,Redis发送的失效消息由需要失效的槽而不是键名组成。在给定插槽的情况下,由客户机推断出需要从其缓存中删除的相关键名。
这意味着客户端需要使用相同的哈希函数来追踪本地缓存中的键如何映射到插槽。这允许我们在失效通知到达时,对客户端缓存执行基于插槽的失效。为此,我们将分别在本地缓存中添加和丢弃键时使用的add()和discard()方法。
处理失效
无效消息如何发送到被追踪的客户端取决于客户端正在使用的Redis序列化协议(RESP)。早期版本的Redis使用RESP2,但是它的后续版本RESP3已经存在于Redis 6中,并且Redis 7将完全不支持旧协议。
RESP3包含许多新功能,包括服务器在客户端现有连接上“推送”除了实际回复内容的附加信息。此通道用于在使用服务器辅助的客户端缓存功能时传递无效通知。
然而,由于RESP3比较新,目前只有少数客户端支持它,因此RSACSC也可以与RESP2一起工作。由于RESP2缺乏“推送”能力,RSACSC使用Redis中对PubSub的现有支持向相关方广播失效消息。
管理器的作用是处理失效和插槽映射的键。下面是它的样子:
管理器初始化会生成一个连接池,从该连接池为PubSub创建自己的客户端以及通过应用程序请求的任何后续缓存连接。它还维护一个名为slots的字典,该字典将一个slot number映射到它所保存的一组键名。最后,它维护LRU缓存实现的Cache类。
start()方法通过在单独的线程中监听invalidate PubSub通道来启动管理器,这并不奇怪。在该通道上截获的消息由_handler()方法处理。它依次调用invalidate()方法以使请求的插槽无效:
失效是要一个接一个地从相应的插槽集合中弹出键,然后从缓存中删除它们。最后,管理器暴露了一个工厂方法get_connection(),使用它来获取新的缓存连接:
一些评估
这篇文章并不是关于基准测试或Python本身的性能,但是理解该机制的影响是很重要的。为此,我在2019 MacBook Pro上使用了benchmark.py脚本,其中一个Redis实例使用默认值在本地运行(除了我关闭了快照功能)。
在执行测试之前,基准脚本用1000个键填充数据库,并将缓存管理器的容量设置为100。然后它运行几个计时测试来测量性能。对于两种类型的连接,每个测试都重复五次:常规连接和缓存连接。
第一个测试的结果实际上证明了缓存的一个缺点:缓存未命中。在这个测试中,我们对整个数据库中的每个键只读取一次,因此每次访问本地缓存都会导致未命中:
请注意,只计算最后一次运行的平均值,因此系列测试中的每一次都被视为热身。上面的平均值显示,每1000次读取,缓存未命中导致读取增加近13ms,大约18%的延迟增加。
但是,在适合缓存的数据集上重复测试(也就是说,只有100个键显示了更令人鼓舞的结果)。虽然第一次缓存运行显示延迟增加,但随后的运行中延迟减少两个数量级:
下一个测试名为eleven_reads,因为它会将数据库中的每个键读取一次,同时读取其他10个始终相同的键。这个高度集成的用例提供了缓存好处的一个更显著的证明(尽管这本身不是目的)。
最后一个测试加入了一个额外的写入操作,这将触发部分缓存的失效。缓存的延迟略有增加,这既是因为额外的write命令,也是因为需要重新提取缓存的内容:
部分思考
花时间缓存是有用处的。您可能已经熟悉了Redis的Keyspace notifications,它们是关于keyspace的事件,例如对PubSub通道上发送的键的修改。实际上,Keyspace notifications的使用方式与Redis服务器辅助客户端缓存的使用方式几乎相同,能获得类似的结果。
由于PubSub不是一种可靠的消息传输方式,使用Keyspace notifications或基于RESP2的RSACSC都可能导致失效通知丢失和内容过时。然而,随着RESP3的出现,只要连接处于活动状态,RSACSC通知就会被发送。任何断开连接都可以通过本地缓存重置轻松处理。
将RESP3从PubSub广播移到特定于连接的通知还意味着客户端将只获得感兴趣的插槽的失效通知。这意味着用于通信的资源消耗更少。
不管使用的是不是RESP版本,客户端作者都可以使用RSACSC进行缓存,而不仅仅是获取整个字符串。该机制不用了解用于存储密钥值的实际数据结构,因此所有核心Redis类型和模块声明的任何自定义类型都可以与之一起使用。
此外,客户机不仅可以缓存键值元组,还可以缓存请求及其响应(同时追踪所涉及的键)。这样做可以缓存GETRANGE返回的子字符串、通过LRANGE获得列表元素或任何其他类型的查询。
关于Redis的CRC64函数的一点笔记
我知道我不想在这个练习中实现CRC函数。我以为Python已经为我准备好了。
要查找哪个CRC Redis正在使用,只需查看它的源代码--在src/crc64.c文件开头:
我对“Python CRC64-jones”进行了快速搜索,在浏览完文档之后,我选择pip安装crcmod,这样我就可以使用它预定义的crc-64-jones摘要。
过了一段时间,我发现了我的东西不起作用的原因。通过对文档的仔细检查发现,crcmod使用了一个不同的多项式。他们在一起,你能看出区别吗?
此外,crcmod坚决拒绝使用Redis的多项式,并声称:
当然,后来我放弃并移植了Redis CRC64实现。不是困难任务:一个拷贝粘贴,几个搜索替换,一行实际代码重写。如果要使用RSACSC,请确保使用的CRC64实现签出到0xe9c6d914c4b8d9ca。
英文原文:https://engineering.redislabs.com/posts/redis-assisted-client-side-caching-in-python/
译者:QL
完)