缓存的原理是在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前缓存的响应副本返回给用户,从而避免重新向服务器发起资源请求。
缓存的技术种类有很多,比如代理缓存,浏览器缓存,网关缓存,负载均衡器及内容分发网络等,大致可以分为两类,共享缓存和私有缓存。
共享缓存指的是缓存内容可以被多个用户使用,如公司内部架设的Web代理,私有缓存只能单独被用户使用的缓存,如浏览器缓存。
HTTP缓存是前端开发中最长接出的缓存机制之一,他又可细分为强制缓存与协商缓存,二者最大的区别在于判断缓存命中时浏览器是否需要向服务器进行询问。强制缓存不会去询问,协商缓存仍旧需要询问服务器。
- 强制缓存
对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中则可直接从强制缓存中返回请求响应,无需与服务器进行任何通信。
强制缓存相关的两个字段是xpires和cache-control。
expires是在HTTP1.0协议中声明的用来控制缓存失效日期的时间戳字段,他由服务器指定胡通过响应头告诉浏览器,浏览器在收到带有该字段的响应体后进行缓存。
之后浏览器再发送相同的请求便会对比expires与本地当前的时间戳,如果当前请求的本地时间戳小于expires的值,则说明缓存还未过期,可以直接使用。否则缓存过期重新向服务器发送请求获取响应体。
res.writeHEAD(200, {
Expires: new Date('2021-6-18 12: 51: 00').toUTCString(),
})
复制代码
expires存在一个很大的漏洞就是对本地时间戳过分依赖,如果客户端本地的时间与服务器时间不同步,或者客户端时间被修改,那么缓存过期的判断可能就无法和预期相符。
为了解决这个问题,HTTP1.1新增了cache-control字段来对expires的功能进行扩展和完善。
cache-control的值为maxage=xxx来控制响应资源的有效期,他是一个以秒为单位的时间长度,表示该资源再被请求到的一段时间内有效,以此便可避免服务端和客户端时间戳不同步而造成的问题。
res.writeHEAD(200, {
'Cache-Control': 'maxage=1000',
})
复制代码
除了maxage还可以设置其他参数,比如no-cache和no-store。
no-cache表示强制进行协商缓存,对于每次发起的请求不会再去判断强制缓存是否过期,直接进行协商缓存。
no-store表示禁止使用任何缓存策略,客户端的每次请求都直接从服务器获取。
no-cache和no-store是互斥的,不能同时设置。
res.writeHEAD(200, {
'Cache-Control': 'no-cache',
})
复制代码
private和public也是cache-control的一组互斥属性值,他们用以明确响应资源是否可被代理服务器进行缓存。
publicb表示响应资源即可被浏览器缓存又可被代理服务器缓存。private限制了响应资源只能被浏览器缓存。
对于应用程序中不会改变的文件,通常可以使用公共缓存。比如图像,js,css。
res.writeHEAD(200, {
'Cache-Control': 'public, max-age=31600',
})
复制代码
除了max-age还有s-maxage,max-age表示服务器告知浏览器的响应资源的过期时长。一般使用它就可以了。如果是大型项目架构通常会涉及代理服务器缓存,这就需要考虑缓存在代理服务器上的有效性问题,这边是s-maxage存在的意义。他表示缓存在代理服务器上的过期时长,需要配合public来使用。
cache-control能作为expires的完全替代方案,目前expires只作为兼容使用。
- 协商缓存
协商缓存就是在使用本地换刺挠之前,需要向服务器发起一次GET请求,与之协商当前浏览器保存的本地缓存是否已经过期。协商缓存主要解决的问题就是在强制缓存下资源不更新的问题。
客户端再获取到本地缓存后需要向服务器发送一次GET请求,这个请求的请求头中包含if-modified-since字段,值是响应头中的last-modified字段,也就是这个资源的最后修改时间。
服务器接收到请求后通过比对前端转过来的时间和资源的修改时间,如果二者相同则说明缓存为过期,就告诉浏览器直接使用缓存中的文件,如果过期了就返回对应文件并且将新的修改日期重新返回。
const http = require('http');
const fs = require('fs');
const url = require(''url');
http.creatServer((req, res) => {
const { pathname } = url.parse(req.url);
// 获取文件日期
fs.stat(`www/${pathname}`, (err, stat) => {
if (err) {
res.writeHeader(404);
res.write('Not Found');
res.end();
} else {
if (req.headers['if-modified-since']) {
const oDate = new Date(req.headers['if-modified-since']);
const time_client = Math.floor(oDate.getTime() / 1000);
const time_server = Math.floor(stat.mtime.getTime() / 1000);
if (time_server > time_client) { // 服务器的文件时间大于客户端
sendFileToClient();
} else {
res.writeHeader(304);
res.write('Not Modified');
res.end();
}
} else {
sendFileToClient();
}
function sendFileToClient() {
let rs = fs.createReadStream(`www/${pathname}`);
res.setHeader('Last-Modifyed', state.mtime.toGMTString());
rs.pipe(res);
rs.on('error', err => {
res.writeHeader(404);
res.write('Not Found');
res.end();
})
}
}
})
}).listen(8080);
复制代码
这种缓存方式存在两个问题,首先他只是根据资源最后的修改时间戳进行判断,如果文件没有变更只是保存了一下修改时间也会变化。其次标识时间是秒,如果修改特别快在毫秒内完成(程序修改会有这样的速度),那么就无法识别缓存过期。
主要原因就是服务器无法仅依据资源修改的时间戳识别出真正的更新,进而导致缓存不准确。
为了解决这个问题从HTTP1.1规范开始新增了一个ETag的头信息, 实体标签。其内容主要是服务器为不同资源进行哈希运算生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的ETag标签值就会不同,因此可以使用ETag对文件资源进行更精准的变化感知。
const etag = require('etag')
res.setHeader('etag', etag(data));
复制代码
基于ETag发送的请求会在请求头中以If-None-Match传递给服务器。
在协商缓存中ETag并非last-modified的替代方案而是一种补充方案,因为他已经存在一些问题。
首先服务器对生成文件资源的ETag需要付出额外的计算开销,如果资源尺寸较大,数量较多且修改较频繁,那么生成ETag的过程会影响服务器的想能。其次ETag的值分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同。弱验证则根据资源的部分属性值来生成,生成速度快但无法确保妹子字节都相同。并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性校验的成功了。
恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式。
- 缓存策略
HTTP的缓存技术是提升网站的性能,如果不考虑客户端缓存容量和服务器计算能力的理想情况,我们当然希望客户端浏览器上的缓存触发率尽可能高,留存时间尽可能长,同时还要ETag实现当资源更新时进行高效的重新验证。
但实际情况往往是容量和计算能力都有限,因此就需要指定合适的缓存策略,利用有效的资源达到最优的性能效果。
明确需求边界,力求在边界内做到最好。
在使用缓存技术优化性能的过程中,有一个问题是不可逾越的,我们即希望缓存能在客户端极可能长久的保存,又希望他能在资源发生修改时进行及时更新。这是两个互斥的需求。如何兼顾二者呢?
可以将网站所需要的资源按照不同的类型去拆解,为不同类型的资源制定相应的缓存策略。
首先HTML文件包含其他文件的主文件,为保证当其发生改变能及时更新,应该将其设置为协商缓存.
cache-control: no-cache
复制代码
图片文件的修改基本都是替换,同时考虑图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可以采用强制缓存且过期时间不宜过长。
cache-control: max-age=86400
复制代码
css样式表属于文本文件,可能存在的内容不定期修改,还想使用强制缓存来提高重用效率,故可以考虑在样式表文件的冥冥中增加指纹或版本号(一般为hash值),这样发生修改后不同的文件便会有不同的文件指纹,也就是请求的url不同。所以css的缓存时间可以设置长一些, 一年。
cache-control: max-age=31536000
复制代码
js脚本文件可以类似样式表的设置,采用指纹和较长的过期时间,如果js中包含了用户的私人信息而不想让中间代理缓存,可添加private属性。
cache-control: private, max-age=31536000
复制代码
缓存策略就是为不同的资源进行组合使用强制缓存,协商缓存及文件指纹或版本号,这样可以做到一举多得,及时修改更新,较长缓存过期时间及控制所能进行缓存的位置。
缓存设置需要注意不存在适用于所有场景下的最佳缓存策略,凡是恰当的缓存策略都需要根据具体的场景考虑制定。缓存决策要考虑下面几种情况。
- 拆分源码,分包加载
对于大型项目来说,代码里是非常庞大的,如果发生修改的部分集中在几个重要的模块中,那么进行全量的代码更新熙然比较冗杂,因此可以考虑在代码构建过程中按照模块拆分将其打包成多个单独的文件。这样在每次修改后更新提取时,仅需拉取发生改变的模块代码包,从而大大降低了需要下载的内容大小。
- 预估资源的缓存时效
根据不同资源的不同需求特点,规划响应的缓存更新失效,为强制缓存指定合适的max-age,为协商缓存提供验证更新的ETag实体标签。
- 控制中间代理的缓存
凡是涉及用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存。
- 避免网址的冗余
缓存是根据请求资源的URL进行的,不同的资源会有不同的URL,所以尽量不要将相同的资源设置为不同的URL。
- 规划缓存的层次结构
不仅请求的资源类型,文件资源的层次结构也会对指定缓存策略有一定的影响。
- CDN缓存
CND全程是Content Delivery Network,内容分发网络,他是构建在现有网络基础上的虚拟智能网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡,调度及内容分发等功能模块,使用户在请求所需访问的内容时能够就近获取,以此来降低网络阻塞,提高资源对用户的响应速度。
如果没有CDN假设我们的服务器在北京,那么海南的用户访问我们的网站的时候需要不远万里链接北京的服务器获取资源,这样的速度是比较慢的。
CDN的工作原理就是就近响应,如果我们将资源放置在CDN上,当海南的用户访问网站时,资源请求首先进行DNS解析,这个时候DNS会询问CDN服务器有没有就近的服务器,如果存在就链接就近服务的IP地址获取资源。
由于DNS服务器将CDN的域名解析权交给了CNAME指向的专用DNS服务器,所以用户输入域名的解析最终是在CDN专用的DNS服务器上完成的。解析出的IP地址并非确定的CDN缓存服务器地址,而是CDN复杂均衡器的地址。
浏览器会重新向该负载均衡器发起请求,经过对用户IP地址的距离,所请求资源内容的位置及各个服务器状态的综合计算,返回给用户确定的缓存服务器IP地址。
如果这个过程发生所需资源未找到的情况,那么此时便会依次向上一级缓存服务器继续请求查询,直至追溯到网站所在的跟服务器并将资源拉取到本地进行缓存。
虽然这个过程看起来稍微复杂一些,但是用户是无感知的,并且能带来比较明显的资源加载速度的提升,因此对目前所有一线互联网产品来说,使用CDN已经不是一条建议而是规定。
CDN主要针对的是静态资源并非适用网站所有的资源类型。所谓静态资源就是不需要业务服务器参与计算的资源,比如第三方的库,js脚本文件,css样式文件,图片等。如果是动态资源比如依赖服务端渲染的html就不适合放在CDN上。
CDN网络的核心功能包括两点,缓存与会员,缓存指的是将所需的静态资源文件复制一份到CDN花奴才能服务器上,回源指的是如果未在CDN缓存服务器上查找到目标资源,或者CDN缓存服务器上的资源已经过期,则重新追溯到网站跟服务器获取相关资源的过程。
CDN的优化有很多方面,比如CDN自身的性能优化,鼎泰资源静态边缘化,域名合并优化和多级缓存架构优化。这些可能需要前后端一起配合完成。
一般情况CDN会和主站域名区分,这样的好处是避免静态资源请求携带不必要的cookie信息,还有就是考虑浏览器对同一域名下并发请求的限制。
cookie的访问遵循同源策略,同一域名下的所有请求都会携带全部cookie信息,虽然cookie存储空间并不大,但是如果所有资源都放在主站域名下所有的请求全部携带数据量也是很大的。所以将CDN服务器的域名和主站域名进行区分是非常有价值的。
其次因为浏览器对于同域名下的并发请求存在限制,通常Chrome的并发限制是6。可以通过增加类似域名的方式来提高并发请求数。当然这种方式对缓存命中是不友好的,如果并发请求了相同的资源使用了不同的域名,那么之前的缓存就失去了意义。