本文将系统介绍缓存的相关知识,通过用23张图片及5W1H分析法为你详细阐述,希望能帮你系统掌握缓存。
来,先上文章的目录,让大家可以对缓存这块知识先建立一个系统性的认知,然后我会按点逐个击破,读者们也可以按需阅读哈!
维基百科对缓存的定义是:
In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.
简而言之,缓存就是存储数据副本或计算结果的组件,以便后续可以更快地访问。
在计算中,缓存是一个高速数据存储层,其中存储了数据的子集,且通常是短暂性存储,这样日后再次请求该数据时,直接读缓存会比重新计算结果或读数据存储更快。通过缓存,你可以高效地重用之前检索或计算的数据。
从定义上可以看出所谓缓存其实是其他数据的副本,使用缓存是为了更快地检索或计算数据。
(一)硬件层面:如CPU中的高速缓存
从上图可以看出CPU(蓝色)和内存(粉红色)之间存在巨大的性能差距。在CPU访问数据和指令遵循计算机系统的局部性原理:
使用高速缓存可以弥补CPU和内存之间的性能差异,减少CPU浪费计算时间等待内存数据。
(二)软件层面
(三)产品层面
是否可以解决用户的痛点问题决定着用户会不会一开始尝试使用某款产品,是否有极致的用户体验影响用户会不会持续使用某款产品。在业务中使用缓存的目的就是通过扩大系统吞吐量、减少时延和响应时间来优化用户体验,适当的性能优化可以提升体验,增强用户粘性。
在现有的互联网应用中,缓存的使用是一种能够提升服务快速响应的关键技术,也是产品经理无暇顾及的非功能需求,需要在设计技术方案时对业务场景,具有一定的前瞻性评估后,决定在技术架构中是否需要引入缓存解决这种这种非功能需求。
缓存不是架构设计的必选项,也不是业务开发中的必要功能点,只有在业务出现性能瓶颈,进行优化性能的时候才需要考虑使用缓存来提升系统性能。并非所有的业务场景都适合用缓存,读多写少、不要求一致性、时效要求越低、访问频率越高、对最终一致性和数据丢失有一定程度的容忍的场景才适合使用缓存,缓存并不能解决所有的性能问题,倘若滥用缓存会带来额外的维护成本,使得系统架构更复杂更难以维护。
虽然缓存适用于各种各样的案例,但要充分利用缓存,需要进行一定的规划。所以在决定是否缓存一段数据时,请考虑以下问题:
另外把缓存当做存储来使用是一件极其致命的做法,这种错误的认识,将缓存引入系统的那一刻起就意味着已经让系统走上了危险的局面,只有对缓存的使用边界有深刻的理解,才能尽可能减少引入缓存带来的副作用。
其实缓存的思想随处可见,日常生活中人们都会有意无意地用到。比如你会把常看的书放到书桌上,这样你可以更快地拿到它们,而受限于桌面空间,不常看的书就要放在空间更大的书柜里了,等到要看的时候再从书柜里拿出来放到书桌上。这里的书桌其实就是一种缓存介质了。
对于程序员来说,缓存更是家常便饭了。有哪些程序员会用到缓存技术呢?
硬件开发工程师:比如CPU缓存、GPU缓存和数字信号处理器(DSP)缓存等。
软件开发工程师:
客户端开发:比如第4章节讲到的页面、浏览器、APP缓存技术都和客户端息息相关。
后端开发:比如服务端本地缓存、redis和memcached充当数据库缓存、静态页面缓存等。
分布式开发:现在分布式大行其道,分布式缓存必然是分布式开发中绕不开的一环,6.10节的分布式缓存也是我们要重点讲解的。
操作系统开发:操作系统内核负责管理磁盘缓存,比较典型的就有主存中的页面缓存技术。
(一)缓存分类
按照不同的维度,可以对缓存分门别类,如下图所示。
下面我着重按照 缓存所处链路节点的位置 来系统梳理出不同类型的缓存应用。
HTTP协议的无状态性决定了它必须依靠客户端缓存来解决网络传输效率上的缺陷。由于每次请求都是独立的,服务端不保存此前请求的状态和资源,所以也不可避免地导致其携带有重复的数据,造成网络性能降低。HTTP协议对此问题的解决方案便是客户端缓存。
常见的客户端缓存有如下几种:
页面缓存是指将静态页面获取页面中的部分元素缓存到本地,以便下次请求不需要重复资源文件,h5很好的支持的离线缓存的功能,具体实现可通过页面指定manifest文件,当浏览器访问一个带有manifest属性的文件时,会先从应用缓存中获取加载页面的资源文件,并通过检查机制处理缓存更新的问题。
APP可以将内容缓存到内存或者本地数据库中,例如在一些开源的图片库中都具备缓存的技术特性,当图片等资源文件从远程服务器获取后会进行缓存,以便下一次不再进行重复请求,并可以减少用户的流量费用。
客户端缓存是前端性能优化的一个重要方向,毕竟客户端是距离“用户”最近的地方,是一个可以充分挖掘优化潜力的地方。
浏览器缓存通常会专门开辟内存空间以存储资源副本,当用户后退或者返回上一步操作时可以通过浏览器缓存快速的获取数据,减少页面加载时间和带宽使用。在HTTP从1.0到1.1,再到2.0版本的每次演进中,逐步形成了现在被称为“状态缓存”、“强制缓存”(许多资料中简称为“强缓存”)和“协商缓存”的HTTP缓存机制。在HTTP 1.1中通过引入e-tag标签并结合expire、cache-control两个特性能够很好的支持浏览器缓存。
下面我们用思维导图理解强制缓存和协商缓存,请注意体会梳理思路!
对于Etag的补充说明:
HTTP服务器可以根据自己的意愿来选择如何生成这个标识,比如Apache 服务器的Etag值默认是对文件的索引节点(INode),大小和最后修改时间进行哈希计算后得到的。
Etag是HTTP中一致性最强的缓存机制,比如,Last-Modified标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间;又或者如果某些文件会被定期生成,可能内容并没有任何变化,但Last-Modified却改变了,导致文件无法有效使用缓存,这些情况Last-Modified都有可能产生资源一致性问题,只能使用Etag解决。
Etag却又是HTTP中性能最差的缓存机制,体现在每次请求时,服务端都必须对资源进行哈希计算,这比起简单获取一下修改时间,开销要大了很多。Etag和Last-Modified是允许一起使用的,服务器会优先验证Etag,在 Etag一致的情况下,再去对比Last-Modified,这是为了防止有一些HTTP 服务器未将文件修改日期纳入哈希范围内。
扩展知识:内容协商机制
到这里为止,HTTP的协商缓存机制已经能很好地处理通过URL获取单个资源的场景,为什么要强调“单个资源”呢?在HTTP协议的设计中,一个 URL 地址是有可能能够提供多份不同版本的资源,比如,一段文字的不同语言版本,一个文件的不同编码格式版本,一份数据的不同压缩方式版本,等等。因此针对请求的缓存机制,也必须能够提供对应的支持。为此,HTTP协议设计了以Accept(Accept、Accept-Language、Accept-Charset、Accept-Encoding)开头的一套请求Header和对应的以Content-(Content-Language、Content-Type、Content-Encoding)开头的响应Header,这些Headers被称为HTTP的内容协商机制。与之对应的,对于一个URL能够获取多个资源的场景中,缓存也同样也需要有明确的标识来获知根据什么内容来对同一个URL返回给用户正确的资源。这个就是Vary Header的作用,Vary后面应该跟随一组其他Header的名字,比如:
HTTP/1.1 200 OKVary: Accept, User-Agent
以上响应的含义是应该根据MIME类型和浏览器类型来缓存资源,获取资源时也需要根据请求Header中对应的字段来筛选出适合的资源版本。
(二)网络缓存
网络缓存位于客户端以及服务端中间,通过代理的方式解决数据请求的响应,降低数据请求的回源率。回源率又分为以下两种:
网络缓存常见的代理形式分为两种:web代理缓存、边缘缓存。
在介绍网络缓存之前我们先了解下前置知识----两种服务器代理方式:正向代理、反向代理。
正向代理其实就是:客户端通过代理服务器与源服务器进行非直接连接。客户端可以感知到代理服务器的存在,对源服务器透明(源服务器感知不到客户端的存在),如下图所示:
通信时客户端和代理服务器要设置好代理协议,比如Socks协议或者是HTTP协议(可以设置第四章第一节浏览器缓存中的HTTP Header)。
那正向代理有什么作用呢?
反向代理就是:客户端通过代理服务器与源服务器进行非直接连接。客户端只会得知反向代理的IP地址,而不知道在代理服务器后面的服务器集群的存在。如下图所示:
反向代理有什么作用呢?
介绍完2种服务器代理形式后,我们来说下两种网络缓存形式。
web代理缓存:web代理缓存通常是指正向代理,会将资源文件和热点数据放在代理服务器上,当新的请求到来时,如果在代理服务器上能获取数据,则不需要重复请求到应用服务器上。
边缘缓存:和正向代理一样,反向代理同样可以用于缓存,例如nginx就提供了缓存的功能。进一步,如果这些反向代理服务器能够做到和用户请求来自同一个网络,那么获取资源的速度进一步提升,这类的反向代理服务器可以称之为边缘缓存。常见的边缘缓存就是内容分发网络(Content Delivery Network),简称CDN。可以将图片等静态资源文件放到CDN上。
那什么CDN呢?
如果把某个互联网系统比喻为一家跨国企业,那内容分发网络就是它遍布世界各地的分销机构,如果现在有客户要买一块CPU,那订机票飞到美国加州英特尔总部肯定是不合适的,到本地电脑城找个装机店铺才是普遍的做法,在此场景中,内容分发网络就相当于电脑城里的本地经销商。
那CDN的主要工作过程有哪些呢?主要包括路由解析、内容分发、负载均衡、CDN的利用场景。
一次没有内容分发网络参与的DNS域名解析过程如下:
无论是使用浏览器抑或是在程序代码中访问某个网址域名,比如以www.wallbig.club.cn为例,如果没有缓存的话,都会先经过DNS服务器的解析翻译,找到域名对应的IP地址才能开始通信,这项操作是操作系统自动完成的,一般不需要用户程序的介入。不过,DNS服务器并不是一次性地将“www.wallbig.club.cn”直接解析成IP地址,需要经历一个递归的过程。首先DNS会将域名还原为“www.wallbig.club.cn.”,注意最后多了一个点“.”,它是“.root”的含义。早期的域名必须带有这个点才能被DNS正确解析,如今几乎所有的操作系统、DNS服务器都可以自动补上结尾的点号,然后开始如下解析步骤:
下面画个图,方便大家理解这个解析过程:
前面提到过,每种记录类型中还可以包括多条记录,以一个域名下配置多条不同的A记录为例,此时权威服务器可以根据自己的策略来进行选择,典型的应用是智能线路:根据访问者所处的不同地区(比如华北、华南、东北)、不同服务商(比如电信、联通、移动)等因素来确定返回最合适的 A 记录,将访问者路由到最合适的数据中心,达到智能加速的目的。
那如果有CDN参与的话,路由解析的具体工作过程又是哪样的呢?
为了读者更生动地理解以上步骤,我画个时序图,建议和上面的图对比来看:
DNS系统多级分流的设计使得DNS系统能够经受住全球网络流量不间断的冲击,但也并非全无缺点。典型的问题是响应速度,当极端情况(各级服务器均无缓存)下的域名解析可能导致每个域名都必须递归多次才能查询到结果,显著影响传输的响应速度。
专门有一种被称为“DNS 预取”(DNS Prefetching)的前端优化手段用来避免这类问题:如果网站后续要使用来自于其他域的资源,那就在网页加载时生成一个link请求,促使浏览器提前对该域名进行预解释,比如下面代码所示:
<link rel='dns-prefetch' href='//domain.not-wallbig.club'>
而另一种可能更严重的缺陷是DNS的分级查询意味着每一级都有可能受到中间人攻击的威胁,产生被劫持的风险。要攻陷位于递归链条顶层的(比如根域名服务器,cn权威服务器)服务器和链路是非常困难的,它们都有很专业的安全防护措施。但很多位于递归链底层或者来自本地运营商的Local DNS服务器的安全防护则相对松懈,甚至不少地区的运营商自己就会主动进行劫持,专门返回一个错的IP,通过在这个IP上代理用户请求,以便给特定类型的资源(主要是HTML)注入广告,以此牟利。
为此,最近几年出现了另一种新的DNS工作模式:HTTPDNS(也称为 DNS over HTTPS,DoH)。它将原本的DNS解析服务开放为一个基于 HTTPS 协议的查询服务,替代基于UDP传输协议的DNS域名解析,通过程序代替操作系统直接从权威DNS或者可靠的Local DNS获取解析数据,从而绕过传统Local DNS。这种做法的好处是完全免去了“中间商赚差价”的环节,不再惧怕底层的域名劫持,能够有效避免Local DNS不可靠导致的域名生效缓慢、来源IP不准确、产生的智能线路切换错误等问题。
在DNS服务器的协助下,无论是对用户还是服务器,内容分发网络都可以是完全透明的,在两者都不知情的情况下,由CDN的缓存节点接管了用户向服务器发出的资源请求。后面随之而来的问题是缓存节点中必须有用户想要请求的资源副本,才可能代替源站来响应用户请求。这里面又包括了两个子问题:“如何获取源站资源”和“如何管理(更新)资源”。
CDN获取源站资源的过程被称为“内容分发”,“内容分发网络”的名字正是由此而来,可见这是CDN的核心价值。目前主要有以下两种主流的内容分发方式:
主动分发(Push):分发由源站主动发起,将内容从源站或者其他资源库推送到用户边缘的各个CDN缓存节点上。这个推送的操作没有什么业界标准可循,可以采用任何传输方式(HTTP、FTP、P2P等等)、任何推送策略(满足特定条件、定时、人工等等)、任何推送时间,只要与后面说的更新策略相匹配即可。由于主动分发通常需要源站、CDN服务双方提供程序API接口层面的配合,所以它对源站并不是透明的,只对用户一侧单向透明。主动分发一般用于网站要预载大量资源的场景。比如双十一之前一段时间内,淘宝、京东等各个网络商城就会开始把未来活动中所需用到的资源推送到CDN缓存节点中,特别常用的资源甚至会直接缓存到你的手机APP的存储空间或者浏览器的localStorage上。
被动回源(Pull):被动回源由用户访问所触发全自动、双向透明的资源缓存过程。当某个资源首次被用户请求的时候,CDN缓存节点发现自己没有该资源,就会实时从源站中获取,这时资源的响应时间可粗略认为是资源从源站到CDN缓存节点的时间,再加上资源从CDN发送到用户的时间之和。因此,被动回源的首次访问通常是比较慢的(但由于CDN的网络条件一般远高于普通用户,并不一定就会比用户直接访问源站更慢),不适合应用于数据量较大的资源。被动回源的优点是可以做到完全的双向透明,不需要源站在程序上做任何的配合,使用起来非常方便。这种分发方式是小型站点使用CDN服务的主流选择,如果不是自建CDN,而是购买阿里云、腾讯云的CDN服务的站点,多数采用的就是这种方式。
对于“CDN如何管理(更新)资源”这个问题,同样没有统一的标准可言,尽管在HTTP协议中,关于缓存的Header定义中确实是有对CDN这类共享缓存的一些指引性参数,比如“浏览器”小节HTTP header参数Cache-Control的s-maxage,但是否要遵循,完全取决于CDN本身的实现策略。
现在,最常见的做法是超时被动失效与手工主动失效相结合。超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。而手工失效是指CDN服务商一般会提供给程序调用来失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新。
负载均衡就是以统一的接口对外提供服务,但构建和调度服务集群对用户保持透明。深入了解负载均衡强烈推荐看这篇干货:《老生常谈的负载均衡,你真的懂了吗?》
加速静态资源:这是CDN最普遍的应用场景。
安全防御:CDN在广义上可以视作网站的堡垒机,源站只对CDN提供服务,由CDN来对外界其他用户服务,这样恶意攻击者就不容易直接威胁源站。CDN对某些攻击手段的防御,如对DDoS攻击的防御尤其有效。但需注意,将安全都寄托在CDN上本身是不安全的,一旦源站真实IP被泄漏,就会面临很高的风险。
协议升级:不少CDN提供商都同时对接(代售CA的)SSL证书服务,可以实现源站是HTTP协议的,而对外开放的网站是基于HTTPS的。同理,可以实现源站到CDN是HTTP/1.x协议,CDN提供的外部服务是HTTP/2或 HTTP/3协议、实现源站是基于IPv4网络的,CDN提供的外部服务支持IPv6网络,等等。
状态缓存:CDN不仅可以缓存源站的资源,还可以缓存源站的状态,比如源站的301/302转向就可以缓存起来让客户端直接跳转、还可以通过CDN开启HSTS、可以通过CDN进行OCSP装订加速SSL证书访问等。有一些情况下甚至可以配置CDN对任意状态码(比如404)进行一定时间的缓存,以减轻源站压力,但这个操作应当慎重,在网站状态发生改变时去及时刷新缓存。
修改资源:CDN可以在返回资源给用户的时候修改它的任何内容,以实现不同的目的。比如,可以对源站未压缩的资源自动压缩并修改Content-Encoding,以节省用户的网络带宽消耗、可以对源站未启用客户端缓存的内容加上缓存Header,自动启用客户端缓存,可以修改CORS的相关 Header,将源站不支持跨域的资源提供跨域能力等。
访问控制:CDN可以实现IP黑/白名单功能,根据不同的来访IP提供不同的响应结果,根据IP的访问流量来实现QoS控制、根据HTTP的Referer 来实现防盗链等。
注入功能:CDN可以在不修改源站代码的前提下,为源站注入各种功能。
(三)服务端缓存
服务端缓存主要是为了减少CPU/IO、数据库以及下游服务接口的压力,这也是实际编程中最常用的手段。
除减少数据库的压力外,缓存返回数据的响应速度比数据库要快。另外,尽可能不调用外部接口,因为外部接口无论WebSocket、WebService,还是HTTP,其响应速度都是不可控的。如果外部接口响应时间过长,也会影响自身性能。
服务端缓存大致分为以下几种:
(一)缓存击穿
缓存击穿是一个失效的热点Key被并发集中访问,导致请求全部打在数据库上。如下图所示:
解决方案:
热点数据由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存策略来自动管理。
(二)缓存雪崩
某个时刻热点数据出现大规模的缓存失效,大量的请求全部打到数据库,导致数据库瞬时压力过载从而拒绝服务甚至是宕机。
分析:造成缓存雪崩的关键在于在同一时间大规模的key失效。诱因可能是
解决方案:
在触发熔断机制后返回预先配置好的兜底数据,减少过多请求压倒DB导致服务不可用。
Redis基于一个Master主节点多Slave从节点的模式和Redis持久化机制,将一份数据保持在多个实例中,从而实现增加副本冗余量,又使用哨兵机制实现主备切换,在master故障时,自动检测,将某个slave切换为master,最终实现Redis高可用。
(三)缓存穿透
缓存穿透是指用户查询数据库没有的数据,缓存中自然也不会有。那先查缓存再查数据相当于进行了两次无效操作。大量的无效请求将给数据库带来极大的访问压力,甚至导致其过载拒绝服务。下图中红色箭头标出了每一次用户请求到来都会经过的两次无效查询。
分析:造成缓存穿透的原因可能是
解决方案:
虽然不能完全避免数据穿透的现象,但已经可以将99%的穿透查询给屏蔽在Redis层了,极大的降低了底层数据库的压力,减少了资源浪费(布隆过滤器用bitmap实现)。如果想拥有更高的空间利用率和更小的误判,替代的方案是使用布谷鸟过滤器。如果想了解这两种过滤器的相关细节,强烈推荐看这篇文章:《作为一名后台开发,你必须知道的两种过滤器》。
由于布隆过滤器存在“误报”和“漏报”,而且实现也比较复杂,对于空数据查询其实还有一种简单粗暴的策略:
当碰到查询结果为空的key时,放一个空值到缓存中,下次再访问就知道此key是无效的,避免无效查询数据库。但这样花费额外的空间来存储空值。
(四)缓存预热
缓存预热是指系统上线后,提前将热点数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,在线上高并发访问时可以提高数据访问速度和减小数据库压力。
解决方案:
(五)缓存降级
缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,即使是有损部分其他服务,仍然需要保证主服务可用。可以将其他次要服务的数据进行缓存降级,从而提升主服务的稳定性。降级的目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
以参考日志级别设置预案:
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
(六)缓存更新模式
Cache-Aside是最常用的一种缓存更新模式,其逻辑如下:
注意,Cache-Aside更新操作是先更新数据库,成功后,让缓存失效。
如果是先删除缓存,然后再更新数据库,会有什么影响呢?
我们可以思考一下,假设有两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
那么Cache-Aside是否会出现上面提到的问题呢?
我们可以脑补一下,假如有两个并发操作:查询和更新,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。就不会出现后续的查询操作一直都在取老的数据的问题。
这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。
那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
这种case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
所以,这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。当然,最好还是为缓存设置上过期时间。
在上面的Cache Aside中,应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序代码比较复杂。而Read/Write Through是把更新数据库(Repository)的操作由缓存服务自己代理,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
Read-Through模式
Read-Through策略是当缓存失效时(过期或LRU换出),缓存服务自己从数据库加载丢失的数据,填充缓存并将其返回给应用程序。
Cache-Aside和Read-Through策略都是延迟加载数据,也就是说,只有在第一次读取时才加载数据。
Read-Though和Cache-Aside的区别:
Write-Through模式
Write Through套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)。
就其本身而言,Write-Through似乎没有太大作用,而且它会引入额外的写入延迟,因为数据首先写入缓存,然后写入主数据库。它一般和Read-Though配对使用,这样可以获得数据一致性保证而且免于应用程序和缓存失效、更新、集群管理等复杂的问题。
DynamoDB Accelerator (DAX) 是通读/直写缓存的一个很好的例子。它连接DynamoDB和你的应用程序。对DynamoDB的读取和写入可以通过DAX完成。
Write Behind也叫Write Back。其实Linux文件系统的Page Cache算法用的也是这个,所以说底层的东西很多时候是相通。
Write Behind的思想是:在更新数据时,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。
Write Behind的优势有:
事物总有两面性,Write Behind的不足有:
没有完美的方案,只有合适的方案。我们基本上不可能做出一个没有缺陷的设计,软件设计从来都是取舍Trade-Off。比如算法设计中的时间换空间、空间换时间,有时候软件的强一致性和高性能不可兼得,高可用和高性能会有冲突等。
缓存更新这个章节,我们都没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题。比如,更新Cache成功,更新数据库失败了怎么吗?对于事务、分布式事务这个庞大的话题,大家如果有兴趣的话欢迎私信我,后续我再出几篇文章来分析、总结和归纳下。
(七)缓存数据库更新一致性问题
双写一致性:更新数据库后保证redis缓存同步更新
A线程更新数据库后还没更新缓存时,B线程覆盖掉A的数据库值,然后A后写入缓存覆盖掉B的缓存值,导致数据不一致。
更新缓存成功,但是更新数据库失败导致数据不一致
写入数据库未删缓存之前的缓存命中会是旧值(数据不一致时间很短)
A线程的缓存未命中读取数据库旧值后B线程写入数据库并删除缓存(这里没有删除掉缓存,因为缓存不存在,如果存在A线程不可能缓存未命中),之后A线程才把旧值写入缓存导致数据不一致(概率小:读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,但是读操作时间比写操作时间快很多)
A线程删掉缓存后未写入数据库之前,B线程读取数据旧值,A线程写入数据后,B更新缓存,导致数据不一致性。
解决办法:延时双删,延时双删策略会在写库前后删除缓存中的数据,并且给缓存数据设置合理的过期时间,从而可以保证最终一致性。写流程具体如下:
这里为什么会休眠一段时间呢?这里主要是防止:在写请求删除缓存但还未成功写入数据库后,读请求可能将旧值加载到缓存。
读流程:先读缓存,当缓存未命中,再从数据库中读取,然后再写入缓存。
延时双删虽然解决了上述讨论的缓存对数据库不一致的问题,但这种策略存在以下问题:
休眠时间需要业务方设置(难点在时间怎么设置),并且要大于一次读操作时间,才能在旧值加载到就缓存之后二次顺利删掉缓存。
(八)缓存过期
缓存过期是个很复杂的问题。没有解决这一问题的“灵丹妙药”,但有几个简单的策略可供参考:
始终对所有缓存键设置生存时间(TTL),但Write-Through缓存更新策略(第6章第6节中有讲到)除外。可以将生存时间设置成很长的时间,例如数小时,甚至数天。这种方法能够捕获应用程序错误,例如在更新底层数据库时,忘记更新或删除给定的缓存键。最终,缓存键会自动过期并刷新。
对于频繁更改的数据,只需设置较短的TTL (几秒钟) 即可。例如评论、排行榜、活动流等,不要添加Write-Through缓存或复杂的过期逻辑。如果某条数据库查询在生产环境中被大量访问,只需改动几行代码就能为此查询添加TTL为5秒的缓存键。在你评估更优雅的解决方案时让你的应用程序保持正常运行。
Ruby on Rails团队研究出了一种更新的模式 - 俄罗斯套娃缓存。在这种模式下,嵌套记录通过其自有缓存键进行管理,顶层资源就是这些缓存键的集合。假设您有一个包含用户、文章和评论的新闻网页。在这种方法中,他们中的每个都是自己的缓存键,页面则分别查询每个键。
若不确定缓存键是否会在数据库更新时受到影响,只需删除此缓存键。如果你使用的是延迟更新策略(Cache-Aside、Write-Through)会在需要时刷新此键。
有关缓存过期和俄罗斯套娃缓存的详细介绍,请参阅 Basecamp Signal vs Noise博客文章“俄罗斯套娃”缓存的性能影响。
还可以使用另一种模式以在下游服务不可用时一定程度提高服务的弹性,也就是使用两个TTL:一个软TTL和一个硬TTL。客户端将尝试根据软TTL刷新缓存项,但如果下游服务不可用或因其他原因未响应请求,则将继续使用现有的缓存数据,直至达到硬TTL。
(九)缓存淘汰
缓存回收策略可以分为:
缓存淘汰算法有:
FIFO(First In First Out):优先淘汰最早进入被缓存的数据。FIFO 实现十分简单,但一般来说它并不是优秀的淘汰策略,越是频繁被用到的数据,往往会越早被存入缓存之中。如果采用这种淘汰策略,很可能会大幅降低缓存的命中率。
LRU(Least Recent Used):优先淘汰最久未被使用访问过的数据。LRU通常会采用HashMap加双端链表的双重结构来实现(如 LinkedHashMap),以HashMap来提供访问接口,保证常量时间复杂度的读取性能,以LinkedList的链表元素顺序来表示数据的时间顺序,每次缓存命中时把返回对象调整到LinkedList开头,每次缓存淘汰时从链表末端开始清理数据。对大多数的缓存场景来说,LRU都明显要比FIFO策略合理,尤其适合用来处理短时间内频繁访问的热点对象。但相反,它的问题是如果一些热点数据在系统中经常被频繁访问,但最近一段时间因为某种原因未被访问过,此时这些热点数据依然要面临淘汰的命运,LRU依然可能错误淘汰价值更高的数据。
LFU(Least Frequently Used):优先淘汰最不经常使用的数据。LFU会给每个数据添加一个访问计数器,每访问一次就加1,需要淘汰时就清理计数器数值最小的那批数据。LFU可以解决上面LRU中热点数据间隔一段时间不访问就被淘汰的问题,但同时它又引入了两个新的问题,首先是需要对每个缓存的数据专门去维护一个计数器,每次访问都要更新,多线程并发更新要加锁就会带来高昂的开销;另一个问题是不便于处理随时间变化的热度变化,譬如某个曾经频繁访问的数据现在不需要了,它也很难自动被清理出缓存。
缓存淘汰策略直接影响缓存的命中率,没有一种策略是完美的、能够满足全部系统所需的。不过,随着淘汰算法的发展,近年来的确出现了许多相对性能要更好的,也更为复杂的新算法。以LFU分支为例,针对它存在的两个问题,近年来提出的TinyLFU和W-TinyLFU算法就往往会有更好的效果。
TinyLFU(Tiny Least Frequently Used):TinyLFU是LFU的改进版本。为了缓解LFU每次访问都要修改计数器所带来的性能负担,TinyLFU 会首先采用Sketch对访问数据进行分析,所谓Sketch是统计学上的概念,指用少量的样本数据来估计全体数据的特征,这种做法显然牺牲了一定程度的准确性,但是只要样本数据与全体数据具有相同的概率分布,Sketch得出的结论仍不失为一种高效与准确之间权衡的有效结论。借助Count–Min Sketch算法(可视为布隆过滤器的一种等价变种结构),TinyLFU可以用相对小得多的记录频率和空间来近似地找出缓存中的低价值数据。为了解决 LFU 不便于处理随时间变化的热度变化问题,TinyLFU采用了基于“滑动时间窗”的热度衰减算法,简单理解就是每隔一段时间,便会把计数器的数值减半,以此解决“旧热点”数据难以清除的问题。
W-TinyLFU(Windows-TinyLFU):W-TinyLFU又是TinyLFU的改进版本。TinyLFU在实现减少计数器维护频率的同时,也带来了无法很好地应对稀疏突发访问的问题,所谓稀疏突发访问是指有一些绝对频率较小,但突发访问频率很高的数据,譬如某些运维性质的任务,也许一天、一周只会在特定时间运行一次,其余时间都不会用到,此时 TinyLFU就很难让这类元素通过Sketch的过滤,因为它们无法在运行期间积累到足够高的频率。应对短时间的突发访问是LRU的强项,W-TinyLFU就结合了LRU和LFU两者的优点,从整体上看是它是LFU策略,从局部实现上看又是LRU策略。具体做法是将新记录暂时放入一个名为Window Cache的前端LRU缓存里面,让这些对象可以在Window Cache中累积热度,如果能通过TinyLFU的过滤器,再进入名为Main Cache的主缓存中存储,主缓存根据数据的访问频繁程度分为不同的段(LFU策略,实际上W-TinyLFU只分了两段),但单独某一段局部来看又是基于LRU策略去实现的(称为Segmented LRU)。每当前一段缓存满了之后,会将低价值数据淘汰到后一段中去存储,直至最后一段也满了之后,该数据就彻底清理出缓存。
另外还有两种高级淘汰策略ARC(Adaptive Replacement Cache)、LIRS(Low Inter-Reference Recency Set),大家有兴趣可以再深入阅读,对其他缓存淘汰策略感兴趣的读者也可以参考维基百科中对Cache Replacement Policies的介绍。
(十)分布式缓存
相比起缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多,所以对分布式缓存来说,处理与网络有相关的操作是对吞吐量影响更大的因素,往往也是比淘汰策略、扩展功能更重要的关注点,这决定了尽管也有Ehcache、Infinispan这类能同时支持分布式部署和进程内嵌部署的缓存方案,但通常进程内缓存和分布式缓存选型时会有完全不同的候选对象及考察点。我们决定使用哪种分布式缓存前,首先必须确认自己需求是什么?
从访问的角度来说,如果是频繁更新但甚少读取的数据,通常是不会有人把它拿去做缓存的,因为这样做没有收益。对于甚少更新但频繁读取的数据,理论上更适合做复制式缓存;对于更新和读取都较为频繁的数据,理论上就更适合做集中式缓存。笔者简要介绍这两种分布式缓存形式的差别与代表性产品:
复制式缓存:复制式缓存可以看作是“能够支持分布式的进程内缓存”,它的工作原理与Session复制类似。缓存中所有数据在分布式集群的每个节点里面都存在有一份副本,读取数据时无须网络访问,直接从当前节点的进程内存中返回,理论上可以做到与进程内缓存一样高的读取性能;当数据发生变化时,就必须遵循复制协议,将变更同步到集群的每个节点中,复制性能随着节点的增加呈现平方级下降,变更数据的代价十分高昂。复制式缓存的代表是JBossCache,这是JBoss针对企业级集群设计的缓存方案,支持JTA 事务,依靠JGroup进行集群节点间数据同步。以JBossCache为典型的复制式缓存曾有一段短暂的兴盛期,但今天基本上已经很难再见到使用这种缓存形式的大型信息系统了,JBossCache被淘汰的主要原因是写入性能实在差到不堪入目的程度,它在小规模集群中同步数据尚算差强人意,但在大规模集群下,很容易就因网络同步的速度跟不上写入速度,进而导致在内存中累计大量待重发对象,最终引发OutOfMemory崩溃。如果对JBossCache没有足够了解的话,稍有不慎就要被埋进坑里。为了缓解复制式同步的写入效率问题,JBossCache的继任者Infinispan提供了另一种分布式同步模式(这种同步模式的名字就叫做“分布式”),允许用户配置数据需要复制的副本数量,譬如集群中有八个节点,可以要求每个数据只保存四份副本,此时,缓存的总容量相当于是传统复制模式的一倍,如果要访问的数据在本地缓存中没有存储,Infinispan完全有能力感知网络的拓扑结构,知道应该到哪些节点中寻找数据。
集中式缓存:集中式缓存是目前分布式缓存的主流形式,集中式缓存的读、写都需要网络访问,其好处是不会随着集群节点数量的增加而产生额外的负担,其坏处自然是读、写都不再可能达到进程内缓存那样的高性能。集中式缓存还有一个必须提到的关键特点,它与使用缓存的应用分处在独立的进程空间中,其好处是它能够为异构语言提供服务,譬如用C语言编写的Memcached完全可以毫无障碍地为Java语言编写的应用提供缓存服务;但其坏处是如果要缓存对象等复杂类型的话,基本上就只能靠序列化来支撑具体语言的类型系统(支持Hash类型的缓存,可以部分模拟对象类型),不仅有序列化的成本,还很容易导致传输成本也显著增加。举个例子,假设某个有100个字段的大对象变更了其中1个字段的值,通常缓存也不得不把整个对象所有内容重新序列化传输出去才能实现更新,因此,一般集中式缓存更提倡直接缓存原始数据类型而不是对象。相比之下,JBossCache通过它的字节码自审(Introspection)功能和树状存储结构(TreeCache),做到了自动跟踪、处理对象的部分变动,用户修改了对象中哪些字段的数据,缓存就只会同步对象中真正变更那部分数据。如今Redis广为流行,基本上已经打败了Memcached及其他集中式缓存框架,成为集中式缓存的首选,甚至可以说成为了分布式缓存的实质上的首选,几乎到了不必管读取、写入哪种操作更频繁,都可以无脑上Redis的程度。也因如此,之前说到哪些数据适合用复制式缓存、哪些数据适合集中式缓存时,笔者都在开头加了个拗口的“理论上”。尽管Redis最初设计的本意是NoSQL数据库而不是专门用来做缓存的,可今天它确实已经成为许多分布式系统中无可或缺的基础设施,广泛用作缓存的实现方案。
从数据一致性角度说,缓存本身也有集群部署的需求,理论上你应该认真考虑一下是否能接受不同节点取到的缓存数据有可能存在差异。譬如刚刚放入缓存中的数据,另外一个节点马上访问发现未能读到;刚刚更新缓存中的数据,另外一个节点访问在短时间内读取到的仍是旧的数据,等等。根据分布式缓存集群是否能保证数据一致性,可以将它分为AP和CP两种类型。此处又一次出现了“理论上”,是因为我们实际开发中通常不太会把追求强一致性的数据使用缓存来处理,可以这样做,但是没必要(可类比MESI等缓存一致性协议)。譬如,Redis集群就是典型的AP式,有着高性能高可用等特点,却并不保证强一致性。而能够保证强一致性的ZooKeeper、Doozerd、Etcd等分布式协调框架,通常不会有人将它们当为“缓存框架”来使用,这些分布式协调框架的吞吐量相对Redis来说是非常有限的。不过ZooKeeper、Doozerd、Etcd倒是常与Redis和其他分布式缓存搭配工作,用来实现其中的通知、协调、队列、分布式锁等功能。
分布式缓存与进程内缓存各有所长,也有各有局限,它们是互补而非竞争的关系,如有需要,完全可以同时把进程内缓存和分布式缓存互相搭配,构成透明多级缓存(Transparent Multilevel Cache,TMC),如下图所示。先不考虑“透明”的话,多级缓存是很好理解的,使用进程内缓存做一级缓存,分布式缓存做二级缓存,如果能在一级缓存中查询到结果就直接返回,否则便到二级缓存中去查询,再将二级缓存中的结果回填到一级缓存,以后再访问该数据就没有网络请求了。如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。
尽管多级缓存结合了进程内缓存和分布式缓存的优点,但它的代码侵入性较大,需要由开发者承担多次查询、多次回填的工作,也不便于管理,如超时、刷新等策略都要设置多遍,数据更新更是麻烦,很容易会出现各个节点的一级缓存、以及二级缓存里数据互相不一致的问题。必须“透明”地解决以上问题,多级缓存才具有实用的价值。一种常见的设计原则是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。大致做法是当数据发生变动时,在集群内发送推送通知(简单点的话可采用Redis的PUB/SUB,求严谨的话引入ZooKeeper或Etcd来处理),让各个节点的一级缓存自动失效掉相应数据。当访问缓存时,提供统一封装好的一、二级缓存联合查询接口,接口外部是只查询一次,接口内部自动实现优先查询一级缓存,未获取到数据再自动查询二级缓存的逻辑。
附加解释下CAP定理:CAP是分布式计算领域所公认的著名定理。其描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:
在进行缓存结构设计的时候,需要考虑的点有很多:
对缓存带来的价值持怀疑态度:从成本、延迟和可用性等方面来评估引入缓存的合理性,并仔细评估引入缓存带来的额外风险和收益。
业务流量量级评估:对于低并发低流量的应用而言,引入缓存并不会带来性能的显著提升,反而会带来应用的复杂度以及极高的运维成本。也不是任何数据都需要使用缓存,比如图片视频等文件使用分布式文件系统更合适而不是缓存。因此,在引入缓存前,需要对当前业务的流量进行评估,在高并发大流量的业务场景中引入缓存相对而言收益会更高;
缓存组件选型:缓存应用有很多如Redis、Memcached以及tair等等,针对每一种分布式缓存应用的优缺点以及适用范围、内存效率、运维成本甚至团队开发人员的知识结构都需要了解,才能做好技术选型;
缓存相关指标评估:在引入缓存前,需要着重评估value大小、峰值QPS、缓存内存空间、缓存命中率、过期时间、过期淘汰策略、读写更新策略、key值分布路由策略、数据一致性方案等多个因素,要做到心中有数;
缓存高可用架构:分布式缓存要高可用,比如缓存的集群设计、主从同步方案的设计等等,只有缓存足够可靠,才能服务于业务系统,为业务带来价值;
完善的监控平台:当缓存投入生产环境后,需要有一套监控系统能够显式的观测缓存系统的运行情况,才能更早的发现问题,同时对于预估不足的非预期热点数据,也需要热点发现系统去解决非预期的热点数据缓存问题。
缓存最近访问原则:将缓存数据放在离用户最近的地方,无疑会极大的提升响应的速度,这也是多级缓存设计的核心思想。
考虑缓存数据的安全问题。包括加密、与外部缓存队列通信时的传输安全性以及缓存投毒攻击和侧信道攻击的影响。
文章来源:梁其用_腾讯后台开发工程师
_https://mp.weixin.qq.com/s?__biz=MzI2NDU4OTExOQ==&mid=2247534557&idx=1&sn=
576bc35c36024e8e2b35e0fb28acb3ca&chksm=
eaa85d8ddddfd49b641b76a9f5bdb5c40869f3e7d621c2687fb143ec6f014231bcc75368d441&scene=21#wechat_redirect
联系客服