Redis 6 中的客户端缓存

本文是Client side caching in Redis 6的翻译,文中的“我”皆是Redis作者 Salvatore Sanfilippo。如有翻译不妥之处,请不吝指教!

纽约 Redis 日结束后,我五点半在酒店醒来,仍然与意大利的时区保持着相当的同步,然后立刻走在了曼哈顿的大街上,完全爱上了这儿的景观和自己只是数以百万人中的普通人这种感觉。但我仍然在回想Redis 6发布的感觉,作为最重要的特性,新的Redis协议(RESP3),将会有很慢的采用曲线,而这也有重复的理由:聪明的人不会无理由的切换手头上的工具。说回来,我为什么如此迫切的想要改进协议呢?主要有两个原因,为了给客户端提供更加语义化的回复,也为了给哪些旧协议很难实现的功能打开大门;其中一个特性对我来说尤其重要:客户端缓存。

回到大约一年前。我去旧金山的Redis Conf 2018时,有一个坚定的想法:客户端缓存是未来Redis最重要的特性。如果我们需要快速存储和快速缓存,那么我们就需要在客户端存储数据的子集。这是为了提供小延迟、大规模数据的想法的自然延伸。事实上,几乎所有的大公司都已经这么做了,因为这几乎是唯一的办法。然而,Redis无法在这个过程中帮助客户端。一个幸运的巧合是,Ben Malec在Redis Conf上做了一个关于客户端缓存的演讲,仅仅使用了Redis提供的工具和一些非常聪明的想法。

Ben采取的办法确实打开了我的想象。为了实现自己的想法,Ben相处两个关键性的主意。第一个是使用Redis集群中的“哈希槽”的概念,以便将key划分为16k个组。这样,客户机就不需要跟踪每个key的有效性,而是可以为一组key使用单个元数据条目。Ben使用Pub/Sub在key发生更改时发送通知,因此他需要应用程序在各个部分提供一些功能,但是整个过程非常可靠。修改一个key?发布一条invalidation消息使其无效。在客户端,是否缓存了这个key?客户端记住每个缓存key的时间戳,以及在接收invalidation消息的时间戳,以记住每个哈希槽的失效时间。使用一个key的时候,通过检查key所在的哈希槽的缓存时间戳是不是早于失效时间戳,来做一个懒驱逐(eviction):在这种情况下key在客户端缓存中是旧数据,客户端必须向服务器请求。

观看了演讲之后,我意识到这是一个非常好的主意,它能让Redis做一部分客户端的事情,并且让客户端缓存更加简单和高效,所以我回到家就开始写文档来描述我的设计

但是为了让我的设计能实现,我必须集中精力改进Redis协议,所以我开始写RESP3的规范和代码,以及Redis 6的其它特性,比如ACL等。而且,客户端缓存占据了其它想法的资源,所以我不得不放弃一些别的想法和特性。

我当时正在纽约街头思考这个想法。后来和参会的朋友们一起吃午饭,喝咖啡休息。当我回到我的酒店房间,距离我的航班起飞,我还有当天晚上和第二天大半个白天,所以我开始为Redis 6实现客户端缓存,和我一年前写给开发组的提议基本一样:过了一年,它仍然看起来很棒。

Redis服务器辅助客户端缓存,最终称为“追踪(tracking)”(但我可能会改变主意),是一个非常简单的功能,由几个关键的想法组成。

key空间整体被分为几个哈希槽,但不仅仅是Ben的那种哈希槽。我们用24比特位作为CRC64的输出,所以会有1600多万个不同的哈希槽。为什么这么多?因为我觉得你可能会有1亿个key,而在客户端缓存中,一个失效消息不应该影响过多的key。Redis用于存储invalidation表的内存开销为130MiB,一个1600万个条目,每个条目8字节的数组。这对我来说没问题,如果你想要这个功能,你就要充分利用客户端所有的内存,所以使用130MB服务器端内存作为代价;你所赢得的是更细粒度的失效。

客户端缓存是默认关闭的,你可以使用如下简单的命令来开启这个特性:

CLIENT TRACKING on

服务端回复+OK表示正常,同时从那时开始,每一个只读命令,除了返回key给调用者以外,还有一个副作用,就是记录所有客户端请求的key的所有的哈希槽(但仅仅是对只读命令的key,这是服务端和客户单的约定)。Redis存储这些信息的方法很简单。每个Redis客户端都有一个惟一的ID,因此,如果ID为123的客户端执行一个MGET 命令,它的key对应的哈希槽是1、2和5,我们将得到一个包含以下条目的无效表:

1 -> [123]
2 -> [123]
5 -> [123]

稍后,ID为444的客户端来请求哈希槽5中的key,所以这个表变为:

5 -> [123, 444]

现在,其他一些客户端在哈希槽5中更改了一些key。发生的情况是,Redis将检查Invalidation表,发现客户端123和444可能都在该槽上缓存了key。我们会向客户发送一个失效消息,他们可以有多种处理方式:要么记住哈希槽最新的失效时间戳,然后在使用的时候才检查缓存对象的失效时间戳,如果超出这个时间戳,则删除这个key。或者,客户端可以获取关于这个哈希槽的缓存内容,然后直接删除哈希槽即可。这种使用24位散列函数的方法不是问题,即使缓存了数千万个key,我们也不会有很长的哈希槽记录。在发送无效消息之后,服务端可以从Invalidation表中删除条目,这样服务端将不再向这些客户端发送无效消息,直到它们再次读取该哈希槽内的key。

客户端并不需要真正使用哈希的全部24比特位。比如,客户端可以仅仅使用20比特位来记录,然后服务端的无效消息需要转发给对应的哈希槽。虽然这样做可能不是很好,但是对于内存紧张的系统,这个方法可能有用。

如果你严格按照我所说的进行操作,你就会认为同一个连接可能同时接收到正常的客户端响应和无效消息。这在RESP3中是可能的,因为无效操作是作为“push”消息类型发送的。然而,如果客户端是阻塞的,而不是事件驱动的,那么情况就会变得复杂:应用程序需要某种方法来不时读取最新数据,而这看起来既复杂又脆弱。在这种情况下,使用另一个线程和客户端连接来接收无效消息可能要好得多。所以你可以这样做:

CLIENT TRACKING on REDIRECT 1234

即,我们让服务端将所有本该在这次连接中的失效消息,发送到客户端1234。例如,在连接池的情况下,多个连接可能要求将失效消息转发到统一的一个客户端连接。你所需要做的就是创建一个连接(Invalidation连接)来接受失效消息,调用CLIENT ID命令来获取客户端id,然后在其它连接开启追踪转发。

还有一个问题:如果我们的从Invalidation连接出现异常,丢失了这个连接,那会发生什么?由于不再接收失效消息,我们可能会遇到麻烦。通常应用程序会检测连接中断,并重置当前缓存(或采取更柔和的措施,比如把所有哈希槽的失效时间戳设置为几秒后,以便有时间填充缓存,代价就是提供了几秒的过期数据)。但更好的办法是,Invalidation连接不时发送ping命令以确保它是活动的。然而,为了降低返回已失效数据的风险,Redis服务端还会通知客户端,需要将失效消息重定向到另外的客户端(即告诉客户端,当前的Invalidation连接已断开),只需要使用特殊的push消息:这样,客户端在执行下一个查询时,就会知道这个消息。

我描述的整个过程仅仅是合并到Redis unstable分支的内容。这也许不是最终的过程,我们还有好几个月才会发布Redis 6 RC版本,还有时间来改进这个过程:发送反馈给Redis。我也在想办法来在RESP2上开启这个特性。仅仅在追踪转发开启的时候才行,Invalidation连接进入了Pub/Sub模式,这样失效消息就会以Pub/Sub消息发送,这样旧客户端也可以用这个特性。

我希望这能足够的刺激你:如果整个客户端缓存过程执行的非常好,那我们提供文档给各个客户端库的作者,让他们知道如何实现这个特性。这样,数据会比原来离应用程序更近,甚至在小团队支持的、避免复杂的客户端缓存的应用程序中,他们也可以方便地用上客户端缓存。对于已经这样做的大型团队和大型应用程序,者可以减少实现的开销和复杂性。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.