前言在互联网高速发展的今天,缓存技术得到了广泛的应用。不管是行业内还是行业外,只要一提到性能问题,大家都会脱口而出“用缓存来解决”。这种说法是片面的,甚至是一知半解,但是作为专业人士,我们需要对缓存有更深更广的理解。缓存技术存在于应用场景的方方面面。从浏览器请求到反向代理服务器,从进程内缓存到分布式缓存。其中,缓存策略和算法层出不穷,今天就带大家走进缓存。每个开发人员都非常熟悉文本缓存。为了提高程序的性能,我们会添加缓存,但是在哪里添加缓存,如何添加呢?假设一个网站需要提高性能,缓存可以放在浏览器、反向代理服务器、应用进程或分布式缓存系统中。从用户请求数据到数据返回,数据经过了浏览器、CDN、代理服务器、应用服务器、数据库。每个链接都可以使用缓存技术。开始向浏览器/客户端请求数据,通过HTTP和CDN获取数据变化,通过反向代理到达代理服务器(Nginx)获取静态资源。向下到应用服务器,可以通过进程内(堆内)缓存、分布式缓存等渐进方式获取数据。如果以上缓存都没有命中数据,则将源返回给数据库。缓存请求的顺序是:用户请求→HTTP缓存→CDN缓存→代理服务器缓存→进程内缓存→分布式缓存→数据库。好像可以在技术架构的每一个环节都加入缓存,看看缓存技术在各个环节是如何应用的。HTTP缓存当用户通过浏览器请求服务器时,会发起一个HTTP请求。如果对每个HTTP请求都进行缓存,可以减轻应用服务器的压力。第一次请求时,浏览器本地缓存库没有缓存数据,会从服务端取数据放到浏览器缓存库中。下一次请求时,它会读取本地或有关服务的信息。一般信息通过HTTP请求头Header传递。目前常见的缓存方式有两种,分别是:强制缓存与缓存1.1。强制缓存当浏览器的本地缓存库存储缓存信息时,如果缓存数据没有失效,可以直接使用缓存数据。否则,需要重新获取数据。这种缓存机制看起来比较直白,那么如何判断缓存的数据是否失效呢?这里需要注意HTTPHeader中的Expires和Cache-Control这两个字段。Expires是服务器返回的过期时间。当客户端第一次请求服务器时,服务器会返回资源的过期时间。如果客户端再次请求服务器,它将请求时间与过期时间进行比较。如果请求时间小于过期时间,说明缓存没有过期,可以直接使用本地缓存库的信息。反之则表示数据已经过期,必须重新从服务器获取信息,获取完成后会更新最新的过期时间。这个方法在HTTP1.0中用的比较多,在HTTP1.1中会被Cache-Control取代。Cache-Control中有一个max-age属性,单位是秒,用来表示缓存内容在客户端的过期时间。例如:max-age为60秒,当前缓存没有数据,客户端第一次请求后会将数据放入本地缓存。那么,如果客户端在60秒内发送请求,则不会请求应用服务器,而是直接从本地缓存中返回数据。如果两次请求之间的时间超过60秒,则需要从服务器获取数据。1.2.比较缓存需要比较前后两个缓存标志位来决定是否使用缓存。当浏览器第一次请求时,服务器会把缓存ID和数据一起返回,浏览器会备份到本地缓存库中。当浏览器再次请求时,它会将备份的缓存ID发送给服务器。服务器根据缓存标识进行判断,如果判断数据没有变化,则向浏览器发送304成功状态码。这时候浏览器就可以使用缓存的数据了。服务器只返回Header,不返回Body。下面介绍两条识别规则:1.2.1.Last-Modified/If-Modified-Since规则当客户端第一次请求时,服务器会返回该资源的最后修改时间,记录为Last-Modified。客户端将此字段与资源一起缓存。Last-Modified保存后,会在下次请求时和Last-Modified-Since字段一起发送。当客户端再次请求服务器时,会将Last-Modified连同请求的??资源一起发送给服务器。此时Last-Modified会被命名为If-Modified-Since,存储的内容也是一样的。服务器收到请求后,会比较If-Modified-Since字段和保存在服务器上的Last-Modified字段:如果Last-Modified在服务器上的最后修改时间大于请求的If-Modified-因为,这意味着资源已被更改。将资源(包括Header+Body)返回给浏览器,同时返回状态码200。如果资源的最后修改时间小于等于If-Modified-Since,则表示资源没有被修改,只返回Header,返回状态码304。当浏览器收到这个消息后,就可以使用本地缓存库中的数据了。注意:Last-Modified和If-Modified-Since指的是同一个值,只是在客户端和服务器上的调用方式不同。1.2.2.ETag/If-None-Matchrule当客户端第一次请求时,服务端会为每个资源生成一个ETag标签。这个ETag是根据每个资源生成的唯一Hash字符串。当资源发生变化时,ETag也会随之变化。之后将ETag返回给客户端,客户端将请求的资源和ETag都缓存到本地。ETag保存后,会在下一次请求中作为If-None-Match字段发送。当浏览器第二次向服务器请求同一个资源时,会向服务器发送该资源对应的ETag。ETag应要求转换为If-None-Match,但其内容不变。服务端收到请求后,会将If-None-Match与服务端资源的ETag进行比较:如果不一致,则说明资源已被更改,并返回资源(Header+Body)与状态码200,如果一致,说明资源没有被修改,则返回Header,返回状态码304,浏览器收到这个消息后,就可以使用本地缓存库中的数据了。注意:ETag和If-None-Match指的是相同的值,只是在客户端和服务器上的调用不同。CDN缓存HTTP缓存主要缓存静态数据,将从服务器获取的数据缓存到客户端/浏览器。如果在客户端和服务器之间加一层CDN,就可以让CDN为应用服务器提供缓存。如果缓存在CDN上,则不需要请求应用服务器。而HTTP缓存中提到的两种策略也可以在CDN服务器上实现。CDN的全称是ContentDeliveryNetwork,即内容分发网络。让我们看看它是如何工作的:客户端将URL发送到DNS服务器。DNS使用域名解析将请求定向到CDN网络中的DNS负载平衡器。DNS负载均衡器告诉DNS最近的CDN节点的IP,DNS告诉客户端最新的CDN节点的IP。客户端请求最近的CDN节点。CDN节点从应用服务器获取资源返回给客户端,同时缓存静态信息。注:客户端下次交互的对象是CDN缓存,CDN可以将缓存信息同步到应用服务器。CDN接受客户端的请求。它是离客户端最近的服务器。它会在背后链接多台服务器,起到缓存和负载均衡的作用。负载均衡缓存说完客户端(HTTP)缓存和CDN缓存,我们离应用服务越来越近了。在到达应用程序服务之前,请求必须通过负载均衡器。虽然它的主要工作是负载均衡应用服务器,但它也可以用作缓存。这里可以缓存一些不经常修改的数据,比如用户信息、配置信息等。通过服务定期刷新此缓存。以Nginx为例,看看它是如何工作的:用户请求在到达应用服务器之前,会先访问Nginx负载均衡器。如果找到缓存信息,则直接返回给用户。如果没有找到缓存的信息,Nginx会返回到应用服务器获取信息。另外还有缓存更新服务,定时更新应用服务器中比较稳定的信息到Nginx本地缓存中。进程内缓存经过客户端、CDN、负载均衡器,最后来到应用服务器。应用部署在应用服务器上,这些应用以进程的形式运行,那么进程中的缓存是什么呢?进程内缓存也称为托管堆缓存。以Java为例,这部分缓存是放在JVM的托管堆上的,会受到托管堆回收算法的影响。因为它运行在内存中,对数据的响应速度很快,所以我们通常会把热点数据放在这里。当进程内缓存未命中时,我们将搜索进程外缓存或分布式缓存。这种缓存的优点是没有序列化和反序列化,是最快的缓存。缺点是缓存空间不宜太大,会影响垃圾收集器的性能。目前流行的实现包括Ehcache、GuavaCache和Caffeine。这些架构可以很容易地将一些热数据放入进程内缓存中。这里需要注意几个缓存的回收策略。具体实现架构的回收策略会有所不同,但大体思路是一样的:FIFO(FirstInFirstOut):先进先出算法,先放入缓存的数据先取出。LRU(LeastRecentlyUsed):最近最少使用算法将最长时间未使用的数据从缓存中移除。LFU(LeastFrequentlyUsed):最不频繁使用的算法,将一段时间内使用频率最低的数据从缓存中移除。在如今的分布式架构中,如果在多个应用中使用进程内缓存,就会存在数据一致性问题。这里推荐两种方案:消息队列修改方案定时器修改方案4.1。消息队列修改方案应用程序在修改了自己的缓存数据和数据库数据后,向消息队列发送数据变更通知。其他应用订阅消息通知,并在修改缓存数据时收到通知。4.2.为了避免耦合和降低复杂度,Timer修改方案对“实时一致性”不敏感。每个应用都会启动一个Timer,定时从数据库中拉取最新的数据,更新缓存。但是,有些应用更新数据库后,其他节点在通过Timer获取数据的同时,会读到脏数据。这里需要控制Timer的频率,以及对实时性要求不高的应用和场景。进程内缓存有哪些使用场景?场景一:只读数据可以认为是在进程启动时加载到内存中。当然,将数据加载到像Redis这样的进程外缓存服务中也可以解决这类问题。场景二:高并发,可以考虑使用进程内缓存,例如:秒杀。分布式缓存说完进程内缓存,自然要过渡到进程外缓存。与进程内缓存不同,进程外缓存在应用程序运行进程之外。具有更大的缓存容量,可以部署到不同的物理节点。它通常作为分布式缓存实现。分布式缓存是一种与应用程序分离的缓存服务。它最大的特点是是一个独立的应用/服务,与本地应用隔离,多个应用可以直接共享一个或多个缓存应用/服务。由于是分布式缓存,缓存的数据会分布到不同的缓存节点,每个缓存节点缓存的数据大小通常是有限的。数据缓存到不同的节点。为了方便访问这些节点,需要引入缓存代理,类似于Twemproxy。他会帮助请求找到对应的缓存节点。同时,如果缓存节点数量增加,代理只能将新的缓存数据识别分片到新的节点进行水平扩展。为了提高缓存的可用性,会在原有的缓存节点上加入Master/Slave的设计。当缓存数据写入到Master节点时,会同时同步一份到Slave节点。一旦Master节点出现故障,可以通过agent直接切换到Slave节点,然后Slave节点成为Master节点,保证缓存的正常运行。每个缓存节点还提供缓存过期机制,周期性地将缓存内容以快照的形式保存到文件中,以便在缓存崩溃后可以启动预热加载。5.1.高性能当缓存做成分布式时,数据会按照一定的规则分配给各个缓存应用/服务。如果我们把这些缓存应用/服务称为缓存节点,每个节点一般可以缓存一定量的数据,比如:一个Redis节点可以缓存2G的数据。如果要缓存的数据量比较大,需要扩展多个缓存节点来实现。缓存节点那么多,客户端请求不知道访问哪个节点怎么办?如何将缓存的数据放到这些节点上?缓存代理服务已经帮助我们解决了这些问题,例如:Twemproxy不仅可以帮助缓存路由,还可以管理缓存节点。下面介绍缓存数据分片的三种算法。通过这些算法,缓存代理可以很容易地找到分片数据。5.1.1.哈希算法哈希表是最常见的数据结构,通过对数据记录的key值进行Hash实现,然后对需要分片的缓存节点数取余数进行数据分布。例如:有3条记录数据分别为R1、R2、R3。它们的ID分别为01、02、03,假设以这3条记录的ID为键值进行Hash算法,结果仍然是01、02、03。我们要将这3条记录将一条数据放入三个缓存节点,结果取模3取余,即为三个记录分别放置的缓存节点。Hash算法是一定水平的平均放置,策略比较简单。如果要增加缓存节点,现有数据会有很大的变化。5.1.2.ConsistencyHashAlgorithmConsistencyHash是将数据根据特征值映射到一个端到端的Hash环上,同时也将缓存节点映射到这个环上。如果要缓存数据,可以通过数据的键值(Key)在环上找到自己的存储位置。这些数据按照自身ID的Hash后得到的值,依次排列在环上。如果此时要插入一条新的数据,它的ID是115,那么应该插入到下图所示的位置。同样的,如果要添加一个缓存节点N4150,也可以放在下图所示的位置。该算法对于增加缓存数据和缓存节点比较小。5.1.3.RangeBasedAlgorithm在这种方法中,数据根据键值(如ID)划分为不同的区间,每个缓存节点负责一个或多个区间。这有点像一致性哈希。例如:有三个缓存节点N1、N2、N3。他们用来存储数据的区间分别是,N1(0,100),N2(100,200),N3(300,400)。然后数据会按照自己的ID为key放入Hash结果中。5.2.可用性从事物的两个方面来看,分布式缓存在带来高性能的同时,我们也需要关注它的可用性,那么我们需要防范哪些潜在的风险呢?5.2.1.缓存雪崩当缓存失效,缓存过期被清除,更新缓存,请求无法命中缓存,此时请求会直接返回数据库,如果以上情况频繁出现或者同时出现,会造成大面积的请求直接上数据库,造成数据库访问瓶颈,这种情况我们称之为缓存雪崩,从以下两个方面思考解决方案:缓存:同时避免缓存失效,以及为不同的key设置不??同的超时时间,添加互斥锁,缓存的更新操作通过加锁进行保护,保证只有一个线程执行缓存更新。一旦缓存失效,可以通过缓存快照的方式快速重建缓存。为缓存节点增加主备机制,当主缓存失效时切换到备份缓存继续工作。在设计上,有几点建议供大家参考:熔断机制:当某个缓存节点无法工作时,需要通知缓存代理不要将请求路由到该节点,减少用户等待和请求时间。限流机制:在接入层和代理层可以进行限流。当缓存服务无法支持高并发时,前端可以将无响应的请求放入队列或丢弃。隔离机制:当缓存无法提供服务或正在预热重建时,将请求放入队列中,这样请求就不会因为被隔离而被路由到其他缓存节点。这样其他节点就不会因为这个节点的问题而受到影响。当缓存重建时,请求按顺序从队列Process中取出。5.2.2.缓存穿透缓存一般以Key和Value的形式存在。当一个Key对应的Value不存在时,请求将返回给数据库。如果对应的Value不存在,就会频繁请求数据库。对数据库造成访问压力。如果有人利用这个漏洞进行攻击,那就麻烦了。解决方案:如果一个Key对应的Value查询返回空,我们还是缓存空的结果。如果该值没有改变,则下一次查询将不会请求数据库。将所有可能的数据哈希成一个足够大的Bitmap,然后不存在的数据会被Bitmap过滤器拦截,避免对数据库的查询压力。5.2.3.缓存故障当请求数据时,某个缓存刚刚失效或正在写入缓存。同时,缓存的数据可能此时被高并发请求,成为“热点”数据。这就是缓存击穿的问题。这个和缓存雪崩的区别是这个是针对某个缓存的,而前者是针对多个缓存的。解决方案:问题原因是cache是??同时读/写的,所以只保证一个线程同时写。写入完成后,其他请求可以再次使用缓存。比较常见的做法是使用mutex(互斥锁)。当缓存失效时,不是立即写入缓存,而是先设置一个mutex(互斥锁)。写入缓存后,释放锁以允许请求访问它。总结总结一下,缓存设计有五大策略,从用户请求出发:HTTP缓存CDN缓存负载均衡缓存进程内缓存分布式缓存其中,前两种缓存静态数据,后三种缓存动态数据:HTTP缓存包括强制缓存和比较缓存。CDN缓存和HTTP缓存是很好的搭档。负载均衡器缓存相对稳定的资源,需要服务协助才能工作。进程内缓存效率高,但容量有限。有两种解决方案来处理缓存同步问题。分布式缓存容量大,能力强。牢记三大性能算法,防范三大缓存风险。
