Redis中的客户端缓存

内容纲要

Redis中的服务器辅助客户端缓存

客户端缓存是一种用于创建高性能服务的技术。它利用应用程序服务器(与数据库节点相比,这些服务器通常是不同的计算机)上的可用内存,直接在应用程序端存储数据库信息的一些子集。

通常,当需要数据时,应用服务器会向数据库询问这些信息,如下图所示:

当使用客户端缓存时,应用程序将把热门查询的回复直接存储在应用程序内存中,以便以后可以重用这些回复,而无需再次联系数据库:

虽然用于本地缓存的应用程序内存可能不是很大,但访问本地计算机内存所需的时间与访问数据库等网络服务相比要小几个数量级。由于经常访问相同的一小部分数据,因此这种模式可以大大减少应用程序获取数据的延迟,同时减少数据库端的负载。

此外,有许多数据集的项目变化非常不频繁。例如,社交网络中的大多数用户帖子要么是不可更改的,要么很少被用户编辑。此外,通常只有一小部分帖子非常受欢迎,要么是因为一小部分用户有很多粉丝,要么是因为最近的帖子有更多的可见度,这就很清楚为什么这种模式非常有用。

通常客户端缓存的两个关键优势是:

  1. 数据可用的延迟非常小。
  2. 数据库系统接收更少的查询,允许它以更少的节点数量为相同的数据集服务。

计算机科学中有两个难题……

上述模式的一个问题是:如何使应用程序所持有的信息无效,以避免向用户显示过时的数据。例如,上面的应用程序在本地缓存用户:1234的信息后,Alice可能会将其用户名更新为Flora。然而,应用程序可能继续为用户1234提供旧用户名。

有时,根据我们正在建模的确切应用程序,这并不是什么大问题,因此客户机只会为缓存的信息使用固定的最大“生存时间”。一旦经过了一定的时间,信息就不再被认为是有效的。更复杂的模式,当使用Redis时,利用Pub/Sub系统向侦听客户端发送无效消息。这是可行的,但从所使用的带宽的角度来看,这很棘手,成本也很高,因为这种模式通常涉及向应用程序中的每个客户机发送无效消息,即使某些客户机可能没有无效数据的任何副本。而且,修改数据的每个应用程序查询都需要使用PUBLISH命令,这会使数据库花费更多的CPU时间来处理该命令。

不管使用什么模式,都有一个简单的事实:许多非常大的应用程序实现了某种形式的客户端缓存,因为这是拥有快速存储或快速缓存服务器的下一个逻辑步骤。出于这个原因,Redis 6实现了对客户端缓存的直接支持,以使这个模式更容易实现,更容易访问,更可靠,更高效。

Redis客户端缓存的实现

Redis客户端缓存支持被称为 Tracking (跟踪),有两种模式:

  1. 在默认模式下,服务器会记住给定客户端访问了哪些key,并在相同的key修改时发送无效消息。这将消耗服务器端的内存,但只会为客户机内存中可能存在的key集发送无效消息。
  2. 在broadcasting 广播模式下,服务器不会试图记住给定客户端访问了哪些key,因此这种模式在服务器端完全不消耗内存。相反,客户端订阅诸如object:user:这样的key前缀,并且每次触发与订阅的前缀匹配的key时都会收到通知消息。

回顾一下,现在让我们暂时忘记广播模式,专注于第一种模式。稍后我们将更详细地描述广播。

默认模式下:

  1. 如果客户端愿意,可以启用跟踪。在没有启用跟踪的情况下启动连接。
  2. 启用跟踪后,服务器会记住每个客户端在连接生命周期内请求的key(通过发送关于这些key的读取命令)。
  3. 当某个客户端修改了某个key,或者因为它有关联的过期时间而被移除,或者因为maxmemory策略而被移除时,所有启用了跟踪且可能缓存了该key的客户端都会收到一条无效消息。
  4. 当客户端收到无效消息时,需要删除相应的密钥,以避免提供过时的数据。

下面是协议的示例:

  • Client 1 -> Server: CLIENT TRACKING ON
  • Client 1 -> Server: GET foo
  • (服务器记住客户端1可能缓存了key “foo”)
  • (客户端1可能会记住本地内存中的“foo”值)
  • Client 2 -> Server: SET foo SomeOtherValue
  • Server -> Client 1: INVALIDATE "foo"

从表面上看,这看起来很棒,但如果您想象一下,在长期连接中,1万个连接的客户端都要求数百万个key,那么服务器最终会存储太多的信息。出于这个原因,Redis使用了两个关键思想来限制服务器端使用的内存数量和处理实现该功能的数据结构的CPU成本:

  • 服务器记住可能在单个全局表中缓存了给定key的客户端列表。这个表称为无效表。无效表可以包含一个最大数量的条目。如果插入了新key,服务器可能会假装该key已被修改(即使没有),并向客户端发送无效消息,从而删除旧的条目。这样做,它可以回收用于该key的内存,即使这将迫使拥有key本地副本的客户端将其驱逐。
  • 在无效表中,我们不需要存储指向客户端结构的指针,这会在客户端断开连接时强制执行垃圾收集过程:相反,我们所做的只是存储客户端ID(每个Redis客户端都有一个唯一的数字ID)。如果客户端断开连接,当缓存槽失效时,信息将被增量地垃圾收集。
  • 有一个单独的key命名空间,不按数据库编号划分。因此,如果一个客户端正在数据库2中缓存key foo,而其他一些客户端更改了数据库3中key foo的值,仍然会发送一个无效消息。通过这种方式,我们可以忽略数据库数量,从而减少内存使用和实现复杂性。

两种连接方式

使用Redis 6支持的新版本Redis协议RESP3,可以在同一个连接中运行数据查询和接收无效消息。然而,许多客户端实现可能更喜欢使用两个独立的连接来实现客户端缓存:一个用于数据,另一个用于无效消息。因此,当客户端启用跟踪时,它可以指定通过指定不同连接的“客户端ID”将无效消息重定向到另一个连接。许多数据连接可以将无效消息重定向到同一连接,这对于实现连接池的客户端非常有用。两个连接模型是RESP2唯一支持的模型(它缺乏在同一个连接中复用不同类型信息的能力)。

下面是一个在旧的RESP2模式下使用Redis协议的完整会话示例,涉及以下步骤:启用跟踪重定向到另一个连接,请求key,并在key被修改后获得无效消息。

首先,客户端打开第一个用于失效的连接,请求连接ID,并通过Pub/Sub订阅到特殊通道,该通道在RESP2模式下用于获取失效消息(记住RESP2是常用的Redis协议,而不是更高级的协议,你可以选择使用Redis 6使用HELLO命令):

现在我们可以从数据连接中启用跟踪:

客户端可能决定在本地内存中缓存"foo" => "bar"。

另一个客户端现在将修改"foo" key的值:

结果,invalidations连接将收到一条使指定key失效的消息。

客户端将检查这个缓存槽中是否有缓存的 key,并将删除不再有效的信息。

注意,发布/订阅消息的第三个元素不是一个 key,而是一个只有一个元素的Redis数组。由于我们发送了一个数组,如果有一组 key要使其失效,我们可以在一条消息中完成。在刷新(FLUSHALL或FLUSHDB)的情况下,将发送一个空消息。

关于RESP2和Pub/Sub连接使用的客户端缓存,为了读取无效消息,需要了解的一件非常重要的事情是,使用Pub/Sub完全是为了重用旧的客户端实现,但实际上消息并没有真正发送到通道,并由订阅它的所有客户端接收。只有我们在CLIENT命令的REDIRECT参数中指定的连接才会实际接收发布/订阅消息,这使得该特性更具可伸缩性。

当改用RESP3时,无效消息将作为推送消息发送(在同一连接中,或者在使用重定向时在辅助连接中)(有关详细信息,请阅读RESP3规范)。

tracking跟踪什么

如您所见,默认情况下,客户端不需要告诉服务器它们正在缓存哪些键。在只读命令的上下文中提到的每个key都由服务器跟踪,因为它可以被缓存。

这有一个明显的优点,即不需要客户端告诉服务器它正在缓存什么。此外,在许多客户端实现中,这就是你想要的,因为一个好的解决方案可能只是缓存所有还没有缓存的东西,使用先进先出的方法:我们可能想要缓存固定数量的对象,我们检索的每一个新数据,我们都可以缓存它,丢弃旧的缓存对象。更高级的实现可能会删除最少使用的对象或类似的内容。

请注意,无论如何,如果服务器上有写流量,缓存插槽将在此期间失效。一般来说,当服务器认为我们得到的东西也会被缓存时,我们就会做出权衡:

  1. 当客户端倾向于使用欢迎新对象的策略缓存许多内容时,效率会更高。
  2. 服务器将被迫保留更多关于客户端key的数据。
  3. 客户端将收到关于它没有缓存的对象的无用失效消息。

因此,下一节将介绍一种替代方案。

选择缓存

客户端实现可能希望只缓存选定的key,并显式地与服务器通信它们将缓存什么和不缓存什么。在缓存新对象时,这将需要更多的带宽,但同时减少了服务器必须记住的数据量和客户端接收到的无效消息量。

为了做到这一点,跟踪必须使用OPTIN选项启用:

CLIENT TRACKING on REDIRECT 1234 OPTIN

在这种模式下,默认情况下,读查询中提到的key不应该被缓存,相反,当客户端想要缓存某些东西时,它必须在实际检索数据的命令之前立即发送一个特殊的命令:

CLIENT CACHING YES
+OK
GET foo
"bar"

缓存命令会影响在它之后立即执行的命令,但是如果下一个命令是MULTI,那么事务中的所有命令都将被跟踪。类似地,对于Lua脚本,脚本执行的所有命令都将被跟踪。

广播模式

到目前为止,我们描述了Redis实现的第一个客户端缓存模型。还有另一种方法,称为广播,它从不同的权衡角度来看待问题,它不会在服务器端消耗任何内存,而是向客户端发送更多的无效消息。在这种模式下,我们有以下主要行为:

  • 客户端使用BCAST选项启用客户端缓存,使用PREFIX选项指定一个或多个前缀。例如:CLIENT TRACKING on REDIRECT 10 BCAST前缀对象:前缀用户:。如果根本没有指定前缀,则假定前缀为空字符串,因此客户端将收到每个被修改的键的无效消息。相反,如果使用了一个或多个前缀,则在无效消息中只发送与指定前缀之一匹配的键。
  • 服务器不会在无效表中存储任何东西。相反,它使用不同的前缀表,其中每个前缀都关联到一个客户端列表。
  • 没有两个前缀可以跟踪键空间的重叠部分。例如,有前缀“foo”和“foob”是不允许的,因为它们都会触发键“foobar”的无效。但是,只使用前缀“foo”就足够了。
  • 每当匹配任何前缀的密钥被修改时,所有订阅该前缀的客户端都将收到无效消息。
  • 服务器将消耗与注册前缀数量成比例的CPU。如果你只有几个,很难看出有什么不同。有了大量的前缀,CPU成本就会变得相当大。
  • 在这种模式下,服务器可以为订阅给定前缀的所有客户端创建一个回复,并向所有客户端发送相同的回复。这有助于降低CPU的使用率。

NOLOOP选项

默认情况下,客户端跟踪将向修改key的客户端发送无效消息。有时客户端需要这样做,因为它们实现了非常基本的逻辑,不涉及在本地自动缓存写操作。然而,更高级的客户端可能希望将正在执行的写操作缓存到本地内存表中。在这种情况下,在写入后立即接收无效消息是一个问题,因为它将迫使客户端删除它刚刚缓存的值。

在这种情况下,可以使用NOLOOP选项:它可以在正常模式和广播模式下工作。使用此选项,客户端可以告诉服务器,它们不想接收它们修改的key的无效消息。

避免竞态条件

在实现客户端缓存将无效消息重定向到不同的连接时,您应该意识到可能存在竞争条件。请看下面的交互示例,我们将数据连接称为“D”,无效连接称为“I”:

[D] client -> server: GET foo
[I] server -> client: Invalidate foo (somebody else touched it)
[D] server -> client: "bar" (the reply of "GET foo")

正如您所看到的,由于对GET的响应到达客户端的速度较慢,我们在已经不再有效的实际数据之前收到了无效消息。我们将继续提供一个过时的foo键。为了避免这个问题,在发送命令时使用占位符填充缓存是一个好主意:

Client cache: set the local copy of "foo" to "caching-in-progress"
[D] client-> server: GET foo.
[I] server -> client: Invalidate foo (somebody else touched it)
Client cache: delete "foo" from the local cache.
[D] server -> client: "bar" (the reply of "GET foo")
Client cache: don't set "bar" since the entry for "foo" is missing.

在对数据消息和无效消息使用单个连接时,不可能出现这种竞争条件,因为在这种情况下,消息的顺序总是已知的。

当与服务器失去连接时该怎么办

类似地,如果为了获取无效消息而丢失了与套接字的连接,则可能会得到陈旧的数据。为了避免这个问题,我们需要做到以下几点:

  • 确保如果连接丢失,将刷新本地缓存。
  • 无论是使用带有Pub/Sub的RESP2还是使用RESP3,都会定期ping无效通道(即使连接处于Pub/Sub模式,您也可以发送ping命令!)。如果连接看起来中断,并且我们无法接收ping回,则在最长时间后关闭连接并刷新缓存。

缓存什么

客户端可能希望运行关于给定缓存键在请求中实际被服务的次数的内部统计数据,以便将来了解哪些是适合缓存的。一般来说:

  • 我们不想缓存很多不断变化的键。
  • 我们不想缓存很多很少请求的键。
  • 我们希望缓存经常被请求并以合理的速度更改的键。对于一个键没有以合理的速率变化的例子,可以考虑一个持续递增的全局计数器。

然而,更简单的客户端可能只是使用一些随机抽样来清除数据,只记住上一次提供给定缓存值的时间,并试图清除最近没有提供的键。

实现客户端库的其他提示

  • 处理TTL:如果您希望支持带TTL的缓存键,请确保您还请求键的TTL并在本地缓存中设置TTL。
  • 对每个键都设置最大TTL是个好主意,即使它没有TTL。这可以防止错误或连接问题,从而使客户端在本地副本中拥有旧数据。
  • 限制客户机使用的内存量是绝对需要的。当添加新密钥时,必须有一种方法来驱逐旧密钥。

限制Redis使用的内存量

一定要为Redis所记住的最大键数配置一个合适的值,或者在Redis端使用完全不消耗内存的BCAST模式。注意,当BCAST不被使用时,Redis所消耗的内存与跟踪的键的数量和请求这些键的客户端数量成正比。

Leave a Comment

您的电子邮箱地址不会被公开。 必填项已用*标注

close
arrow_upward