学习就是带着问题前行
缓存是什么?
缓存击穿是什么?
缓存雪崩是什么?
如何保证分布式缓存的数据一致性?
如何进行缓存预热?
如何设计缓存热点探测?
曾经问过一个技术修为很高的朋友,为什么你学习新的技术,可以掌握得那么全面,而且都是些非常细节,一般技术人员无法看到的知识点。
他这样回答的:
要经常问自己为什么。在自己真正开始学习新的一种技术之前,首先我会问自己,如果让我来设计或者做这件事,我会怎么做?为什么要这么设计?如果出现某些场景存在什么样的问题?还有什么可以替代的技术方案 ?
他是一个我很佩服的技术人,一个很喜欢钻研底层原理的技术人,同时也是一个十分愿意分享技术的指路人。他本着开源分享的理念会在B站定期分享直播,大家有感兴趣的,可以去B站搜索 【阿里柏羲】。
其实想说的一个问题是,要保持好奇心,多问为什么,带着问题找答案会有如沐春风的感觉 。
PART1
缓存的概念
缓存在wiki上的定义:用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。典型的应用场景:有cpu cache, 磁盘cache等。
缓存简单来讲就是把一些外存上的数据保存在内存中,为什么保存在内存上,因为内存的访问速度远远超过磁盘IO或者网络IO。我们运行的所有程序里面的变量都是存放在内存中的,所以如果想将值放入内存上,可以通过变量的方式存储。一般在Java应用中,我们以K-V的形式存储。
缓存同样是分布式系统中的重要组件,主要解决高并发,大数据场景下,热点数据或者静态数据访问的性能问题。提供高性能的数据快速访问。所谓分布式多级缓存,就是指在整个系统的不同层级进行数据的缓存,以提升系统的访问速度。
缓存不只是一个技术上的字眼。在生活中,其实随处可见。比如说快递。会在不同的地区设立仓库,货物会就近缓存到最近的货仓。等需要发货的时候,从最近仓库发货,继续后续的配送。
缓存也映射着一种为人处世的思想。大部分事情,极端的做法都是不可取的。无论是最快(清一色高速组件),还是最便宜(清一色最慢组件),都不是最好的解决方案。符合现实情况的协调各方利益的可行的解决方案可能才是真的完美。凡事都要留一点缓冲的余地。
PART2
缓存的优势
在数据层引入缓存,有以下几个好处:
-
提升数据读取速度
-
提升系统扩展能力,通过扩展缓存,提升系统承载能力
-
降低存储成本,Cache+DB的方式可以承担原有需要多台DB才能承担的请求量,节省机器成本
PART3
初涉分布式缓存请求链
接入层Nginx将请求负载均衡到应用层Nginx,常用的负载均衡算法是轮询/一致性哈希。(轮询可以是请求更加的平均,一致性哈希可以提升应用层Nginx的缓存命中率。)
应用层Nginx首先访问Local Cache(Lua Shared Dict、Nginx Proxy Cache等),如果Local Cache命中,则直接返回。Nginx本地缓存应对热点问题非常有效。
如果Local Cache未命中,则会读取分布式缓存(如Redis)。如果命中,则返回结果,并写入应用层Nginx本地缓存。如果分布式缓存未命中,则会回源到Tomcat集群。此时首先读取Tomcat本地缓存,若有则返回,并异步的写入Redis集群。若无则回源到DB中去查询,然后再写入到Redis中。这里面需要注意的一点是,Tomcat的Local Cache可以有效的缓解缓存失效风暴的问题。
不同的层级的缓存解决的问题显然也是不一样的。应用层Nginx本地缓存,解决热点数据问题。分布式缓存,较少回源率。Tomcat应用服务器Local Cache,本地缓存用于解决缓存失效风暴。
PART4
确认是否需要缓存
缓存不是适用于所有的系统。在使用缓存之前,需要确认你的项目是否真的需要缓存。使用缓存会引入的一定的技术复杂度。
一般来说从两个方面来个是否需要使用缓存:
-
CPU占用:如果你有某些应用需要消耗大量的cpu去计算,比如正则表达式,如果你使用正则表达式比较频繁,而其又占用了很多CPU的话,那你就应该使用缓存将正则表达式的结果给缓存下来。
-
数据库IO占用:如果你发现你的数据库连接池比较空闲,那么不应该用缓存。但是如果数据库连接池比较繁忙,甚至经常报出连接不够的报警,那么是时候应该考虑缓存了。
-
项目的量级,如果是单机环境,机器内存足够且数据量不大的情况下,可以优先考虑进行内缓存。数据量级上去,分布式部署之后,再上分布式缓存。
PART5
缓存数据的方式
是否过期
不过期缓存
通常情况下我们使用缓存都会设置一个过期时间,但在某些场景下我们需要设置不过期的缓存。这个时候通常情况下是在事务结束后去写入缓存的,此时需要注意的是不要同步的去写入缓存,以免阻塞主流程。对于这种常驻缓存的数据,一定要合理的管理,一般情况下可以定期的全量更新缓存。
对于长尾缓存(缓存相对集中,但是有可能两会很大的),如以时间为维度的数据(订单/流水等),可以考虑使用LRU Cache,来使不常用的数据剔除缓存。
过期缓存
对于过期缓存,我们通常的用法是”懒加载”的方式。数据变更的时候去删除缓存,当读取数据时,缓存中没有,回源DB之后再写入缓存。这里面需要根据业务场景去设置一个合理的超时时间,对于比较热点的数据,可以根据使用场景让缓存有一定的延迟,在用户的忍受范围之内就行了(如商品库存/火车票库存等)。
细粒度缓存
进行维度化缓存与增量缓存。对于一个电商系统,一个商品可能拆分成基础属性,图片列表、上下架、规格参数等。当商品信息变更时,此时就需要控制缓存的粒度了,尽量小成本的去更新缓存。如商品上下架,就只更新上下架维度的缓存即可。
大Value缓存
当使用Redis缓存时,应尽量避免使用大Value存储,这样会拖慢读取的速度。考虑采用Memcached等其他适合存储大对象的缓存。建议优先考虑是:将其拆分为更小维度的数据,然后再组合返回前端。
热点缓存
对于热点缓存,通常情况下需要设置多级缓存,尽量避免数据通过网络去获取。当分布式缓存挂掉时,此时还需要考虑到”缓存击穿”的问题,此时可以使用白名单/布隆过滤器来作为缓存的最后一道防线。
PART6
缓存的多级分类
浏览器缓存
一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。作为Web应用开发。浏览器是离用户最近的地方。浏览器的缓存可以减轻服务器的资源访问压力。浏览器的缓存的实现原理是基于HTTP缓存。所谓的http缓存,就是浏览器自己给你的一个功能,一个缓存数据库,夹在服务端和客户端中间,你只需要设置一些参数即可实现 缓存/不缓存/时效内缓存/时效外缓存等(默认存在缓存)。
强缓存:Expires & Cache-Control
如果Cache-Control与expires同时存在,Cache-Control生效。expires 的一个缺点就是,返回的到期时间是服务器端的时间,这样存在一个问题,如果客户端的时间与服务器的时间相差很大,那么误差就很大,所以在HTTP 1.1版开始,使用 Cache-Control: max-age=***秒 替代。
除非有特殊需求,最好还是不要禁用缓存,毕竟是用缓存能节省宽带,节省服务器资源。
协商缓存:Etag If-None-Match Last-Modified/If-Modified-Since
协商缓存是利用的是【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】这两对Header来管理的。
查看单个请求的Response Header,也能看到304的状态码和Not Modified的字符串,只要看到这个就可说明这个资源是命中了协商缓存,然后从客户端缓存中加载的,而不是服务器最新的资源。
Etag是一个更加严格的验证,它是根据文件的内容生成Etag(数据签名,最常用做法是对资源内容进行哈希计算),收到带Etag这个头,下次浏览器发送request就会带上If-Match或者If-Non-Match,服务器收到这个request的上If-Match或者If-Non-Match后,通过读取它的值对比资源存在的地方的Etag,服务器就告诉浏览器是否可以使用缓存。
协商缓存跟强缓存不一样,强缓存不发请求到服务器,所以有时候资源更新了浏览器还不知道,但是协商缓存会发请求到服务器,所以资源是否更新,服务器肯定知道。大部分web服务器都默认开启协商缓存,而且是同时启用【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】。
如果没有协商缓存,每个到服务器的请求,就都得返回资源内容,这样服务器的性能会极差。协商缓存需要配合强缓存使用,Last-Modified这个header,还有强缓存的相关header,因为如果不启用强缓存的话,协商缓存根本没有意义。
注意点:
1. 分布式系统里多台机器间文件的Last-Modified必须保持一致,以免负载均衡到不同机器导致比对失败;
2. 分布式系统尽量关闭掉ETag(每台机器生成的ETag都会不一样);
浏览器缓存缺点:
网站发生了更新,需要用户端进行缓存清理。浏览器本地仍保存着旧版本的静态资源(css、js等)文件,从而导致无法预料后果
网关(代理)缓存
由一个代理服务器下载的页面存储。一个代理服务器为多个用户提供一条通道。缓冲的代理允许一个代理服务器减少对同一个网站的同样页面的请求次数。一旦代理服务器的一个用户请求了某页,代理服务器就保存该页以服务于它的其他用户的同样请求。
网关是不处理具体的业务逻辑的。只是针对黑白名单、权限、流量限制等问题的一些控制。请求到达网关后,网关会请求服务配置中心获取该服务的地址,那么如果每次请求都这么干的话无疑增大网关和服务配置中心的网络开销。
事实上我们在网关启动的时候就可以请求服务配置中心把结果缓存下来,在下次使用的时候从缓存里取。即用空间换时间。然后注册一个watcher在服务配置中心更新的时候回调网关进行更新该服务的provider地址列表。
CDN缓存
CDN属于服务器端缓存。一般CDN都使用的第三方代理服务。客户端浏览器先检查是否有本地缓存是否过期,如果过期,则向CDN边缘节点发起请求,CDN边缘节点会检测用户请求数据的缓存是否过期,如果没有过期,则直接响应用户请求,此时一个完成http请求结束;如果数据已经过期,那么CDN还需要向源站发出回源请求,来拉取最新的数据。
CDN的优势:
(1)CDN节点解决了跨运营商和跨地域访问的问题,访问延时大大降低;
(2)大部分请求在CDN边缘节点完成,CDN起到了分流作用,减轻了源站的负载。
CDN缓存的缺点:
CDN的分流作用不仅减少了用户的访问延时,也减少的源站的负载。但其缺点也很明显:当网站更新时,如果CDN节点上数据没有及时更新,即便用户再浏览器使用Ctrl +F5的方式使浏览器端的缓存失效,也会因为CDN边缘节点没有同步最新数据而导致用户访问异常。
线程级别缓存
在每次请求线程中,将需要使用的公用变量放入ThreadLocal中,需要注意的是,结束之后,需要进行清理。否则会导致内存无法释放。
publicstatic ThreadLocal<User> localUserInfo =newThreadLocal<User>();
另外,可以使用弱引用,在并发请求很高的情况下,内存如果出现不足,会根据策略自动释放部分引用。如下:
publicstatic ThreadLocal<SoftReference<User>> threadLocal =newThreadLocal<>();
进程级缓存
进程内缓存,就是使用java应用虚拟机内存的缓存。对于数据量不大的,我们可以采用进程内缓存。或者只要内存足够富裕,都可以采用
Google Guava工具包中的一个非常方便易用的本地化缓存实现,基于LRU算法实现,支持多种缓存过期策略。
EhCache 是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider
Caffeine是使用Java8对Guava缓存的重写版本,在Spring Boot 2.0中将取代,基于LRU算法实现,支持多种缓存过期策略
在应对高并发的情况下,如果有适当的环境还是觉得进程内缓存为首选,另外一点程序要尽量避免线程切换,尽量异步化。如果可以最好能预估出缓存数据的大小,避免内存泄漏等现象发生。
优点:
-
进程内缓存性能比较高,延迟会更小,更节省带宽
-
由于和应用程序位于同一进程,共享相同的虚拟内存,所以在状态维护上更容易
-
其次进程内的缓存不设计到网络传输,所以没有序列化的过程,在性能上更胜一筹。
-
进程内缓存的数据类型几乎可以是语言级别支持的任意类型,数据类型设计上比大多数分布式缓存设备支持要灵活许多。
分布式缓存
分布式缓存是为了解决数据库服务器和Web服务器之间的瓶颈,如果一个网站流量很大这个瓶颈将会非常明显,每次数据库查询耗费的时间将不容乐观。对于更新速度不是很快的站点,可以采用静态化来避免过多的数据查询,可使用Freemaker或Velocity来实现页面静态化。对于更新数据以秒级的站点,静态化也不会太理想,可通过分布式缓存系统来解决,如Redis、MemCache、SSDB等。
数据库缓存
数据库默认也会带有缓存。数据量不大的情况下,可以开启Mysql的查询缓存进行一定的查询优化。在部分变化频率较低的表对应的查询语句中,开启Query Cache,对性能会有一定提升。
PART7
企业级缓存的问题拾遗
构建大型互联网系统会面临很多的挑战,主要有:
百万级QPS的资源调用 (高并发)
99.99%的可用性 (高可用)
毫秒级的核心请求响应时间 (高性能)
设计这样的互联网系统,不可避免的要考虑使用分布式缓存,并从可用性、并发性、性能多个方面进行综合考量。
1、缓存更新
一般来说缓存的更新有几种情况:
Cache Aside 更新模式 - 同时更新缓存和数据库
这是最常用的缓存模式了,具体的流程是:
失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从 cache 中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
这里为何不直接对缓存进行更新。这里有几个坑需要注意:
1. 先更新数据库,再更新缓存。这种做法最大的问题就是两个并发的写操作导致脏数据。
2. 先删除缓存,再更新数据库。这个逻辑是错误的,因为两个并发的读和写操作导致脏数据。
3. 先更新数据库,再删除缓存。这是最常见的处理方式。
Read/Write Through 更新模式 - 先更新缓存,缓存负责同步更新数据库
在Read/Write Through 更新模式中,应用程序只需要维护缓存,数据库的维护工作由缓存代理了
Read Through
Read Through 模式就是在查询操作中更新缓存,也就是说,当缓存失效的时候,Cache Aside 模式是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载。
Write Through
Write Through 模式和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由缓存自己更新数据库(这是一个同步操作)。
Write Behind Caching 更新 - 先更新缓存,缓存定时异步更新数据库
Write Behind Caching 更新模式就是在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是直接操作内存速度快。因为异步,Write Behind Caching 更新模式还可以合并对同一个数据的多次操作到数据库,所以性能的提高是相当可观的。
但是带来的问题是,数据不是强一致性的,而且可能会丢失。
三种模式,各有优劣。根据业务场景自行选择。
缓存是通过牺牲强一致性来提高性能的。使用缓存提升性能,就是会有数据更新的延迟。这需要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期时间,这个时间太短太长都不好,太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势。太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。
2、缓存过期&淘汰策略
缓存系统中的所有数据,根据数据的使用频率以及场景,我们可以分为过期key以及不过期key,那么对齐过期缓存我们该如何淘汰呢?下面有常用的几种方案:
FIFO:使用FIFO算法来淘汰过期缓存。
LFU:使用LFU算法来淘汰过期缓存。
LRU:使用LRU算法来淘汰过期缓存。
以上几种方案是在缓存达到最大缓存大小的时候的淘汰策略,如果没有达到最大缓存大小,我们有下面几种方式:
1. 定时删除策略:设置一个定时任务,在规定时间内检查并且删除过期key。
定期删除策略:这种策略需要设置删除的周期以及时长,如何设置,需要根据具体场合来计算。
2. 惰性删除策略:在使用时检查是否过期,如果过期直接去更新缓存,否则直接返回。
3、缓存穿透
伪造大量请求,去请求系统中不存在的数据。导致大量请求瞬间回源查询。导致缓存被击穿。服务器压力加大。当然缓存挂掉的话,正常的用户请求也有可能造成缓存击穿的效果。
解决方案:
1. 小数据量使用BitMap
2. 大数据量使用布隆过滤器
4、缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决方案:
1. 设置热点数据永远不过期
2. 加分布式锁
5、缓存雪崩
雪崩效应是由于缓存服务器宕机等原因导致命中率降低,大量的请求穿透到数据库,导致数据库被冲垮,业务系统出现故障,服务很难再短时间内回复。避免
雪崩主要从以下几方面考虑:
1. 缓存高可用,避免单点故障,保证缓存高命中率
2. 降级和流控,故障期间通过降级非核心功能来保证核心功能可用性故障期间通过拒掉部分请求保证有部分请求还能正常响应
3. 清楚后端资源容量,更好的预知风险点,提前做好准备。即使出现问题,也便于更好的流控(具体应该放量多少)
在大量的请求并发进入时,由于某些原因未起到预期的缓冲效果,哪怕只是很短的一段时间,导致请求全部流转到数据库,造成数据库压力过重。解决它可以
1. 通过“加锁排队”或者“缓存时间增加随机值”。
2. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
3. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中
6、缓存预热
单机缓存
单机情况下,在应用程序启动事件里,进行首次初始化。
分布式缓存
分布式环境下预热,可以单写个应用程序去跑,也有的会单写套框架机制去处理(更智能化)。其目的是在系统上线之前,所有的缓存都预先加载完毕。
7、缓存热点预测
针对缓存的热点预测。我们需要借助用户行为日志搜集,对数据进行实时分析,然后将分析的热点结果发布到中间件中,进行订阅消费热点数据。下面的架构仅供参考。
PART8
企业级自研缓存服务案例
-
有赞的TMC (下图均为官方资料图)
-
新浪微博的feed架构(下图均为官方资料图)