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 模式的 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
消息。
关于与 Pub/Sub 连接一起用于读取失效消息的 RESP2 和客户端侧缓存,需要了解的一件非常重要的事情是,使用 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 脚本的情况下,脚本执行的所有命令都将被跟踪。
广播模式
到目前为止,我们描述了 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
的全局计数器。
然而,更简单的客户端可能只是使用某些随机采样来驱逐数据,只需记住给定缓存值上次提供服务的时间,并尝试驱逐最近未提供服务的键。
实现客户端库的其他提示
- 处理 TTL:确保你也请求键 TTL,并在本地缓存中设置 TTL,如果你想支持缓存具有 TTL 的键。
- 对每个键设置最大 TTL 是一个好主意,即使它没有 TTL。这可以防止错误或连接问题,这些问题会导致客户端在本地副本中拥有旧数据。
- 绝对需要限制客户端使用的内存量。当添加新键时,必须有一种方法来驱逐旧键。
限制 Redis 使用的内存量
务必为 Redis 记住所需的最大键数配置一个合适的值,或者使用 BCAST 模式,该模式在 Redis 端根本不消耗内存。请注意,当不使用 BCAST 时,Redis 消耗的内存与跟踪的键数和请求此类键的客户端数成正比。