纽约 Redis 节结束了,我 5:30 起床,与意大利时区保持着很好的同步,并立即走上曼哈顿的街道,我完全爱上了这里的风景,享受着成为这里一份子的美妙感觉。然而,我还在思考着 Redis 6 的 release 版本,它是目前最重要的特性了,新版本的 Redis 协议(RESP3)推进的很慢,这是有充分理由的:聪明的人在没有充分理由的情况下是不会切换工具的。但是我为什么这么想改进协议呢?主要有两个原因,一是为客户端提供更多的语义应答,二是因为需要提供一个难以用旧协议实现的新特性,特别是一个对我来说最重要的特性:客户端缓存。
回到大约一年前,我参加了旧金山的 Redis Conf 2018,坚信客户端缓存是 Redis 未来最重要的特性。如果我们需要快速存储和快速缓存,那么我们需要在客户端存储部分信息。这是为大规模数据提供低时延服务很自然的想法。实际上,几乎所有的大公司都已经这样做了,因为这是最终唯一可行的途径。然而,Redis 没有在这个过程中帮助客户端。很幸运的是,Ben Malec 在 Redis Conf 上做了一个关于客户端缓存的演讲,仅仅使用了 Redis 提供的工具和一些非常聪明的想法。| 编者注:Ben Malec 的演讲视频见 https://www.youtube.com/watch?v=kliQLwSikO4
Ben 的方法给了我很大的启发。Ben 的设计中有两个关键的想法。第一个是使用 Redis 集群“哈希槽”的思想,将 key 分成 16k 个组。这样,客户端就不需要跟踪每个 key 的有效性,而是可以为一组 key 使用单个元数据项。Ben 使用了 Pub/Sub,在 key 更改时发送通知,因此他需要应用程序提供一些帮助,但是这种模式是非常固定的。要修改一个 key ?还要发布一条消息,使其失效。在客户端,是否缓存 key ?记住缓存每个 key 和收到失效消息的时间戳,记住每个槽的失效时间。当使用一个给定的缓存 key 时,通过检查你缓存 key 的时间戳是否比槽失效的时间戳早,来做一个懒驱逐:在这种情况下,这个 key 就是过期的数据,你就需要再次访问服务器。
看完这个演讲之后,我意识到这是一个能够用于服务器内的非常棒的想法,为了能让 Redis 为客户端做部分工作,并且让客户端缓存更加简单,更加高效。因此我回到家就写了一篇设计文档。| 文档 URL:https://groups.google.com/d/msg/redis-db/xfcnYkbutDw/kTwCozpBBwAJ
但是为了实现我的设计,我必须把 Redis 协议换成更好的,因此我开始编写 RESP3 的规范和代码,还有 Redis 6 的其他特性,如 ACL 等,于是客户端缓存就成为了我基于 Redis 产生的众多 idea 之一。
我在纽约的街头思考着这个想法。之后和朋友去吃了午饭喝了咖啡。当我回到酒店的时候,距离第二天坐飞机就只剩一个晚上了,因此我开始编写 Redis 6 的客户端缓存的实现。这个想法是我在一年前就提出来了,现在它仍然看起来很棒。
Redis server 的客户端缓存,最终叫”tracking”(我有可能还会改),是一个由几个关键想法组成的非常简单的特性。
键空间被划分为多个“缓存槽”,但是要比 Ben 使用的哈希槽要多很多。我们使用 CRC64 的 24 位输出,因此有超过 1600 万个不同的槽。为什么会有这么多呢?因为我认为你可能会在 server 中存储 1 亿多个 key,并且一个失效消息影响的 key 不应该比在客户端缓存中的 key 多。Redis 中获取失效表的内存开销是 130M:8 字节的指针数组指向 16M 的条目。这对我来说是可以接受的,如果你想要这个特性,你就要充分利用客户端的内存,因此使用 130M 在服务端侧是好的,你可以获得更加细粒度的失效。
客户端使用“optin”的方式开启这个特性,仅需一个简单的命令:
CLIENT TRACKING on
服务端回复 +OK,从这一时刻开始,在命令表中的每个命令都会被标记为“只读”,不再给调用者返回 keys,而是记住客户端请求的所有 key(但也只是使用了只读命令的 key,这是服务器和客户端之间的协议)。Redis 保存这种信息的方式非常简单。每个 Redis 客户端都有一个唯一的 ID,所以如果是客户端 ID 123 执行了一个 MGET 命令,需要从槽 1,2,5 中获取 keys,我们就会在失效表中记录以下条目:
但是之后客户端 ID 444 也会到槽 5 请求 keys,因此表就会变成:
现在一些其他的客户端修改了槽 5 中的某个 key。Redis 就会检查失效表,发现客户端 123 和 444 都缓存了这个槽上的 key。我们将会向两个客户端发送一条失效消息,然后他们可以用任意一种方式处理:要么记录最后一次槽失效的时间戳,然后懒检查缓存对象的时间戳(或者使用递增的“epoch”:这样会更安全),并在比较之后将其逐出。另外,客户端可以通过获取表直接回收缓存到特定槽中的对象。这种具有 24 位哈希函数的方法不是一个问题,因为我们即使缓存了成百上千万个 key,都不会有一个很长的列表。在发送失效消息后,我们就能把这些条目项从失效表中移除,这样直到这些客户端不再从槽中读取 key 时,我们才不再给他们发送失效消息。
需要注意的是客户端不必全部使用 24 位的哈希函数。他们也可能只使用 20 位的,然后只用移动 Redis 发送给他们的失效消息槽。虽然不确定这么做会不会不好,但是对于内存紧张的系统可能会有用。
如果你严格按照我说的去做,你会认为同一连接会同时收到正常的客户端响应和失效消息。对于 RESP3,这是可能的,因为失效消息是作为“push”消息类型发送的。但是,如果客户端是阻塞客户端,而不是事件驱动的客户端,那么情况将变得很复杂:应用程序需要某种方式不时读取新数据,这看起来复杂而脆弱。在这种情况下,最好使用另一个应用程序线程和另一个客户端连接,以接收失效消息。因此,您可以执行以下操作:
基本上,我们可以说通过当前连接获得的所有 key,我们希望将失效消息发送给客户端 1234。例如,在连接池的情况下,多个客户端可能会要求将失效消息重定向到单个客户端。你需要做的就是创建此特殊连接以接收失效消息,调用 CLIENT ID 来获取客户端 ID,然后开启追踪。
还有一个问题:如果我们从失效连接中丢失了服务器的连接,那会发生什么?一旦接收不到失效消息我们可能会遇到麻烦。通常应用会检测到连接中断,然后重新连接,并且刷新当前缓存(或采取更柔和的方法,比如把所有哈希槽的失效时间戳设置为几秒后,以便有时间填充缓存,代价就是提供了几秒的过期数据)。但是更好的办法是失效的线程不时地发送 ping 命令以确保它的状态是 active 的。然而为了降低过期数据的风险,Redis 也会使用特殊的 push 消息,通知其客户端将失效消息重定向到其他客户端,告诉它们当前连接已经断开,这样在下一个查询时,客户端就会收到该消息。
我描述的整个过程只是合入到 Redis unstable 分支的内容。这也许不是最终的过程,但是在我们发布 Redis 6 之前还有好几个月,因此还有时间来进行改变:发送反馈给我就行了。我也在想办法让 RESP2 也能支持该特性。这也只是在重定向开启的时候才起作用,监听消息的客户端进入 Pub/Sub 模式,才会发送 Pub/Sub 消息。这样旧客户端也可以使用该特性了。
我希望这能激发你的兴趣:如果客户端缓存执行的不错,我们会提供文档给客户端库开发者,让他们知道如何支持该特性,这样数据会离应用更近,甚至就在小团队支持的、避免实现客户端缓存的应用中。而对于已经实现客户端缓存的大团队和大型应用,也可以减少开销和实现的复杂性。
本文转载自 中间件小哥 公众号。
原文链接:https://mp.weixin.qq.com/s/4hpu4R-IG3CgLTi_U7rjig
评论