众所周知,计算机科学中只有两个难题:缓存失效和命名。正如您从名称中猜到的那样,这篇文章是关于处理第一个问题:缓存失效问题。我写这篇文章是因为 Redis 6 中的一项新功能使客户端更容易管理本地缓存。这个小型项目的源文件可以在 rsacsc-py 存储库中找到。
我不会讨论缓存的必要性。实际上,我们几乎在计算机工程的各个方面都缓存数据,以减少我们随后访问它所需的时间。我们在很多层面上进行缓存,并将继续这样做,直到有人最终破解了 瞬时通信,并且可能在那之后也是如此。
Redis 是一个 远程服务器,这意味着其中的任何数据都可以通过网络访问。从 Redis 客户端的角度来看,网络引起的延迟通常是总体延迟的最大贡献者。通过缓存以前的回复来避免重复调用可以缓解这种情况。
Redis 使用键值存储数据模型,这是一种将唯一标识符映射到每个数据结构及其中的数据的抽象。因为数据访问始终按键名进行,所以 Redis 客户端很容易在本地存储其各自的数据以进行缓存。但这带来了使缓存失效的难题。
名副其实的 服务器辅助客户端缓存 是 Redis 版本 6 中新增的一项功能。它旨在通过让服务器发送失效通知来协助管理本地缓存。服务器跟踪客户端访问的键,并在这些键更改时通知客户端。
因为 Redis 服务器辅助客户端缓存(简称 RSACSC)在第一个候选版本中确实有点 不成熟,所以我想把它拿出来进行一次真实的测试。这个想法是做一个概念验证,以便更好地了解已有的东西以及仍然缺少的东西。
我倾向于在 Python 中进行原型设计,并且一个简短的 非正式投票 支持这种方法。我脑海中有一个三部分设置
为了建立连接,我选择了 redis-py。它提供了 Redis 类,该类是一个简单的零麻烦客户端,并且 Python 的性质使其易于扩展。
对缓存组件的要求是基本的,所以我很高兴采用 Python 中 OrderedDict 文档中的 LRU 缓存示例。
对于此实验,我选择为单个 Redis 命令(即 GET)实现缓存。前提是让客户端使用 读取穿透 模式:也就是说,尝试从本地缓存中读取,如果未命中,则推迟到 Redis。子类化 Redis 客户端并覆盖其 get() 方法,我们得到以下结果
class Redis(redis.Redis):
def __init__(self, manager, *args, **kwargs):
super().__init__(self, *args, **kwargs)
self._manager = manager
self._client_id = super().client_id()
self.execute_command('CLIENT', 'TRACKING', 'ON',
'redirect', self._manager.client_id)
def get(self, name):
try:
value = self._manager.cache[name]
except KeyError:
value = super().get(name)
self._manager.cache[name] = value
return value
新的 Redis 类初始化并不复杂。它首先调用其基类的 __init__() 并设置一些属性。最后调用 Redis 的 CLIENT TRACKING 命令,该命令启用服务器对连接的辅助。
类的 get() 方法是魔法发生的地方。我们尝试通过名称从缓存(可通过连接的管理器获得)读取键的值。如果发生 KeyError(即缓存未命中),我们恢复为基类的 get() 来获取数据并将其存储在缓存中。
一旦 Redis 客户端选择加入跟踪,服务器就会维护客户端从中读取的键的记录。Redis 没有跟踪每个单独的键,而是使用键名上的哈希函数将它们分配给槽。具体来说,它使用键名 CRC64 摘要的 24 个最低有效位,从而产生大约 1600 万个可能的槽。
这减少了服务器跟踪多个客户端的多个键所需的资源。因此,Redis 发送的失效消息包含需要失效的 槽,而不是键名。客户端需要推断出需要从其缓存中删除的相关键名。
这意味着客户端需要采用相同的哈希函数来跟踪本地缓存中的键如何映射到槽。这使我们可以在收到失效通知时执行基于槽的客户端缓存失效。为此,我们将在分别从本地缓存添加和丢弃键时使用 add() 和 discard() 方法。
def slot(key):
''' Returns the slot for a key '''
crc = crc64(key)
crc &= 0xffffff
return crc
def add(self, key):
''' Adds a key to the internal tracking table '''
slot = self.slot(key)
self.slots[slot].add(key)
def discard(self, key):
''' Removes a key from the internal tracking table '''
slot = self.slot(key)
self.slots[slot].discard(key)
失效消息如何发送到被跟踪的客户端取决于客户端使用的 Redis 序列化协议 (RESP)。早期版本的 Redis 使用 RESP2,但它的继任者 RESP3 已经存在于 Redis 6 中,并将完全弃用 Redis 7 中的旧协议。
RESP3 包含许多新功能,包括服务器可以在现有连接上向客户端“推送”额外信息以及实际回复的能力。此通道用于在使用服务器辅助客户端缓存功能时传递失效通知。
但是,由于 RESP3 是如此之新,因此目前只有少数客户端支持它,因此 RSACSC 也适用于 RESP2。由于 RESP2 缺少“推送”功能,因此 RSACSC 使用 Redis 中对 PubSub 的现有支持向感兴趣的各方广播失效消息。
处理失效和键到槽的映射是管理器的用途。这是它的样子
class Manager(object):
def __init__(self, pool, capacity=128):
self.pool = pool
self.capacity = capacity
self.client_id = None
self.client = redis.Redis(connection_pool=self.pool)
self.slots = defaultdict(set)
self.cache = Cache(self, maxsize=self.capacity)
self.start()
def start(self):
''' Starts the manager '''
self.client_id = self.client.client_id()
self._pubsub = self.client.pubsub(ignore_subscribe_messages=True)
self._pubsub.subscribe(**{'__redis__:invalidate': self._handler})
self._thread = self._pubsub.run_in_thread()
管理器使用连接池进行初始化,从中它为 PubSub 创建自己的客户端,以及使用应用程序请求的任何后续缓存连接。它还维护一个名为 slots 的字典,该字典将槽号映射到它拥有的键名集。最后,它维护 Cache 类,该类是 LRU 缓存的实现。
毫不奇怪,start() 方法通过开始在单独的线程中侦听 __redis__:invalidate PubSub 通道来启动管理器。在该通道上截获的消息由 _handler() 方法处理。反过来,它调用 invalidate() 方法使请求的槽失效
def _handler(self, message):
''' Handles invalidation messages '''
slot = message['data']
self.invalidate(slot)
def invalidate(self, slot):
''' Invalidates a slot's keys '''
slot = int(slot)
while self.slots[slot]:
key = self.slots[slot].pop()
del self.cache[key]
失效只是从相应槽的集合中逐个弹出键,然后从缓存中删除它们的问题。最后,管理器公开了一个工厂方法 get_connection(),该方法由代码用于获取新的缓存连接
def get_connection(self, *args, **kwargs):
''' Returns a cached Redis connection '''
conn = CachedRedis(self, connection_pool=self.pool, *args, **kwargs)
return conn
这篇文章不是关于基准测试或 Python 本身的性能,但了解该机制的影响很重要。为此,我使用了 benchmark.py 脚本,在 2019 MacBook Pro 上本地运行 Redis 实例,使用默认设置(除了我关闭了快照)。
在执行测试之前,基准测试脚本使用 1000 个键填充数据库,并使用 100 的容量设置缓存管理器。然后,它运行几个定时测试来测量性能。每种类型的连接(常规连接和缓存连接)的每个测试重复五次。
第一个测试的结果实际上证明了缓存的 缺点 之一:缓存未命中。在此测试 single_read中,我们仅从整个数据库中读取每个键一次,因此每次访问本地缓存都会导致未命中
请注意,平均值仅针对最后运行计算,因此系列中的每次首次运行都被视为预热。上面的平均值表明,错过的缓存读取使每次 1,000 次读取增加近 13 毫秒,大约增加了 18% 的延迟。
然而,在一个可以放入缓存的数据集(即只有 100 个键)上重复测试,结果令人鼓舞。虽然第一次缓存运行显示延迟增加,但随后的运行将延迟降低了两个数量级。
下一个测试名为 eleven_reads ,因为它读取数据库中的每个键一次,以及另外 10 个始终相同的键。 这种高度综合的用例提供了更加显著的缓存优势证明(尽管这本身并不是目的)。
最后一个测试使用额外的写入请求扩展了 eleven_reads ,该写入请求写入 10 个常量键之一,从而触发缓存的一部分失效。 缓存运行的延迟略有增加,这既是因为额外的写入命令,也是因为需要重新获取缓存的内容。
这是一段花费时间进行缓存的好方法。 您可能已经熟悉 Redis 的 键空间通知,这些通知是关于键空间的事件(例如对键的修改),通过 PubSub 通道发送。 实际上,键空间通知可以以与 Redis 服务器辅助客户端缓存大致相同的方式使用,以实现类似的结果。
由于 PubSub 不是一种可靠的消息传输方式,因此使用键空间通知或基于 RESP2 的 RSACSC 可能会导致丢失失效通知和陈旧内容。 但是,随着 RESP3 的出现,只要连接处于活动状态,RSACSC 通知就会被传递。 然后可以通过本地缓存重置轻松处理任何断开连接。
RESP3 中从 PubSub 广播到连接特定通知的转变也意味着客户端只会收到对其感兴趣的槽的失效通知。 这意味着在通信上花费的资源更少,而越少越好。
无论使用哪个 RESP 版本,客户端作者都可以使用 RSACSC 进行缓存,而不仅仅是 GET 整个字符串。 该机制与用于存储键值的实际数据结构无关,因此所有核心 Redis 类型和模块声明的任何自定义类型都可以与它一起使用。
此外,客户端不仅可以缓存键值对,还可以缓存请求及其回复(同时跟踪所涉及的键)。 这样做可以缓存 GETRANGE 返回的子字符串、通过 LRANGE 获得的列表元素,或几乎任何其他类型的查询。
我最不想在此练习中实现的一件事是 CRC 函数。 我认为 Python 已经为我准备好了正确的函数。
要查找 Redis 使用的 CRC,您可以直接查看其源代码 - 文件 src/crc64.c 一开始就告诉我们
/* Redis uses the CRC64 variant with "Jones" coefficients and init value of 0.
*
* Specification of this CRC64 variant follows:
* Name: crc-64-jones
* Width: 64 bites
* Poly: 0xad93d23594c935a9
* Reflected In: True
* Xor_In: 0xffffffffffffffff
* Reflected_Out: True
* Xor_Out: 0x0
* Check("123456789"): 0xe9c6d914c4b8d9ca
我快速搜索了 “Python CRC64 jones” ,并在浏览了 文档 后,我选择 pip install crcmod ,以便可以使用其预定义的 crc-64-jones 摘要。
过了一段时间,而且远比我愿意承认的时间要长得多,我才找到了我的东西不起作用的原因。 更仔细地检查文档后发现,crcmod 使用了一个不同的(相差一位,但这是另一个笑话)多项式。 它们都在这里,你能发现区别吗?
Redis 0xad93d23594c935a9
crcmod 0x1AD93D23594C935A9
更重要的是,crcmod 坚决拒绝使用 Redis 的多项式,并小气地声称
>>> crcmod.mkCrcFun(0xad93d23594c935a9)
Traceback (most recent call last):
...
ValueError: The degree of the polynomial must be 8, 16, 24, 32 or 64
然后,我当然只是放弃并移植了 Redis CRC64 实现。 这并不是一项艰巨的任务:一次复制粘贴、几次搜索替换和一行实际代码需要重写。 如果您要尝试 RSACSC,请确保您使用的 CRC64 实现校验到 0xe9c6d914c4b8d9ca。