*Redis 服务器辅助的客户端缓存
客户端缓存是一种用于创建高性能服务的技术。它利用应用程序服务器中可用的内存——应用程序服务器通常是与数据库节点不同的计算机——以便在应用程序端直接存储数据库信息的某个子集。
通常当需要某些数据时,应用程序服务器会向数据库询问此类信息,如下图所示:
+-------------+ +----------+
| | ------- GET user:1234 -------> | |
| Application | | Database |
| | <---- username = Alice ------- | |
+-------------+ +----------+
当使用客户端缓存时,应用程序会将流行查询的回复直接存储在应用程序内存中,以便以后可以重用这些回复,而无需再次联系数据库。
+-------------+ +----------+
| | | |
| Application | (无需通信) | Database |
| | | |
+-------------+ +----------+
| 本地缓存 |
| |
| 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"
这在表面上看起来很棒,但如果你考虑到有 10k 个连接的客户端,每个客户端在每次长寿命连接的历史中都请求了数百万个键,服务器最终可能会存储太多信息。因此,Redis 使用两个关键思想来限制服务器端使用的内存量,以及实现该功能的数据结构的 CPU 成本:
- 服务器在单个全局表中记住可能缓存了给定键的客户端列表。此表称为 失效表。此失效表可以包含最大数量的条目,如果插入新键,服务器可以通过假装该键已被修改(即使它没有)并发送失效消息给客户端来驱逐旧条目。这样做,它可以回收用于此键的内存,即使这会迫使拥有该键本地副本的客户端驱逐它。
- 在失效表中,我们不需要存储指向客户端结构的指针,这会在客户端断开连接时强制进行垃圾回收:相反,我们所做的只是存储客户端 ID(每个 Redis 客户端都有一个唯一的数字 ID)。如果客户端断开连接,当缓存槽被失效时,信息将作为增量垃圾回收。
- 只有一个键命名空间,不按数据库编号划分。因此,如果客户端在数据库 2 中缓存键
foo,而另一个客户端在数据库 3 中更改键foo的值,仍然会发送失效消息。这样我们可以忽略数据库编号,同时减少内存使用和实现复杂性。
*双连接模式
使用 Redis 6 支持的新版本 Redis 协议 RESP3,可以在同一连接中运行数据查询和接收失效消息。然而许多客户端实现可能更喜欢使用两个单独的连接来实现客户端缓存:一个用于数据,一个用于失效消息。因此,当客户端启用跟踪时,它可以通过指定不同连接的 "客户端 ID" 将失效消息重定向到另一个连接。许多数据连接可以将失效消息重定向到同一连接,这对于实现连接池的客户端很有用。双连接模型也是 RESP2 唯一支持的模式(RESP2 缺乏在同一连接中复用不同类型信息的能力)。
我们将展示一个示例,这次使用旧 RRESP2 模式中的实际 Redis 协议,展示一个完整的会话,涉及以下步骤:启用跟踪并重定向到另一个连接,请求一个键,并在该键被修改后获得失效消息。
首先,客户端打开一个将用于失效的连接,请求连接 ID,并通过 Pub/Sub 订阅用于在 RESP2 模式下获取失效消息的特殊频道(记住 RESP2 是通常的 Redis 协议,而不是你可以使用 HELLO 命令在 Redis 6 中可选使用的更高级协议):
(连接 1 -- 用于失效)
CLIENT ID
:4
SUBSCRIBE __redis__:invalidate
*3
$9
subscribe
$20
__redis__:invalidate
:1
现在我们可以从数据连接启用跟踪:
(连接 2 -- 数据连接)
CLIENT TRACKING on REDIRECT 4
+OK
GET foo
$3
bar
客户端可以决定在本地内存中缓存 "foo" => "bar"。
另一个客户端现在将修改 "foo" 键的值:
(某个其他不相关的连接)
SET foo bar
+OK
因此,失效连接将收到一条使指定键失效的消息。
(连接 1 -- 用于失效)
*3
$7
message
$20
__redis__:invalidate
*1
$3
foo
客户端将检查此类缓存槽中是否有缓存的键,并将驱逐不再有效的信息。
注意 Pub/Sub 消息的第三个元素不是单个键,而是一个只包含单个元素的 Redis 数组。由于我们发送的是数组,如果有要失效的键组,我们可以在一条消息中完成。
关于与 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 脚本的情况下,脚本执行的所有命令都将被跟踪。
*广播模式
到目前为止,我们描述了 Redis 实现的第一种客户端缓存模型。还有另一种,称为广播,它从不同的权衡角度来看待问题,在服务器端不消耗任何内存,而是向客户端发送更多的失效消息。在这种模式下,我们有以下主要行为:
- 客户端使用
BCAST选项启用客户端缓存,并使用PREFIX选项指定一个或多个前缀。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:。如果根本没有指定前缀,则假定前缀为空字符串,因此客户端将收到每个被修改键的失效消息。相反,如果使用一个或多个前缀,则只有匹配其中一个指定前缀的键才会在失效消息中发送。 - 服务器不在失效表中存储任何内容。相反,它只使用一个不同的 前缀表,其中每个前缀与客户端列表关联。
- 每当匹配任何前缀的键被修改时,所有订阅了该前缀的客户端都会收到失效消息。
- 服务器将消耗与注册前缀数量成正比的 CPU。如果你只有几个前缀,很难看到任何差异。如果前缀数量很大,CPU 成本可能会变得相当高。
- 在这种模式下,服务器可以执行优化,为订阅给定前缀的所有客户端创建单个回复,并发送相同的回复给所有人。这有助于降低 CPU 使用率。
*NOLOOP 选项
默认情况下,客户端缓存会向修改键的客户端本身发送失效消息。有时客户端需要这个,因为它们实现了一个非常基本的逻辑,不涉及在本地自动缓存写入。然而更高级的客户端可能想要在本地内存表中缓存它们正在进行的写入。在这种情况下,在写入后立即收到失效消息是个问题,因为这会迫使客户端驱逐它刚刚缓存的值。
在这种情况下,可以使用 NOLOOP 选项:它在普通模式和广播模式下都有效。使用此选项,客户端能够告诉服务器它们不想收到由它们自己修改的键的失效消息。
*避免竞争条件
当实现客户端缓存并将失效消息重定向到不同连接时,你应该意识到可能存在竞争条件。参见以下示例交互,我们将数据连接称为 "D",失效连接称为 "I":
[D] 客户端 -> 服务器: GET foo
[I] 服务器 -> 客户端: Invalidate foo (其他人触碰了它)
[D] 服务器 -> 客户端: "bar" ("GET foo" 的回复)
如你所见,由于 GET 的回复到达客户端的速度较慢,我们在实际数据之前收到了失效消息,而实际数据已经失效。因此我们将继续提供 foo 键的陈旧版本。为了避免这个问题,在我们发送命令时用占位符填充缓存是个好主意:
客户端缓存: 将 "foo" 的本地副本设置为 "caching-in-progress"
[D] 客户端-> 服务器: GET foo.
[I] 服务器 -> 客户端: Invalidate foo (其他人触碰了它)
客户端缓存: 从本地缓存中删除 "foo"。
[D] 服务器 -> 客户端: "bar" ("GET foo" 的回复)
客户端缓存: 不设置 "bar",因为 "foo" 的条目缺失。
当使用单个连接同时处理数据和失效消息时,这种竞争条件是不可能的,因为在那种情况下消息的顺序总是已知的。
*与服务器失去连接时该怎么做
类似地,如果我们失去用于获取失效消息的套接字连接,我们可能会得到陈旧数据。为了避免这个问题,我们需要执行以下操作:
- 确保如果连接丢失,本地缓存被刷新。
- 无论是使用 RESP2 与 Pub/Sub,还是 RESP3,都要定期 ping 失效频道(即使在 Pub/Sub 模式下,你也可以发送 PING 命令!)。如果连接看起来坏了,我们无法收到 ping 回复,在一段时间后,关闭连接并刷新缓存。
*缓存什么
客户端可能想要运行关于给定缓存键在实际请求中被服务的次数的内部统计,以便在未来了解什么是值得缓存的。一般来说:
- 我们不想缓存许多不断变化的键。
- 我们不想缓存许多很少被请求的键。
- 我们想要缓存经常被请求且以合理速率变化的键。对于变化速率不合理的键的例子,想想一个不断被 INCR 的全局计数器。
然而更简单的客户端可能只使用一些随机采样来驱逐数据,只记住给定缓存值最后一次被服务的时间,尝试驱逐最近没有被服务的键。
*关于客户端库实现的其他提示
- 处理 TTL:确保你也请求键的 TTL,如果你想支持缓存具有 TTL 的键,请在本地缓存中设置 TTL。
- 在每个键上设置最大 TTL 是个好主意,即使它没有 TTL。这是防止错误或连接问题导致客户端本地副本中有旧数据的良好保护。
- 限制客户端使用的内存量是绝对必要的。当添加新键时,必须有一种方法来驱逐旧键。
*限制 Redis 使用的内存量
只需为 Redis 记住的最大键数配置一个合适的值,或者使用在 Redis 端完全不消耗内存的 BCAST 模式。注意,当不使用 BCAST 时,Redis 消耗的内存与跟踪的键数量以及请求这些键的客户端数量成正比。