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

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

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

  1. +-------------+ +----------+
  2. | | ------- GET user:1234 -------> | |
  3. | Application | | Database |
  4. | | <---- username = Alice ------- | |
  5. +-------------+ +----------+

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

  1. +-------------+ +----------+
  2. | | | |
  3. | Application | ( No chat needed ) | Database |
  4. | | | |
  5. +-------------+ +----------+
  6. | Local cache |
  7. | |
  8. | user:1234 = |
  9. | username |
  10. | Alice |
  11. +-------------+

虽然用于本地缓存的应用程序内存可能不大,但访问本地计算机内存所需的时间比访问像数据库这样的网络服务要小得多。由于通常只有一小部分数据频繁被访问,这种模式可以大大减少应用程序获取数据的延迟,同时减轻数据库端的负载。

此外,许多数据集中的项目非常不频繁地更改。例如,社交网络中的大多数用户帖子要么是不可变的,要么很少由用户编辑。再加上通常只有一小部分帖子非常受欢迎,要么是因为一小部分用户有很多关注者,要么是因为最近的帖子有更多的可见性,这就是为什么这种模式可能非常有用。

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

  1. 数据可用性非常低的延迟。
  2. 数据库系统接收到的查询更少,允许它使用更少的节点提供相同的数据集。

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

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

有时,这并不是什么大问题,因此客户端可以使用固定的最长“生存时间”(TTL)来缓存信息。一旦过了一定的时间,该信息将不再被认为是有效的。更复杂的模式,当使用 Redict 时,利用 Pub/Sub 系统向监听的客户端发送失效消息。这可以实现,但从使用的带宽角度来看是棘手和昂贵的,因为通常这种模式涉及向应用程序中的每个客户端发送失效消息,即使某些客户端可能没有任何已失效数据的副本。此外,每个修改数据的应用程序查询都需要使用 PUBLISH 命令,这使得数据库需要更多的 CPU 时间来处理这个命令。

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

Redict 实现客户端缓存

Redict 的客户端缓存支持称为 Tracking,并有两种模式:

  • 在默认模式下,服务器记住了给定客户端访问的键,并在这些相同的键被修改时发送失效消息。这在服务器端消耗内存,但只发送客户端可能已在内存中的键的失效消息。
  • broadcasting 模式下,服务器不尝试记住给定客户端访问了哪些键,因此这种模式在服务器端不消耗任何内存。相反,客户端订阅诸如 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”

这表面上看起来很棒,但如果你想象有 10k 个连接的客户端在长时间连接上请求了数百万的键,服务器最终会存储太多信息。因此,Redict 使用了两个关键思想来限制服务器端使用的内存量和处理实现该功能的数据结构的 CPU 成本:

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

两种连接模式

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

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

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

  1. (连接 1 -- 用于失效)
  2. CLIENT ID
  3. :4
  4. SUBSCRIBE __redict__:invalidate
  5. *3
  6. $9
  7. subscribe
  8. $20
  9. __redict__:invalidate
  10. :1

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

  1. (连接 2 -- 数据连接)
  2. CLIENT TRACKING on REDIRECT 4
  3. +OK
  4. GET foo
  5. $3
  6. bar

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

另一个不同的客户端现在将修改 “foo” 键的值:

  1. (一些其他无关的连接)
  2. SET foo bar
  3. +OK

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

  1. (连接 1 -- 用于失效)
  2. *3
  3. $7
  4. message
  5. $20
  6. __redict__:invalidate
  7. *1
  8. $3
  9. foo

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

请注意,Pub/Sub 消息的第三个元素不是单个键,而是只有一个元素的 Redict 数组。由于我们发送了一个数组,如果有一组键要失效,我们可以在单个消息中这样做。在发生冲洗(FLUSHALLFLUSHDB)的情况下,将发送一个 null 消息。

了解使用 RESP2 和 Pub/Sub 连接来读取失效消息的客户端缓存非常重要,使用 Pub/Sub 完全是一个技巧,为了重用旧的客户端实现,但实际上消息并不是真的发送到一个通道并被所有订阅它的客户端接收。只有在 CLIENT 命令的 REDIRECT 参数中指定的连接才会实际接收到 Pub/Sub 消息,使这个特性更加可扩展。

当使用 RESP3 时,失效消息会作为 push 消息发送(要么在同一个连接中,要么在使用重定向时在辅助连接中),具体可以阅读 RESP3 规范以获取更多信息。

跟踪跟踪的内容

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

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

请注意,无论如何,如果服务器上有写入流量,在时间的推移中缓存槽将被失效。一般来说,当服务器假设我们获取的我们也缓存时,我们正在进行权衡:

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

因此,下一节将描述另一种选择。

选择性缓存

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

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

  1. CLIENT TRACKING on REDIRECT 1234 OPTIN

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

  1. CLIENT CACHING YES
  2. +OK
  3. GET foo
  4. "bar"

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

广播模式

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

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

NOLOOP 选项

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

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

避免竞态条件

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

  1. [D] 客户端 -> 服务器:GET foo
  2. [I] 服务器 -> 客户端:使 foo 失效(其他人触摸了它)
  3. [D] 服务器 -> 客户端:"bar""GET foo" 的回复)

如您所见,由于 GET 的回复到达客户端的速度较慢,我们在实际不再有效的数据到达之前收到了失效消息。所以我们将继续提供 foo 键的过时版本。为了避免这个问题,最好在发送命令时使用占位符填充缓存:

  1. 客户端缓存:将 "foo" 的本地副本设置为 "caching-in-progress"
  2. [D] 客户端-> 服务器:GET foo
  3. [I] 服务器 -> 客户端:使 foo 失效(其他人触摸了它)
  4. 客户端缓存:从本地缓存中删除 "foo"
  5. [D] 服务器 -> 客户端:"bar""GET foo" 的回复)
  6. 客户端缓存:由于缺少 "foo" 的条目,不设置 "bar"

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

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

类似地,如果我们失去了用于获取失效消息的套接字连接,我们可能会以过时的数据结束。为了避免这个问题,我们需要做以下事情:

  1. 确保如果连接丢失,本地缓存被清空。
  2. 无论是使用 RESP2 与 Pub/Sub,还是 RESP3,都要定期 ping 失效通道(即使在 Pub/Sub 模式下,也可以发送 PING 命令!)。如果连接看起来断了,我们无法接收 ping 响应,经过最长时间后,关闭连接并清空缓存。

要缓存什么

客户端可能想要运行内部统计,以了解给定缓存键在请求中实际提供服务的次数,以了解将来要缓存什么。一般来说:

  • 我们不想缓存不断变化的许多键。
  • 我们不想缓存很少被请求的许多键。
  • 我们想要缓存经常请求并且以合理速率变化的键。对于以不合理速率变化的键的一个例子,想想一个不断被 INCR 增加的全局计数器。

然而,更简单的客户端可能只是使用某种随机抽样来逐出数据,只记住给定缓存值上次提供服务的时间,尝试逐出最近未提供服务的键。

实现客户端库的其他提示

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

限制 Redict 使用的内存量

确保为 Redict 配置了合适的值,以记住的最大键数,或者使用不消耗 Redict 端任何内存的 BCAST 模式。请注意,当不使用 BCAST 时,Redict 消耗的内存与跟踪的键数和请求这些键的客户端数量成正比。