介绍
前端缓存一般是指一个资源(如 html、css、js、images 等)存在于服务器和客户端(浏览器)之间的副本。
缓存会根据进来的请求保存输出内容的副本,当下一个请求来到的时候,如果是相同的 URL,缓存会根据缓存机制决定是直接使用副本响应访问请求,还是向源服务器再次发送请求。
比较常见的就是浏览器会缓存访问过网站的网页,当再次访问这个 URL 地址的时候,如果网页没有更新,就不会再次下载网页,而是直接使用本地缓存的网页。只有当网站明确标识资源已经更新,浏览器才会再次下载网页。
优点
- 减少网络带宽与流量消耗
- 降低服务器压力
- 减少网络延迟与请求,提升页面渲染速度。
分类与原理
缓存位置分类
- 我们可以在 Chrome 的开发者工具中,通过 Network 中的 Size 列 看到一个请求最终的处理方式。
- 如果显示的是文件大小,就表示是通过网络请求取得。
- 如果是
from memory cache、from disk cache 或 from ServiceWorker
,这些说明是在缓存中读取的。
- 缓存读取优先级是由上到下寻找,找到即返回,找不到则继续。
- Service Worker
- Memory Cache
- Disk Cache
- 网络请求
Memory Cache
Memory Cache 是内存中的缓存,与之相对 Disk Cache 就是硬盘上的缓存。按照操作系统的常理:先读内存,再读硬盘。Disk Cache 因为它的优先级更低一些,将在后面介绍。
几乎所有的网络请求资源都会被浏览器自动加入到 Memory Cache 中。但是也正因为数量很大但是浏览器占用的内存不能无限扩大这样两个因素,Memory Cache 注定只能是个“短期存储”。
常规情况下,浏览器的标签页关闭后该次浏览的 Memory Cache 便失效,给其他标签页腾出位置。而如果极端情况下,例如一个页面的缓存就占用了超级多的内存,那可能在标签页没关闭之前,排在前面的缓存就已经失效了。
Memory Cache 细分一下主要有两块
preloader
在浏览器打开网页的过程中,会先请求 HTML 然后解析。之后如果浏览器发现了 js、css 等需要解析和执行的资源时,它会使用 CPU 资源对它们进行解析和执行。
在古老的年代(大约 2007 年以前),(请求 js/css - 解析执行 - 请求下一个 js/css - 解析执行下一个 js/css)
这样的“串行”操作模式在每次打开页面之前进行着。
很明显在解析执行的时候,网络请求是空闲的,这就有了发挥的空间:我们能不能一边解析执行 js/css,一边去请求下一个(或下一批)资源呢?
这就是 preloader
要做的事情。不过 preloader
没有一个官方标准,所以每个浏览器的处理都略有区别。例如有些浏览器还会下载 css 中的 @import
内容或者 <video>
的 poster
等。
而这些被 preloader
请求过来的资源就会被放入 Memory Cache 中,供之后的解析执行操作使用。
preload
虽然看上去和刚才的 preloader
就差了俩字母,实际上这个大家应该更加熟悉一些,例如 <link rel="preload">
。这些显式指定的预加载资源,也会被放入 Memory Cache 中。
Memory Cache 机制保证了一个页面中如果有两个相同的请求,例如两个 src 相同的 <img>
或两个 href 相同的 <link>
这些实际只会被请求一次,避免了浪费。
不过在匹配缓存时,除了匹配完全相同的 URL 之外,还会比对他们的类型、CORS 中的域名规则等。
因此一个作为脚本(script)类型被缓存的资源是不能用在图片(image)类型的请求中的,即便他们src相等。
在从 Memory Cache 获取缓存内容时,浏览器会忽视例如 max-age=0/no-cache
等头部配置。例如页面上存在几个相同 src 的图片,即便它们可能被设置为不缓存,但依然会从 Memory Cache 中读取。
这是因为 Memory Cache 只是短期使用,大部分情况生命周期只有一次浏览而已。而 max-age=0
在语义上普遍被解读为不要在下次浏览时使用,所以和 Memory Cache 并不冲突。
那么如果不想让一个资源进入缓存,就连短期也不行。就需要使用 no-store
。存在这个头部配置的话,即便是 Memory Cache 也不会存储,自然也不会从中读取。
Disk Cache
Disk Cache 也叫 HTTP Cache,是存储在硬盘上的缓存,因此它是持久存储的,是实际存在于文件系统中的。而且它允许相同的资源在跨会话,甚至跨站点的情况下使用,例如两个站点都使用了同一张图片。
Disk Cache 会严格根据 HTTP 头信息中的各类字段来判定哪些资源可以缓存,哪些资源不可以缓存,哪些资源是仍然可用的,哪些资源是过时需要重新请求的。
当命中缓存之后,浏览器会从硬盘中读取资源,虽然比起从内存中读取慢了一些,但比起网络请求还是快了不少的。一般前端绝大部分的缓存都来自 Disk Cache。
凡是持久性存储都会面临容量增长的问题,Disk Cache 也不例外,在浏览器自动清理时,会使用算法去把最老的或者最可能过时的资源删除,因此是一个一个删除的。
不过每个浏览器识别最老的或者最可能过时的资源的算法不尽相同,可能也是它们差异性的体现。
Service Worker
上述的缓存策略以及缓存/读取/失效的动作都是由浏览器内部判断进行的,我们只能设置响应头的某些字段来告诉浏览器,而不能自己操作。而 Service Worker 可以让我们自己去定义缓存哪些文件,什么情况下取出文件。
Service Worker 能够操作的缓存是有别于浏览器内部的 Memory Cache 或者 Disk Cache。
我们可以在 Chrome 的开发者工具 -> Application -> Cache Storage 找到这个缓存区。除了位置不同之外,这个缓存是永久性的,即关闭标签页或者浏览器,下次打开依然还在。
有两种情况会导致这个缓存中的资源被清除:手动调用 API cache.delete(resource)
或者容量超过限制,被浏览器全部清空。
如果 Service Worker 没能命中缓存,一般情况会使用 fetch()
方法继续获取资源。这时候,浏览器就去 Memory Cache 或者 Disk Cache 进行下一次找缓存的工作了。
注意:经过 Service Worker 的 fetch()
方法获取的资源,即便它并没有命中 Service Worker 缓存,甚至实际走了网络请求,也会标注为 from ServiceWorker
。
网络请求
如果一个请求在上述 3 个位置都没有找到缓存,那么浏览器会正式发送网络请求去获取内容。
- 之后容易想到,为了提升之后请求的缓存命中率,自然要把这个资源添加到缓存中去。
- 根据 Service Worker 中的
handler
决定是否存入 Cache Storage。 - 根据 HTTP 头部的相关字段
(Cache-Control/Pragma等)
决定是否存入 Disk Cache。 - Memory Cache 保存一份资源的引用,以备下次使用。
失效策略分类
- Memory Cache 是浏览器为了加快读取缓存速度而进行的自身的优化行为,不受开发者控制,也不受 HTTP 协议头的约束,算是一个黑盒。
- Service Worker 是由开发者编写的额外的脚本,且缓存位置独立,出现也较晚,使用还不算太广泛。
- 所以我们平时最为熟悉的其实是 Disk Cache,也叫 HTTP Cache,因为不像 Memory Cache,它遵守 HTTP 协议头中的字段。
- 平时所说的强制缓存,协商缓存,以及 Cache-Control 等,也都归于此类。
强制缓存 (强缓存)
介绍
强制缓存的含义是,当客户端请求后,会先访问缓存数据库看缓存是否存在,如果存在则直接返回,不存在则请求服务器,响应后再写入缓存数据库。
强制缓存直接减少请求数,是提升最大的缓存策略。它的优化覆盖了文章开头提到过的请求数据的全部三个步骤。如果考虑使用缓存来优化网页性能的话,强制缓存应该是首先被考虑的。
- 常用强制缓存的字段是
Cache-Control
和Expires
。
常用字段与实现原理
Expires
Expires
是一个绝对时间,是缓存过期时间。用以表达在这个时间点之前发起请求可以直接从浏览器中读取数据,而无需重新发起请求。Expires
是一个绝对的时间(当前时间+缓存时间)- 例如:
Expires: Thu, 10 Nov 2020 08:45:11 GMT
。
- 例如:
- 由于受限于本地时间,如果修改了本地时间,或其他原因导致服务器时间和客户端时间不一致,可能会造成缓存失效等问题。
- 写法太复杂了,表示时间的字符串如果多个空格,少个字母,都会导致非法属性从而设置失效。
Cache-Control
Cache-control
的优先级比Expires
的优先级高Cache-control
表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求。Cache-control
是相对时间,表示在2592000s
内再次请求这条数据,都会直接获取缓存数据库中的数据,直接使用。- 例如:
Cache-control: max-age=2592000
- 例如:
Cache-control
常用值(可混用):private
:所有的内容只有客户端才可以缓存,代理服务器不能缓存,默认值。public
:所有的内容都可以被缓存,包括客户端和代理服务器,如 CDN。max-age
:即最大有效时间,在上面的例子中我们可以看到。must-revalidate
:如果超过了max-age
的时间,浏览器必须向服务器发送请求,验证资源是否还有效。no-cache
:虽然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由后续的对比来决定。no-store
: 真正意义上的“不存”。所有内容都不走缓存,包括强制和对比。
max-age=0
和no-cache
等价吗?从规范的字面意思来说,max-age
到期是 应该(SHOULD) 重新验证,而no-cache
是 必须(MUST) 重新验证。但实际情况以浏览器实现为准,大部分情况他们俩的行为还是一致的。(如果是max-age=0, must-revalidate
就和no-cache
等价了。)
对于强制缓存,服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接用缓存,不在时间内,执行协商缓存策略。
Cache-Control
的优先级虽然高于Expires
,但是为了兼容HTTP/1.0
和HTTP/1.1
,实际项目中两个字段都会设置。
协商缓存 (对比缓存)
介绍
当强制缓存失效(超过规定时间)时,就需要使用协商缓存,由服务器决定缓存内容是否失效。
流程上说,浏览器先请求缓存数据库,返回一个缓存标识。之后浏览器拿这个标识和服务器通讯,如果缓存未失效,则返回 HTTP 状态码 304
表示继续使用,于是客户端继续使用缓存;如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。
协商缓存在请求数上和没有缓存是一致的,但如果是 304
的话,返回的仅仅是一个状态码
而已,并没有实际的文件内容
,因此 在响应体体积上的节省是它的优化点。它的优化通过减少响应体体积,来缩短网络传输时间。所以和强制缓存相比提升幅度较小,但总比没有缓存好。
常用字段与实现原理
- 协商缓存有 2 组字段(不是两个)
Last-Modified & If-Modified-Since
- 比较步骤
- 服务器通过
Last-Modified
字段告知客户端,资源最后一次被修改的时间。- 例如:
Last-Modified: Mon, 10 Nov 2020 09:10:11 GMT
。
- 例如:
- 浏览器将这个值和内容一起记录在缓存数据库中
- 下一次请求相同资源时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的
Last-Modified
的值写入到请求头的If-Modified-Since
字段。 - 服务器会将
If-Modified-Since
的值与此资源最新的Last-Modified
字段进行对比。如果相等,则表示未修改,响应304
;反之,则表示修改了,响应200
状态码,并返回数据。
- 但是他还是有一定缺陷的:
- 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
- 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。
Etag & If-None-Match
Etag
的优先级高于Last-Modified
- 为了解决上述问题,出现了一组新的字段
Etag 和 If-None-Match
。 Etag
存储的是文件的特殊标识(一般都是 hash 生成的)
,服务器存储着文件的Etag
字段。- 之后的流程和
Last-Modified
一致,只是Last-Modified
字段和它所表示的更新时间改变成了Etag
字段和它所表示的文件 hash,把If-Modified-Since
变成了If-None-Match
。 - 服务器同样进行比较,命中返回
304
,不命中返回新资源和200
。 - 因此上面
Last-Modified 和 If-Modified-Since
的缺陷就弥补了,就算是动态生成的文件,只要我们决定Etag
不变,那么就可以达到缓存的效果。
对于协商缓存,将缓存信息中的
Etag和Last-Modified
通过请求发送给服务器,由服务器校验,返回304
状态码时,浏览器直接使用缓存。
协商缓存是可以和强制缓存一起使用的,作为在强制缓存失效后的一种后备方案。实际项目中他们也的确经常一同出现。
小结
常见协议头相关字段
请求资源
- 调用 Service Worker 的
fetch
事件响应 - 查看 Memory Cache
- 查看 Disk Cache。这里又细分:
- 如果有强制缓存且未失效,则使用强制缓存,不请求服务器,这时的状态码全部是
200
。 - 如果有强制缓存但已失效,使用协商缓存,比较后确定
304
还是200
。
- 如果有强制缓存且未失效,则使用强制缓存,不请求服务器,这时的状态码全部是
缓存资源
- 发送网络请求,等待网络响应。
- 把响应内容存入 Disk Cache (如果 HTTP 头信息配置可以存的话)。
- 把响应内容的引用存入 Memory Cache (无视 HTTP 头信息的配置)。
- 把响应内容存入 Service Worker 的 Cache Storage (如果 Service Worker 的脚本调用了
cache.put()
)。
流程图
缓存是否有效情况
注意点
- 强缓存情况下,只要缓存还没过期,就会直接从缓存中取数据,就算服务器端有数据变化,也不会从服务器端获取了,这样就无法获取到修改后的数据。
- 解决的办法有:在修改后的资源加上随机数或者版本号、时间戳,确保不会从缓存中取。
- 尽量减少
304
的请求,因为我们知道,协商缓存每次都会与后台服务器进行交互,所以性能上不是很好。从性能上来看尽量多使用强缓存。 - 在 Firefox 浏览器下,使用
Cache-control: no-cache
是不生效的,其识别的是no-store
。这样能达到其他浏览器使用Cache-control: no-cache
的效果。所以为了兼容Firefox
浏览器,经常会写成Cache-control: no-cache, no-store
。
其他
CDN 缓存
CDN 可以理解为分布在每个县城的火车票代售点,用户在浏览网站的时候,CDN 会选择一个离用户最近的 CDN 边缘节点来响应用户的请求,这样海南移动用户的请求就不会千里迢迢跑到北京电信机房的服务器(假设源站部署在北京电信机房)上了。
- CDN的优势很明显:
- CDN 节点解决了跨运营商和跨地域访问的问题,访问延时大大降低。
- 大部分请求在 CDN 边缘节点完成,CDN 起到了分流作用,减轻了源站的负载。
- 浏览器本地缓存失效后,浏览器会向CDN边缘节点发起请求。类似浏览器缓存,CDN边缘节点也存在着一套缓存机制。
- CDN 的分流作用不仅减少了用户的访问延时,也减少的源站的负载。
- CDN 缓存的缺点
- 当网站更新时,如果 CDN 节点上数据没有及时更新,即便用户在浏览器使用 Ctrl +F5 的方式使浏览器端的缓存失效,也会因为 CDN 边缘节点没有同步最新数据而导致用户访问异常。
CDN 边缘节点缓存策略因服务商不同而不同,但一般都会遵循 HTTP 标准协议,通过 HTTP 响应头中的
Cache-Control
的max-age
的字段来设置 CDN 边缘节点数据缓存时间。
- 当客户端向 CDN 节点请求数据时,CDN 节点会判断缓存数据是否过期,若缓存数据并没有过期,则直接将缓存数据返回给客户端。否则,CDN 节点就会向源站发出回源请求,从源站拉取最新数据,更新本地缓存,并将最新数据返回给客户端。
- CDN 服务商一般会提供基于文件后缀、目录多个维度来指定 CDN 缓存时间,为用户提供更精细化的缓存管理。
- CDN 缓存时间会对“回源率”产生直接的影响。若 CDN 缓存时间较短,CDN 边缘节点上的数据会经常失效,导致频繁回源,增加了源站的负载,同时也增大的访问延时。若 CDN 缓存时间太长,会带来数据更新时间慢的问题。开发者需要增对特定的业务,来做特定的数据缓存时间管理。
- CDN 边缘节点对开发者是透明的,相比于浏览器 Ctrl+F5 的强制刷新来使浏览器本地缓存失效,开发者可以通过 CDN 服务商提供的“刷新缓存”接口来达到清理 CDN 边缘节点缓存的目的。这样开发者在更新数据后,可以使用“刷新缓存”功能来强制 CDN 节点上的数据缓存过期,保证客户端在访问时,拉取到最新的数据。
数据库缓存
数据库的缓存一般由数据库提供,可以对表建立高速缓存。
- 数据库中,用户可能多次执行相同的查询语句,为了提高查询效率,数据库会在内存划分一个专门的区域,用来存放用户最近执行的查询,这块区域就是缓存。
- 数据库缓存的使用必须在一定的应用环境下:查询的数据库表不会经常变动、有大量相同的查询(如订单信息查询、查询字典、用户信息)。
- 这个缓存策略也可以用在前端,比如某些信息不变的情况下,可以在前端设置一个对象,保存请求的地址、参数、结果,第一次请求时会保存请求的地址、参数和结果,再次请求时,如果请求的地址、参数一样,则查询该对象直接返回请求的结果。(可以通过请求拦截实现并设置间隔多久更新一次缓存)
缓存雪崩、击穿、穿透
名词简介 | 缓存穿透 | 缓存雪崩 | 缓存击穿 |
---|---|---|---|
触发条件 | 访问一个不存在的 key,缓存不起作用,请求会穿透到 DB,流量大时 DB 就挂掉了。 | 大量的 key 设置了相同的过期时间,导致缓存同一时刻全部失效,这时候 DB 压力过大挂掉。 | 一个存在的 key,在缓存过期的那一刻,同时出现大量的请求(热点 key),这些请求会击穿到 DB,导致 DB 瞬时需求过大挂掉。 |
举个栗子 | 各种方式故意攻击的情况 | 整点秒杀 | 微博热搜 |
解决方案 | 过滤不存在的 key,不相信任何人丢来的数据。 不存在的 key,将空值写入缓存,设置较短的过期时间。 使用布隆过滤器存储所有可能访问的 key,不存在的 key 直接被过滤,存在的 key 则再进一步查询缓存和数据库。 运维大大进行服务器配置,限制 ip 等措施。 | 给缓存过期时间设置一个随机时间,将缓存的过期时间分散开。【击穿/雪崩通用方案】 | 【击穿/雪崩通用方案】 |
【击穿/雪崩通用方案】
- 可以将热点数据设置永不过期(极端场景),要着重考虑刷新的时间间隔和数据异常如何处理的情况。
- 加互斥锁:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。
- 访问某个热点 key 之前,如果不存在此 key,查询数据库有结果,设置他为一个短期的 key,访问结束后后再删除该短期 key。
缓存的同步、复制与分发
缓存的同步指的是写命中缓存的时候,如何保持缓存与磁盘上数据一致性的问题。
一般有两种方式
直写式 WT(Write Through)
:当 CPU 要将数据写入内存时,除了更新缓冲内存上的数据外,也将数据写在磁盘中以维持主存与缓冲内存的一致性,当要写入内存的数据多起来的话,速度自然就慢了下来。回写式 WB(Write Back)
:当 CPU 要将数据写入内存时,只会先更新缓冲内存上的数据,随后再让缓冲内存在总线不塞车的时候才把数据写回磁盘,所以速度会快得多。
两种方式各有利弊,直写缓存方法利用了高速缓存中的数据始终与主存储器中数据匹配的特点。但是,需要的总线周期却非常耗时,从而降低性能。回写缓存可以维持性能,因为写入始终是在“爆发”中进行的,因而运行所需的总线周期将大大减少。
两个 CPU,或者 CPU 与 DMA 同时共享一块物理内存时,writer 在写完后,要 write back,这样另一个 reader 才能看到它写入的数据;在 writer 变为 reader 的时候,需要 invalidate,否则看不到另一个 writer 写入的数据。
所以在共享的时候,需要同时做 write back 和 invalidate。
参考来源
- 一文读懂前端缓存