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