客户端缓存参考
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 客户端缓存支持被称为 Tracking,并有两种模式:
- 在默认模式下,服务器会记住给定客户端访问了哪些键,并在相同的键被修改时发送失效消息。这会在服务器端消耗内存,但只会为客户端可能在内存中的键集发送失效消息。
- 在广播模式下,服务器不会尝试记住给定客户端访问了哪些键,因此这种模式在服务器端完全不消耗内存。相反,客户端订阅键前缀,如
object:
或user:
,并且每次触碰到与订阅前缀匹配的键时,都会收到通知消息。
回顾一下,现在让我们暂时忘记广播模式,专注于第一种模式。我们将在后面更详细地描述广播。
- 如果客户端需要,可以启用跟踪。连接开始时未启用跟踪。
- 当启用跟踪时,服务器会记住每个客户端在连接生命周期内请求了哪些键(通过发送关于这些键的读取命令)。
- 当某个客户端修改了一个键,或者因为该键关联了过期时间而被驱逐,或者因为maxmemory策略而被驱逐时,所有启用了跟踪功能并可能缓存了该键的客户端都会收到一个失效消息的通知。
- 当客户端接收到失效消息时,他们需要移除相应的键,以避免提供过时的数据。
这是一个协议的示例:
- 客户端 1
->
服务器: CLIENT TRACKING ON - 客户端 1
->
服务器: GET foo - (服务器记得客户端1可能缓存了键“foo”)
- (客户端1可能会在其本地内存中记住“foo”的值)
- 客户端 2
->
服务器: SET foo SomeOtherValue - 服务器
->
客户端 1: 无效 "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.
当使用单一连接同时传输数据和失效消息时,这种竞争条件是不可能的,因为在这种情况下,消息的顺序总是已知的。
与服务器失去连接时该怎么办
同样地,如果我们失去了用于获取失效消息的套接字连接,我们可能会得到过时的数据。为了避免这个问题,我们需要做以下事情:
- 确保如果连接丢失,本地缓存被刷新。
- 无论是使用RESP2与Pub/Sub,还是使用RESP3,都要定期对失效通道进行ping操作(即使连接处于Pub/Sub模式,也可以发送PING命令!)。如果连接看起来已断开且我们无法收到ping回复,在达到最大时间后,关闭连接并清空缓存。
缓存什么
客户可能希望运行内部统计,了解某个缓存键在请求中实际被提供的次数,以便将来了解哪些内容适合缓存。一般来说:
- 我们不想缓存许多不断变化的键。
- 我们不希望缓存那些很少被请求的键。
- 我们希望缓存那些经常被请求并且以合理速率变化的键。以一个不以合理速率变化的键为例,想象一个全局计数器,它不断地
INCR
增加。
然而,较简单的客户端可能只是使用一些随机抽样来驱逐数据,只需记住给定缓存值最后一次被提供的时间,尝试驱逐最近未被提供的键。
实现客户端库的其他提示
- 处理TTLs:如果您想支持缓存带有TTL的键,请确保您还请求键的TTL并在本地缓存中设置TTL。
- 为每个键设置最大TTL是一个好主意,即使它没有TTL。这可以防止由于错误或连接问题导致客户端在本地副本中保留旧数据。
- 限制客户端使用的内存量是绝对必要的。必须有一种方法在添加新键时驱逐旧键。
限制Redis使用的内存量
请确保为Redis记住的最大键数配置一个合适的值,或者使用在Redis端完全不消耗内存的BCAST模式。请注意,当不使用BCAST时,Redis消耗的内存与跟踪的键数和请求这些键的客户端数量成正比。