【Redis】【译】Redis 的客户端缓存机制(Client-side caching in Redis)

776 阅读16分钟

【译】Redis 的客户端缓存机制

注:本文是对 Redis 官方文档 《client-side-caching》 的中文翻译,由于中英文表述习惯可能存在差异,会适当给出批注来帮助理解。

概要

本文主要内容包括:分析了客户端缓存机制的思路、优缺点,在实际使用中可能会遇到的问题,相关命令 CLIENT TRACKINGOPTINNOLOOP 的使用方法,客户端的连接方式,对连接断开、出现竞态条件的分析,实现第三方库时的一些建议等。

更新记录

  • 2022-08-03:发布

【客户端缓存】是用于创建高性能服务的一项技术,其需要使用服务器上的可用内存,这里所说的服务器是相较于数据库节点(直接在应用端存放数据库的部分信息)而言的。通常,当应用服务器访问数据库信息时,如图所示:

 +-------------+                                +----------+
 |             | ------- GET user:1234 -------> |          |
 | Application |                                | Database |
 |             | <---- username = Alice ------- |          |
 +-------------+                                +----------+

但开启了客户端缓存,应用会把经常查询的结果保存到本地,因此下次查询时可以重用这些被缓存的响应,这样一来就不用二次访问数据库了:

 +-------------+                                +----------+
 |             |                                |          |
 | Application |       ( No chat needed )       | Database |
 |             |                                |          |
 +-------------+                                +----------+
 | Local cache |
 |             |
 | user:1234 = |
 | username    |
 | Alice       |
 +-------------+

尽管用于本地缓存的应用内存可能不是很大,访问本地计算机内存所需的时间是至少在数量级上是小于访问网络服务的,如访问数据库。由于最常访问的数据往往是重复的且仅限于一小部分数据,这种方式可以大大减少应用程序获取数据的延迟,并减小数据库端的负载(load)。

此外,有许多数据集的项几乎不变。例如,社交网络中的大多数用户帖子要么是不可变的,要么是不可修改的、要么是很少由用户编辑。基于这个事实,通常只有很少量的帖子是位居热榜的,要么是因为一小部分头部用户有很多粉丝,亦或是因为最新的帖子有更多的可见性,因此为什么这样的模式很有用是不难理解的。

通常,客户端缓存有两个核心优势:

  1. 减少了数据访问的延迟
  2. 数据库系统接受的查询请求减少了,因此相同的数据规模下,可以用较少的节点提供服务。

注:这里说的节点应该是指在分布式环境下,数据库中的表按照水平切分、垂直切分等方式分发到不同服务器节点上,之前由于并发访问量大,因此同一个节点需要一主多从的方式提高读请求的效率,现在数据库负载降低,也许一主一从就足以应对了。

计算机科学中的两个难题

上述模式的一个问题是如何让应用缓存的过期数据失效。例如,在第一个例子中,应用程序本地缓存了 user:1234 此时 Alice 可能会将用户名更新为 Flora。然而,应用程序此时还保存着更新前的用户名。

有时,根据我们所建模的具体应用程序,这不算是一个大问题,客户端的缓存信息使用一个固定大小的最大生存时间(TTL),当缓存信息到期时会自动失效。对于更复杂的情况,利用发布订阅机制向监听客户端发送缓存失效通知,也是可行的,但很棘手且成本很高,会占用更高的带宽,因为这种模式通常涉及向应用中的每个客户端发送过期通知,即使某些客户端可能没有已失效数据的任何副本。此外,每个修改数据的应用查询都需要使用 PUBLISH 命令,从而使得数据库处理请求消耗更多的 CPU 时间。

无论使用什么模式,都存在一个简单的事实:许多大型应用程序都会实现某种形式的客户端缓存机制,因为这是支持快速存储(或快速缓存服务器)的下一个逻辑步骤(the next logical step)。基于这个原因,Redis 6 实现了对客户端缓存的直接支持,这使得该模式更容易实现、访问、更可靠、更高效。

Redis 客户端缓存的实现

Redis 的客户端缓存支持被称之为【跟踪(Tracking)】,其有两种模式:

  • 默认模式:服务器会记住哪些客户端访问了哪些键,当这些键被修改时向客户端发送失效通知(invalidation messages)。虽然会占用一定的服务器内存,但是只会为客户端内存中可能存在的键发送通知。
  • 广播模式:服务器不会试图记住给定客户端访问了哪些键,因此这种模式不会消耗服务器内存。相反,客户端订阅键的前缀(例如,类似 object:user: 这样的键前缀)每当一个匹配键前缀的键被修改时,对应的订阅客户端都会收到通知。

现在回顾一下,让我们暂时忘记广播模式,专注于第一种模式。我们稍后将更详细地解释广播模式。

  1. 如果需要,客户端可以启用跟踪(tracking)功能。连接启动时默认是没有启用跟踪的。
  2. 当启用跟踪时,通过客户端发送的关于这些键的读取命令,服务器会记住每个客户端连接在其生命周期内请求的键。
  3. 当一个键被某些客户端修改时,或是有相关的键过期被淘汰(eviction),亦或是因 maxmemory 策略而被淘汰时,所有启用跟踪并可能缓存了该键的客户端都会收到一个过期通知。
  4. 当客户端收到失效通知时,它们需要删除相应的密钥,避免提供过期数据,导致数据不一致问题。

下面是该的协议的示例:

  • Client 1 -> Server: CLIENT TRACKING ON
  • Client 1 -> Server: GET foo
  • (服务器记录了 Client 1 可能在本地缓存了键 "foo")
  • (Client 1 将 "foo" 的值存储在本地缓存中)
  • Client 2 -> Server: SET foo SomeOtherValue
  • Server -> Client 1: INVALIDATE "foo" (向客户端 1 发送失效通知)

乍一看还好,可是你想象一下在 10k 并发连接下,客户端都为长连接,并涉及对数百万量级密钥的访问,如此一来服务器就存储了太多的信息。出于这个原因,Redis 使用了两个关键思路,来限制服务器端使用的内存量和 CPU 成本(具体地,实现该特性,会涉及到一些内部的数据结构实现):

  • 服务器使用一个单例模式下的全局表来存储那些启用缓存的客户端名单,这个表被称为 失效表(Invalidation Table)。失效表可以包含最大数量的条目。(在失效表已满的条件下)向该表中插入一个新键时,服务器可能会假装该键被修改,从而就可以删除一个旧条目来释放出一个位置,并向客户端发送一个失效通知。这样做就可以回收该密钥占用的内存,只不过这将迫使拥有该密钥本地缓存的客户端将其删除。
  • 在失效表中,我们不需要存储指向客户端结构的指针,我们只需要存储客户端 ID(每个 Redis 客户端都有一个唯一的数字 ID)。如果客户端断开连接,随着缓存槽失效,GC 机制会渐进式地回收相关信息。
  • 使用全局唯一的键名称空间,而不是按照数据库编号划分的。因此,如果一个客户端在 db[2] 中缓存键 foo,而另一个客户端更改了 db[3] 中键 foo 的值,则仍然会发送一条失效通知。通过这种方式,我们可以忽略数据库编号,从而减少内存使用量和实现复杂性。

两种连接模式

RESP3 是 Redis 6 支持的新版本 Redis 协议,它可以在同一个连接中执行数据查询并接收失效通知。然而,许多客户端实现(ps:各种第三方 connector)可能更倾向于使用两个独立连接,来实现客户端缓存:一个用于数据,另一个用于失效通知。因此,当客户端启用【跟踪】时,它可以通过指定不同连接的 “客户端 ID” 将失效通知重定向到另一个连接。数据连接也可以将失效通知重定向到同一个连接,这对于实现了连接池的客户端很有用。两种连接模型也被 RESP2 支持(该版本缺乏在同一个连接中复用不同类型信息的能力)。

下面是一个在旧版的 RESP2 模式下使用 Redis 协议的完整会话的示例,其涉及的步骤有:启用跟踪并重定向到另一个连接、请求密钥、在密钥被修改时接受失效通知。

开始时,客户端建立第一条连接用于无效通知、请求连接 ID、订阅指定频道,该频道在 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" 。现在另一个客户端会修改这个键:

 (Some other unrelated connection)
 SET foo bar
 +OK

结果,连接 1 会收到一条关于该键的失效通知:

 (Connection 1 -- used for invalidations)
 *3
 $7
 message
 $20
 __redis__:invalidate
 *1
 $3
 foo

客户端将检查本地缓存槽中是否有缓存的键,并将对应的失效键删除。

注意,Pub/Sub 消息的第三个元素不是一个键,而是一个只有一个元素的 Redis 数组。当有一组键失效,我们可以在单个消息中,使用数组发送失效通知。对于 FLUSHALLFLUSHDB 的情况,则会发送一条 null 消息。

关于使用 RESP2 和 Pub/Sub 连接来读取失效通知的客户端缓存,要了解的一件非常重要的事情:为了重用旧的客户端实现,使用 Pub/Sub 纯粹是一个技巧,事实上,消息并没有真正发送到通道中、并被所有订阅客户端接收。实际上,只有在 CLIENT 命令的 REDIRECT 参数中指定的连接才会接受到发布订阅消息,这使得该特性更具可扩展性。

当改用 RESP3 版本时,不管是在同一连接中,还是在重定向的另一个连接中,失效通知消息被当作 push 消息发送(详情见 RESP3 规范)。

到底追踪了哪些内容

正如你所看到的,默认情况下,客户端不需要告诉服务器它们正在缓存哪些键。服务器会跟踪在只读命令上下文中提到的每个键,因为它可以被缓存。

这有一个明显的优点,即不需要客户端告诉服务器它正在缓存什么。此外,在许多客户端实现中,一个好的解决方案可能是使用一个队列来缓存所有尚未缓存的内容,因此这也是你想要的:我们可能想缓存固定数量的对象,每当检索一个新数据,就可以缓存它,并淘汰掉最久没访问的缓存数据。更高级的实现可能会删除最少使用的对象或类似对象。

很好理解,和对象池差不多,淘汰数据使用 LRU 算法。

注意,如果服务器流量中有写操作,缓存槽在此期间必须是无效的。通常情况下,服务器认为我们获取数据后就会在本地缓存,我们就要做一个权衡:

  1. 当客户端倾向于对新获取到的数据缓存时,效率往往会更高。
  2. 服务器将不得不保存更多关于客户端键的数据。
  3. 客户端将收到它没有缓存的对象的相关无用的失效通知。

因此,下一节将介绍一种替代方案。

Opt-in 可选缓存

在客户端实现中,可能只想缓存选定的键,并显式地告诉服务器哪些键要缓存,哪些不要被缓存。在缓存新对象时,这将需要更多的带宽,但同时减少了服务器必须保存的数据量和客户端接收到的无用的消息量。为了做到这一点,【跟踪】必须启用 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:。如果没有指定前缀,则默认前缀为空字符串,因此客户端将收到每个被修改的键的无效消息。相反,如果使用了前缀,则只会把失效通知中与指定前缀匹配的键发送到客户端。
  • 服务器不在【失效表】中存储任何内容。相反,它改用【前缀表】 (Prefixes Table),其中每个前缀都和一个客户端列表相关联。
  • 任意两个前缀都不可以同时跟踪键空间的重叠部分。例如,不能同时跟踪前缀 foofoob,因为它们都会触发发键 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 的过期版本。为了避免这个问题,我们可以在发送带有占位符的命令时删除(populate,pop,理解为弹出、删除)本地缓存:

 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.

当使用单个连接传递数据和失效通知时,这样的竞争条件是不会发生的,因为消息顺序总是可以预知的。

注:就像事务的可串行化级别一样,不会出现不一致问题,缺点就是并发性能不好。

当客户端与服务器的连接断开时该怎么办

类似地,如果为了获取失效通知而丢失了套接字连接,则会导致数据不一致问题从而导致程序退出。为了避免这个问题,我们需要做到以下几点:

  1. 确保一旦连接丢失,就要刷新本地缓存。
  2. 无论是使用带有结合发布订阅模式的 RESP2,还是 RESP3,都会周期性地 ping 失效通知通道( invalidation channel),即使当前连接处于发布订阅模式下,也可以发送 ping 命令。如果连接看起来似乎中断了,且无法收到 ping 的响应,在一定时间后关闭连接并刷新缓存。

客户端应该缓存哪些内容

客户端可能想要得到关于指定缓存键在请求中实际访问次数的内部统计数据,以便后续了解哪些内容适合缓存。一般来说:

  • 我们不想缓存很多不断处于变化中的键。
  • 我们不希望缓存那些几乎不被访问的键。
  • 我们希望缓存那些经常被访问且匀速修改的键。对于键不以合理的频率更改的一个例子是,考虑一个不断 INCR 的全局计数器。

然而,更简单的客户端可能只是使用随机抽样来清除缓存,只不过要记住上次提供给定缓存值的时间,从而尝试删除那些最近没被访问过的键。

实现客户端第三方库的一些提示

  • 处理TTL:如果你想提供支持 TTL 的缓存键,应当确保在请求键 TTL 的同时,也同步修改本地缓存中的 TTL。
  • 一个推荐的做法是,不管每个键有没有 TTL,都给它们设置最大 TTL,从而防止出现可能会导致数据不一致性问题的 bugs 或连接问题。
  • 限制客户端能使用的最大内存是很有必要的,通过该限制可以使得添加新键的同时淘汰旧的键。

限制 Redis 的可用内存大小

确保设置了 Redis 能保存的键的最大数量,或者在 Redis 端使用完全不消耗内存的 BCAST 广播模式。注意,当不使用 BCAST 时,Redis 所消耗的内存与跟踪的键数量以及请求此类键的客户端数量成正比。

鸣谢

说明

  • 【Redis】系列相关博客正在更新中,感兴趣的朋友欢迎 star,您的支持是我继续更新下去的最大动力!
  • 由于本人水平、精力有限,文中可能存在疏漏之处,欢迎读者大佬们指正。
  • 对于高质量、格式规范的建议(示例:给出原文具体段落、修改内容、相关依据),确认无误后会合并到博客中,并将贡献者加入【鸣谢】名单中。
  • 可以转载但要注明出处。