Redis 6 中的客户端缓存
[注意:本文已不再描述 Redis 6 最终实现中的客户端实现,该实现后来发生了重大变化,详见 https://redis.io/topics/client-side-caching]
纽约 Redis 日结束了。我早上 5:30 在酒店起床,此时身体仍然与意大利时区相当同步,随后立即走上曼哈顿的街道,完全沉醉于这里的景色,以及置身于数百万个其他数字之中、仅仅作为其中一个数字的美妙感觉。然而,我当时正在思考 Redis 6 的发布,想到其中可能最重要的功能——新版本的 Redis 协议(RESP3)——很可能会经历一条非常缓慢的采用曲线,而且这是有充分理由的:明智的人不会在没有非常充分理由的情况下更换工具。毕竟,我为什么如此迫切地想改进协议?主要有两个原因:为客户端提供语义更丰富的响应,以及为那些难以用旧协议实现的新功能打开空间;其中有一项功能对我来说尤其重要:client side caching(客户端缓存)。
把时间倒回大约一年前。我带着一个坚定的想法来到旧金山参加 Redis Conf 2018:客户端缓存是 Redis 未来最重要的事情。如果我们需要快速的数据存储和快速的缓存,那么就需要在客户端内部存储一部分信息。这是“以很小的延迟、在大规模环境下提供数据”这一理念的自然延伸。实际上,几乎每一家大型公司都已经这样做了,因为这最终是应对负载、得以生存的唯一方式。然而,Redis 没有办法协助客户端完成这一过程。一次幸运的巧合是,Ben Malec(本·马莱克)恰好在 Redis Conf 做了一场关于客户端缓存的演讲[1],只使用 Redis 提供的工具和许多非常巧妙的想法。
[1] https://www.youtube.com/watch?v=kliQLwSikO4
Ben 采取的方法确实打开了我的想象空间。为了让他的设计奏效,Ben 使用了两个关键思路。第一个是利用 Redis Cluster 的“hash slots(哈希槽)”概念,将键划分为 16K 个组。这样,客户端就不需要跟踪每个键是否有效,而是可以为一组键使用单个元数据条目。Ben 使用 Pub/Sub(发布/订阅)在键发生变化时发送通知,因此整个应用程序都需要提供一些协助,不过这个方案非常扎实。修改一个键?同时发布一条使它失效的消息。在客户端一侧缓存键?记住每个键被缓存时的时间戳;收到失效消息时,也记住每个槽的失效时间。当使用某个已缓存的键时,通过检查该键的缓存时间戳是否早于它所属槽所收到的失效时间,执行 lazy eviction(惰性驱逐):如果是,那么该键就是过期数据,你必须再次向服务器请求。
看完演讲后,我意识到这是一个可以在服务器内部使用的绝妙想法:让 Redis 为客户端完成一部分工作,使客户端缓存更简单、更有效。于是我回家后写了一份文档,描述我的设计[2]。
[2] https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ
但要让我的设计真正运行起来,我必须专注于将 Redis 协议切换到更好的形式,于是我开始编写 RESP3 的规范,后来又编写了 RESP3 的代码,以及 ACL 等 Redis 6 的其他功能;而客户端缓存则加入了那一大堆我因为缺乏时间而以某种方式搁置的 Redis 想法之中。
然而,我走在纽约街头时仍在思考这个想法。后来我和会议上的朋友一起去吃午饭、喝咖啡。回到酒店房间时,晚上还剩下全部时间,直到第二天搭乘航班前也还有大半天,于是我开始为 Redis 6 编写客户端缓存的实现,严格遵循一年前我发给邮件组的方案:它看起来依然很棒。
Redis 服务器辅助的客户端缓存最终被称为“tracking”(不过我可能会改变主意),它是一个由几个关键思路组成的非常简单的功能。
键空间被划分为“caching slots(缓存槽)”,但它们比 Ben 使用的哈希槽多得多。我们使用 CRC64 输出中的 24 位,因此共有略多于 1600 万个不同的槽。为什么要这么多?因为我认为你可能希望服务器拥有 1 亿个键,同时一条失效消息不应影响客户端缓存中的超过几个键。Redis 内部用于保存失效表的内存开销为 130 MB:这是一个包含 1600 万个条目的 8 字节指针数组。这对我来说没问题。如果你想使用这个功能,就会充分利用客户端上的所有内存,因此服务器端使用 130 MB 是可以接受的;你获得的是粒度细得多的失效机制。
客户端通过“opt in”的方式启用该功能,只需执行一个简单的命令:
CLIENT TRACKING on服务器返回熟悉的 +OK,从这一刻起,命令表中标记为“只读”的每条命令不仅会向调用者返回键,还会作为副作用,记住截至目前客户端请求过的所有键所属的缓存槽(但仅限使用只读命令的那些键,这是服务器与客户端之间的约定)。Redis 存储这些信息的方式很简单。每个 Redis 客户端都有唯一 ID,因此如果客户端 ID 123 对哈希到槽 1、2 和 5 的键执行 MGET,我们将得到如下的失效表条目:
1 -> [123]
2 -> [123]
5 -> [123]但随后客户端 ID 444 也请求槽 5 中的键,于是表会变成:
5 -> [123, 444]现在,某个其他客户端修改了槽 5 中的某个键。会发生什么?Redis 会检查失效表,发现客户端 123 和 444 都可能在该槽中缓存了键。我们会向两个客户端发送失效消息;收到消息后,它们可以自由地以任何方式处理:要么用时间戳记住该槽最后一次失效的时间,之后以惰性方式检查缓存对象的时间戳(或者如果你更喜欢,也可以使用递增的“epoch(纪元)”;这种方式更安全),并根据比较结果将其驱逐;要么客户端也可以通过维护一个表来记录它在这个特定槽中缓存的内容,直接回收这些对象。使用 24 位哈希函数的这种方案不存在问题,因为列表完全不会很长,即使缓存数千万个键也是如此。发送失效消息后,我们可以从失效表中移除这些条目;这样,在客户端再次读取该槽中的键之前,我们就不会再向它们发送失效消息。
请注意,客户端并不一定要真正使用哈希函数的全部 24 位。例如,它们可以只使用 20 位,然后对 Redis 发来的失效消息中的槽编号也进行相应的移位。我不确定这样做是否有很多充分理由,但在内存受限的系统中,这或许是一个办法。
如果你仔细看了上面的说明,现在可能会想到:同一条连接既接收普通的客户端响应,也接收失效消息。这在 RESP3 中是可行的,因为失效消息会作为“push(推送)”消息类型发送。然而,如果客户端是阻塞式客户端,而不是事件驱动客户端,事情就开始变得复杂:应用程序需要某种方式不时读取新数据,而这看起来复杂且脆弱。在这种情况下,也许更好的办法是使用另一个应用线程和另一条客户端连接来接收失效消息。因此,你可以这样做:
CLIENT TRACKING on REDIRECT 1234基本上,我们可以说:当前连接获取的所有键,都希望将失效消息改为发送给客户端 1234。例如,在使用连接池时,多个客户端可以要求将失效消息重定向到同一个客户端。你需要做的只是创建这条特殊连接来接收失效消息,调用 CLIENT ID 以了解该客户端连接的 ID,随后启用跟踪。
还剩下一个问题:如果我们与服务器之间用于接收失效消息的连接断开了,会发生什么?由于不再收到失效消息,我们可能会遇到问题。通常,应用程序会检测到连接已断开,然后重新连接,同时清空当前缓存(或者采取更温和的处理方式,例如将所有槽的时间戳设置为未来几秒,以便在提供可能过期几秒的数据的同时,留出一些时间填充缓存)。不过,如果失效线程不时对连接执行 ping 以确认连接仍然存活,可能会是更好的办法。然而,为了降低数据过期的风险,Redis 也会开始通知那些将失效消息重定向给其他客户端、但该其他客户端现已断开的客户端,告知它们这一情况;Redis 只需使用特殊的推送消息即可做到这一点:客户端在下一次查询时就会知道。
我所描述的内容刚刚合并进 Redis unstable。它可能还不是最终方案,但距离第一个 Redis 6 候选发布版还有几个月,我们有时间改变一切:请把你的反馈发给我。我也在研究如何为 RESP2 启用这一功能。这只有在启用重定向时才能工作,而且负责监听消息的客户端可能需要进入 Pub/Sub 模式,这样我们就可以发送类似 Pub/Sub 的消息。这样一来,旧客户端就能被完全复用。
我希望这些内容足以激发你的兴趣:如果我们能在 Redis 内部把这项功能实现得非常好,然后为客户端作者编写文档,让他们知道如何提供支持,那么数据就能比以往更接近应用程序,即使是由小团队开发、迄今一直避免尝试实现客户端缓存的应用程序也不例外。对于已经在这样做的大型团队和超大型应用程序来说,开销以及实现的复杂性都可以降低。
随机一篇博客