【译】Redis 客户端缓存 (Redis server-assisted client side caching)

【【译】Redis 客户端缓存 (Redis server-assisted client side caching)】官方文档的原文链接 :https://redis.io/topics/client-side-caching
水平有限,没看明白的地方保留了原文,自己写的部分用 (?..) 标了出来
由redis服务端辅助的客户端缓存 客户端缓存是一种用于创建高性能服务的技术。它利用应用服务器(通常它们和redis节点不在同一台服务器)中的可用内存,直接在redis客户端存储数据库的某个子集。
通常,当应用服务器需要数据时会向redis数据库进行请求,如下图所示:

+-------------++----------+ || ------- GET user:1234 -------> || | Application || Database | || <---- username = Alice ------- || +-------------++----------+

当使用客户端缓存时,应用将把热点的数据保存在自己的内存中,不需要再访问redis。
+-------------++----------+ |||| | Application |( No chat needed )| Database | |||| +-------------++----------+ | Local cache | || | user:1234 = | | username| | Alice| +-------------+

虽然应用程序能够用于本地缓存的内存可能不是很大,但是访问本地内存的时间消耗要比访问redis之类的网络服务低好几个数量级。由于一小撮数据的访问频率非常高,因此这种模式可以极大地减少应用获取数据的延迟,并且还可以减少redis的负载。
此外,有很多数据集变化频率很低。例如,社交网络中的大多数用户帖子往往是是不可变或很少被编辑的。通常情况下只有一小部分高热度的帖子,比如有很多关注者的一小部分头部用户发的,或者最近的帖子,这就是为什么这种模式非常有用的原因。
通常,客户端缓存有两个重要优点:
  1. 数据访问延迟很低
  2. 数据库系统需要处理的请求更少,这样相同大小的数据集可以使用更少的节点进行处理
There are only two big problems in computer science… (?计算机科学中只有两个大问题:缓存失效和命名) 上述模式的一个问题是如何使应用端本地缓存的数据失效,以避免向用户显示过时的数据。例如,在上面的例子中,应用在本地缓存了 user:1234 的信息之后,用户Alice可能会将她的用户名更新为Flora。但此时应用可能仍然为她提供旧的用户名。
根据我们的应用程序需求,在有些情况下这不是什么大问题,客户端只需要为缓存设置一个“过期时间”就行了,一旦超过了该时间访问将视为无效。
另一个更复杂的方法是:利用reids发布/订阅向正在监听的客户端发送数据失效消息。
这确实可以实现,但是从带宽使用量的角度来看,这种操作是很复杂和昂贵的,因为这通常需要向每个客户端都发送一遍发送失效消息,即使某些客户端可能并没有缓存该失效数据。此外,每个redis写命令发生时都需要使用 PUBLISH 发送通知,这会消耗更多的服务器cpu。
不管使用什么模式,都需要面对一个简单的事实:许多非常大的应用程序都实现了某种形式的客户端缓存,因为这是实现快速存储或快速缓存服务器的一个理所当然的步骤。出于这个原因,Redis 6实现了对客户端缓存的直接支持,以便使客户端缓存更易于实现、更易于访问、更可靠并且高效。
客户端缓存的Redis实现 Redis支持的客户端缓存被称为 _Tracking_(追踪),它有两种模式:
  • 在默认模式下,服务器会记住每个客户端访问到的键,并在修改键时对相应客户端发送键失效消息。这将消耗服务器端的内存,但可以仅对内存中有这些键的客户端发送失效消息。
  • _broadcasting_ ,即广播模式中,服务器不记录每个客户端各自访问了哪些键,因此该模式下服务器不用消耗额外内存。不过它需要客户端来订阅键前缀的频道,如object:user:,并将在每次碰到匹配此类前缀的键时收到通知消息。
To recap, for now let’s forget for a moment about the broadcasting mode, to focus on the first mode. We’ll describe broadcasting later more in details.
为了简单起见,现在让我们暂时忘记广播模式,专注于第一种模式。广播模式我们稍后将会有更详细的描述。
  1. 客户端可以自主启用追踪功能。连接时默认没有启用该功能。
  2. 追踪开启时,服务器会记住每个客户端在连接期间请求的键 (通过关于这些键的读命令)。
  3. 如果某个键被修改,或者因为超时以及内存淘汰策略被驱逐。所有启用了追踪并且可能缓存了该键的客户端都会收到一个_invalidation message_ (键失效消息)。
  4. 当客户端收到失效消息时,需要删除相应键的本地缓存,以避免提供过时的数据。
以下是该协议的一个例子
  • Client 1 -> Server: CLIENT TRACKING ON (客户端开启功能)
  • Client 1 -> Server: GET foo
  • (服务器记住了客户端1可能有键foo的缓存)
  • (客户端1可能在本地内存中记录foo的值)
  • Client 2 -> Server: SET foo SomeOtherValue 其他客户端修改foo
  • Server -> Client 1: 通知客户端1的 foo 本地缓存失效
这种方式看起来很美,但是你想想:如果有上万个长期连接的客户端每个都请求了数百万的键,服务器根本无法存储这么多信息。出于这个原因,Redis使用了两个关键概念来限制服务器使用的内存量,以及实现该特性所需付出的CPU成本:
  • 服务器会在一个单独的全局表中存储给定的键以及可能缓存了该键的客户端列表。这个全局的表被叫做 Invalidation Table (失效表)。失效表有一个最大容量限制,如果满了以后有新的键加入,服务器将驱逐旧的条目,并通过当做这个key被修改了的方式向客户端发送失效消息。通过这种方式,服务器可以回收用于该键的内存,虽然这会导致客户端中该键的缓存被移除。
  • 在失效表中,我们实际上不需要存储指向客户端结构体的指针,如果这样那么客户端在断开连接时将强制执行垃圾回收。相反,我们只需存储客户端ID(每个Redis客户端都有一个惟一的数字ID)。如果客户端断开连接,随着缓存失效,信息将被逐步的作为垃圾回收。
  • (失效表)有一个单独的命名空间,不按数据库编号划分。因此,如果客户端缓存了数据库2中的键“foo”,而其他一些客户端更改了数据库3中的键“foo”的值,仍然会发送一条失效消息。通过这种方式,我们可以忽略数据库编号,从而减少内存使用和实现复杂性。
两种连接模式 使用Redis 6所支持的新版本协议RESP3,可以在同一连接中执行数据查询并接收失效消息。但是,许多客户端可能更倾向于使用两个独立的连接实现客户端缓存功能:一个用于正常的数据传输,另一个用来接收失效消息。
因此,当客户端启用追踪时,它可以指定将失效消息重定向到另一个客户端id对应的连接上。多个数据处理连接可以将无效消息重定向到同一个失效消息连接上,这对实现了连接池的客户端非常有用。这种两种连接的模型也是唯一支持旧版本协议RESP2的模型(RESP2 没有在同一个连接中对不同类型的信息进行多重处理的能力)。
我们将通过在旧的RRESP2中使用实际的Redis协议来展示一个完整的会话,包括以下步骤:启用追踪并重定向到另一个连接、请求键,以及在键修改后获得一条失效消息。
首先,客户端建立第一个连接,将用于失效消息发送。获取连接ID,并通过发布/订阅功能订阅用于失效消息的频道。
(Connection 1 -- used for invalidations) CLIENT ID :4 SUBSCRIBE __redis__:invalidate *3 $9 subscribe $20 __redis__:invalidate :1

现在我们可以开启数据处理连接的追踪了(并进行重定向 REDIRECT):
(Connection 2 -- data connection) CLIENT TRACKING on REDIRECT 4 +OKGET foo $3 bar

客户端可能决定在本地内存中缓存键值对foo => bar
另一个客户端修改foo的值:
#(另一个无关的连接) SET foo bar +OK

结果,先前建立的连接1将收到一条使指定键失效的消息。
(Connection 1 -- used for invalidations) *3 $7 message $20 __redis__:invalidate *1 $3 foo

客户端将检查缓存中是否有这个键,并驱逐失效的信息。
注意:发布/订阅的消息中的第三个元素不是一个单独的值,而是只有一个元素的数组。因为我们发送数组的话,如果有一组键要作废,可以在一条消息中完成。
要理解与RESP2一起使用的客户端缓存,以及用于读取失效消息的发布/订阅连接,有一点非常重要:使用发布/订阅这种方式完全是为了复用旧的客户端实现的技巧,但实际上消息并没有真正发送到一个频道并被所有订阅它的客户端接收。只有我们在 CLIENT 命令的REDIRECT 参数中指定的连接才会实际接收到发布/订阅消息,这使得该特性更具可伸缩性。
当使用新的RESP3协议时,失效消息被作为 push 消息发送 (有关更多信息,请阅读RESP3规范)。
What tracking tracks 正如你看到的,默认条件下客户端不需要告诉服务器它们缓存了哪些键。所有读命令中涉及到的键都会被服务器追踪,因为它们 可能被缓存 了。
这有一个明显的优点,即不需要客户端告诉服务器它正在缓存什么。此外在很多客户端实现中这正是期望的结果,因为一个好的实现方式就是缓存一切还没有缓存的内容。
使用后进先出的方式:我们可能想给缓存的对象数量设置一个上限,每有一个(超出容量的)新数据,我们可以缓存它,同时丢弃最老的缓存对象。更高级的实现方式可能会丢弃最少使用的对象或类似的其他方式。
注意,只要发生写操作,相应的缓存就会在此时失效。通常情况下,当服务器假设客户端会缓存全部信息,我们分析一下这样做的利弊:
  1. It is more efficient when the client tends to cache many things with a policy that welcomes new objects.(没看懂。。。可能是指LRU?)
  2. 服务器将被迫保留更多关于客户端键的数据。
  3. 客户端将接收那些它并没有缓存的键的无用失效消息。
接下来描述另一种方式。
选择性缓存 (注:此部分工作仍在进行中,尚未在Redis内部实现)
客户端可能只想要缓存指定的某些键,并显式的告知服务器哪些缓存,哪些不缓存。这将在缓存新对象时需要更多的带宽,但同时会减少服务器必须记住的数据量和客户端接收的失效效消息的数量。
为此,开启缓存时需要使用OPTIN选项:
CLIENT TRACKING on REDIRECT 1234 OPTIN

在这种模式的默认情况下,在读取查询中涉及到的键不应该被缓存。当客户端想要缓存某些值时,它必须在实际的读命令之前发送一个特殊的命令(CACHING):
CACHING +OK GET foo "bar"

为了让协议更高效,CACHING 可以指定 NOREPLY 选项:这时该命令将没有回复
CACHING NOREPLY GET foo "bar"

CACHING 命令会影响紧随其后执行的命令,但是如果下一个命令是 MULTI,那么事务中的所有命令都会被追踪。同样,如果是Lua脚本,脚本执行的所有命令都将被追踪。
广播模式 到目前为止,我们描述了Redis实现的第一种客户端缓存模型(由服务器保存key被哪些客户端缓存,进行通知)。还有另一个被称为广播的模式。这是从另一个角度解决问题:不额外消耗服务器内存,而是向客户端发送更多的无用的失效消息。这种模式下,redis主要有以下行为:
  • 客户端使用 BCAST 选项开启客户端缓存,并通过 PREFIX 来指定一个或多个前缀。例如 CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:。如果没有指定前缀,将默认为空字符串,这时客户端将接收所有的键更改时发出的失效消息。如果指定了前缀,那么只有能被这些前缀匹配上的键更改时客户端才会收到失效消息。
  • 服务端不存储任何失效表。代替它的是一个 Prefixes Table 前缀表,每个前缀与一组客户端相关联。
  • 每当有匹配到前缀的键发生更改,所有关联了该前缀的客户端都将收到失效消息。
  • 随着注册的前缀数量增多,服务器将成正比的消耗更多cpu。如果前缀量很少的话,看不出有什么区别。如果使用大量前缀时,cpu消耗量可能会非常高。
  • 在这种模式下,服务端可以执行优化,为所有订阅了给定前缀的客户端创建同一个回复,并发送给所有客户端。这有助于降低CPU使用率。
NOLOOP 选项 默认情况下,该方式甚至会向修改了键的那个客户端也发送失效消息。对某些客户端来说确实需要这样做,因为他们只实现了非常基本的逻辑,而没有实现写操作时自动更新缓存。然而,更高级的客户端可能会在写操作时更新本地缓存。在这种情况下,在写操作之后立即收到一条失效消息将导致一个问题,它将令客户端删除它刚刚缓存的值。
在这种情况下,可以使用NOLOOP选项:它在默认模式和广播模式下都可以运行。开启该选项后,客户端可以告诉服务端在由它们自己更改键的情况下,不想收到失效消息。
避免竞态条件 当客户端实现缓存接收失效消息重定向到其他连接的功能时,你应该知道可能存在竞态条件。看下面的交互例子(数据处理连接为D,失效消息连接为I):
[D] client -> server: GET foo [I] server -> client: Invalidate foo (其他客户端更改,发送了失效消息) [D] server -> client: "bar" (第一步命令 "GET foo" 的回复)

正如你看到的样子,我们先收到了失效消息而后接到 GET 命令的响应。所以客户端将继续提供foo键的旧版本。To avoid this problem, it is a good idea to populate the cache when we send the command with a placeholder:(?没明白怎么翻,结合后文看大意是缓存前先设置占位符,没有占位符的就不缓存)
#客户端缓存:给键 "foo" 本地设置进程内缓存[D] client-> server: GET foo [I] server -> client: Invalidate foo (其他客户端更改了它)Client cache: 从本地缓存中删除 "foo" [D] server -> client: "bar" (第一步命令 "GET foo" 的回复) Client cache: 不缓存值 "bar",因为键 “foo”不存在.

当数据处理和无效消息使用同一个连接时,则不可能出现这种竞态条件,因为在这种情况下,消息的顺序是确定的(失效消息和GET 命令的响应之间的顺序)。
当与服务器断开连接怎么办 如果我们断开了用于获取失效消息的连接,则可能导致获取到旧的数据。为了避免该问题,我们需要做以下几件事:
  1. 确保如果连接断开,清空所有客户端缓存
  2. 无论是基于RESP2使用发布/订阅 还是 RESP3,定期的ping失效消息的频道。如果连接看起来断开了,并且我们无法接收ping的回复,在到达最大时间之后,关闭连接并清空缓存。
怎样缓存 客户端可能想要运行一个内部统计,来统计一个给定的缓存键在一个请求中实际生效的次数,来判断将来怎样做好缓存。总之:
  • 我们不想缓存太多变化频繁的键
  • 我们不想缓存低频访问的键
  • 我们希望缓存那些请求频率高并且变化频率合理的键。举个变化频率不合理的例子:一个不断自增的全局计数器。
然而,简单的客户端可能只是使用随机抽样来淘汰数据,仅仅记住缓存值上次被使用的时刻,然后试图淘汰最久没有使用的键(lru)。
关于客户端库实现的其他提示
  • 处理TTL:如果希望缓存键时支持超时删除,请确保同时请求键的TTL并在本地缓存中设置TTL。
  • 即使某些键没有过期时间的需求,但是给每个键设置都一个最大超时时间是很好的做法。这是防止bug或连接问题的一种很好的方法,这些问题会使客户端在本地副本中保留旧数据。
  • 客户端使用的内存数需要得到限制。当添加新的键时,必须有一种方法来驱逐旧的键。
限制redis内存使用总量 只需要确保在默认模式下设置合理的记住的键数量限制(即失效表的容量限制),或者直接使用不消耗redis服务器额外内存的广播模式。请注意,当不使用广播模式时,Redis消耗的内存与所追踪的键的数量以及请求这些键的客户端数量成正比。

    推荐阅读