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