众所周知,计算机科学中只有两个难题:缓存失效和命名。正如您从这篇文章的标题中可能猜到的,这篇文章是关于如何解决第一个难题:缓存失效问题。我写这篇文章是因为 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 的性能本身,但了解该机制的影响非常重要。为此,我在一台 2019 年的 MacBook Pro 上使用了 benchmark.py 脚本,该脚本运行着一个本地 Redis 实例,使用默认设置(除了我关闭了快照)。
在执行测试之前,基准测试脚本使用 1000 个键填充数据库,并将缓存管理器设置为容量为 100。然后它运行几个计时测试来衡量性能。每个测试都针对两种类型的连接(常规和缓存)重复五次。
第一个测试的结果实际上展示了缓存的一个缺点:缓存未命中。在这个测试 single_read 中,我们只从整个数据库中读取每个键一次,因此每次访问本地缓存都会导致未命中
请注意,平均值仅针对最后几次运行计算,因此该系列中的第一次运行被视为预热。上面的平均值表明,缓存读取未命中对于每 1000 次读取会增加大约 13 毫秒,大约是延迟增加 18%。
但是,在适合缓存的数据集上重复测试(即只有 100 个键)会显示出更令人鼓舞的结果。虽然第一次缓存运行显示延迟有所增加,但随后的运行将延迟降低了两个数量级
下一个测试名为 eleven_reads,因为它读取数据库中的每个键一次,以及始终相同的另外 10 个键。这个高度人工的用例提供了更戏剧性的缓存优势证明(尽管这不是其目的)。
最后一个测试扩展了 eleven_reads,并向 10 个常量键之一添加了写入请求,这会触发缓存一部分的失效。缓存运行的延迟略微增加,这是因为额外的写入命令,也因为需要重新获取缓存的内容。
这是一种花时间缓存的好方法。你可能已经熟悉 Redis 的 键空间通知,它们是关于键空间的事件,例如对键的修改,在 PubSub 通道上发送。实际上,键空间通知可以用与 Redis 服务器辅助的客户端缓存相同的方式来使用,以实现类似的结果。
由于 PubSub 不是一种可靠的消息传输方式,因此使用键空间通知或基于 RESP2 的 RSACSC 可能会导致失效通知丢失和内容陈旧。然而,随着 RESP3 的出现,只要连接存活,RSACSC 通知就会被传递。任何断开连接都可以通过本地缓存重置轻松处理。
RESP3 从 PubSub 广播到连接特定通知的转变也意味着客户端只会收到对其感兴趣的插槽的失效通知。这意味着在通信上花费的资源更少,而更少意味着更好。
无论使用哪个 RESP 版本,客户端作者都可以使用 RSACSC 来缓存比获取整个字符串更多的东西。这种机制独立于用于存储键值的实际数据结构,因此所有核心 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。