客户端缓存参考
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:
或user:
,并在每次触及与订阅前缀匹配的键时收到通知消息。
回顾一下,现在让我们暂时忘记广播模式,重点关注第一种模式。我们稍后将更详细地描述广播。
- 客户端如果需要,可以启用跟踪。连接开始时默认未启用跟踪。
- 启用跟踪后,服务器会记住每个客户端在连接生命周期内请求了哪些键(通过发送关于这些键的读命令)。
- 当某个客户端修改了键,或者由于关联的过期时间被逐出,或者由于 maxmemory 策略被逐出时,所有启用了跟踪且可能缓存了该键的客户端都会收到一条失效消息通知。
- 客户端收到失效消息后,需要移除相应的键,以避免提供过时的数据。
这是一个协议示例
- 客户端 1
->
服务器: CLIENT TRACKING ON - 客户端 1
->
服务器: GET foo - (服务器记住客户端 1 可能缓存了键 "foo")
- (客户端 1 可能会在其本地内存中记住 "foo" 的值)
- 客户端 2
->
服务器: SET foo SomeOtherValue - 服务器
->
客户端 1: INVALIDATE "foo"
表面上看这很棒,但如果你想象有 1 万个连接的客户端通过长时间连接请求数百万个键,服务器最终会存储太多信息。因此,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 规范)。
跟踪的内容
如你所见,客户端默认无需告诉服务器它们缓存了哪些键。在只读命令上下文中提及的每个键都会被服务器跟踪,因为它们可能被缓存。
这有一个明显的优势,即无需客户端告诉服务器它正在缓存什么。此外,在许多客户端实现中,这正是你想要的,因为一个好的解决方案可能是只缓存尚未缓存的所有内容,使用先进先出的方法:我们可能想要缓存固定数量的对象,每当我们检索到新数据时,我们就可以缓存它,同时丢弃最旧的缓存对象。更高级的实现可能会选择丢弃最少使用的对象或类似的方法。
请注意,无论如何,如果服务器上有写入流量,缓存槽位会随着时间的推移而失效。一般来说,当服务器假设我们获取的内容也会被缓存时,我们正在进行权衡
- 当客户端倾向于使用欢迎新对象的策略缓存许多内容时,效率更高。
- 服务器将被迫保留更多关于客户端键的数据。
- 客户端将收到关于它未缓存对象的无用失效消息。
因此,下一节中描述了一种替代方案。
选择加入 (Opt-in) 和选择退出 (Opt-out) 缓存
选择加入 (Opt-in)
客户端实现可能只想缓存选定的键,并明确告知服务器它们将缓存什么以及不缓存什么。这在缓存新对象时会需要更多带宽,但同时减少了服务器需要记住的数据量以及客户端收到的失效消息量。
为此,必须使用 OPTIN 选项启用跟踪
CLIENT TRACKING ON REDIRECT 1234 OPTIN
在此模式下,默认情况下,读查询中提及的键不应被缓存,相反,当客户端想要缓存某些内容时,必须在实际检索数据的命令之前立即发送一个特殊命令
CLIENT CACHING YES
+OK
GET foo
"bar"
CACHING
命令会影响紧随其后执行的命令。然而,如果下一个命令是 MULTI
,则事务中的所有命令都将被跟踪。类似地,在 Lua 脚本的情况下,脚本执行的所有命令都将被跟踪。
选择退出 (Opt-out)
选择退出缓存允许客户端自动在本地缓存键,而无需显式地为每个键选择加入。这种方法确保所有键默认都被缓存,除非另有指定。选择退出缓存可以通过减少为单个键启用缓存的显式命令的需求来简化客户端缓存的实现。
必须使用 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.
当使用单个连接用于数据和失效消息时,不会发生这种竞态条件,因为在这种情况下消息的顺序总是已知的。
与服务器失去连接时怎么办
类似地,如果我们与用于获取失效消息的 socket 连接丢失,我们可能会得到过时的数据。为了避免这个问题,我们需要做以下几件事
- 确保如果连接丢失,本地缓存被清空。
- 无论使用 RESP2 与 Pub/Sub,还是 RESP3,都要定期 ping 失效通道(即使连接处于 Pub/Sub 模式也可以发送 PING 命令!)。如果连接看起来已断开,并且我们无法收到 ping 回复,则在经过最大时间后,关闭连接并清空缓存。
缓存什么
客户端可能希望运行内部统计数据,了解给定缓存键在请求中实际被服务的次数,以便将来了解哪些内容适合缓存。一般来说
- 我们不希望缓存许多持续变化的键。
- 我们不希望缓存许多很少被请求的键。
- 我们希望缓存那些经常被请求且变化频率适中的键。对于一个变化频率不适中的键的例子,想想一个持续
INCR
增加的全局计数器。
然而,更简单的客户端可能只会使用一些随机采样来逐出数据,只需记住给定缓存值最后一次被服务的时间,尝试逐出最近未被服务的键。
实现客户端库的其他提示
- 处理 TTL:如果要支持缓存带有 TTL 的键,请确保同时也请求键的 TTL 并在本地缓存中设置 TTL。
- 对每个键设置一个最大 TTL 是个好主意,即使它没有自己的 TTL。这可以防止因错误或连接问题导致客户端本地副本数据过旧的情况。
- 限制客户端使用的内存量是绝对必要的。当添加新键时,必须有一种方法来逐出旧键。
限制 Redis 使用的内存量
请确保为 Redis 记住的最大键数配置一个合适的值,或者使用 BCAST 模式,该模式在 Redis 端完全不消耗内存。请注意,当不使用 BCAST 时,Redis 消耗的内存与被跟踪的键数和请求这些键的客户端数量成比例。