文档:客户端缓存

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

通常,当需要数据时,应用服务器会向 Valkey 请求此类信息,如下图所示

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

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

+-------------+                                +----------+
|             |                                |          |
| Application |       ( No chat needed )       |  Valkey  |
|             |                                |          |
+-------------+                                +----------+
| Local cache |
|             |
| user:1234 = |
| username    |
| Alice       |
+-------------+

尽管用于本地缓存的应用程序内存可能不是很大,但访问本地计算机内存所需的时间比访问像 Valkey 这样的网络服务要小几个数量级。由于通常一小部分数据会被频繁访问,这种模式可以大大减少应用程序获取数据的延迟,同时减轻 Valkey 端的负载。

此外,许多数据集中的条目变更非常不频繁。例如,社交网络中大多数用户帖子要么是不可变的,要么很少被用户编辑。再加上通常只有一小部分帖子非常受欢迎,这要么是因为一小部分用户拥有大量关注者,要么是因为最近的帖子有更高的可见度,这清楚地说明了为什么这种模式会非常有用。

通常,客户端缓存的两个主要优点是

  1. 数据可用性延迟非常低。
  2. Valkey 系统接收的查询量减少,使其能够以更少的节点服务相同的数据集。

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

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

有时,根据我们建模的具体应用程序,这并不是什么大问题,因此客户端只会为缓存的信息使用固定的最大“生存时间”。一旦经过给定时间,信息将不再被视为有效。更复杂的模式在使用 Valkey 时,会利用 Pub/Sub 系统向监听客户端发送失效消息。这虽然可行,但从所用带宽的角度来看是复杂且昂贵的,因为此类模式通常涉及向应用程序中的每个客户端发送失效消息,即使某些客户端可能没有失效数据的任何副本。此外,每个更改数据的应用程序查询都需要使用 PUBLISH 命令,这会占用 Valkey 更多的 CPU 时间来处理此命令。

无论使用何种方案,一个简单的事实是:许多大型应用程序都实现了某种形式的客户端缓存,因为它是拥有快速存储或快速缓存服务器的下一个逻辑步骤。因此,Valkey 直接支持客户端缓存,以使这种模式更易于实现、更易于访问、更可靠和更高效。

Valkey 客户端缓存的实现

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

  • 在默认模式下,服务器会记住给定客户端访问了哪些键,并在这些键被修改时发送失效消息。这会在服务器端消耗内存,但仅针对客户端可能在内存中持有的键集发送失效消息。
  • 广播 模式下,服务器不会尝试记住给定客户端访问了哪些键,因此此模式在服务器端根本不消耗内存。相反,客户端订阅键前缀,例如 object:user:,并在每次触及与订阅前缀匹配的键时接收通知消息。

概括地说,现在我们暂时忘掉广播模式,专注于第一种模式。我们稍后将更详细地描述广播。

  1. 客户端如果需要可以启用跟踪。连接开始时未启用跟踪。
  2. 启用跟踪后,服务器会记住每个客户端在连接生命周期内请求了哪些键(通过发送关于这些键的读取命令)。
  3. 当某个键被某个客户端修改、或因关联的过期时间而被驱逐、或因 maxmemory 策略而被驱逐时,所有已启用跟踪且可能缓存了该键的客户端都将收到一条 失效消息
  4. 当客户端收到失效消息时,它们需要删除相应的键,以避免提供过时数据。

这是一个协议示例

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

这表面看起来很棒,但如果您想象有 1 万个连接的客户端都在长期连接中请求数百万个键,服务器最终会存储过多的信息。因此,Valkey 采用了两个关键思想来限制服务器端使用的内存量以及处理实现该功能的数据结构的 CPU 成本

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

双连接模式

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

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

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

(Connection 1 -- used for invalidations)
CLIENT ID
:4
SUBSCRIBE __redis__:invalidate
*3
$9
subscribe
$20
__redis__:invalidate
:1

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

(Connection 2 -- data connection)
CLIENT TRACKING on REDIRECT 4
+OK

GET foo
$3
bar

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

现在,另一个客户端将修改键 "foo" 的值

(Some other unrelated connection)
SET foo bar
+OK

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

(Connection 1 -- used for invalidations)
*3
$7
message
$20
__redis__:invalidate
*1
$3
foo

客户端将检查此缓存槽中是否有缓存的键,并将驱逐不再有效的信息。

请注意,Pub/Sub 消息的第三个元素不是单个键,而是一个只包含一个元素的 Valkey 数组。由于我们发送的是一个数组,如果有多个键需要失效,我们可以在一条消息中完成。在进行清除操作(FLUSHALLFLUSHDB)时,将发送一个 null 消息。

关于使用 RESP2 和 Pub/Sub 连接读取失效消息的客户端缓存,一个非常重要的一点是,使用 Pub/Sub 完全是为了 重用旧的客户端实现,但实际上消息并非真正发送到某个通道并由所有订阅该通道的客户端接收。只有我们在 CLIENT 命令的 REDIRECT 参数中指定的连接才会实际接收 Pub/Sub 消息,这使得该功能更具可扩展性。

当使用 RESP3 时,失效消息会作为 push 消息发送(可以在同一连接中发送,或者在使用重定向时在辅助连接中发送)(有关更多信息,请阅读 RESP3 规范)。

跟踪机制跟踪的内容

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

这有一个明显的优势,即不需要客户端告诉服务器它正在缓存什么。此外,在许多客户端实现中,这正是您想要的,因为一个好的解决方案可能是简单地使用先进先出方法缓存所有尚未缓存的内容:我们可能希望缓存固定数量的对象,每当我们检索到新数据时,都可以将其缓存,同时丢弃最旧的缓存对象。更高级的实现可能会转而丢弃最少使用的对象或类似内容。

请注意,无论如何,如果服务器上存在写入流量,缓存槽将在时间推移过程中失效。通常,当服务器假定我们获取的内容也会被缓存时,我们正在做出一种权衡

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

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

选择性缓存

客户端实现可能只希望缓存选定的键,并明确告知服务器它们将缓存什么以及不缓存什么。这在缓存新对象时需要更多带宽,但同时减少了服务器必须记住的数据量以及客户端收到的失效消息量。

为此,必须使用 OPTIN 选项启用跟踪

CLIENT TRACKING on REDIRECT 1234 OPTIN

在此模式下,默认情况下,读取查询中提及的键 不应被缓存;相反,当客户端想要缓存某些内容时,它必须在实际的数据检索命令之前立即发送一个特殊命令

CLIENT CACHING YES
+OK
GET foo
"bar"

CACHING 命令会影响紧随其后执行的命令,但如果下一个命令是 MULTI,则事务中的所有命令都将被跟踪。类似地,对于 Lua 脚本,脚本执行的所有命令都将被跟踪。

广播模式

至此,我们描述了 Valkey 实现的第一种客户端缓存模型。还有另一种称为广播的模式,它从不同的权衡角度看待问题,不在服务器端消耗任何内存,而是向客户端发送更多的失效消息。在此模式下,我们有以下主要行为

  • 客户端使用 BCAST 选项启用客户端缓存,并使用 PREFIX 选项指定一个或多个前缀。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:。如果根本未指定前缀,则假定前缀为空字符串,因此客户端将收到所有被修改键的失效消息。相反,如果使用一个或多个前缀,则只有与指定前缀之一匹配的键才会在失效消息中发送。
  • 服务器不在失效表中存储任何内容。相反,它使用一个不同的 前缀表,其中每个前缀都与一个客户端列表相关联。
  • 两个前缀不能跟踪键空间的重叠部分。例如,拥有前缀“foo”和“foob”是不允许的,因为它们都会触发键“foobar”的失效。然而,只使用前缀“foo”就足够了。
  • 每次修改与任何前缀匹配的键时,订阅该前缀的所有客户端都将收到失效消息。
  • 服务器消耗的 CPU 与注册前缀的数量成正比。如果前缀数量很少,很难看出任何差异。当前缀数量很大时,CPU 开销可能会变得相当大。
  • 在此模式下,服务器可以优化为订阅给定前缀的所有客户端创建一个单独的回复,并将相同的回复发送给所有客户端。这有助于降低 CPU 使用率。

NOLOOP 选项

默认情况下,客户端跟踪将向修改键的客户端发送失效消息。有时客户端希望如此,因为它们实现了不涉及自动本地缓存写入的非常基本的逻辑。然而,更高级的客户端可能希望将它们在本地内存表中所做的写入也进行缓存。在这种情况下,在写入后立即收到失效消息是一个问题,因为它会强制客户端驱逐刚刚缓存的值。

在这种情况下,可以使用 NOLOOP 选项:它在普通模式和广播模式下都有效。使用此选项,客户端能够告诉服务器它们不希望收到对其修改的键的失效消息。

避免竞态条件

当实现客户端缓存并将失效消息重定向到不同连接时,您应该注意可能存在竞态条件。请看下面的交互示例,我们将数据连接称为“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.

当数据和失效消息使用单个连接时,这种竞态条件是不可能发生的,因为在这种情况下消息的顺序始终是已知的。

与服务器连接断开时应采取的措施

同样,如果与用于获取失效消息的套接字连接丢失,我们可能会得到过时的数据。为了避免这个问题,我们需要做以下事情

  1. 确保如果连接丢失,本地缓存会被清空。
  2. 无论是使用带有 Pub/Sub 的 RESP2 还是 RESP3,都请定期 ping 失效通道(即使连接处于 Pub/Sub 模式下,您也可以发送 PING 命令!)。如果连接看起来已损坏且我们无法收到 ping 回复,则在达到最大时间后,关闭连接并清空缓存。

缓存什么

客户端可能希望运行内部统计,统计某个给定缓存键在请求中实际被服务的次数,以便将来了解哪些内容适合缓存。总的来说

  • 我们不希望缓存许多持续变化的键。
  • 我们不希望缓存许多很少被请求的键。
  • 我们希望缓存那些经常被请求且以合理速率变化的键。举一个键变化速率不合理的例子,可以想象一个不断 INCRement 的全局计数器。

然而,更简单的客户端可能只会使用一些随机采样来驱逐数据,只记住给定缓存值上次被服务的时间,尝试驱逐最近未被服务的键。

实现客户端库的其他提示

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

限制 Valkey 使用的内存量

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