「HTTP」 - 强制缓存与协商缓存

334 阅读12分钟

在任何一个前后端交互的项目中,访问服务器获取数据都是很常见的事情,但是如果相同的数据被重复请求了对此,势必会带来浪费网络带宽的问题,以及延迟浏览器渲染要处理的内容,从而影响用户体验

因此使用缓存技术对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略

缓存原理

在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求时,如果判断缓存命中则拦截请求,将之前缓存的响应副本返回给用户,从而避免向服务器发起资源请求

缓存种类

缓存的技术种类有很多,比如代理缓存、浏览器缓存、网关缓存、负载均衡即内容分发网络等,他们大致可以分为两类: 共享缓存和私有缓存

  • 共享缓存指的是缓存内容可以被多个用户使用,如公司内部假设的web代理
  • 私有缓存指的是只能单独被用户使用的缓存,比如浏览器缓存

HTTP缓存可以说是前端开发中最长接触的缓存机制之一,我们下面要讨论的是两种缓存方式:强制缓存和协商缓存。

强制缓存与协商缓存区别:

两者区别在于判断缓存命中时,浏览器是否需要向服务端进行询问已协商缓存的相关信息,进而判断是否需要就响应内容进行重新请求

强制缓存

对于强制缓存而言,如果浏览器所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无须与服务器进行通信

在介绍强制缓存命中判断之前,我们先来看一段响应头的部分信息

access-control-allow-origin: *
age: 734978
content-length: 40830
content-type: image/jpeg
cache-control: max-age=31536000
expires: Web, 14 Fed 2021 12:23:42 GMT

与强缓存有关的字段是 expires 和 cache-control , expires 是在HTTP 1.0 协议中 声明的用来控制缓存失效日期时间戳的字段,他由服务端指定后通过响应头告知浏览器, 浏览器在接收到带有该字段的响应体后进行缓存

expires作用与痛点

当客户端收到带有expires响应头的响应后,再次发起想用的资源请求时,便会将本地时间戳与响应头中的expires 字段进行对比,如果本地时间戳小于 expires 的值,说明缓存未过期,可直接使用缓存而无须再次请求,反之需要重新请求

由此可以看出,expires的痛点是 过分依赖本地时间戳, 如果客户端本地时间戳不准, 或者用户主动修改客户端时间,那么强制缓存的判断可能就无法符合预期的效果

cache-control

为解决expires的痛点, 从HTTP1.1便开始引入cache-control字段来对expires字段进行拓展和完善,cache-control通过设置了maxage=31536000 的属性值来控制缓存的有效期,它是一个以秒为单位的时间长度,表示该资源在请求到后的31536000秒后有效, 如此便可避免服务器和客户端时间戳不同步带来的问题

除了设置maxage之外,cache-control还可以配置其他属性值来更加精准的控制缓存,下面介绍cache-control的其他属性值

no-cache和no-stroe

no-cache表示使用协商缓存,对于每次缓存,不回去判断缓存是否有效,而是直接与服务器协商来验证缓存的有效性

no-store表示不使用缓存,客户端的每次请求都需要服务端给予全新的响应

注意: no-cache 和 no-store是两个互斥的属性,不可能同时设置

返回以下响应头表示不使用缓存

Cache-Control: no-store

指定no-cache 或 max-age=0 表示客户端可以缓存资源,但是每次缓存资源都必须重新验证其有效性。意思是每次都会发起HTTP请求,但是当缓存内容有效时可以跳过HTTP响应体的下载

Cache-Control: no-cache

Cache-Control: max-age=0

private和public

public表示响应资源即可以被浏览器缓存,也可以被代理服务器缓存

private表示响应资源只能被浏览器缓存,若为指定则默认为private

对于应用程序中大概率不会改变的文件,通常可以在响应头前添加public。例如静态文件等

Cache-Control: public, max-age=31536000

max-age和s-maxage

max-age 属性值会比 s-maxage 更常用,它表示服务器端告知客户端浏览器响应资源的过期时长。在一般项目的使用场景中基本够用,对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题。这便是 s-maxage 存在的意义,它表示缓存在代理服务器中的过期时长,且仅当设置了 public 属性值时才有效。

协商缓存

协商缓存是指在使用本地缓存之前,需要向服务端发起一次GET请求,与之协商当前浏览器保存的本地缓存是否过期

通常是采用所请求资源最近一次修改的时间戳来判断的

假设一下场景:

客户端向服务端请求一个 index.js 的 JavaScript文件资源,为了使该资源被再次请求时能通过协商缓存的机制使用本地缓存,客户端与服务端如何配合工作?

1、首先返回该文件的响应头中应该包含一个名为last-modified的字段,该字段的属性值为目标文件最近一次修改的时间戳,简单截取请求头与响应头:

//请求头包含
Request URL: http://localhost:3000/image.jpg
Request Method: GET

//响应头包含
last-modified: Thu, 29 Apr 2021 03:09:28 GMT
cache-control: no-cache

2、 再次请求该 index.js 文件时,由于使用的是协商缓存,客户端如何与服务端协商本地缓存是否过期呢?答案是再次请求的请求头中,会包含一个ifmodified-since字段,其值是上一次响应头中返回的last-modified字段的值

3、当服务器收到该请求后便会对比请求资源当前的修改时间戳与 if-modified-since字段的值,如果二者相同则说明缓存未过期,可继续使用本地缓存,否则服务器重新返回全新的文件资源

命中协商缓存时的简略请求头与响应头:

// 再次请求的请求头
Request URL: http://localhost:3000/image.jpg
Request Method: GET
If-Modified-Since: Thu, 29 Apr 2021 03:09:28 GMT

// 协商缓存有效的响应头
Status Code: 304 Not Modified

注意: 当命中协商缓存时,返回的响应状态码是304,即缓存有效且重定向到本地缓存上。而强制缓存若有效,再次请求的状态码依旧是200

last-modifed 的不足


通过 last-modified 所实现的协商缓存能够满足大部分的使用场景,但也存在两个比较明显的缺陷:

1、首先它只是根据资源最后的修改时间戳进行判断的,虽然请求的文件资源进行了编辑,但内容并没有发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断验证为失效,需要重新进行完整的资源请求。这无疑会造成网络带宽资源的浪费,以及延长用户获取到目标资源的时间。
2、其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证缓存的有效性,是无法识别出该次文件资源的更新的。

其实造成上述两种缺陷的原因相同,就是服务器无法仅依据资源修改的时间戳来识别出真正的更新,进而导致重新发起了请求,该重新请求却使用了缓存的 Bug 场景。

基于ETag的协商缓存

为了弥补时间戳判断的不足,从HTTP1.1规范开始新增了一个ETag的头信息

其优点是对于文件资源的变化进行更准确的感知,只要文件编码存在差异,对应的ETag标签值就会不同。

协商缓存图片资源的案例:

Content-Type: image/jpeg
ETag: "xxx"
Last-Modified: Fri, 12 Jul 2021 18:30:00 GMT
Content-Length: 9887

如果响应头中同时返回了last-modified和ETag, ETag的优先级更高,进行协商缓存时以ETag为准

再次请求头:

If-Modified-Since: Fri, 12 Jul 2021 18:30:00 GMT
If-None-Match: "xxx"

再次响应头:

Content-Type: image/jpeg
ETag: "xxx"
Last-Modified: Fri, 12 Jul 2021 18:30:00 GMT
Content-Length: 0

若验证缓存有效,则返回 304 状态码响应重定向到本地缓存,所以上面响应头中的内容长度 Content-Length 字段值也就为 0 了。

ETag的不足

不像强制缓存中 cache-control 可以完全替代 expires 的功能,在协商缓存中,ETag 并非 last-modified 的替代方案而是一种补充方案,因为它依旧存在一些弊端。更好的实践是二者配合使用

1、一方面服务器对于生成文件资源的 ETag 需要付出额外的计算开销,如果资源的尺寸较大,数量较多且修改比较频繁,那么生成 ETag 的过程就会影响服务器的性能。
2、另一方面 ETag 字段值的生成分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同;弱验证则根据资源的部分属性值来生成,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率,所以恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式。

\

缓存决策

前面我们较为详细地介绍了浏览器 HTTP 缓存的配置与验证细节,下面思考一下如何应用 HTTP 缓存技术来提升网站的性能。假设在不考虑客户端缓存容量与服务器算力的理想情况下,我们当然希望客户端浏览器上的缓存触发率尽可能高,留存时间尽可能长,同时还要 ETag 实现当资源更新时进行高效的重新验证。

但实际情况往往是容量与算力都有限,因此就需要制定合适的缓存策略,来利用有限的资源达到最优的性能效果。明确能力的边界,力求在边界内做到最好。

缓存决策树

在面对一个具体的缓存需求时,到底该如何制定缓存策略呢?我们可以参照图所示的决策树来逐步确定对一个资源具体的缓存策略。

决策步骤为:

  1. 根据资源内容的属性判断是否需要缓存, 如果不希望对该资源进行缓存(包含敏感信息等),直接设置cache-control 属性值为no-store
  2. 判断使用协商缓存还是强制缓存, 如果协商缓存,则设置cache-control属性值中包含no-cache
  3. 最后如果启用了协商缓存,则设置请求资源的 last-modified 和 ETag 实体标签等参数。
  4. 如果是强制缓存,接下来考虑是否允许中间代理服务器缓存该资源,参考之前在强制缓存中介绍的内容,可通过为 cache-control 字段添加 private 或 public 来进行控制。,然后设置强制缓存的过期时间

注意:

缓存是限制域名的

  • 根域下的缓存是共享的。比如 a.com、foo.a.com、bar.a.com 的根域都是 a.com,他们是共享缓存;
  • 同理,域名不同的缓存不共享。比如 a.com、b.com、c.com,他们之间即使加载相同资源也仅在该域名下有效,不共享。

参考链接

代码示例

使用node实现缓存流程

const http = require('http')
const fs = require('fs')
const url = require('url')
const etag = require('etag')
http.createServer((req, res) => {
  
  console.log(req.method, req.url)
  
  
  const { pathname } = url.parse(req.url)
  
  if (pathname === '/') {
    
    const data = fs.readFileSync('./index.html')
    
    res.end(data)
    
  } else if (pathname === '/img/01.jpg') {
    
    const data = fs.readFileSync('./img/01.jpg')
    
    res.writeHead(200, {
      
      // 缺点:客户端时间和服务器时间可能不同步
      
      Expires: new Date('2021-5-27 21:40').toUTCString()
      
    })
    
    res.end(data)
    
  } else if (pathname === '/img/02.jpg') {
    
    const data = fs.readFileSync('./img/02.jpg')
    
    res.writeHead(200, {
      
      'Cache-Control': 'max-age=5' // 滑动时间,单位是秒
      
    })
    
    res.end(data)
    
  } else if (pathname === '/img/03.jpg') {
    
    const { mtime } = fs.statSync('./img/03.jpg')
    
    
    const ifModifiedSince = req.headers['if-modified-since']
    
    
    if (ifModifiedSince === mtime.toUTCString()) {
      
      // 缓存生效
      
      res.statusCode = 304
      
      res.end()
      
      return
      
    }
    
    
    const data = fs.readFileSync('./img/03.jpg')
    
    
    // 告诉客户端该资源要使用协商缓存
    
    //   客户端使用缓存数据之前问一下服务器缓存有效吗
    
    //   服务端:
    
    //     有效:返回 304 ,客户端使用本地缓存资源
    
    //     无效:直接返回新的资源数据,客户端直接使用
    
    res.setHeader('Cache-Control', 'no-cache')
    
    // 服务端要下发一个字段告诉客户端这个资源的更新时间
    
    res.setHeader('last-modified', mtime.toUTCString())
    
    res.end(data)
    
  } else if (pathname === '/img/04.jpg') {
    
    const data = fs.readFileSync('./img/04.jpg')
    
    // 基于文件内容生成一个唯一的密码戳
    
    const etagContent = etag(data)
    
    
    const ifNoneMatch = req.headers['if-none-match']
    
    
    if (ifNoneMatch === etagContent) {
      
      res.statusCode = 304
      
      res.end()
      
      return
      
    }
    
    
    // 告诉客户端要进行协商缓存
    
    res.setHeader('Cache-Control', 'no-cache')
    
    // 把该资源的内容密码戳发给客户端
    
    res.setHeader('etag', etagContent)
    
    res.end(data)
    
  } else {
    
    res.statusCode = 404
    
    res.end()
    
  }
  
}).listen(3000, () => {
  
  console.log('http://localhost:3000')
  
})