缓存如何满足每日 12 亿个API请求?


在 RevenueCat,我们每天处理超过 12 亿个请求。只有在以下情况下您才能有效地做到这一点:

  • 您可以在许多 Web 服务器之间分配负载。
  • 您可以使用缓存来加速对热数据的访问并保护后端系统和数据存储的容量。

缓存很关键,需要实现三件事:

  1. 低延迟:它需要很快。如果缓存服务器出现问题,您无法重试。如果服务器无响应,您无法尝试打开新连接。如果这样做,Web 服务器将在处理请求时陷入困境,而新请求会以每秒数万个的速度不断堆积。
  2. 启动并预热:它需要启动并保存大部分热数据。如果丢失,肯定会导致后端系统负载过大。
  3. 一致性:它还需要一致,即永远不会保存陈旧或不正确的数据。

为了实现所有这些,您需要非常小心地操作和设计您的系统。我们将在这里介绍我们为在 RevenueCat 实现快速、可靠和一致的缓存所做的一些事情。

预先建立的连接
对于缓存操作来说,打开 TCP 连接速度很慢。TCP 握手会向缓存服务器添加 2-3 个额外的数据包和一次额外的往返,此外,如果事情未按预期工作,TCP 还会重试并等待。

  • 缓存服务器通常受到网络流量的限制,因此减少数据包数量对其容量非常重要。
  • 缓存响应来自内存,它们非常快(约为 100us),因此网络往返(同一可用区约为 500-700us)将是响应时间的主导因素。添加另一个往返来建立连接几乎使缓存响应时间增加了一倍。

我们的缓存客户端管理一个开放连接池。您可以配置启动时建立的连接数以及池中保留的最大连接数。
连接由应用程序借用来执行请求,并在请求结束时返回到池中。
在高使用率期间,当连接大部分不在池中时,可能会打开更多连接。如果返回时池已满,它们就会被拆除。
您需要找到最佳设置来平衡与缓存服务器建立的连接数量和高峰期间缓存的使用情况,以避免经常创建新连接。


快速失败
在理想的情况下,您的服务器始终有空闲的 CPU 周期、不拥塞的网络,并且缓存服务器同样拥有处理您的请求的资源。在实践中,虽然您可以监控容量并根据需要进行扩展,但情况并非总是如此。

这意味着有时缓存服务器会变得无响应。它通常是小故障、网络故障、短暂的尖峰。重试缓存操作通常会起作用,但风险极大。有时它不会很快恢复,此时无害的重试可能会导致整个服务基础设施瘫痪。

考虑以下场景:
假设服务器上每秒有 1000 个请求。您服务的大部分内容,例如 95% 的内容都超出了缓存,只有 5% 的内容使用了数据库。仅命中缓存的请求需要 10 毫秒,而使用数据库的请求需要 50 毫秒,因此平均响应时间为 12 毫秒。平均而言,服务器将有 12 个并发请求。

如果服务器变得无响应,您可能会考虑重试请求......您有两个选择:

  • 立即重试:如果立即重试操作,则对该缓存服务器的请求速率将加倍。如果服务器由于负载而发生故障,您将完全确保它永远处于死机状态,看不到恢复的迹象。我什至没有提及您是否尝试重试多次:)。此外,如果出现一些暂时性问题,如果您立即重试,很可能会遇到同样的问题。重试通常需要等待一段时间才能尝试。
  • 短暂睡眠后重试:假设您等待了相当低的 100 毫秒。有可能某些请求多次访问同一个缓存服务器,但我们假设只访问一次。我们还假设我们(非常)幸运,只有 25% 的请求受到影响并且必须从该缓存服务器检索数据。延迟平均增加 100ms*25/100 = 25ms。这意味着我们原来的延迟现在增加了两倍,达到 37 毫秒。这也意味着您将需要 3 倍的服务器容量。您可能会运行一些过度配置来处理峰值,但您肯定无法维持突然增加 3 倍的流量。你可能会指出,仅仅进行睡眠并不会真正使用CPU,但是对服务器的并发请求数量将增加三倍,将需要更多内存来处理它们,CPU 将在这些连接上更加繁忙的多任务处理......甚至数据库也会当请求开始减慢时获得更长的连接,更不用说如果缓存不成功则负载会增加。如果数据库变慢,请求也会变慢,死亡螺旋会让事情变得越来越糟……在重负载下,一次 100 毫秒的睡眠重试可能会让您的整个服务器群崩溃。

那么……如果缓存服务器无响应并且请求失败,您应该做什么?快速失败,假设失败并继续,永不重试。

更重要的是,您应该在一段时间内“记下来”,而不是尝试新的连接。TCP 是为了可靠性而构建的,因此在连接建立中内置了超时、重试和等待,可以充当隐藏的“100 毫秒睡眠”。

综上所述,我们如何实现低延迟:

  • 低超时:配置低连接和接收超时,因此您很快就会假设服务器有问题,而不是陷入长时间等待响应的境地。由于服务器仅使用 RAM,缓存延迟非常稳定,P99 较低,因此您可以积极使用低至 100 毫秒的数字。但没关系,正如我们所看到的,这些等待缓存的时间仍然很长,因此您将需要更多的时间来处理故障。
  • 快速失败并标记下来:发生故障时,客户端应将服务器标记为关闭几秒钟。如果池中仍然存在健康连接,则可以使用它们,但在标记为关闭状态时不得尝试新连接,如果没有健康连接,请求应立即失败。
  • 将失败视为缓存未命中,应用程序将回退到数据源。


热键
我们提到过某些按键可能会过热。最典型的例子是当你有一些在每个请求中获取的配置、一些速率限制器或一个真正大客户的 api 键时......

在极端情况下,单个键keyt可能会被请求太多,以至于单个内存缓存服务器无法处理它。

业界使用了多种技术:

  • 键分割:包括同一键的多个版本,“分片”键。例如keyX将变成keyX/1、keyX/2、keyX/3等,并且每个将被放置在不同的服务器中。客户端将从其中一个读取(通常从其客户端 ID 确定),但向所有客户端写入以保持一致。该系统的难点在于如何检测热键并构建管道,使所有客户端都知道要拆分哪些键、拆分多少,并协调它们同时执行以避免不一致。另外,您需要快速执行此操作,因为有时热键是由现实生活中的事件或趋势触发的,因此热键列表不是静态的。如果您想在我们的缓存客户端中实现此功能,请告诉我们,我们可以在这里讨论一些策略。
  • 本地缓存:更简单的机制是检测热键并将其缓存在客户端本地。您只能对很少更改的数据执行此操作,因为本地缓存无法提供适当的一致性,但通常使用较低的 TTL 并选择允许在本地缓​​存的键,您可以找到可接受的权衡。仍然存在如何检测什么是热键的问题。为此,新的 memcache 的元命令协议可以解决这个问题,它支持返回上次访问键的时间,并且您可以实现概率热缓存。如果你多次看到某个键的上次访问时间小于 X 秒,则表明该键很热。我们的元内存缓存客户端完全支持这一点,我们成功地使用它在每个实例上仅缓存约 100 个真正热门的项目,从而减少了 25% 的缓存工作负载。与键分割不同,本地缓存不需要在所有服务器之间进行协调。


避免“惊群”
如果键非常热并且过期或被删除,所有 Web 服务器都会错过并尝试同时从后端获取值,从而导致负载峰值、延迟增加和饱和,从而级联回 Web 服务层。这被称为“惊群”。

在 RevenueCat,我们通常通过在写入期间更新缓存来保持缓存一致性,这有助于减少惊群。但还有其他缓存模型:

  • 低 TTL:您使用相对较低的 TTL 定期刷新缓存。这对于非用户数据(例如配置)很有帮助。
  • 无效:例如,您可以从数据库传输更改,并使缓存中的值无效。

如果过期或失效的键确实很热,这两种模型可能会导致惊群出现很多问题。

为了避免这种情况,您可以使用在元内存缓存库中实现的两种机制:

  • 重新缓存策略:获取包含指示重新缓存 TTL 的重新缓存策略。当剩余 ttl < 给定值时,其中一个客户端将“获胜”,将丢失并将重新填充缓存中的值,而其他客户端将“失败”并继续使用现有值。
  • 过时策略:在删除命令中,您可以选择将键标记为过时,从而触发与上述相同的机制:单个客户端将丢失,而其他客户端继续使用旧值。

还有第三种惊群情况:当强烈要求的键被驱逐时。由于 memcache 总是尝试逐出最近最少使用 (LRU) 键,这种情况应该很少见,但有时可能会发生:服务器重新启动等……重点是,当大量请求的键丢失时,它也可能导致所有网络服务器立即访问后端。我们的客户也针对这种情况实施了租赁政策。

一致性
有句俗话“计算机科学中只有两件难事:缓存失效和命名事物”,这句话经常被认为是菲尔·卡尔顿(Phil Karlton)说的。

虽然它的目的很有趣,但如果您曾经必须致力于缓存一致性,您就会知道这确实很难。

除了缓存服务器之外,我们还有许多同时处理流量的 Web 服务器。即使它是一台Web服务器,它也可以在多个CPU上并发执行请求。不幸的是,这意味着将会出现引入缓存一致性问题的竞争。 

我们的元内存缓存库允许对低级元命令进行大量控制,有助于处理一致性和高吞吐量使用:

  • 比较和交换:在写入值时检测竞争。在读取过程中,您会获得一个令牌,并将该令牌与写入一起发送。如果读取后该值被修改,则令牌将不匹配并且写入将失败。
  • 租约:因此只有一个客户端被授予更新缓存的权利。Memcache 在未命中时放置一个标记,其他客户端知道有另一个客户端正在填充缓存,而不是它们之间的竞争。
  • 重新缓存策略来实现 stale-while-revalidate 语义。一旦客户端将更新缓存,而其他客户端则使用过时的值。
  • 标记过时:您可以将其标记为过时,而不是删除密钥,然后一个缓存客户端将更新缓存,确保缓存重新验证,但不会出现大量删除。
  • 降低 TTL:通过“触摸”,您可以调整密钥的 TTL(无需读取和写回该值),这样您就可以确保密钥很快就会过期。
  • 写入失败跟踪:跟踪写入错误。

介绍我们用于保持缓存一致性的最重要策略:

1、写入失败跟踪器
写入失败几乎总是意味着缓存中存在不一致。您想要写入的值未写入,因此缓存的状态未知,但可能是错误的。
正如我们上面所讨论的,在处理缓存时重试缓存操作可能会导致性能下降和级联问题。
我们的策略是保持快速失败方法,但记录哪些密钥无法写入。我们的缓存客户端允许您注册一个处理程序,以便您获得写入失败的流。我们收集这些数据,对它们进行重复数据删除,并使每个报告的键的缓存至少失效一次。这确保了该值将从缓存中重新填充,并达到一致性。
这种简单的机制允许我们将缓存写入视为“总是成功”,这极大地简化了在 CRUD 操作期间处理缓存一致性的场景。

2、两个store之间的CRUD操作一致
在写入期间,您需要更新数据库和缓存以保持一致。虽然数据库通常提供事务的概念,但当涉及两个不同的存储时,保证一致性就很复杂。
我们实施了 CRUD 策略来访问数据。它们实现高度一致的缓存机制,并且可以轻松重用,只需配置行为、数据源等。我们强烈建议为 CRUD 访问构建抽象,以抽象出更新数据库和缓存的细微差别,以便产品工程师可以专注于业务逻辑,并且这些策略经过严格的战斗测试并且可以安全使用。

我们实现的策略是在之前 和 之后进行缓存操作:

  1. 之前:将值的缓存 TTL 降低到较低值,例如 30 秒
  2. 写入数据库
  3. 之后:使用新值更新缓存

降低数据库操作前的TTL是实现高度一致更新的简单而有效的策略。

我们还在数据库操作之前减少 TTL,并在之后发出删除操作。最后一次删除不需要同步并等待响应,因为等待 TTL 减少并且保证将其记录为写入失败,并在失败时重试。 

结论
我们分享了一些用于操作高性能且一致的缓存基础设施的技术和策略。其中许多可以通过我们的 Python 开源缓存客户端实现开箱即用,或者也可以适应并用于其他语言。如果您面临类似的挑战,或者您只是想了解如何确保 RevenueCat 平台对我们的客户可靠,我们希望这会对您有所帮助。