客户端缓存参考
Redis 中的服务器辅助客户端缓存
客户端缓存是一种用于创建高性能服务的技术。 它利用应用程序服务器上的可用内存,这些服务器是 通常与数据库节点相比,不同的计算机用于存储一些子集 的数据库信息。
通常,当需要数据时,应用程序服务器会向数据库询问 此类信息,如下图所示:
+-------------+ +----------+
| | ------- GET user:1234 -------> | |
| Application | | Database |
| | <---- username = Alice ------- | |
+-------------+ +----------+
当使用客户端缓存时,应用程序将存储 popular 查询,以便它可以 稍后重用此类回复,而无需再次联系数据库:
+-------------+ +----------+
| | | |
| Application | ( No chat needed ) | Database |
| | | |
+-------------+ +----------+
| Local cache |
| |
| user:1234 = |
| username |
| Alice |
+-------------+
虽然用于本地缓存的应用程序内存可能不是很大, 访问本地计算机内存所需的时间约为 与访问网络服务(如数据库)相比,数量级更小。 由于经常频繁访问相同比例的数据, 此模式可以大大减少应用程序获取数据的延迟 同时,数据库端的负载。
此外,在许多数据集中,项目变化非常不频繁。 例如,社交网络中的大多数用户帖子要么是不可变的,要么是 用户很少编辑。除此之外,通常一个小的 帖子非常受欢迎的百分比,要么是因为一小部分用户 有很多关注者和/或因为最近的帖子更多 可见性,很明显为什么这样的模式非常有用。
通常,客户端缓存的两个主要优点是:
- 数据可用时延迟非常小。
- 数据库系统接收的查询较少,因此能够使用较少的节点来提供相同的数据集。
计算机科学有两个难题......
上述模式的一个问题是,如何使 应用程序正在保留,以避免向 用户。例如,在上述应用程序本地缓存信息 对于 user:1234,Alice 可能会将她的用户名更新为 Flora。然而,应用程序 可能会继续为 user:1234 提供旧用户名。
有时,根据我们正在建模的确切应用程序,这不是
Big Deal,因此客户端将只使用
缓存的信息。给定的时间过后,信息
将不再被视为有效。更复杂的模式,当使用 Redis 时,
利用 Pub/Sub 系统将失效消息发送到
倾听客户端。这可以工作,但很棘手且成本高昂
所用带宽的观点,因为此类模式通常涉及
将失效消息发送到应用程序中的每个客户端,甚至
如果某些客户端可能没有无效数据的任何副本。此外
更改数据的每个应用程序查询都需要使用PUBLISH
命令,这会消耗数据库更多的 CPU 时间来处理此命令。
无论使用哪种 Schema,都有一个简单的事实:许多非常大的 应用程序实现了某种形式的客户端缓存,因为它是 拥有快速存储或快速缓存服务器的下一个逻辑步骤。对于这个 Redis 6 实现对客户端缓存的直接支持的原因,以便 使此模式更易于实现、更易于访问、更可靠, 和高效。
客户端缓存的 Redis 实现
Redis 客户端缓存支持称为 Tracking,有两种模式:
- 在默认模式下,服务器会记住给定客户端访问的密钥,并在修改相同的密钥时发送失效消息。这会消耗服务器端的内存,但仅针对客户端可能在内存中的密钥集发送失效消息。
- 在广播模式下,服务器不会尝试记住给定客户端访问了哪些密钥,因此此模式在服务器端根本不消耗内存。相反,客户端订阅密钥前缀,例如
object:
或user:
,并在每次触摸与订阅前缀匹配的键时收到通知消息。
回顾一下,现在让我们暂时忘记广播模式,以 专注于第一种模式。我们稍后将更详细地介绍广播。
- 如果客户愿意,他们可以启用跟踪。连接在未启用跟踪的情况下开始。
- 启用跟踪后,服务器会记住每个客户端在连接生命周期内请求的密钥(通过发送有关此类密钥的读取命令)。
- 当密钥被某些客户端修改,或由于具有关联的过期时间而被逐出,或由于 maxmemory 策略而被逐出时,所有启用了跟踪且可能缓存了密钥的客户端都会收到失效消息的通知。
- 当客户端收到失效消息时,它们需要删除相应的密钥,以避免提供过时的数据。
以下是协议的一个示例:
- 客户端 1 服务器:客户端跟踪开启
->
- 客户端 1 服务器:GET foo
->
- (服务器记住 Client 端 1 可能缓存了密钥 “foo”)
- (客户端 1 可能记住其本地内存中的 “foo” 的值)
- 客户端 2 服务器:SET foo SomeOtherValue
->
- 服务器客户端 1:INVALIDATE “foo”
->
这从表面上看看起来不错,但如果你想象 10k 个连接的客户端都是 通过长期连接请求数百万个密钥,服务器最终会 存储太多信息。出于这个原因,Redis 在 来限制服务器端使用的内存量和 CPU 成本 处理实现该功能的数据结构:
- 服务器会记住可能已将给定键缓存在单个全局表中的 Client 端列表。此表称为 Invalidation Table。失效表可以包含最大数量的条目。如果插入了新密钥,服务器可以通过假装该密钥已被修改(即使未修改)并向客户端发送失效消息来驱逐旧条目。这样做,它可以回收用于此密钥的内存,即使这将强制拥有该密钥本地副本的客户端逐出它。
- 在失效表中,我们实际上不需要存储指向客户端结构的指针,这会在客户端断开连接时强制执行垃圾收集过程:相反,我们所做的只是存储客户端 ID(每个 Redis 客户端都有一个唯一的数字 ID)。如果客户端断开连接,则信息将在缓存槽失效时以增量方式进行垃圾回收。
- 有一个 single keys 命名空间,不按数据库编号划分。因此,如果客户端正在缓存密钥
foo
在数据库 2 中,其他一些客户端更改了 key 的值foo
在数据库 3 中,仍将发送失效消息。这样我们就可以忽略数据库编号,从而减少内存使用和实现复杂性。
双连接模式
使用 Redis 6 支持的新版本 Redis 协议 RESP3,可以在同一连接中运行数据查询并接收失效消息。但是,许多客户端实现可能更喜欢使用两个单独的连接来实现客户端缓存:一个用于数据,一个用于失效消息。因此,当客户端启用跟踪时,它可以通过指定其他连接的“客户端 ID”来指定将失效消息重定向到另一个连接。许多数据连接可以将失效消息重定向到同一连接,这对于实现连接池的客户端非常有用。双连接模型是 RESP2 唯一支持的模型(它缺乏在同一连接中多路复用不同类型信息的能力)。
以下是在旧 RESP2 模式下使用 Redis 协议的完整会话示例,涉及以下步骤:启用跟踪、重定向到另一个连接、请求密钥,以及在密钥被修改后获取失效消息。
首先,客户端打开第一个连接,该连接将用于失效,请求连接 ID,并通过 Pub/Sub 订阅用于在 RESP2 模式下获取失效消息的特殊通道(请记住,RESP2 是通常的 Redis 协议,而不是您可以使用的更高级的协议。 (可选)在 Redis 6 中,使用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
因此,invalidations 连接将收到一条消息,使指定的密钥失效。
(Connection 1 -- used for invalidations)
*3
$7
message
$20
__redis__:invalidate
*1
$3
foo
客户端将检查此缓存槽中是否有缓存的密钥,并将逐出不再有效的信息。
请注意,Pub/Sub 消息的第三个元素不是单个键,而是
是一个只有一个元素的 Redis 数组。由于我们发送了一个数组,如果有
是要失效的 key 组,我们可以在一条消息中做到这一点。
如果出现同花 (FLUSHALL
或FLUSHDB
)、一个null
消息将被发送。
了解用于 的客户端缓存的非常重要的一点
RESP2 和 Pub/Sub 连接,以便读取失效消息,
是使用 Pub/Sub 完全是为了重用旧客户端而采取的技巧
实现,但实际上消息并没有真正发送到通道
并被所有订阅它的客户端接收。只有我们之间的联系
在REDIRECT
参数的CLIENT
命令实际上会
接收 Pub/Sub 消息,使该功能更具可扩展性。
当使用 RESP3 时,将发送失效消息(在
相同的连接,或者在使用重定向时在 Secondary Connection 中)
如push
消息(有关更多信息,请阅读 RESP3 规范)。
跟踪哪些内容
如您所见,默认情况下,客户端不需要告诉服务器哪些键 他们正在缓存。在只读上下文中提到的每个键 命令由服务器跟踪,因为它可以被缓存。
这有一个明显的优点,就是不需要客户端告诉服务器 什么是缓存。此外,在许多 clients 实现中,这就是 您想要的,因为一个好的解决方案可能是只缓存所有不是 已经缓存,使用先进先出的方法:我们可能需要缓存一个 固定数量的对象,我们检索到的每一个新数据,我们都可以缓存它, 丢弃最早的缓存对象。更高级的实现可能会改为 drop the least use object 或类似对象。
请注意,无论如何,如果服务器上有写入流量,则缓存插槽 将在此期间失效。通常,当 server 假设我们得到的也缓存了,我们正在做出权衡:
- 当 Client 端倾向于使用欢迎新对象的策略缓存许多内容时,它会更有效。
- 服务器将被迫保留有关客户端密钥的更多数据。
- 客户端将收到有关它未缓存的对象的无用失效消息。
因此,下一节将介绍另一种选择。
选择加入和选择退出缓存
选择加入
客户端实现可能只想缓存选定的键,并进行通信 明确地向服务器发送它们将缓存哪些内容,不缓存哪些内容。这将 缓存新对象时需要更多带宽,但同时会降低 服务器必须记住的数据量和 客户端收到的失效消息。
为此,必须使用 OPTIN 选项启用跟踪:
CLIENT TRACKING ON REDIRECT 1234 OPTIN
在这种模式下,默认情况下,读取查询中提到的键不应该被缓存,相反,当客户端想要缓存某些东西时,它必须在实际命令之前立即发送一个特殊命令来检索数据:
CLIENT CACHING YES
+OK
GET foo
"bar"
这CACHING
command 影响紧随其后的命令执行。
但是,如果下一个命令是MULTI
中,所有
交易将被跟踪。同样,对于 Lua 脚本,所有
将跟踪脚本执行的命令。
选择退出
选择退出缓存允许客户端在本地自动缓存密钥,而无需明确选择加入每个密钥。 此方法可确保默认情况下缓存所有键,除非另有指定。 选择退出缓存可以减少对显式命令来启用单个键缓存的需求,从而简化客户端缓存的实现。
必须使用 OPTOUT 选项启用跟踪,才能启用选择退出缓存:
CLIENT TRACKING ON OPTOUT
如果要从跟踪和缓存中排除特定密钥,请使用 CLIENT UNTRACKING 命令:
CLIENT UNTRACKING key
广播模式
到目前为止,我们介绍了 Redis 实现的第一个客户端缓存模型。 还有另一个方法,称为广播,它从 不同权衡的 point of view 时,不会占用 服务器端,而是向客户端发送更多失效消息。 在此模式下,我们有以下主要行为:
- 客户端使用
BCAST
选项,使用PREFIX
选择。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:
.如果根本没有指定前缀,则假定前缀为空字符串,因此客户端将收到每个修改的键的失效消息。相反,如果使用一个或多个前缀,则只会在失效消息中发送与指定前缀之一匹配的键。 - 服务器不在 invalidation 表中存储任何内容。相反,它使用不同的前缀表,其中每个前缀都与一个客户端列表相关联。
- 没有两个前缀可以跟踪键空间的重叠部分。例如,不允许使用前缀 “foo” 和 “foob”,因为它们都会触发键 “foobar” 的失效。但是,仅使用前缀 “foo” 就足够了。
- 每次修改与任何前缀匹配的密钥时,订阅该前缀的所有客户端都将收到失效消息。
- 服务器将按注册前缀的数量成比例地消耗 CPU。如果你只有几个,就很难看出任何区别。使用大量前缀时,CPU 成本可能会变得相当大。
- 在此模式下,服务器可以执行优化,为订阅给定前缀的所有客户端创建单个回复,并将相同的回复发送给所有客户端。这有助于降低 CPU 使用率。
NOLOOP 选项
默认情况下,客户端跟踪会将失效消息发送到 修改密钥的客户端。有时客户想要这个,因为他们 实现不涉及自动缓存的非常基本的 logic 在本地写入。但是,更高级的客户端可能希望缓存甚至 写入它们在本地 In-Memory 表中执行的写入作。在这种情况下,接收 写入后立即出现失效消息是个问题,因为它 将强制客户端驱逐它刚刚缓存的值。
在这种情况下,可以使用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 的回复到达 client,我们在实际数据之前收到了失效消息 已不再有效。因此,我们将继续提供 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.
当对两者使用单个连接时,这种争用条件是不可能的 data 和 invalidation 消息,因为消息的顺序始终是已知的 在那种情况下。
失去与服务器的连接时该怎么办
同样,如果我们失去了与套接字的连接,我们为了 获取失效消息,我们可能会以 stale data 结尾。为了避免 这个问题,我们需要做以下几件事:
- 确保在连接丢失时刷新本地缓存。
- 在将 RESP2 与 Pub/Sub 或 RESP3 一起使用时,请定期对失效通道执行 ping作(即使连接处于 Pub/Sub 模式,您也可以发送 PING 命令!如果连接看起来已断开,并且我们无法收到 ping 回传,请在最长时间后关闭连接并刷新缓存。
要缓存的内容
客户端可能希望运行有关次数的内部统计信息 给定的缓存密钥实际上是在请求中提供的,要在 未来什么好缓存。通常:
- 我们不想缓存许多不断变化的 key。
- 我们不想缓存许多很少请求的 key。
- 我们希望缓存经常请求并以合理速率更改的键。对于 key 未以合理速率更改的示例,请考虑一个持续
INCR
emented.
但是,更简单的客户端可能只使用一些随机采样来逐出数据 记住上次提供给定缓存值的时间,尝试逐出 最近未送达的密钥。
实现客户端库的其他提示
- 处理 TTL:如果您想支持使用 TTL 缓存密钥,请确保同时请求密钥 TTL 并在本地缓存中设置 TTL。
- 在每个键上设置最大 TTL 是一个好主意,即使它没有 TTL。这可以防止 bug 或连接问题,这些问题会使客户端在本地副本中具有旧数据。
- 限制客户端使用的内存量是绝对必要的。添加新键时,必须有一种方法可以驱逐旧键。
限制 Redis 使用的内存量
请务必为 Redis 记住的最大键数配置合适的值,或者使用 Redis 端完全不消耗内存的 BCAST 模式。请注意,不使用 BCAST 时 Redis 消耗的内存与跟踪的密钥数量和请求此类密钥的客户端数量成正比。