目录
在计算机科学中只有两个大问题...
客户端缓存的Redis实现
两种连接模式
Tracking跟踪什么
带Opt-in选项的缓存
广播模式
NOLOOP选项
避免竞争条件
与服务器断开连接时执行的操作
要缓存的内容
关于客户端库实现的其他要点
限制Redis内存使用量
客户端缓存是一种用于创建高性能服务的技术。它利用应用程序服务器中的可用内存(通常是与数据库节点不同的计算机),以便将数据库信息的某些子集直接存储在应用程序端。
通常,当需要某些数据时,应用服务器将向数据库询问此类信息,如下图所示:
+-------------+ +----------+
| | ------- GET user:1234 -------> | |
| Application | | Database |
| | <---- username = Alice ------- | |
+-------------+ +----------+
当使用客户端缓存时,应用程序会将热门查询的结果直接存储在应用程序内存中,以便以后可以重用此类查询结果,而无需再次关联数据库。
+-------------+ +----------+
| | | |
| Application | ( No chat needed ) | Database |
| | | |
+-------------+ +----------+
| Local cache |
| |
| user:1234 = |
| username |
| Alice |
+-------------+
尽管用于本地缓存的应用程序内存可能不是很大,但与请求诸如数据库这样的网络服务相比,访问本地计算机内存所需的时间要少几个数量级。由于那一小部分热点数据会被频繁地访问,因此该模式可以极大地减少应用程序获取数据的延迟,同时也减少了数据库端的负载。
此外,在许多数据集中的项很少会进行变更。例如,社交网络中的大多数用户帖子要么不能变更,要么很少被用户编辑。再加上通常只有一小部分帖子非常受欢迎,要么因为一小群用户拥有大量关注者,要么因为优先显示最近的帖子,这样,为什么这种模式会很有用就一目了然了。
通常,客户端缓存的两个主要优点是:
以上模式的一个问题是如何使应用程序持有的信息无效,以避免向用户显示过期的数据。例如,上述应用程序在本地缓存了user:1234信息后,Alice将其用户名更新为Flora。但是应用程序可能会继续为用户1234提供旧的用户名。
有时,对于我们要建模的具体应用程序,这个问题可能不是什么大问题,因此客户端只需要使用固定的最大“生存时间”来缓存信息就可以了。一旦经过了设置的时间,该信息将不再有效。在使用Redis时,更复杂的方式可以利用发布/订阅系统,向监听的客户端发送失效消息。从使用带宽的角度来看,这是可行的,不过却很困难开销也会很大,因为这种方式即使其中某些客户端没有失效数据的任何副本,通常也会向应用程序中的每个客户端发送失效消息。此外,更新数据的每个应用程序请求都需要使用PUBLISH命令,这会使数据库花费更多的CPU时间来处理该命令。
无论使用哪种模式,都有一个简单的事实:许多大型的应用程序都实现了某种形式的客户端缓存,因为这是拥有快速存储或快速缓存服务器的下一步。基于这个原因,Redis 6实现了对客户端缓存的直接支持,以便该模式更易于实现,更易于访问,更可靠和高效。
Redis客户端缓存的支持称为跟踪(Tracking),它有两种模式:
现在广播模式先放一放,先来看一下第一种模式。我们将在后面详细介绍广播。
以下是协议的示例:
乍看之下,这似乎很不错,不过你可以想象一下,如果有1万个长期保持连接的客户端都请求上百万个键,服务器会由于存储太多信息而宕机。因此,Redis用两个关键思想来限制用来实现该特性的服务器端内存使用量,以及处理数据结构的CPU消耗:
使用Redis 6支持的新版Redis协议RESP3,可以在同一个连接中运行数据的查询和接收失效消息。不过,许多客户端实现可能倾向于使用两个单独的连接来实现客户端缓存:一个用于数据,另一个用于失效消息。因此,当某客户端启用跟踪时,它可以通过设置不同连接的“客户端ID”指定失效的消息重定向到另一个连接。许多数据连接可以将失效消息重定向到同一连接,这对于实现了连接池的客户端会很有用。这两个连接模式是仅有的同样适用于RESP2协议(缺乏在同一连接中多路传输不同类型信息的能力)。
下面,我们将举一个例子,先通过Redis旧的RRESP2协议,来完成一个完整的会话,包括以下步骤:启用跟踪重定向到另一个连接,并发起键的请求以及在修改键后获取失效信息。
首先,客户端打开第一个将用于失效的连接,请求连接的ID,并通过Pub / Sub订阅在RESP2模式下用于获得失效消息的专用通道(请记住,RESP2是我们通常使用的Redis协议,而不是更先进的协议,譬如,可使用HELLO命令与Redis 6结合使用):
(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数组。因为我们发送的是一个数组,所以如果有成组的键要设置失效,我们只需把这些键放在一条消息里就可以了。
对于使用RESP2进行客户端缓存以及读取失效消息的的Pub / Sub连接的理解,非常重要的一点是,使用Pub / Sub完全是为了重用旧的客户端实现,不过实际上,该消息并未真正通过某个频道发送并被所有订阅该频道的客户端所接收。只有我们在CLIENT命令的REDIRECT参数中指定的连接才会收到Pub / Sub消息,从而使该功能更具可扩展性。
如果改为使用RESP3协议,则失效消息将作为推送消息进行发送。(在同一连接中,或者使用重定向时的第二个连接中)(有关的详细信息,请参阅RESP3规范)。
如您所见,默认情况下,客户端不需要告诉服务器它们正在缓存哪些键。服务器会跟踪在上下文的只读命令中涉及到的每个键,因为它可能会被缓存。
这有个明显的优点,即不需要客户端告诉服务器它正在缓存什么。此外,在许多客户端的实现中,这正是我们想要的,因为一个好的解决方案可能是使用先进先出的方法仅缓存尚未缓存的所有内容:我们可能希望缓存固定数量的对象,我们可以将检索到的每个新数据都进行缓存,而去掉最早的缓存对象。更高级的实现可能会删掉最不常用的对象或类似这样的对象。
请注意,无论如何,如果服务器上有写流量,缓存插槽将在写的这段时间内失效。通常,当服务器认为我们获取的数据会缓存时,我们需要进行权衡:
因此,下一节将介绍另一种方法。
(注:这部分还在开展中,尚未在Redis中实现)
客户端实现可能只想缓存某些特定的键,并把它们将缓存什么而不缓存什么显式地与服务器进行通信:这会在缓存新对象时,需要更多的带宽,不过同时会减少服务器需要记录的数据量,也会减少客户端收到的失效消息量。
为此,必须使用OPTIN选项启用跟踪:
CLIENT TRACKING on REDIRECT 1234 OPTIN
在这种模式下,默认情况下,读请求中涉及的键不会被缓存,而是当客户端要缓存某些内容时,要在实际的检索数据的命令之前发送一个特定命令:
CACHING
+OK
GET foo
"bar"
为了充分发挥协议的效率,可以将CACHING命令带上NOREPLY选项 :在这种情况下,它将完全静默:
CACHING NOREPLY
GET foo
"bar"
CACHING命令的作用范围是紧随其后执行的命令,但是如果下一条命令是复合命令(MULTI),那么将跟踪事务中的所有命令。Lua脚本也类似,脚本中执行的所有命令都会被跟踪。
到目前为止,我们介绍了Redis实现的第一个客户端缓存模式。还有一个称为广播(broadcasting)的模式,它从另一个折衷的角度来看问题,它不消耗服务器端的内存,而是向客户端发送更多的失效消息。在这种模式下,有以下的主要行为:
默认情况下,客户端的跟踪甚至会向修改键的客户端发送失效消息。有时客户端可能希望这样做,因为它们实现了非常基本的逻辑,即不进行写操作的本地自动缓存。但是,更高级的客户端中可能希望将其正在执行的写也缓存进本地内存表中。在这种情况下,在写操作之后马上收到失效消息就会是一个问题,因为这将迫使客户端剔除其刚刚缓存的值。
在这种情况下,可以使用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.
当数据和失效消息仅使用一个连接时,不会出现这样的竞态条件,因为在这种情况下消息的顺序始终是已知的。
同样,如果断开了获取失效消息的套接字连接,可能最终会获取到旧数据。为了避免这个问题,我们需要做以下事情:
客户端可能希望运行关于所缓存的键在实际的请求中提供服务的次数的内部统计信息,以便将来更好地对数据进行缓存。一般来说:
不过,更简单的客户端可能只是通过一些随机采样来剔除数据,仅记住上一次提供服务的缓存值,来剔除最近未用到的那些键。
只需确保为Redis需要保存的键的最大数设置一个合适的值,或者使用BCAST模式,该模式在Redis端不占用内存。注意,当不使用BCAST时,Redis消耗的内存与跟踪的键数以及请求这些键的客户端数成正比。
本文翻译自Redis官网https://redis.io/topics/client-side-caching,水平所限,翻译难免有所不足,欢迎大家一起探讨。如需转载,请注明出处。