KeyDB 服务器辅助客户端缓存
客户端缓存是一种用于创建高性能服务的技术。它利用应用程序服务器(通常与数据库节点是不同的计算机)中的可用内存,将数据库信息的子集直接存储在应用程序端。
通常,当需要某些数据时,应用程序服务器会向数据库请求这些信息,如下图所示
当使用客户端缓存时,应用程序会将常用查询的回复直接存储在应用程序内存中,以便以后可以重复使用这些回复,而无需再次联系数据库。
尽管用于本地缓存的应用程序内存可能不是很大,但访问本地计算机内存所需的时间比请求数据库等网络服务所需的时间要小几个数量级。由于通常相同的小部分数据被频繁访问,这种模式可以大大降低应用程序获取数据的延迟,同时减少数据库端的负载。
此外,许多数据集中的项目很少发生变化。例如,社交网络中的大多数用户帖子是不可变的,或者很少被用户编辑。考虑到通常只有一小部分帖子非常受欢迎(要么因为一小部分用户拥有大量粉丝,要么因为最新帖子有更多可见性),很明显为什么这种模式会非常有用。
通常,客户端缓存的两个主要优点是
- 数据以极低的延迟可用。
- 数据库系统接收的查询更少,从而可以用更少的节点服务相同的数据集。
#
计算机科学中只有两个大问题...上述模式的一个问题是如何使应用程序持有的信息失效,以避免向用户呈现过时的数据。例如,当应用程序在本地缓存 user:1234 信息后,Alice 可能会将其用户名更新为 Flora。然而,应用程序可能会继续为用户 1234 提供旧的用户名。
有时,根据我们建模的具体应用程序,这个问题并不大,因此客户端将为缓存信息使用固定的最大“生存时间”。一旦经过给定的时间量,信息将不再被视为有效。当使用 KeyDB 时,更复杂的模式利用 Pub/Sub 系统向监听的客户端发送失效消息。这可以实现,但从所使用的带宽的角度来看,这很棘手且成本高昂,因为这种模式通常涉及向应用程序中的每个客户端发送失效消息,即使某些客户端可能没有任何失效数据的副本。此外,每个更改数据的应用程序查询都需要使用 PUBLISH
命令,这会花费数据库更多的 CPU 时间来处理此命令。
无论使用何种方案,有一个简单的事实:许多非常大的应用程序都实现了某种形式的客户端缓存,因为这是拥有快速存储或快速缓存服务器的下一个逻辑步骤。因此,KeyDB 6 实现了对客户端缓存的直接支持,以使这种模式更容易实现、更易于访问、更可靠和更高效。
#
KeyDB 客户端缓存的实现KeyDB 客户端缓存支持称为 *追踪*,它有两种模式
- 在默认模式下,服务器会记住给定客户端访问了哪些键,并在相同的键被修改时发送失效消息。这会消耗服务器端内存,但只针对客户端可能在内存中缓存的键集发送失效消息。
- 相反,在 *广播* 模式下,服务器不会尝试记住给定客户端访问了哪些键,因此此模式根本不会消耗服务器端的任何内存。相反,客户端订阅诸如
object:
或user:
等键前缀,并且每次触及与此类前缀匹配的键时,都会收到通知消息。
回顾一下,现在我们暂时忘记广播模式,专注于第一种模式。我们稍后将更详细地描述广播。
- 客户端可以根据需要启用追踪。连接开始时未启用追踪。
- 启用追踪后,服务器会记住每个客户端在连接生命周期内请求了哪些键(通过发送关于这些键的读取命令)。
- 当某个客户端修改了某个键,或者由于关联的过期时间而被逐出,或者由于 *maxmemory* 策略而被逐出时,所有启用追踪且可能缓存了该键的客户端都会收到 *失效消息* 的通知。
- 当客户端收到失效消息时,它们需要删除相应的键,以避免提供过时的数据。
这是一个协议示例
- 客户端 1
->
服务器:CLIENT TRACKING ON - 客户端 1
->
服务器:GET foo - (服务器记住客户端 1 可能已缓存键 "foo")
- (客户端 1 可能会在其本地内存中记住 "foo" 的值)
- 客户端 2
->
服务器:SET foo SomeOtherValue - 服务器
->
客户端 1:INVALIDATE "foo"
这表面上看起来很棒,但如果您考虑 10k 个连接的客户端在每个长寿命连接的历史中都请求数百万个键,服务器最终会存储过多的信息。因此,KeyDB 使用两个关键思想来限制服务器端使用的内存量以及处理实现该功能的数据结构的 CPU 成本
- 服务器将可能缓存了给定键的客户端列表存储在一个全局表中。此表称为 **失效表**。此失效表可以包含最大数量的条目,如果插入新键,服务器可以通过假装该键已修改(即使未修改)来逐出旧条目,并向客户端发送失效消息。通过这样做,它可以回收用于此键的内存,即使这会强制拥有该键本地副本的客户端将其逐出。
- 在失效表中,我们实际上不需要存储指向客户端结构的指针,这会强制在客户端断开连接时进行垃圾回收:相反,我们只存储客户端 ID(每个 KeyDB 客户端都有一个唯一的数字 ID)。如果客户端断开连接,信息将在缓存槽失效时逐步进行垃圾回收。
- 只有一个键命名空间,不按数据库编号划分。因此,如果客户端在数据库 2 中缓存键
foo
,而另一个客户端更改了数据库 3 中键foo
的值,则仍将发送失效消息。这样,我们可以忽略数据库编号,从而减少内存使用和实现复杂性。
#
双连接模式使用 KeyDB 6 支持的新版 KeyDB 协议 RESP3,可以在同一连接中运行数据查询并接收失效消息。然而,许多客户端实现可能更喜欢使用两个独立的连接来实现客户端缓存:一个用于数据,一个用于失效消息。因此,当客户端启用追踪时,可以通过指定不同连接的“客户端 ID”来将失效消息重定向到另一个连接。许多数据连接可以将失效消息重定向到同一连接,这对于实现连接池的客户端很有用。双连接模型也是 RESP2(缺少在同一连接中多路复用不同类型信息的能力)唯一支持的模型。
我们将展示一个示例,这次使用旧的 RRESP2 模式下的实际 KeyDB 协议,说明一个完整的会话,包括以下步骤:启用追踪重定向到另一个连接,请求一个键,以及在该键被修改后收到失效消息。
首先,客户端打开第一个连接,该连接将用于失效,请求连接 ID,并通过 Pub/Sub 订阅特殊通道,该通道用于在 RESP2 模式下获取失效消息(请记住,RESP2 是通常的 KeyDB 协议,而不是您可以使用 HELLO
命令选择性地与 KeyDB 6 一起使用的更高级的协议)
现在我们可以从数据连接启用追踪
客户端可能会决定将 "foo" => "bar"
缓存到本地内存中。
另一个客户端现在将修改 "foo" 键的值
结果,失效连接将收到一条消息,使指定的键失效。
客户端将检查该缓存槽中是否有缓存的键,并将逐出不再有效的信息。
请注意,Pub/Sub 消息的第三个元素不是单个键,而是一个 KeyDB 数组,只有一个元素。由于我们发送一个数组,如果有要失效的键组,我们可以在一条消息中完成。
关于使用 RESP2 和 Pub/Sub 连接读取失效消息的客户端缓存,一个非常重要的一点是,使用 Pub/Sub 完全是为了 **重用旧的客户端实现**,但实际上消息并非真正发送到一个通道并由所有订阅它的客户端接收。只有我们在 CLIENT
命令的 REDIRECT
参数中指定的连接才会实际接收 Pub/Sub 消息,从而使该功能更具可伸缩性。
当使用 RESP3 时,失效消息会作为 `push` 消息发送(在同一连接中,或在使用重定向时的辅助连接中)(更多信息请阅读 RESP3 规范)。
#
追踪什么如您所见,客户端默认不需要告诉服务器它们正在缓存哪些键。在只读命令上下文中提及的每个键都会被服务器追踪,因为它 *可能被缓存*。
这显然有一个优势,即不需要客户端告诉服务器它正在缓存什么。此外,在许多客户端实现中,这正是您想要的,因为一个好的解决方案可能只是使用先进先出方法缓存所有尚未缓存的东西:我们可能想要缓存固定数量的对象,每次检索新数据时,我们都可以将其缓存,丢弃最旧的缓存对象。更高级的实现可能会丢弃最少使用的对象等等。
请注意,无论如何,如果服务器上有写入流量,缓存槽将随着时间的推移而失效。通常,当服务器假设我们获取的数据也会被缓存时,我们正在进行权衡
- 当客户端倾向于使用欢迎新对象的策略缓存许多事物时,效率更高。
- 服务器将被迫保留更多关于客户端键的数据。
- 客户端将收到关于它未缓存的对象的无用失效消息。
因此,下一节将介绍另一种选择。
#
选择性缓存客户端实现可能只希望缓存选定的键,并明确告知服务器它们将缓存哪些以及不缓存哪些:这在缓存新对象时需要更多带宽,但同时会减少服务器必须记住的数据量以及客户端收到的失效消息数量。
为此,必须使用 OPTIN 选项启用跟踪
在这种模式下,默认情况下,读取查询中提到的键 *不应被缓存*,而是当客户端想要缓存某些东西时,它必须在实际检索数据的命令之前立即发送一个特殊命令
CACHING
命令会影响紧随其后执行的命令,但如果下一个命令是 MULTI
,事务中的所有命令都将被跟踪。类似地,对于 Lua 脚本,脚本执行的所有命令都将被跟踪。
#
广播模式到目前为止,我们描述了 KeyDB 实现的第一种客户端缓存模型。还有另一种,称为广播,它从不同的权衡角度看待问题,它不消耗服务器端的任何内存,而是向客户端发送更多的失效消息。在这种模式下,我们有以下主要行为
- 客户端使用
BCAST
选项启用客户端缓存,并使用PREFIX
选项指定一个或多个前缀。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:
。如果没有指定任何前缀,则前缀被视为空字符串,因此客户端将收到每个被修改的键的失效消息。如果使用一个或多个前缀,则只会在失效消息中发送与指定前缀之一匹配的键。 - 服务器不会在失效表中存储任何内容。相反,它只使用一个不同的 **前缀表**,其中每个前缀都与一个客户端列表相关联。
- 没有两个前缀可以跟踪键空间中重叠的部分。例如,不允许同时使用前缀 foo 和 foob,因为它们都会触发 foobar 键的失效。但是,只使用前缀 foo 就足够了。
- 每当任何前缀匹配的键被修改时,所有订阅该前缀的客户端都将收到失效消息。
- 服务器将消耗与注册前缀数量成比例的 CPU。如果只有少数几个,很难看到任何差异。如果前缀数量很大,CPU 成本可能会变得相当大。
- 在这种模式下,服务器可以执行优化,为订阅给定前缀的所有客户端创建单个回复,并将相同的回复发送给所有客户端。这有助于降低 CPU 使用率。
#
NOLOOP 选项默认情况下,客户端跟踪甚至会向修改了键的客户端发送失效消息。有时客户端希望这样做,因为它们实现了一个非常基本的逻辑,不涉及自动在本地缓存写入。然而,更高级的客户端可能希望甚至将它们正在进行的写入也缓存到本地内存表中。在这种情况下,写入后立即收到失效消息是一个问题,因为它将强制客户端逐出刚刚缓存的值。
在这种情况下,可以使用 NOLOOP
选项:它在普通模式和广播模式下都有效。使用此选项,客户端能够告诉服务器它们不希望接收由自己修改的键的失效消息。
#
避免竞态条件当实现客户端缓存并将失效消息重定向到不同连接时,您应该注意可能存在竞态条件。请看下面的交互示例,我们将数据连接称为“D”,失效连接称为“I”
如您所见,由于 GET 的回复到达客户端较慢,我们在实际数据(已不再有效)之前收到了失效消息。因此,我们将继续提供 foo 键的过时版本。为了避免这个问题,最好在发送命令时使用占位符来填充缓存
当数据和失效消息都使用单个连接时,这种竞态条件是不可能发生的,因为在这种情况下消息的顺序始终是已知的。
#
当与服务器失去连接时该怎么办同样,如果我们失去了用于获取失效消息的套接字连接,我们可能会得到过时的数据。为了避免这个问题,我们需要做以下事情
- 确保如果连接丢失,本地缓存被清除。
- 无论是在 RESP2 中使用 Pub/Sub,还是在 RESP3 中,定期 ping 失效通道(即使连接处于 Pub/Sub 模式下,也可以发送 PING 命令!)。如果连接看起来已损坏且我们无法收到 ping 回复,则在最大时间后,关闭连接并清除缓存。
#
缓存什么客户端可能希望运行关于给定缓存键在请求中实际服务的次数的内部统计数据,以便将来了解什么值得缓存。一般来说
- 我们不希望缓存许多持续变化的键。
- 我们不希望缓存许多很少请求的键。
- 我们希望缓存经常请求且变化率合理的键。例如,一个变化率不合理的键是持续
INCR
ement 的全局计数器。
然而,更简单的客户端可能只使用某种随机采样来逐出数据,只记住给定缓存值最后一次服务的时间,试图逐出最近未服务的键。
#
关于客户端库实现的其他提示- 处理 TTLs:如果您想支持带 TTL 的缓存键,请确保也请求键 TTL 并在本地缓存中设置 TTL。
- 在每个键中设置最大 TTL 是个好主意,即使它没有 TTL。这是防止 bug 或连接问题导致客户端在本地副本中包含旧数据的好保护措施。
- 限制客户端使用的内存量是绝对必要的。当添加新键时,必须有一种方法来逐出旧键。
#
限制 KeyDB 使用的内存量只需确保为 KeyDB 记住的最大键数配置一个合适的值,或者使用在 KeyDB 端完全不消耗内存的 BCAST 模式。请注意,当不使用 BCAST 时,KeyDB 消耗的内存量与追踪的键数和请求这些键的客户端数成正比。