Redis 6 中的客户端缓存
原文由 Salvatore Sanfilippo 于 发布,订阅该博客
[注意:本文已不再描述 Redis 6 最终实现中的客户端实现,该实现已发生重大变化,详见 https://redis.io/topics/client-side-caching]
纽约的 Redis Day 结束了,我早上 5:30 在酒店醒来,作息还和意大利时区保持着同步,便立刻走上曼哈顿的街头,完全沉醉于眼前的景色,以及那种置身于数百万人之中、自己不过是一个数字的奇妙感觉。然而我心里想的却是 Redis 6 的发布,总觉得其中或许最重要的特性——Redis 协议的新版本(RESP3)——将会经历非常缓慢的采用过程,而且理由也很充分:明智的人不会没有充分的理由就更换工具。毕竟,我为什么如此迫切地想改进协议?主要有两个原因,一是为客户端提供更具语义的回复,二是为那些用旧协议难以实现的新功能打开大门;而其中对我而言最重要的一个功能,就是客户端缓存。
把时间拨回大约一年前。我来到旧金山的 Redis Conf 2018,坚定地认为客户端缓存是 Redis 未来最重要的事情。如果我们需要高速的存储和高速的缓存,那么就需要在客户端内部存放一部分数据。这是“以极低延迟、大规模地提供数据”这一理念的自然延伸。实际上,几乎所有超大型公司都已经在这么做了,因为这最终是应对负载的唯一生存之道。然而 Redis 却没有任何方式来协助客户端完成这一过程。巧合的是,Ben Malec 在那次 Redis Conf 上恰好做了一场关于客户端缓存的演讲[1],仅仅利用 Redis 已有的工具和一些非常巧妙的思路。
[1] https://www.youtube.com/watch?v=kliQLwSikO4
Ben 的方案真的打开了我的思路。为了让设计奏效,他用了两个关键想法。第一个是借用 Redis Cluster 中“哈希槽”的概念,将键划分成 1.6 万个分组。这样客户端就不需要逐个跟踪每个键的有效性,而可以为一组键使用一条元数据。Ben 使用 Pub/Sub 来在键发生变更时发送通知,因此需要在应用的各个环节提供一些配合,不过整体方案非常扎实。修改了一个键?就同时发布一条使其失效的消息。在客户端,你在缓存键吗?记住你缓存每个键时的时间戳,并且在收到失效消息时,也记住每个槽的失效时间。当使用某个已缓存的键时,进行惰性淘汰,检查你缓存的那个键的时间戳是否早于该键所属槽收到的失效时间戳:如果是,就说明这是过期数据,你必须重新向服务器请求。
看完这个演讲后,我意识到这是一个可以在服务器内部实现的绝佳想法,让 Redis 为客户端分担一部分工作,使客户端缓存变得更简单、更有效,于是我回到家写了一份描述我的设计方案的文档[2]。
[2] https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ
但要让我的设计真正跑起来,我必须先把精力放在将 Redis 协议切换到更好的版本上,于是我开始为 RESP3 编写规范,随后编写代码,还有 ACL 等其他 Redis 6 的功能,而客户端缓存则和其他许多因时间不足而被我或多或少搁置的 Redis 想法一起,被丢进了那个巨大的“搁置间”。
然而我走在纽约街头时,脑子里想的还是这个想法。之后和会上认识的朋友一起吃了午餐、喝了咖啡。回到酒店房间后,我还有一整个晚上的时间,以及第二天起飞前的大半天,于是我开始动手为 Redis 6 实现客户端缓存,基本沿用了我一年前写给小组的那份提案:它看起来依然很棒。
Redis 的服务器辅助式客户端缓存,最终被命名为“tracking”(跟踪,不过我可能还会改主意),是一个非常简单的功能,只由几个关键想法组成。
键空间被划分为“缓存槽”,但数量远多于 Ben 所用的哈希槽。我们取 CRC64 输出的 24 位,因此大约有 1600 多万个不同的槽。为什么要这么多?因为我认为,你可能拥有一台存有 1 亿个键的服务器,却不希望一条失效消息就影响到客户端缓存中的大量键。Redis 内部为失效表付出的内存开销是 130 MB:一个包含 1600 万个条目、每个 8 字节指针的数组。在我看来这完全可以接受,如果你想使用这个功能,就一定会在客户端充分利用所有内存,那么在服务器端用掉 130 MB 也是值得的;你换来的是粒度细得多的失效机制。
客户端以“主动开启”的方式启用该功能,只需一条简单命令:
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,然后再启用 tracking。
还剩下一个问题:如果我们与服务器之间的失效链路断开了会怎样?由于失效消息将不再被接收,我们可能会遇到麻烦。通常应用会检测到连接已断开,并重新连接,同时清空当前缓存(或采取更柔和的处理方式,比如将所有槽的时间戳设为未来几秒,以便在提供可能有几秒延迟的过期数据的同时,有时间重新填充缓存)。不过,让失效线程不时地 ping 一下连接以确保其存活,可能是个更好的主意。然而为了降低脏数据的风险,Redis 也会通过特殊的 push 消息,通知那些已将失效消息重定向到另一个客户端、而该客户端现已断开的客户端:客户端在下一次执行查询时就能得知这一情况。
我所描述的内容刚刚被合并到 Redis unstable 分支中。这可能还不是最终定论,但在第一个 Redis 6 候选版本发布之前还有几个月的时间,一切都还可以改变:欢迎给我反馈。我也在研究为 RESP2 启用该功能的方法。这只有在启用重定向时才可行,而负责监听消息的客户端可能需要进入 Pub/Sub 模式,这样我们就可以发送类似 Pub/Sub 消息的内容。通过这种方式,旧客户端也能被完全复用。
希望这些足以吊起你的胃口:如果我们能在 Redis 内部很好地实现它,并为客户端作者提供文档以指导如何提供支持,数据就能比以往任何时候都更贴近应用,即便是在那些迄今为止一直回避尝试实现客户端缓存的小团队应用中也是如此。对于已经在做这件事的大团队和超大型应用而言,开销和实现的复杂性都可以随之降低。
随机一篇博客
评论
登录后参与讨论