缓存原理
在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新像服务器发起资源请求。
缓存技术种类
- 代理缓存
- 浏览器缓存
- 网关缓存
- 负载均衡器
- 内容分发网络等
它们大致可以分为两类:共享缓存和私有缓存。共享缓存指的是缓存内容可以被多个用户使用,如公司内部架设的Web代理;私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存
HTTP缓存
HTTP缓存应该是前端开发中最常接触的缓存机制之一,它可细分为强制缓存和协商缓存,二者最大的区别在于判断缓存命中时,浏览器是否需要向服务器端进行询问以协商缓存的相关信息,进而判断是否需要就响应内容进行重新请求
强制缓存
对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无需与服务器进行任何通信
请求头示例:
access-control-allow-origin: *
age: 734978
content-length: 40830
content-type: image/jepg
cache-control:max-age=31536000
expires: Web, 14 Fed 2022 2:22:42 GMT
其中与强制缓存相关的两个字段是cache-control和expires,expires是在HTTP1.0协议中声明的用来控制缓存失效日期时间戳的字段,它由服务器指定后通过响应头告知浏览器,浏览器在接收到带有该字段的响应体后进行缓存。
若之后浏览器再次发起相同的资源请求,便会对比expires与本地当前时间戳,如果当前请求的本地时间戳小于expires的值,则说明浏览器缓存的响应还未过期,可以直接使用而无需向服务器端再次发起请求。只有当本地时间戳大于expires值发生缓存过期时,才允许重新向服务器发起请求
从上述强制缓存是否过期的判断机制中不难看出,这个方式存在一个很大的漏洞,即对本地时间戳过分依赖,如果客户端本地的时间与服务器端的时间不同步,那么对于缓存过期判断可能就无法和预期相符
为了解决expires判断的局限性,从HTTP1.1协议开始增加了cache-control字段来对expires的功能进行扩展和完善。从上述代码中可见cache-control设置了max-age=31536000的属性值来控制响应资源的有效性,它是一个以秒为单位的时间长度,表示该资源在被请求到后的31536000秒内有效,如此便可避免服务器端和客户端时间戳不同步而造成的问题。除此之外,cache-control还可配置一些其他属性来控制缓存,如下:
- no-cache 和 no-store: 设置
no-cache并非像字面上的意思不使用缓存,其表示为强制进行协商缓存,即对于每次发起的请求都不会再去判断强制缓存是否过期,而是与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。设置no-store则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器给与全新的响应。no-cache和no-store是两个互斥的属性值,不能同时设置 关闭缓存Cache-Control: no-store指定no-cache或者max-age=0表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。这意味着每次都会发起HTTP请求,但当缓存内容仍有效时可以跳过HTTP响应体的下载 - private 和 public
private和public也是cache-control的一组互斥属性值,它们用以明确响应资源是否可以被代理服务器进行缓存 若资源响应头中的cache-control字段设置了public属性值,则表示响应资源可以被浏览器缓存,又可以被代理服务器缓存private则限制了相应资源只能被浏览器缓存,若未显示指定则默认值为private对于应用程序中不会改变的文件,你通常可以在发送响应头前添加积极缓存。这包括例如由应用程序提供的静态文件,例如图像、css文件和javascript文件Cache-Control:public, max-age=31000 - max-age 和 s-maxage
max-age属性值会比s-maxage更常用,它表示服务器端告知客户端浏览器相应资源的过期时长。在一般项目的使用场景中基本够用,对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题。这便是s-maxage存在的意义,它表示缓存在代理服务器中的过期时长,且仅当设置了public属性值时才有效。
由此可见cache-control能作为expires的完全替代方案,并且拥有其所不具备的一些缓存控制特性,在项目实践中使用它就足够了,目前expires还存在的唯一理由是考虑可用性方面的向下兼容。
协商缓存
顾名思义,协商缓存就是在使用本地缓存之前需要向服务器端发起一次GET请求,与之协商当前浏览器保存的本地缓存是否已经过期。
last-modifed
通常是采用锁请求资源最近一次修改的时间戳来判断的,列如:假设客户端浏览器需要向服务器请求一个mainfest.js的js文件资源,为了让该资源被再次请求时能通过协商缓存的机制使用本地缓存,那么首次返回该资源的响应头中应包含一个名为last-modified的字段,该字段的属性值为该js文件最近一次修改的时间戳,关键请求头与响应头信息
Request URL: http://localhost:3000/mainfest.js
Request Mehtod: GET
last-modified: Thu, 29 Apr 2020 02:44:12 GMT
cache-control: no-cache
当我们刷新网页时,由于该js文件使用的是协商缓存,客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一个GET请求,进行缓存有效性的协商,此次GET请求的请求头需要包含一个if-modified-since字段,其值正是上次响应头中last-modified的字段值
当服务器收到该请求后便会对比请求资源当前的修改时间戳与if-modified-since 字段的值,如果二者相同则说明缓存未过期可继续使用本地缓存,否则服务器重新返回全新的文件资源,关键请求头和响应头字段
Request URL: http://localhost:3000/mainfest.js
Request Method: GET
If-Modified-Since: Thu,28 Apr 2020 02:44:12 GMT
//协商缓存有效的响应头
Status Code: 304 Not Modified
这里需要注意的是,协商缓存判断缓存有效的响应状态码是304,即缓存有效响应重定向到本地缓存上。这和强制缓存有所不同,强制缓存若有效,则再次请求的状态响应码是200 last-modified的不足 通过该last-modified所实现的协商缓存能够满足大部分的使用场景,但也存在2个比较明显的缺陷: 1.首先它只是根据资源最后的修改时间戳进行判断的,如果请求的文件进行了编辑,但内容并没发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断验证为失效,需要重新进行完整资源请求。这无疑会造成网络宽带资源的浪费,以及延长用户获取到目标资源的时间。 2.其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证协商缓存的有效性是无法识别出该次文件资源的更新的
其实造成上述两种缺陷的原因相同,就是服务器无法仅依据资源修改的时间戳来识别出真正的更新,进而导致重新发起了请求,该重新请求却使用了缓存的BUG场景
基于ETag的协商缓存
为了弥补通过时间戳判断的不足,从HTTP1.1规范开始新增了一个ETag的头信息,即实体标签(Entity Tag) 其内容主要是服务器为不同资源进行哈希运算所生产的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的ETag标签就会不同,因此可以使用ETag对文件资源进行更精准的变化感知。关键响应头:
Content-Type: image/jpeg
ETag: "ce39043d22da35436o34oddkdfd2020"
Last-Modified: Fri, 12 Jul 2020 02:44:12 GMT
Content-Length: 9899
上述响应头中同时包含了last-modified和ETag
ETag的不足 不像强制缓存中cache-control可以完全替代expires的功能,在协商缓存中,ETag并非last-modified的替代方案而是一种补充方案,因为它存在一些弊端 1.一方面,服务器对于生成文件资源的ETag需要付出额外的计算开销,如果资源尺寸较大、数量较多且修改比较频繁,那么生成ETag的过程就会影响服务器的性能
2.另一方面,ETag字段值的生成分为前验证和弱验证,前验证根据资源内容进行生成,能够保证每个字节都相同;弱验证则根据资源的部分属性值来生产,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下也会因为不够准确而降低协商缓存有效验证的成功率,所以恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式
缓存决策
如何一个用HTTP缓存技术来提升网站性能。假设在不考虑客户端缓存容量和服务器算力的理想情况下,我们当然 希望客户端浏览器上的缓存触发率尽可能高,留存时间尽可能长,同时还要ETag实现当资源更新时进行高效的重新验证
但实际情况往往是容量和算力有限,因此需要制定合适的缓存策略,来利用有限的资源达到最优的性能效果。明确能力的边界,力求在边界内做到最好。
缓存决策树
在面对一个具体的缓存需求时,到底该如何制定缓存策略呢?我们可以参照如图所示的决策树来逐步确定对一个资源具体的缓存策略
node服务缓存设置示例
const http = require('http')
const fs = require('fs')
const url = require('url')
const etag = require('etag') // 需安装etag依赖包
http.createServer((req,res) => {
console.log(req.method,req.url)
const { pathname } = url.parse(req.url)
if (pathname === '/') {
const data = fs.readFileSync('./http_cache.html')
res.end(data)
}else if (pathname === '/1.jpg') {// expires
const data = fs.readFileSync('./1.jpg')
// 指定缓存过期时间
res.writeHead(200, {
Expires: new Date("2022-2-27 12:00:00").toUTCString()
})
res.end(data)
}else if (pathname === '/2.jpg') { // cache-control:max-age=xx
const data = fs.readFileSync('./2.jpg')
// 10秒内强制缓存
res.writeHead(200, {
'Cache-Control': 'max-age=10'
})
res.end(data)
} else if (pathname === '/3.jpg') { // last-modified
// 获取资源修改时间
const { mtime } = fs.statSync('./3.jpg')
const ifModifiedSince = req.headers['if-modified-since']
if (ifModifiedSince === mtime.toUTCString()) {
// 一致缓存生效
res.statusCode = 304
res.end()
return
}
const data = fs.readFileSync('./3.jpg')
res.setHeader('last-modified', mtime.toUTCString())
res.setHeader('Cache-Control', 'no-cache')
res.end(data)
} else if (pathname === './4.jpg') { // ETag简单实现
const data = fs.readFileSync('./4.jpg')
const etagContent = etag(data)
res.setHeader('etag', etagContent)
res.setHeader('Cache-Control', 'no-cache')
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch === etagContent) { // etag比对相等,协商缓存命中
res.statusCode = 304
res.end()
return
}
res.end(data)
} else {
res.statusCode = 404
res.end()
}
}).listen(3000, () => {
console.log('http://localhost:3000')
})