Redis 客户端缓存

Redis 客户端缓存

简介

Redis 客户端缓存的技术通常被用来构建高性能的服务。在一般情况下服务与 Redis 的请求与响应情况如下:

1
2
3
4
5
+-------------+                                +----------+
| | ------- GET user:1234 -------> | |
| Application | | Database |
| | <---- username = Alice ------- | |
+-------------+ +----------+

而在客户端缓存技术则会将响应内容缓存至客户端中,样例如下:

1
2
3
4
5
6
7
8
9
10
11
+-------------+                                +----------+
| | | |
| Application | ( No chat needed ) | Database |
| | | |
+-------------+ +----------+
| Local cache |
| |
| user:1234 = |
| username |
| Alice |
+-------------+

应用程序的内存可能不是很大相应用于本地缓存的空间也会收到很大的限制,但与访问数据库等网络服务相比,访问本地计算机内存所需的时间要小几个数量级。 由于经常访问相同比例的小部分数据,这种模式可以大大减少应用程序获取数据的延迟,同时减少数据库端的负载。这种模式的优点在于:

  1. 数据的延迟会非常小
  2. 数据库系统接收的查询更少,允许它以更少的节点为相同的数据集提供服务。

实现方式

Redis 采用了名为 Tracking 的技术实现了客户端缓存:

  • 在默认模式下,服务器会记录客户端访问的 key 并在发生变动时将无效请求(使客户端缓存失效)发送至客户端。这样做会损耗服务器内存,但是能确保客户端可以精准的收到无效请求。
  • 在广播模式下,客户端会订阅一些 key 的前缀,例如 object:user: 然后在这些 key 经过改动后收到广播通知。这样不会损耗服务器内存,但是所有客户端都可能经常收到不相关的通知。

在默认模式中的读写流程如下:

  1. 客户端根据需求启用 Tracking 功能。(在连接开始时未启用 Tracking)
  2. 启用 Tracking 后,服务器会记录每个客户端在连接生命周期内请求的密钥(通过发送有关此类密钥的读取命令)
  3. 当客户端修改某个 key 或是它过期或是因清除策略被删除时,所有启用了追踪并可能缓存了该 key 的客户端都会拿到一条无效请求通知。
  4. 当客户端收到无效消息时,他们需要删除相应的密钥,以避免提供过时的数据。

在广播模式下的主要行为如下:

  • 客户端使用该选项启用客户端缓存 BCAST 使用该选项指定一个或多个前缀 PREFIX。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object: PREFIX user:如果根本没有指定前缀,则假定前缀为空字符串,因此客户端将收到每个被修改的键的失效消息。相反,如果使用一个或多个前缀,则只有与指定前缀之一匹配的键才会在失效消息中发送。
  • 服务器不会在无效表中存储任何内容。相反,它使用不同的 Prefixes Table,其中每个前缀都与客户端列表相关联。
  • 没有两个前缀可以跟踪键空间的重叠部分。例如,不允许使用前缀 foofoob,因为它们都会触发键 foobar 的失效。但是,仅使用前缀 foo 就足够了。
  • 每次修改匹配任何前缀的键时,所有订阅该前缀的客户端都会收到失效消息。
  • 服务器将消耗与注册前缀数量成正比的 CPU。如果你只有几个,很难看出任何区别。使用大量前缀,CPU 成本会变得非常大。
  • 在这种模式下,服务器可以优化为订阅给定前缀的所有客户端创建单个回复,并向所有客户端发送相同的回复。这有助于降低 CPU 使用率。

实现原理

从表面上看,这看起来很棒,但是如果您想象 10k 个连接的客户端都要求通过长期连接请求数百万个密钥,那么服务器最终会存储太多信息。 出于这个原因,Redis 使用两个关键思想来限制服务器端使用的内存量和处理实现该功能的数据结构的 CPU 成本。

  • 服务器会维护一个由客户端和它所缓存的 key 构成的全局表,这个表被称为无效表(Invalidation Table)。此表可以容纳最大数量的元素。如果有新的 key 插入,服务器会将旧的 key 视作已经改动,并向客户端发送无效请求。通过此种方式可以使得客户端释放此 key 的内存,即使这会使拥有此 key 的本地客户端将其逐出。
  • 在无效表中,我们实际上不需要存储指向客户端的指针,这样会使客户端断开连接时强制执行垃圾收集过程:相反,我们仅仅存储客户端 ID(每个 Redis 客户端都有一个唯一的数字 ID)。如果客户端断开俩连接,则随着该缓存插槽位置的失效,信息将被增量垃圾收集。
  • 还存在一个 key 的命名空间,它不被数据库编号分割。所以如果一个客户端在 2 号库缓存了 foo,而另一个客户端在 3 号库更新了 foo 则无效请求依然会被发送。这样一来我们可以减少内存负载并且减少实现的复杂性。

缓存排除

默认情况下,客户端跟踪将向修改密钥的客户端发送失效消息。有时客户端需要这样做,因为它们实现了非常基本的逻辑,不涉及在本地自动缓存写入。但是,更高级的客户端甚至可能希望缓存他们在本地内存表中所做的写入。在这种情况下,在写入后立即接收无效消息是一个问题,因为它会强制客户端驱逐它刚刚缓存的值。

在这种情况下,可以使用该 NOLOOP 选项:它可以在正常模式和广播模式下工作。使用此选项,客户端可以告诉服务器他们不想接收他们修改的密钥的无效消息。

避免竞争条件

在实现客户端缓存将失效消息重定向到不同的连接时,您应该知道可能存在竞争条件。请参阅以下示例交互,其中我们将调用数据连接“D”和无效连接“I”:

1
2
3
[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 密钥的陈旧版本。为了避免这个问题,当我们发送带有占位符的命令时填充缓存是一个好主意:

1
2
3
4
5
6
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.

当对数据和失效消息使用单个连接时,这种竞争条件是不可能的,因为在这种情况下消息的顺序总是已知的。

内存限制

请务必为 Redis 记住的最大键数配置一个合适的值,或者在 Redis 端使用完全不消耗内存的 BCAST 模式。请注意,不使用 BCAST 时 Redis 消耗的内存与跟踪的键数和请求此类键的客户端数成正比。

简单试用

注:在测试时采用的 Redis 版本为 6.2.5

打开第一个 REDIS 客户端,然后输入下面的命令查看客户端 ID:

1
CLIENT ID

注:之后会返回此客户端的 ID

订阅无效请求话题:

1
SUBSCRIBE __redis__:invalidate

注:此后客户端会无法操作,仅会输出无效请求相关信息。

打开第二个 Redis 客户端,然后输入下面的命令打开客户端缓存:

1
CLIENT TRACKING on REDIRECT <Client_1 ID>

注:此命令会打开客户端缓存,并将无效请求重定位至客户端 1 中。

将任意内容写入客户端缓存,然后将其修改即可

1
2
get wq
set wq 1

之后在第一个客户端内应当见到如下输出:

1
2
3
4
5
6
1) "subscribe"
2) "__redis__:invalidate"
3) (integer) 1
1) "message"
2) "__redis__:invalidate"
3) 1) "wq"

参考资料

Redis 官方文档

Redis 命令手册


Redis 客户端缓存
https://wangqian0306.github.io/2022/redis-client-side-caching/
作者
WangQian
发布于
2022年6月21日
许可协议