在互联网高速发展的今天,缓存技术被普遍地应用。不管业内仍是业外,只要是提到性能问题,你们都会脱口而出“用缓存解决”。前端
这种说法带有片面性,甚至是只知其一;不知其二,可是做为专业人士的咱们,须要对缓存有更深、更广的了解。算法
缓存技术存在于应用场景的方方面面。从浏览器请求,到反向代理服务器,从进程内缓存到分布式缓存。其中缓存策略、算法也是层出不穷,今天就带你们走进缓存。数据库
缓存对于每一个开发者来讲是至关熟悉了,为了提升程序的性能咱们会去加缓存,可是在什么地方加缓存,如何加缓存呢?浏览器
假设一个网站,须要提升性能,缓存能够放在浏览器,能够放在反向代理服务器,还能够放在应用程序进程内,同时能够放在分布式缓存系统中。缓存
从用户请求数据到数据返回,数据通过了浏览器、CDN、代理服务器、应用服务器以及数据库各个环节。每一个环节均可以运用缓存技术。服务器
从浏览器/客户端开始请求数据,经过 HTTP 配合 CDN 获取数据的变动状况,到达代理服务器(Nginx)能够经过反向代理获取静态资源。网络
再往下来到应用服务器能够经过进程内(堆内)缓存,分布式缓存等递进的方式获取数据。若是以上全部缓存都没有命中数据,才会回源到数据库。数据结构
缓存的请求顺序是:用户请求 → HTTP 缓存 → CDN 缓存 → 代理服务器缓存 → 进程内缓存 → 分布式缓存 → 数据库。架构
看来在技术的架构每一个环节均可以加入缓存,看看每一个环节是如何应用缓存技术的。并发
当用户经过浏览器请求服务器的时候,会发起 HTTP 请求,若是对每次 HTTP 请求进行缓存,那么能够减小应用服务器的压力。
当第一次请求的时候,浏览器本地缓存库没有缓存数据,会从服务器取数据,而且放到浏览器的缓存库中,下次再进行请求的时候会根据缓存的策略来读取本地或者服务的信息。
通常信息的传递经过 HTTP 请求头 Header 来传递。目前比较常见的缓存方式有两种,分别是:
当浏览器本地缓存库保存了缓存信息,在缓存数据未失效的状况下,能够直接使用缓存数据。不然就须要从新获取数据。
这种缓存机制看上去比较直接,那么如何判断缓存数据是否失效呢?这里须要关注 HTTP Header 中的两个字段 Expires 和 Cache-Control。
Expires 为服务端返回的过时时间,客户端第一次请求服务器,服务器会返回资源的过时时间。若是客户端再次请求服务器,会把请求时间与过时时间作比较。
若是请求时间小于过时时间,那么说明缓存没有过时,则能够直接使用本地缓存库的信息。
反之,说明数据已通过期,必须从服务器从新获取信息,获取完毕又会更新最新的过时时间。
这种方式在 HTTP 1.0 用的比较多,到了 HTTP 1.1 会使用 Cache-Control 替代。
Cache-Control 中有个 max-age 属性,单位是秒,用来表示缓存内容在客户端的过时时间。
例如:max-age 是 60 秒,当前缓存没有数据,客户端第一次请求完后,将数据放入本地缓存。
那么在 60 秒之内客户端再发送请求,都不会请求应用服务器,而是从本地缓存中直接返回数据。若是两次请求相隔时间超过了 60 秒,那么就须要经过服务器获取数据。
须要对比先后两次的缓存标志来判断是否使用缓存。浏览器第一次请求时,服务器会将缓存标识与数据一块儿返回,浏览器将两者备份至本地缓存库中。浏览器再次请求时,将备份的缓存标识发送给服务器。
服务器根据缓存标识进行判断,若是判断数据没有发生变化,把判断成功的 304 状态码发给浏览器。
这时浏览器就可使用缓存的数据。服务器返回的就只是 Header,不包含 Body。
下面介绍两种标识规则:
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-Since 指的是同一个值,只是在客户端和服务器端的叫法不一样。
2)ETag / If-None-Match 规则
客户端第一次请求的时候,服务器会给每一个资源生成一个 ETag 标记。这个 ETag 是根据每一个资源生成的惟一 Hash 串,资源若是发生变化 ETag 随之更改,以后将这个 ETag 返回给客户端,客户端把请求的资源和 ETag 都缓存到本地。
ETag 被保存之后,在下次请求时会看成 If-None-Match 字段被发送出去。
在浏览器第二次请求服务器相同资源时,会把资源对应的 ETag 一并发送给服务器。在请求时 ETag 转化成 If-None-Match,但其内容不变。
服务器收到请求后,会把 If-None-Match 与服务器上资源的 ETag 进行比较:
注意:ETag 和 If-None-Match 指的是同一个值,只是在客户端和服务器端的叫法不一样。
HTTP 缓存主要是对静态数据进行缓存,把从服务器拿到的数据缓存到客户端/浏览器。
若是在客户端和服务器之间再加上一层 CDN,可让 CDN 为应用服务器提供缓存,若是在 CDN 上缓存,就不用再请求应用服务器了。而且 HTTP 缓存提到的两种策略一样能够在 CDN 服务器执行。
CDN 的全称是 Content Delivery Network,即内容分发网络。
让咱们来看看它是如何工做的吧:
CDN 接受客户端的请求,它就是离客户端最近的服务器,它后面会连接多台服务器,起到了缓存和负载均衡的做用。
说完客户端(HTTP)缓存和 CDN 缓存,咱们离应用服务愈来愈近了,在到达应用服务以前,请求还要通过负载均衡器。
虽然说它的主要工做是对应用服务器进行负载均衡,可是它也能够做缓存。能够把一些修改频率不高的数据缓存在这里,例如:用户信息,配置信息。经过服务按期刷新这个缓存就好了。
以 Nginx 为例,咱们看看它是如何工做的:
经过了客户端、CDN、负载均衡器,咱们终于来到了应用服务器。应用服务器上部署着一个个应用,这些应用以进程的方式运行着,那么在进程中的缓存是怎样的呢?
进程内缓存又叫托管堆缓存,以 Java 为例,这部分缓存放在 JVM 的托管堆上面,同时会受到托管堆回收算法的影响。
因为其运行在内存中,对数据的响应速度很快,一般咱们会把热点数据放在这里。
在进程内缓存没有命中的时候,咱们会去搜索进程外的缓存或者分布式缓存。这种缓存的好处是没有序列化和反序列化,是最快的缓存。缺点是缓存的空间不能太大,对垃圾回收器的性能有影响。
目前比较流行的实现有 Ehcache、GuavaCache、Caffeine。这些架构能够很方便的把一些热点数据放到进程内的缓存中。
这里咱们须要关注几个缓存的回收策略,具体的实现架构的回收策略会有所不一样,但大体的思路都是一致的:
在分布式架构的今天,多应用中若是采用进程内缓存会存在数据一致性的问题。
这里推荐两个方案:
应用在修改完自身缓存数据和数据库数据以后,给消息队列发送数据变化通知,其余应用订阅了消息通知,在收到通知的时候修改缓存数据。
为了不耦合,下降复杂性,对“实时一致性”不敏感的状况下,每一个应用都会启动一个 Timer,定时从数据库拉取最新的数据,更新缓存。
不过在有的应用更新数据库后,其余节点经过 Timer 获取数据之间,会读到脏数据。这里须要控制好 Timer 的频率,以及应用与对实时性要求不高的场景。
进程内缓存有哪些使用场景呢?
说完进程内缓存,天然就过渡到进程外缓存了。与进程内缓存不一样,进程外缓存在应用运行的进程以外,它拥有更大的缓存容量,而且能够部署到不一样的物理节点,一般会用分布式缓存的方式实现。
分布式缓存是与应用分离的缓存服务,最大的特色是,自身是一个独立的应用/服务,与本地应用隔离,多个应用可直接共享一个或者多个缓存应用/服务。
既然是分布式缓存,缓存的数据会分布到不一样的缓存节点上,每一个缓存节点缓存的数据大小一般也是有限制的。
数据被缓存到不一样的节点,为了能方便的访问这些节点,须要引入缓存代理,相似 Twemproxy。他会帮助请求找到对应的缓存节点。
同时若是缓存节点增长了,这个代理也会只能识别而且把新的缓存数据分片到新的节点,作横向的扩展。
为了提升缓存的可用性,会在原有的缓存节点上加入 Master/Slave 的设计。当缓存数据写入 Master 节点的时候,会同时同步一份到 Slave 节点。
一旦 Master 节点失效,能够经过代理直接切换到 Slave 节点,这时 Slave 节点就变成了 Master 节点,保证缓存的正常工做。
每一个缓存节点还会提供缓存过时的机制,而且会把缓存内容按期以快照的方式保存到文件上,方便缓存崩溃以后启动预热加载。
当缓存作成分布式的时候,数据会根据必定的规律分配到每一个缓存应用/服务上。
若是咱们把这些缓存应用/服务叫作缓存节点,每一个节点通常均可以缓存必定容量的数据,例如:Redis 一个节点能够缓存 2G 的数据。
若是须要缓存的数据量比较大就须要扩展多个缓存节点来实现,这么多的缓存节点,客户端的请求不知道访问哪一个节点怎么办?缓存的数据又如何放到这些节点上?
缓存代理服务已经帮咱们解决这些问题了,例如:Twemproxy 不但能够帮助缓存路由,同时能够管理缓存节点。
这里有介绍三种缓存数据分片的算法,有了这些算法缓存代理就能够方便的找到分片的数据了。
1)哈希算法
Hash 表是最多见的数据结构,实现方式是,对数据记录的关键值进行 Hash,而后再对须要分片的缓存节点个数进行取模获得的余数进行数据分配。
例如:有三条记录数据分别是 R一、R二、R3。他们的 ID 分别是 0一、0二、03,假设对这三个记录的 ID 做为关键值进行 Hash 算法以后的结果依旧是 0一、0二、03。
咱们想把这三条数据放到三个缓存节点中,能够把这个结果分别对 3 这个数字取模获得余数,这个余数就是这三条记录分别放置的缓存节点。
Hash 算法是某种程度上的平均放置,策略比较简单,若是要增长缓存节点,对已经存在的数据会有较大的变更。
2)一致性哈希算法
一致性 Hash 是将数据按照特征值映射到一个首尾相接的 Hash 环上,同时也将缓存节点映射到这个环上。
若是要缓存数据,经过数据的关键值(Key)在环上找到本身存放的位置。这些数据按照自身的 ID 取 Hash 以后获得的值按照顺序在环上排列。
若是这个时候要插入一条新的数据其 ID 是 115,那么就应该插入到以下图的位置。
同理若是要增长一个缓存节点 N4 150,也能够放到以下图的位置:
这种算法对于增长缓存数据,和缓存节点的开销相对比较小。
3)Range Based 算法
这种方式是按照关键值(例如 ID)将数据划分红不一样的区间,每一个缓存节点负责一个或者多个区间,跟一致性哈希有点像。
例如:存在三个缓存节点分别是 N一、N二、N3。他们用来存放数据的区间分别是N1(0, 100]、 N2(100, 200]、 N3(300, 400]。
那么数据根据本身 ID 做为关键字作 Hash 之后的结果就会分别对应放到这几个区域里面了。
根据事物的两面性,在分布式缓存带来高性能的同时,咱们也须要重视它的可用性。那么哪些潜在的风险是咱们须要防范的呢?
1) 缓存雪崩
当缓存失效、缓存过时被清除、缓存更新的时候,请求是没法命中缓存的,这个时候请求会直接回源到数据库。
若是上述状况频繁发生或者同时发生的时候,就会形成大面积的请求直接到数据库,形成数据库访问瓶颈。咱们称这种状况为缓存雪崩。
从以下两方面来思考解决方案。
缓存方面:
2)缓存穿透
缓存通常是 Key,Value 方式存在,一个 Key 对应的 Value 不存在时,请求会回源到数据库。
假如对应的 Value 一直不存在,则会频繁地请求数据库,对数据库形成访问压力。若是有人利用这个漏洞攻击,就麻烦了。
解决方法:若是一个 Key 对应的 Value 查询返回为空,咱们仍然把这个空结果缓存起来,若是这个值没有变化下次查询就不会请求数据库了。
将全部可能存在的数据哈希到一个足够大的 Bitmap 中,那么不存在的数据会被这个 Bitmap 过滤器拦截掉,避免对数据库的查询压力。
3)缓存击穿
在数据请求的时候,某一个缓存恰好失效或者正在写入缓存,同时这个缓存数据可能会在这个时间点被超高并发请求,成为“热点”数据。
这就是缓存击穿问题,这个和缓存雪崩的区别在于,这里是针对某一个缓存,前者是针对多个缓存。
解决方案:致使问题的缘由是在同一时间读/写缓存,因此只有保证同一时间只有一个线程写,写完成之后,其余的请求再使用缓存就能够了。
比较经常使用的作法是使用 mutex(互斥锁)。在缓存失效的时候,不是当即写入缓存,而是先设置一个 mutex(互斥锁)。当缓存被写入完成之后,再放开这个锁让请求进行访问。
总结一下,缓存设计有五大策略,从用户请求开始依次是:
其中,前两种缓存静态数据,后三种缓存动态数据: