写点什么

缓存架构带来的甜与苦

作者:薛以致用
  • 2024-01-14
    韩国
  • 本文字数:6461 字

    阅读完需:约 21 分钟

缓存架构带来的甜与苦

过了一段时间后,没有人还能记得缓存之前的状况 After a while, no one can remember life before the cache.


缓存在我们架构设计里面是一个常见和有效的设计模式,也是面试环节中常常被问到的一个典型场景,比如 通过 CDN(内容分发网络)进行图片、音视频、游戏安装包等静态资源的缓存和加速访问,数据库和应用中间设计一个缓存层比如 Redis 来减轻数据库压力,并提升响应时间;


对于下游依赖的服务调用,如果很多请求可以重用下游资源或结果,那么我们认为缓存这些数据会有助于我们解决问题,增加一个缓存层,服务大概率会有改进,比如请求延迟降低了,下游服务的不稳定也相对有个平滑的过渡;一切似乎进展顺利时,服务可能正面临潜在的灾难,可能会有流量模式的变化,缓存服务集群的故障,或其他意外发生,导致缓存不可用;这反过来又可能导致该服务直接访问下游服务,导致下游服务的流量激增,导致我们的依赖下游服务和自身服务都出现不可用糟糕情形;


这是一个典型的缓存上瘾的状况,缓存已经从一个有益的补充不经意间升级成为系统能够运行所必需和关键的部分。 这一问题的核心在于缓存引入的新的模式行为,根据某个数据或对象是否被缓存(命中率),其行为有所不同,这种多模态行为的意外可能导致灾难。

一个有趣且有价值的缓存案例

《亚马逊工程师如何将现有超大规模 NoSQL 数据库改造支持大规模分布式事务?》我们提到过,DynamoDB 这个完全云原生和云托管的 NoSQL 数据库服务是由几十个微服务组成,我们稍微回顾下架构,比较核心的服务有 Metadata service、Request Route(RR)Service、Storage Service、Auto Admin Service 等服务;元数据服务保存给定的表或索引对应的存储复制组的路由信息;RR Service 请求路由服务负责认证、授权和将每个请求路由到适当的存储服务器,例如,所有读取和更新请求都会路由到该客户数据的存储节点;请求路由器从元数据服务查找路由信息;Storage Service 存储服务负责在一组存储节点上存储客户数据,每个存储节点托管不同分区的许多副本。

为了处理请求,请求路由 RR 服务必须找到托管该表(主键)对应的存储节点,在 DynamoDB 刚启动时,元数据存储在元数据服务 (也是 DynamoDB 表) 中,这个路由信息包含了表的所有分区、每个分区的键范围以及托管所有分区的存储节点;当请求路由服务收到一个之前未见过的表的请求时,它会下载整个表的路由信息并 本地缓存,由于分区副本的配置信息很少改变,缓存命中率大约为 99.75%

缺点

缓存引入了双模态行为(Bimodal behavior),什么是双模态行为?就是指服务在正常模式(缓存命中)和故障(或异常或特殊,这里指缓存未命中,或缓存服务异常)模式下表现出不同的行为;


在 RR 服务本地缓存为空的冷启动场景下,每个 DynamoDB 客户端请求都会导致元数据查找,所以元数据服务必须扩容到能够与客户端请求相同的速率来处理请求;这种效应在实践中已经观察到,当向请求路由服务集群添加新容量时,有时元数据服务流量会激增到 75%,因此,引入新的请求路由服务集群机器会影响性能,并可能使系统不稳定。此外,无效的缓存可能导致故障级联到系统的其他部分,因为提供源数据服务承担过多直接负载而崩溃。

一个现实的故障案例

说到崩溃,在 2015 年,us-east 美东区域确实发生过一起由于元数据服务突增的高负载带来响应延迟和超时,从而影响存储服务(存储节点确认不了元数据信息,从而下线),最终影响到了客户的请求;原因是当时 GSI 新特性的引入,用户大表的“membership”元数据大小变得比以前大很多,所谓“membership” 就是表的数据被分拆存储到多个分区,而一组分区对应的一个存储节点信息就称为“membership”;当时 DynamoDB 存储服务的网络出现故障,按照设计,存储节点在网络故障或重启后需要访问元数据服务,获取和确认“membership”信息,突增的大量并发同步请求和"membership"元数据增大,给元数据服务带来极大的负担,甚至连管理服务都无法正常对元数据服务进行扩容,最后限流降元数据服务的负载再扩容才恢复整个 DynamoDB 服务;


因此,RR 服务的设计,同样有可能对元数据服务带来冲击,从而对整个系统稳定性带来影响;

如何破?

在可靠性优良架构方法中有一条针对双模态行为的最佳实践:


采用静态稳定设计运行的工作负载,在正常情况和故障事件期间能够得到可预测的结果。


DynamoDB 希望在不影响客户请求性能的情况下,消除和显著减少请求路由服务(以及其他元数据客户端)对本地缓存的依赖。为了以一种经济高效的方式缓解元数据服务动态扩展和可用性风险,DynamoDB 构建了一个名为 MemDS 的内存分布式数据存储,MemDS 将所有元数据存储在内存中,并在 MemDS 集群中复制,数据高度压缩。


在每个请求路由服务虚拟机上依然有一个本地缓冲区,与原设计相比,新的本地缓冲做了两方面的优化;首先,RR 服务本地只缓存被请求键分区的元数据信息,而不是整个表的路由信息,避免浪费,也减少了流量;其次,无论本地缓存是否命中,RR 服务都对 MemDS 发起一次调用,当然,如果本地缓存已命中,对 MemDS 的调用是异步的;

在新缓存设计中,哪怕本地缓存命中也会对 MemDS 的异步调用以刷新缓存,因此,新缓存架构确保 MemDS 集群始终服务于恒定数量的流量,而不管缓存命中率如何,但因此相比于经典的缓存架构,对 MemDS 集群的恒定流量增加了元数据服务集群的负载,但避免了原本设计中的双模态行为,带来的系统不稳定性;

我们在什么情况下会考虑使用缓存?

有几个因素促使我们考虑在系统中添加缓存层;在通常情况下,这始于对依赖的下游服务或存储延迟或在给定并发请求下的效率的观察;例如,我们发现所依赖的下游服务可能开始限流或否则无法承受预期的工作负载;


当我们遇到会导致热键或热分区的不均衡请求模式时,考虑缓存是有帮助的;如果缓存可以在不同的请求中有一个好的命中率,则来自此依赖服务的数据是一个很好的缓存对象,也就是说,对依赖服务的调用结果可以跨多个请求使用;


如果每个请求需要对下游依赖服务进行唯一查询以获取独特不一样的请求结果,则缓存的命中率可以忽略不计,缓存毫无益处。


第二个考虑因素是服务本身及其客户端对最终一致性的容忍度;缓存的数据必然随时间的推移而与源数据会不一致,因此只有当服务及其客户端能够相应地进行补偿时,缓存才能成功;源数据的变化率以及刷新数据的缓存策略将决定数据不一致的程度;这两者是相关的,例如,相对静态或变化缓慢的数据可以缓存更长时间。

本地缓存 vs 外部缓存

什么是本地缓存?

缓存可以存储在服务自身内存中,也可以在服务外部实现,在进程内存中实现的缓存,通常称为 On-Box 缓存,相对更快更容易实现,并能够通过最小的工作量就能带来显著的性能改进。


一旦识别出缓存的需求,On-Box 缓存 通常是优先要评估的方法。与外部缓存相比,它们不会带来额外的运维开销,所以将其集成到现有服务中风险相对较低。我们通常通过应用逻辑(例如,在服务调用完成后主动将结果放入缓存中)或嵌入在服务的客户端中(例如,使用缓存 HTTP 客户端)来实现一个基于内存哈希表的 On-Box 缓存。

本地缓存会遇到的挑战

尽管 On-Box 缓存简单易实现,它也存在一些 缺点一个是不同服务器之间的会出现缓存不一致性问题,如果一个客户端对服务进行重复调用,根据处理请求的所在服务器不同,第一次调用可能会获取到较新的数据,而第二次调用获取到较旧的数据。


第二个缺点是下游服务负载量与该服务集群规模成正比,所以随着该服务集群数量的增长,下游服务仍有可能被压垮;缓解该问题的方法,是监控缓存命中/未命中率以及对下游服务请求总数等指标。


On-Box 缓存也容易遭受 “冷启动”问题,这些问题发生在一个空缓存的新服务器启动时,可能会导致对下游服务的请求突增,以填充其缓存;这在新部署或缓存被全面刷新时尤其突出;缓存一致性和冷启动问题可以通过请求合并技术来解决,我们在后面章节再展开。

什么是外部缓存?

外部缓存将缓存数据存储在独立的集群中,例如使用 Memcached 或 Redis;由于外部缓存为集群中的所有服务器保存相同的值,缓存不一致性问题得以缓解但由于存在更新缓存失败或故障情况,并不能完全消除。与 On-Box 本地缓存相比,下游服务的总体负载减少,且与集群规模无关;服务更新部署等事件期间也不会出现冷启动问题,因为外部缓存在整个部署过程中一直保持稳定的填充状态;最后,与 On-Box 缓存相比,外部缓存提供更多的存储空间,减少了由于空间限制导致的缓存回收的情况;

外部缓存的一些缺点

外部缓存也有一些需要考虑的缺点


第一, 整体系统复杂性和运维成本增加,因为增加了一个需要独立监控、管理和扩展的集群;缓存集群的可用性建设也很重要,比如它是否支持零停机维护或升级;为防止外部缓存集群的不可用降低服务可用性,就必须在服务代码中处理缓存集群不可用、缓存节点故障或缓存读写失败的情况:


  • 一种选择是直接调用下游依赖服务,但采用这种方法时必须谨慎,如果缓存长时间不可用,这将导致下游服务遭受意外的流量尖峰,从而下游服务将会限流或降级,最终降低了整体可用性。

  • 第二种策略是将外部缓存与 On-Box 缓存组合使用,如果外部缓存不可用可以退回到 On-Box 缓存,或者使用降低负载方法并限制对下游服务的最大请求率上限;

  • 第三种选择是实现缓存集群的扩展和弹性;随着缓存集群开始接近其请求率性能上线或内存限制,需要添加节点;这个过程最重要的是,如何识别哪些是我们应用流量特征使得缓存集群到达性能上限或某些限制的先兆指标,以便相应设置监控和警报;其次,要小心地处理,不能造成停机或大规模缓存数据丢失,而且确保新增节点后,重新分布缓存数据,客户端实现一致性哈希算法主动感知集群节点数量的变化;


第二, 使用外部缓存时,我们会特别注意确保在缓存数据格式更改时的健壮性;缓存数据被视为持久存储中的数据,我们确保更新后的服务始终能读取先前版本服务写入的数据,并且旧版本服务可以优雅地处理看到新的格式或字段(例如,在部署期间服务集群存在新旧代码的混合情况时),当然,优雅安全的回滚也是非常重要的;


最后, 外部缓存由服务集群中的单个节点更新,缓存通常没有条件性写和事务等功能,所以我们注意确保缓存更新代码正确,不会使缓存处于无效或不一致状态。

内联缓存 vs 旁路缓存

在我们评估不同缓存方法时需要做出的另一个决定是内联缓存与侧缓存的选择。


内联缓存(Inline Caches)又称为读穿透(Read-Through)或写穿透(Write-Through)缓存,将缓存管理嵌入数据访问 API 接口中,使缓存管理成为该 API 的实现细节;


典型例子包括针对应用程序特定的实现,如 Amazon DynamoDB Accelerator (DAX),以及标准协议实现,如 HTTP 缓存(无论是使用本地缓存客户端还是外部缓存,如 Nginx 或 Varnish HTTP Cache)


内联缓存的主要好处是对客户端提供统一的 API 接口,可以在不更改客户端逻辑的情况下添加、删除或调整缓存;内联缓存还将缓存管理逻辑从应用程序代码中抽取出来,从而消除了潜在错误;HTTP 缓存尤其有吸引力,因为有许多现成的选择,比如独立的 HTTP 代理,以及托管服务像内容分发网络(CDN);


内联缓存的透明性也可能成为可用性的缺点,客户端是没有机会补偿暂时不可用的缓存,例如,如果有一个 Varnish 集群来缓存来自外部 REST 服务的请求,那么如果该缓存集群宕机,从您的服务角度来看,就好像依赖的外部服务本身宕机了;内联缓存的另一个缺点是它需要内置于被缓存的协议或特定服务中,如果没有可用的该协议的内联缓存,那么除非您想自己构建集成的客户端或代理服务,否则就无法使用内联缓存。


旁路缓存(Side Caches),相反是通用对象数据存储,如 Amazon ElastiCache (Memcached 和 Redis) 提供的缓存服务,或像开源的 Ehcache 和 Google Guava 这样的内存缓存库,使用旁路缓存时,应用程序代码在调用数据源之前和之后直接操作缓存,在进行下游服务调用之前检查缓存对象并在完成这些调用后将对象放入缓存中;

缓存过期

缓存过期是缓存实现中最具挑战性的细节之一,选择恰当的 1/ 缓存存储大小、2/ 过期策略和 3/淘汰(Eviction)策略非常关键;


理想的 缓存存储大小基于对预期请求量及这些请求中缓存对象分布的建模;通过这种方式,我们可以估算一个缓存大小,使得在特定流量模式下可以确保高缓存命中率;


过期(失效)策略决定了数据对象在缓存中保留的时间长度;最常见的策略是使用绝对的基于时间的失效(即与每个对象加载时相关联的生存时间 TTL - Time To Live);TTL 的选择基于客户的要求,比如客户对陈旧数据的容忍度有多大,以及数据的静态性(因为缓慢变化的数据可以更积极地持久的缓存);


淘汰策略控制当缓存达到容量限制时如何从缓存中移除项目,最常见的驱逐策略是最近最少使用(Least Recently Used)算法。


到目前为止,这只是一个思想实验,现实世界的流量模式可能与我们设计的模型不同,所以我们需要监控缓存的实际性能,我们首选的方法是发出有关缓存命中率和未命中率(cahce hits & misses)、总缓存大小(total cache size)以及对下游服务的请求数(number of requests to downstream services)


当下游服务不可用时,我们用来提高韧性的另一种模式是使用两个 TTL:一个 Soft TTL 和一个 Hard TTL,客户端将根据 Soft TTL 尝试刷新缓存的数据项,但是如果下游服务不可用或未响应请求,则现有的缓存数据将继续使用,直到达到 Hard TTL;亚马逊云服务的基础身份和访问管理(IAM)客户端就是使用了这个模式的一个例子;


当下游服务出现故障时,我们还将 Soft TTL 和 Hard TTL 方法与背压(Back Pressure) 机制结合使用,以减少影响;


(Back Pressure)背压这个词是来源于工程概念,当气流或液体在管道中运输时,由于管道变细或者受到其他阻碍,导致出现了下游向上游的逆向压力,这种情况就称为背压;


下游服务可以在出现故障时响应一个背压事件,告知服务调用方应该使用缓存数据直到 Hard TTL,并且只请求不在其缓存中的数据;我们将持续这样做,直到下游服务删除背压事件,这种模式允许减轻下游服务压力更好地从故障中恢复,同时维持上游服务的可用性。


当从下游服务收到报错时,我们可以选择返回给客户端最后一个良好缓存值如前面所说的 Soft TTL 结合 Hard TTL 的模式;另一个可以采用的模式,是缓存错误响应,即“负缓存”,使用一个区别于正常缓存数据项的 TTL,并返回给客户端错误信息;


实际情况采取什么策略,取决于具体服务的情况,并评估客户端看到旧数据和错误信息哪种情况更能接受来确定;无论如何处理,在下游服务有故障或异常情况下,缓存中有某些内容是相当重要的,如果不是这样,并且下游服务暂时不可用或因其他原因无法满足请求,上游服务将继续轰炸它,会导致故障加剧;


“惊群效应”或缓存雪崩,是指许多客户端在大约同时请求需要相同未缓存下游资源或对象;比如游戏发新版,在并未预热的情况下,大量的前端几乎同时从 CDN 请求下载同一个文件,这些请求都未命中缓存,导致 CDN 向下游服务比如对象存储 S3 发送大量请求,导致性能下降;


常见的处理可以是请求合并或请求折叠,使得下游被请求的服务只有一个待处理的请求;比如 Amazon Cloudfront 对于同一个 Cache Key 的对象,在边缘节点收到并发多个请求,除了第一个请求会立刻请求下游服务,其他请求会被“pause”,当第一个请求返回时,将结果一起返回给所有的并发请求客户端;


Instagram 团队在 C++中使用 Promise 模式来限制并发访问:

另外一个方式是预热,我们已经非常熟悉 CDN 的预热做法,尤其在前面提到的游戏发版的场景,对于缓存服务集群也可以采取类似的做法,无论是 Netflix 还是 Meta 都采取过类似的策略,避免调用端直接访问昂贵的下游服务或存储:

总结

缓存是我们一个熟悉又陌生的架构模式,熟悉是指,上手非常容易简单,陌生是指,随着流量模式的变化,缓存如果变成系统整体的一个非常关键的组件,那我们需要花费很长时间来监控实际的工作负载,发现上下游不同故障的影响,以及在系统可用性、性能、韧性、成本等方面进行权衡;开篇我们就介绍了 Amazon DynamoDB 中元数据服务的缓存架构的变化,随后我们展开缓存架构常见的考量和对策;


声明:本文仅代表个人观点,跟公司无关

发布于: 刚刚阅读数: 4
用户头像

薛以致用

关注

从客户中来,到客户中去 2018-09-22 加入

16年+ IT 行业经验,关注云计算、团队建设,客户实践案例以及各种生活工作各种新鲜事

评论

发布
暂无评论
缓存架构带来的甜与苦_薛以致用_InfoQ写作社区