缓存令人欣喜令人忧

在 Amazon 构建服务的多年中,我们以不同形式多次经历过以下情形:我们构建的新服务需要进行一些网络调用来满足自身请求。这些调用可能是针对关系数据库、AWS 服务(如 Amazon DynamoDB)或其他内部服务。如果进行的是简单测试或者请求率较低,那么此服务的表现会非常出色,但我们也注意到一个隐患。问题在于对其他服务的调用速度可能会很慢,或者随着调用量的增加,扩展数据库的成本会很高。我们还注意到,许多请求使用的下游资源或查询结果相同,所以我们认为对这些数据进行缓存可以解决这个问题。添加缓存后,我们的服务似乎有了很大改进。我们注意到请求延迟降低,成本降低,下游可用性的小幅下降也已平稳恢复。过了一段时间,没有人会记起缓存之前的状况。依赖关系会相应地减少缓存队列大小,并且数据库规模会缩小。就在一切看起来都进展顺利的同时,服务可能会面临灭顶之灾。流量模式可能发生变化,缓存队列可能出现故障,还可能出现其他导致缓存变冷或不可用的意外情况。这反过来可能会导致下游服务的流量激增,从而造成依赖关系和服务中断。

我们刚刚介绍了一项依赖于缓存的服务。缓存的地位已在不经意间从对服务的有益补充,提升到维持操作必要且关键组成部分。此问题的核心在于缓存引入的模式行为,根据给定的对象是否已缓存,其行为有所不同。一旦这种模式行为的分配发生意外变化,就可能导致灾难。

我们在 Amazon 的构建和运营服务过程中体验了缓存的优势和带来的挑战。本文的其余部分将介绍我们学到的经验教训、最佳做法以及使用缓存的注意事项。

当我们使用缓存时

有多个因素会导致我们考虑向系统添加缓存。许多情况下,这从观察依赖关系在给定请求速率下的延迟或效率开始。例如,这可能是当我们确定依赖关系可能开始限制或无法跟上预期负载时。当我们遇到导致热键/热分区限制的不均匀请求模式时,我们发现考虑缓存很有帮助。如果此类缓存在请求之间具有良好的 缓存命中率,则来自此依赖关系的数据是缓存的理想选择。也就是说,对依赖关系的调用结果可用于多个请求或操作。如果每个请求通常需要对具有每个请求的唯一结果的相关服务进行唯一查询,那么缓存的命中率可以忽略不计,缓存将不起作用。第二个考虑因素是团队的服务及其客户端对 最终一致性的容忍程度。缓存的数据必然随时间的推移而与源数据不一致,因此,只有当服务及其客户端都相应地进行补偿时,缓存才会成功。源数据的更改速率以及用于刷新数据的缓存策略将决定数据的不一致程度。这两个因素是相互关联的。例如,相对静态或变化缓慢的数据可以缓存较长的时间。

本地缓存

服务缓存可在内存中或服务外部实施。机上缓存(通常在进程内存中实施)相对而言比较快速且易于实施,并且只需最少的工作即可提供明显改善。机上缓存通常是在识别缓存需求之后实施和评估的第一个方法。与外部缓存相比,它们无额外的运营开销,因此在集成到现有服务时的风险相对较低。我们通常以内存中哈希表格的形式实施机上缓存,通过应用程序逻辑(例如,在完成服务调用后将结果明确放入缓存中)进行托管,或嵌入到服务客户端(例如,使用缓存 HTTP 客户端)中。

尽管内存中的缓存具有诸多优势并且非常简单,但仍存在一些缺点。其一是队列中各个服务器中的已缓存数据不一致,出现缓存一致性问题。如果客户端重复发起调用,他们可以在第一次调用中使用更新的数据,在第二次调用中使用旧的数据,这取决于哪台服务器来处理请求。

另一个缺点是下游负载现在与服务的队列大小成比例,因此随着服务器数量的增加,它仍可能超出从属服务的能力。我们发现,监控此情形的有效方式是发送缓存命中/未命中的指标,以及向下游服务提出的请求数。

内存中缓存还容易受到“冷启动”问题的影响。如果新服务器启动时缓存完全为空,则会出现这些问题。空缓存会导致从属服务填充缓存时,向该从属服务发出的请求激增。在部署期间或在整个队列范围内清除缓存的其他情况下,这可能是一个重要的问题。缓存一致性和空缓存问题通常可以通过使用请求合并来解决(详情请见下文所述)。

外部缓存

外部缓存可以解决我们刚才讨论过的许多问题。外部缓存将缓存的数据存储在单独的队列中,例如使用 Memcached 或 Redis。缓存一致性问题减少,因为外部缓存保留了队列中所有服务器使用的值。(请注意,这些问题并未完全消除,因为更新缓存时可能会出现故障。) 与内存中的缓存比,下游服务的总体负载降低,与队列大小不成比例。在部署等事件期间不存在冷启动问题,因为在整个部署过程中,外部缓存仍处于填充状态。最后,外部缓存比内存中的缓存提供更多的可用存储空间,从而减少因空间限制而导致缓存被移出的情况。

但是,需要考虑外部缓存的一些缺点。第一个考虑因素是整体系统复杂性和运行负载增加,因为还有额外的队列需要监控、管理和扩展。缓存队列的可用性特征与将其作为缓存的相关服务不同。缓存队列的可用性通常较低,例如,如果它不支持零停机时间升级,并且需要维护时段。

为了防止外部缓存导致服务可用性降级,我们发现必须添加服务代码来处理缓存队列不可用性、缓存节点故障或缓存放置/获取故障。一个选项是恢复到调用相关服务,但我们已经了解到,在采取这种方法时,我们需要小心谨慎。在长时间缓存中断期间,这将导致下游服务的流量出现非典型峰值,从而导致该依赖服务的受限或耗尽,并最终降低可用性。我们更喜欢将外部缓存与内存中的缓存结合使用,如果外部缓存变得不可用,我们可以将其恢复到内存中的缓存,或负载卸除并限制发送到下游服务的最大请求速率。我们在禁用缓存的情况下测试服务行为,以验证我们为防止依赖关系的变化而实施的保护措施是否实际正常工作。

第二个考虑因素是缓存队列的扩展和弹性。当缓存组开始达到其请求速率或内存限制时,需要添加节点。我们要确定哪些指标是这些限制的主要指标,以便相应地设置监视器和警报。例如,在我最近使用的服务上,我们的团队发现,由于 Redis 请求率达到极限,CPU 利用率非常高。为了确定限制并找到正确的警报阈值,我们对真实流量模式进行了负载测试。

在为缓存组增加容量时,我们会非常谨慎,避免导致缓存数据中断或大量丢失。不同的缓存技术有着各自不同的考虑因素。例如,某些缓存服务器不支持在不停机的情况下将节点添加到集群中,而且并非所有缓存客户端库都提供一致的散列(这是将节点添加到缓存组并重新分配缓存数据所必需的)。由于客户端实施一致散列和发现缓存组中的节点的差异性,我们在投入生产之前对添加和删除缓存服务器进行了全面测试。

使用外部缓存时,我们会格外小心地确保存储格式更改时的稳定性。缓存的数据将被视为永久存储。我们确保更新的软件始终可以读取软件早期版本写入的数据,而且较旧版本可以正常处理新格式/字段(例如,在部署期间,当缓存组混合使用新旧代码时)。出现意外格式时,防止发生未捕获的异常情况是抵御“毒丸”的必要条件。但是,这不足以防止所有与格式相关的问题。检测版本格式不匹配并丢弃缓存数据可能导致缓存大量刷新,并造成依赖服务限制或中断。有关序列化格式的问题,“确保部署期间安全回滚”一文有详细介绍。

部缓存的最后考虑因素是它们由服务组中的各个节点进行更新。缓存通常不具有条件放置和事务等功能,因此我们要注意确保缓存更新代码正确,并且不会导致缓存处于无效或不一致的状态。

内联缓存与侧缓存

在评估不同缓存方法时,我们需要做出的另一个决策是选择内联缓存和侧缓存。内联缓存(或读/写缓存)将缓存管理嵌入到主数据访问 API 中,使缓存管理成为该 API 的实施细节。示例包括特定于应用程序的实施,如 Amazon DynamoDB Accelerator (DAX) 和基于标准的实施,如 HTTP 缓存(使用本地缓存客户端或外部缓存服务器,如 Nginx 或 Varnish)。相比之下,侧缓存是通用对象存储,如 Amazon ElastiCache(Memcached 和 Redis)提供的对象存储,或像 Ehcache 和 Google Guava 这样的库用于内存中的缓存。使用侧缓存时,应用程序代码会在调用数据源之前和之后直接处理缓存,在进行下游调用之前检查缓存对象,并在这些调用完成后将对象放入缓存中。

内联缓存的主要优点是客户端的统一 API 模式。可以添加、删除或调整缓存,而不必对客户端逻辑进行任何更改。内联缓存还会将缓存管理逻辑从应用程序代码中拉出,从而消除潜在错误的根源。HTTP 缓存之所以特别吸引人,是因为它提供多现成选项,例如内存库、独立的 HTTP 代理(如前面提到的代理)和托管服务(如内容分发网络 (CDN))。

但是,内联缓存的透明度也可能会降低可用性。在此依赖关系的可用性等式中,外部缓存现在是其中的组成部分。客户端没有暂时不可用的缓存进行补偿的机会。例如,如果您的一个 Varnish 组可缓存来自外部 REST 服务的请求,那么在此缓存组停止运行时,从服务的角度看,就好像依赖关系本身中断了一样。内联缓存的另一个缺点是需要将其内置到其缓存所针对的协议或服务中。如果协议的内联缓存不可用,除非您想自己构建集成的客户端或代理服务,否则不能使用此内联缓存。

缓存过期

在缓存实施细节中,最具挑战性的包括选择正确的缓存大小、过期策略和移出策略。过期策略可决定在缓存中保留项目的时间长度。最常见的策略使用绝对的基于时间的过期时间(即,加载时它会将生存时间 [TTL] 与每个对象相关联)。根据客户端要求选择 TTL,例如,客户端对过时数据的容忍程度以及数据的静态程度,因为缓慢更改的数据可以更积极地进行缓存。理想的缓存大小基于预期请求量模式和这些请求中缓存对象的分配。在此基础上,我们估计缓存大小可以确保这些流量模式具有高缓存命中率。移出策略控制在缓存已满时如何删除缓存中的项目。最常用的移出策略是“最近最少使用”(LRU)。

到目前为止,这只是一个思维练习。现实中的流量模式可能不同于我们建立的模型,因此,我们对缓存的实际性能进行跟踪。我们的首选做法是发送有关以下各项的服务指标:缓存命中和未命中、缓存总大小以及对下游服务的请求次数。

我们学到的经验是必须仔细选择缓存大小和过期策略值。我们希望避免这种情况:开发人员在初始实施过程中随意选择某个缓存大小和 TTL 值,之后从不回头验证这些值是否合适。我们在现实中看到过这种缺乏后续验证的例子,它们不仅导致服务暂时中断,还加重了服务持续中断的问题。

我们使用了另一种模式以在下游服务不可用时提高弹性,也就是使用两个 TTL:一个软 TTL 和一个硬 TTL。客户端将尝试根据软 TTL 刷新缓存项,但如果下游服务不可用或因其他原因未响应请求,则将继续使用现有的缓存数据,直至达到硬 TTL。在 AWS Identity and Access Management (IAM) 客户端中使用了此模式的一个示例。

我们还将软/硬 TTL 方法与背压一起使用,以减小下游服务断流的影响。下游服务在断流时可以通过背压事件作出响应,这表明调用服务在达到硬 TTL 之前应使用缓存数据,并且仅请求不在其缓存中的数据。我们将继续这样做,直至下游服务消除背压。此模式可让下游服务从断流恢复,同时保持上游服务的可用性。

其他注意事项

从下游服务收到错误时缓存有何行为是一个重要的注意事项。处理这种情况的一种选择是使用上次缓存的合适值来回复客户端,例如使用前面说明的软 TTL/硬 TTL 模式。我们采用的另一种选择是使用不同于正缓存条目的 TTL 来缓存错误响应(即我们使用“负缓存”),并将错误传播到客户端。我们在特定情况下选择何种方法,不仅取决于服务的细节,还取决于以下评估的结果:何时让客户端看到过时数据而不是错误会更好?无论我们选择何种方法,重要的是我们确保在错误情况下缓存中有数据。如果不是这样,并且下游服务暂时不可用或因其他原因无法满足某些请求(例如在下游资源被删除时),上游服务将继续向下游服务发送大批流量,而且可能会导致中断或加重已存在的中断问题。我们在现实中看到过相关示例,在这些示例中,缓存负响应失败导致故障率和故障数量增加。

安全是缓存的另一个重要方面。在我们向服务引入缓存时,我们会评估并减轻随之而来的任何额外安全风险。例如,外部缓存队列通常缺乏对序列化数据加密的机制和传输层安全性。这在缓存中保存有敏感用户信息时特别重要。可以使用 Amazon ElastiCache for Redis 之类的服务来缓解该问题,此服务支持传输中加密和静态加密。缓存还容易遭受投毒攻击,在此类攻击中,下游协议中的漏洞允许攻击者使用由其控制的值来填充缓存。这扩大了攻击的影响,原因是在该值保留在缓存中时发起的所有请求都将看到该恶意值。最后一个例子是,缓存还容易遭受侧信道计时攻击。缓存值返回的速度比未缓存值快,因此,攻击者可以使用响应时间来获得其他客户端或租户正在发起的请求的相关信息。

最后一个注意事项是“惊群效应”情况,在该情况下,许多客户端大约在同一时间发起请求,而这些请求都需要同一个未缓存的下游资源。在某个服务器出现并加入具有空白本地缓存的队列时,也可能会发生这种情况。这会导致每个服务器向下游依赖项发起大量请求,从而可能造成限制/断流。为了解决此问题,我们使用了请求合并方法,以使服务器或外部缓存确保只放行一个待处理的请求来获取未缓存的资源。某些缓存库支持请求合并,某些外部内联缓存(例如 Nginx 或 Varnish)同样如此。此外,还可以在现有缓存之上实施请求合并。 

Amazon 采用的最佳实践和注意的事项

本文介绍了 Amazon 在引入缓存时采用的多项最佳实践、所做的取舍和面对的风险。下面总结了 Amazon 的团队在引入缓存时采用的最佳实践和注意的事项:

• 确保从成本、延迟和/或可用性改善方面来看确实有合理的需求来引入缓存。确保数据可以缓存,这表示可以跨多个客户端请求来使用数据。对缓存将带来的价值持怀疑态度,并通过仔细评估来确定收益将超过缓存带来的额外风险。
• 计划按照与服务组和基础设施其余部分相同的严格流程来操作缓存。不要低估这项工作。发送有关缓存利用率和命中率的指标,以确保对缓存进行适当调整。监控关键指标(例如 CPU 和内存),以确保外部缓存队列处于正常状况和适度扩展。设置这些指标的相关警报。确保不必停机或造成大规模的缓存失效即可扩展缓存队列(即验证一致性哈希是否按预期工作)。
• 运用实证方法仔细选择缓存大小、过期策略和移出策略。执行测试,并使用前一个要点中提到的指标来验证和调整这些选择。
• 确保服务灵活应对缓存不可用的情况,其中包括各种会导致无法使用缓存数据来满足请求的情况。这些情况包括冷启动、缓存队列中断、流量模式变化或下游服务长时间中断。在许多情况下,这可能意味着要舍弃一些可用性来确保服务器和依赖服务不会断流(例如,通过卸除负载、限制对依赖服务的请求或提供过时的数据)。在禁用缓存的情况下运行负载测试,以便验证这一点。
• 考虑维护缓存数据所涉及的安全问题,包括加密、与外部缓存队列通信时的传输安全性以及缓存投毒攻击和侧信道攻击的影响。
• 设计让缓存的对象随着时间推移而演变的存储格式(例如使用版本号),并且编写能够读取较旧版本的序列化代码。请提防缓存序列化逻辑中的致命错误。
• 评估缓存处理下游错误的方式,并考虑维护具有不同 TTL 的负缓存。不要通过反复要求获得同一个下游资源和丢弃错误响应来造成或扩大中断问题。

Amazon 内部的许多服务团队都使用缓存技术。此类技术具有很多优点,但我们不要轻易决定加入缓存技术,原因是缺点往往大于优点。我们希望本文能帮助您评估是否在自己的服务中使用缓存技术。


关于作者

Matt 是 Amazon 新兴设备部门的首席工程师,负责开发适用于即将推出的消费类设备的软件和服务。他以前在 AWS Elemental 团队工作,领导整个团队推出了 MediaTailor,这是一个适用于直播和点播视频的服务器端个性化广告插播服务。在任职期间,他还帮助推出了 PrimeVideo 的首个《NFL 周四橄榄球之夜》赛季网络直播节目。加入 Amazon 之前,Matt 在安全行业从事了 15 年的工作,期间先后在 McAfee、Intel 和几家初创公司任职,从事企业安全管理、防恶意软件和防漏洞技术、硬件辅助的安全措施以及 DRM 方面的工作。

Jas Chhabra 是 AWS 的首席工程师。他于 2016 年加入 AWS,从事了几年 AWS IAM 方面的工作,后来加入 AWS Machine Learning 担任现任职务。加入 AWS 之前,他在 Intel 公司担任过多个技术职务,从事 IoT、身份标识和安全方面的工作。他目前关注机器学习、安全和大规模分布式系统,以前关注 IoT、比特币、身份标识和密码学。他拥有计算机科学硕士学位。

避免在分布式系统中回退 通过负载卸除机制来避免过载 确保部署期间安全回滚