原文:https://redis.io/topics/client-side-caching
翻译:Wen Hui
转载:中间件小哥

客户端缓存是用于提供高性能服务的一项技术。它使用应用服务器节点(通常情况下和数据库服务器使用不同的物理机)的可用内存,用来在应用端直接存储一部分数据库信息。
正常情况下当客户端请求应用服务器一些数据时,应用服务器会请求数据库这些信息,如下图所示:

Redis 6.0 客户端缓存的服务器端实现_第1张图片

当使用客户端缓存时,应用服务器端会存储经常访问的数据请求,以便在下次客户端请求过程中重用之前的数据库查询回复,而无需再向数据库进行查询。

Redis 6.0 客户端缓存的服务器端实现_第2张图片

尽管用于本地缓存的应用程序内存可能不是很大,但是与请求诸如数据库之类的网络服务相比,访问本地计算机内存所需的时间要小几个数量级。由于在通常情况下,少量比例数据会经常频繁的被访问,因此该模式可以极大地减少应用程序获取数据的延迟,并同时减少数据库端的负载。
此外,在许多数据集中,信息很少进行更改。例如,社交网络中的大多数用户帖子要么是不变的,要么很少被用户编辑。再加上通常只有一小部分帖子非常受欢迎的事实,要么是因为一小群用户拥有大量关注者,或者因为最近的帖子具有更高的曝光度,由此可见为什么这种模式在实际情况下会非常有用。
通常来说,客户端缓存的两个主要优点是:

  1. 可用的数据延迟非常短。
  2. 数据库系统接收的查询较少,从而可以使用更少的节点来提供相同的数据服务。
    在计算机科学中只有两大问题
    上述模式的问题是在数据被修改或过期时,如何使应用程序保存的信息无效,以避免向用户显示陈旧数据。例如,在上面的应用程序本地缓存了user:1234信息之后,Alice可以将其用户名更新为Flora。但是应用程序可能会继续为用户1234提供旧的用户名。
    有时,取决于我们要建模的应用程序,这个问题并不重要,因此客户端将只使用固定的最大“生存时间”来缓存信息。一旦过了给定的时间,该信息将不再被视为有效。使用Redis时,更复杂的模式会利用发布/订阅系统,以便向侦听的客户端发送无效消息。从使用的带宽的角度来看,这是可行的,但却是棘手且昂贵的,因为这种模式通常涉及向应用程序中的每个客户端发送无效消息,即使某些客户端可能没有无效数据的任何副本。 此外,每个更改数据的应用程序查询都需要使用PUBLISH命令,从而使数据库花费更多的CPU时间来处理此命令。
    无论使用哪种模式,都有一个简单的事实:许多非常大的应用程序都实现某种形式的客户端缓存,因为这是拥有快速存储或快速缓存服务器的下一个逻辑步骤。因此,Redis 6实现了对客户端缓存的直接支持,以使该模式更易于实现,更易于访问,可靠且高效。
    客户端缓存的Redis实现
    Redis客户端缓存支持称为跟踪(tracking),并具有两种模式:
    在默认模式下,服务器会记住给定客户端访问了哪些键,并在别的客户端修改相同的键时向客户端发送无效消息。这将花费服务器端的内存,但仅对在内存中拥有修改的键的客户端发送无效消息。
    相反,在广播模式下,服务器不会尝试记住给定客户端访问了哪些键,因此该模式在服务器端根本不会花费任何内存。相反,客户端会订阅键前缀(例如object:或user :),并且每次其他客户端修改与该前缀匹配的键值时都会收到通知消息。
    回顾一下,现在让我们暂时忘记广播模式,重点关注第一种模式。我们将在后面详细介绍广播模式。
    客户可以根据需要启用跟踪。连接开始时未启用跟踪。
    启用跟踪后,服务器会记住每个客户端在连接生存期内请求过的键(通过发送有关此键的读取命令)。
    当某个客户端修改了某个键,或者由于键具有关联的到期时间而将其逐出,或者由于最大内存策略而将其逐出时,所有启用了跟踪且可能已缓存键的客户端都会收到无效消息通知。
    当客户端收到无效消息时,要求它们删除相应的键值信息,以避免提供过时的数据。
    这是协议的示例:

Redis 6.0 客户端缓存的服务器端实现_第3张图片

从表面上看,这看起来很棒,但是如果你想到有10万个已连接的客户端在每个持久连接中都请求数百万个键,则服务器将会因为存储太多信息而崩溃。因此,Redis使用两个关键思想来限制服务器端使用的内存量以及处理实现该功能的数据结构的CPU成本:
服务器会在一个全局列表中记住可能已将给定键值缓存过的客户端列表。该表称为无效表。这个无效表可以设置最大数量的记录,如果插入了新键值,则服务器可以通过假装已修改(即使没有修改)并将其发送到客户端来驱逐旧条目。这样做,它可以使服务器端回收用于此键值的内存,即使这样会迫使键值在本地客户端缓存被逐出。
在无效表内部,我们实际上不需要存储指向客户端结构的指针,这将在客户端断开连接时在无效表中需要强制执行垃圾回收过程:相反,我们要做的只是存储客户端ID(每个Redis客户端都有唯一的数字ID)。如果客户端断开连接,则随着缓存槽无效,将逐步收集垃圾信息。
在这里只有一个键空间,不以数据库编号做划分。因此,如果客户端在数据库2中缓存键foo,而其他一些客户端在数据库3中更改了键foo的值,则仍然会发送无效消息。这样,我们可以忽略数据库编号,从而减少了内存使用量和实现的复杂度。
两种连接方式
使用Redis 6支持的新版本的Redis协议RESP3,可以在同一连接中运行数据查询并接收无效消息。但是,许多客户端实现可能更喜欢使用两个独立的连接来实现客户端缓存:一个用于数据,另一个用于无效消息。因此,当客户端启用跟踪时,它可以通过指定不同连接的“客户端ID”来指定将无效消息重定向到另一个连接。许多数据连接可以将无效消息重定向到同一连接,这对于实现连接池的客户端很有用。这两个连接模型是RESP2唯一支持的模型(缺乏在同一连接中复用不同类型信息的能力)。
这次我们将通过在旧的RRESP2模式下使用实际的Redis协议显示一个示例,如何完成一个完整的会话,包括以下步骤:启用跟踪重定向到另一个连接,请求键的值信息以及在键的内容被其他客户端修改的情况下,获取服务器发送的键失效信息。
首先,客户端打开第一个用于失效的连接,请求返回连接的ID,并通过Pub / Sub订阅专用通道,该通道在RESP2模式下用于获得失效消息(请记住,RESP2是通常的Redis协议,而不是你可以使用的更高级的协议):

Redis 6.0 客户端缓存的服务器端实现_第4张图片

客户端可以决定在本地内存中缓存“ foo” =>“ bar”。
现在,另一个客户端将修改“ foo”键的值:

Redis 6.0 客户端缓存的服务器端实现_第5张图片

因此,失效连接将收到一条失效信息,该信息使指定的键值失效。

Redis 6.0 客户端缓存的服务器端实现_第6张图片

客户端将检查在此缓存槽中是否有缓存的键值信息,并将逐出不再有效的信息。
请注意,Pub / Sub消息的第三个元素不是单个键,而是只有一个元素的Redis数组。因为我们发送一个数组,所以如果有成组的键无效,我们可以在一条消息中做到这一点。
关于理解使用RESP2的客户端缓存以及为了读取无效消息而进行的Pub / Sub连接非常重要的一点是,使用Pub / Sub完全是一个为了重用旧的客户端实现的技巧,但实际上并未真正发送并被所有订阅该频道的客户所接收。只有我们在CLIENT命令的REDIRECT参数中指定的连接才会实际收到Pub / Sub消息,从而使此功能具有更大的可伸缩性。
如果改用RESP3,则将无效消息作为推送消息发送(在同一连接中,或者在使用重定向时在辅助连接中)(请参阅RESP3规范以获取更多信息)。
追踪用来追踪什么
如上面例子可以看到,默认情况下,客户端不需要告诉服务器它们正在缓存哪些键。服务器会追踪只读命令上下文中提到的每个键,因为它可能会被缓存。
这具有明显的优点,即不需要客户端告诉服务器它正在缓存什么。此外,在许多客户端实现中,这就是你想要的,因为一个好的解决方案可能是使用先进先出的方法仅缓存尚未缓存的所有内容:我们可能希望缓存固定数量的对象,每个对象我们检索到新数据后,就可以对其进行缓存,丢弃最早的缓存对象。更高级的实现可能会删除最不常用的对象或类似对象。
请注意,无论如何,如果服务器上有写流量,则缓存槽将在一段时间内失效。通常,当服务器假设我们得到的东西也缓存时,我们就要进行权衡:

  1. 当客户端倾向于使用欢迎新对象的策略来缓存更多内容时,这样做会更有效率。
  2. 服务器将被迫保留有关客户端键的更多数据。
  3. 客户端将收到有关其未缓存的对象的无效消息。
    因此,下一节将介绍另一种方法
    OPT-IN 模式
    客户端实现可能只希望缓存选定的键,并明确地与服务器通信它们将缓存的内容和不缓存的内容:缓存新对象时,这将需要更多的带宽,但同时会减少服务器需要记住的数据量,以及客户端收到的无效消息数量。
    为此,必须使用OPTIN选项启用跟踪:

Redis 6.0 客户端缓存的服务器端实现

在这种模式下,默认情况下,不应缓存读取查询中的键,而是当客户端要缓存某些内容时,它必须在实际命令检索数据之前立即发送一个特殊命令CACHING:

Redis 6.0 客户端缓存的服务器端实现_第7张图片

为了使协议更有效率,可以使用NOREPLY选项发送CACHING命令:在这种情况下,CACHING命令中客户端不会收到服务器返回的信息:

Redis 6.0 客户端缓存的服务器端实现_第8张图片

CACHING命令会影响随后执行的命令,但是,如果下一个命令是MULTI,则将跟踪事务中的所有命令。同样,对于Lua脚本,将跟踪该脚本执行的所有命令。
广播模式
到目前为止,我们描述了Redis实现的第一个客户端缓存模型。还有一个称为广播模式,它从另一个折衷的角度来看问题,它不消耗服务器端的任何内存,而是向客户端发送更多的无效消息。在这种模式下,我们有以下主要行为:
客户端使用BCAST选项启用客户端缓存,并使用PREFIX选项指定一个或多个前缀。例如:CLIENT TRACKING on REDIRECT 10 BCAST PREFIX object:PREFIX user:。如果根本没有指定任何前缀,则假定该前缀为空字符串,因此客户端将会接收每个被修改的键的无效消息。相反,如果使用一个或多个前缀,则仅在失效消息中发送与指定前缀之一匹配的键。
服务器未在失效表中存储任何内容。相反,它仅使用不同的前缀表,其中每个前缀都与客户端列表相关联。
每次修改与任何前缀匹配的键时,所有订阅该前缀的客户端都将收到无效消息。
服务器的CPU消耗与注册前缀数量成正比。如果只有几个,几乎看不出任何区别。使用大量前缀,CPU成本可能变得非常高。
在这种模式下,服务器可以优化为订阅给定前缀的所有客户端创建单个回复的过程,并将相同的回复发送给所有客户端。这有助于降低CPU使用率。
避免竞争条件
在实施客户端缓存以将无效消息重定向到其他连接时,你应该意识到存在竞争状况。请参见以下示例交互,在此我们将数据连接称为“ D”,并将失效连接称为“ I”

Redis 6.0 客户端缓存的服务器端实现_第9张图片

如上所示,由于对GET的回复返回给客户端会有较长延时,因此在已经不再有效的实际数据之前,我们收到了无效消息。因此,我们将继续提供旧版本的foo键的值信息。为避免此问题,当我们使用占位符发送命令时,填充缓存是一个好主意:

Redis 6.0 客户端缓存的服务器端实现_第10张图片

当对数据和无效消息使用单个连接时,这种竞争条件是不可能的,因为在这种情况下消息的顺序始终是已知的。
与服务器断开连接时该怎么办
同样,如果丢失与套接字的连接以获取无效消息,则可能会以客户端收到陈旧数据结束。为了避免这个问题,我们需要做以下事情:

  1. 确保如果连接丢失,则刷新本地缓存。
  2. 在将RESP2与Pub / Sub一起使用时,或者在RESP3上,都定期对无效订阅连接进行ping操作(即使连接处于Pub / Sub模式下,也可以发送PING命令!)。如果连接看起来断开并且我们无法接收ping回复,请在设定的最长时间后关闭连接并刷新缓存。
    什么需要缓存
    客户可能希望运行有关给定缓存的键在实际请求中被调用的次数的内部统计信息,以了解以后对哪些键使用客户端缓存。一般来说:
    · 我们不想缓存很多不断变化的键。
    · 我们不想缓存很多很少被请求的键。
    · 我们希望缓存经常请求的键并以合理的速率进行更改。有关键没有以合理的速度更改的示例,请考虑一个不断增加的全局计数器。
    但是,更简单的客户端可能只是使用一些随机采样来逐出数据,只是记住最后一次被查询的特定键值,从而试图逐出最近未被查询的键。
    有关客户端库实现的建议
    · 处理TTL:如果要支持带TTL的缓存键,请确保查询键的TTL值并在本地缓存中设置TTL。
    · 即使没有TTL,在每个键中都放置一个最大TTL是一个比较好的做法。这是个很好的保护措施,可避免可能导致客户端在本地副本中包含旧数据的错误或连接问题。
    · 绝对需要限制客户端使用的内存量。添加新键时,必须有一种方法可以将旧键逐出。
    限制Redis使用的内存量
    只需确保为Redis记住的最大键数配置一个合适的值,或者使用BCAST模式,该模式在Redis端根本不占用任何内存。请注意,当不使用BCAST时,Redis消耗的内存与跟踪的键数量以及请求此类键的客户端数量成正比。