一文搞懂 HTTP缓存

652 阅读8分钟

HTTP缓存

缓存的原理是在首次请求资源时保存一份资源的副本,在用户再次请求这个资源时,如果判断缓存命中就拦截请求直接返回之前保存的副本,这样就减少了等待时间和网络流量。

缓存的种类有很多,其大致可归为两类:私有与共享缓存。共享缓存存储的响应能够被多个用户使用,例如:代理缓存、CDN缓存、网关缓存。私有缓存只能用于单独用户,例如:HTTP缓存。本文我们主要介绍HTTP缓存。

HTTP缓存应该算是前端开发中最常接触的缓存机制之一,它又可细分为强制缓存协商缓存,二者最大的区别在于判断缓存命中时,浏览器是否需要向服务器端进行询问以协商缓存的相关信息,进而判断是否需要就响应內容进行重新请求。下面就来具体看HTTP缓存的具体机制及缓存的决策策略。

与HTTP缓存设置有关的设置都是在 HTTP header 上的设置,本文之后提到的字段都是指 HTTP header 上的字段。

示例代码地址

强制缓存

强制缓存是指如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无需与服务器进行通信。

与强制缓存相关的两个字段是 expirescache-control

expires

expires是HTTP1.0协议中的产物,是指资源过期的时间,由服务端返回。既只要下次请求时间小于该字段返回的到期时间就会直接使用缓存资源。

expires的问题也很明显,过期时间是由服务器端返回,如果服务器端时区与客户端时区不一致则会导致误差。

expires设置的时间是 GMT 时间格式,在js中可以通过 Date 对象的 toUTCString 方法将时间转为 GMT 格式。

我们用node起一个服务来试一试

const http = require('http')
const fs = require('fs')
const url = require('url')

http.createServer((req, res) => {
  const { pathname } = url.parse(req.url)
  if (pathname === '/') {
    const data = fs.readFileSync('./index.html')
    res.end(data)
  } else if (pathname === '/images/01.jpeg') {
    const data = fs.readFileSync('./images/01.jpeg')
    res.writeHead(200, {
      // 2022-02-17 17:48:45 当前时间
      Expires: new Date('2022-02-17 17:50:30').toUTCString()
    })
    res.end(data)
  } else {
    res.statusCode = 404
    res.end()
  }
}).listen(3000, () => {
  console.log('http://localhost:3000')
})

image-20220217175155836.png

cache-control

为了解决expires的局限性,http1.1版本中新增了cache-control字段对expires的功能进行完善。

cache-control通过设置 max-age=5 设置缓存过期时间,单位是秒。意思是资源在 5 秒后过期。

上代码

if (pathname === '/images/01.jpeg') {
    const data = fs.readFileSync('./images/01.jpeg')
    res.writeHead(200, {
      'Cache-control': 'max-age=5' // 资源在5s后过期
    })
    res.end(data)
}

image-20220217180136282.png

cache-control 除了可以设置强制缓存之外还可以设置协商缓存,他还有其他的值

cache-control的其他属性

no-cache 和 no-store

no-cacheno-store 是一组 cache-control 的互斥属性。

  • no-store: 不缓存。

  • no-cache:强制协商缓存(后文介绍),每次请求资源不会判断资源是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用缓存策略。

private 和 public

privatepublic 也是 cache-control 的一组互斥属性,他们用以明确相应资源是否可被代理服务器进行缓存。默认值为 private

  • public:表示相应资源既可以被浏览器缓存也可以被代理服务器缓存
  • private:表示响应资源只能被浏览器缓存。

对于不太会改变的资源,我们可以在发送响应头中添加积极缓存。

max-age 和 s-maxage

max-age我们刚才已经介绍过了,max-age 属性值会比 s-maxage 更加常用,他们都表示服务器端告知客户端响应资源的过期时长。在一般的项目中这基本够用,但是有些大型项目通常会涉及使用各种代理服务器的情况,这就需要考虑代理服务器的缓存时长。这便是 s-maxage 存在的意义,他表示缓存在代理服务器中的过期时长,且仅当设置了 public 的属性时才生效

协商缓存

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

通过 last-modifiedcache-control: no-cache 字段来设置协商缓存。

last-modified字段表示资源修改时间通过设置last-modified告诉客户端资源的修改时间,如果客户端再次请求这个资源,请求头上会附带 if-modified-since字段,表示上次获取到的文件修改时间,服务器端在收到这个字段后会对比文件的修改时间,如果文件没有被修改,一般会直接向客户端返回 304 的状态码以告诉客户端可以使用缓存。

上代码

if (pathname === '/images/01.jpeg') {
  const data = fs.readFileSync('./images/01.jpeg')
  // 文件修改时间
  const { mtime } = fs.statSync('./images/01.jpeg')
  const ifModifiedSince = req.headers['if-modified-since']
  // 如果文件修改时间等于 `if-modified-since` 时间,说明资源没有被修改过可以直接返回304状态码
  if (ifModifiedSince === mtime.toUTCString()) {
    res.statusCode = 304
    res.end()
    return
  }
  // 文件发生了修改或者第一次请求资源
  res.setHeader('last-modified', mtime.toUTCString())
  res.setHeader('Cache-Control', 'no-cache')
  res.end(data)
}

第一次请求

image-20220217182839968.png

再次请求

image-20220217182904817.png

last-modified 的不足

  • 文件内容即使没有改变只是改变名字或其他不会改变内容的操作,文件的修改时间也会发生改变。
  • last-modified的单位是秒,所以如果文件在1s内被修改完成,客户端还是会继续走缓存。

基于ETag的协商缓存

为了弥补 last-modified 基于时间戳判断文件是否被修改的不足,http1.1新增了 ETag (Entity Tag)字段。

它的主要功能是服务器为不同的资源进行哈希运算所生成的一个字符串,该字符串类似文件指纹,只要文件内容编码存在差异,对应的 ETag 标签值就会不同,因此可以使用 ETag 对文件资源进行更精准的变化感知。

ETag 的功能类似 last-modified,只不过是把记录文件修改时间改为了记录文件内容通过hash运算生成的字符串了。

在第一次加载资源的时候服务器端会给到客服端 ETag,再次请求资源的时候客户端会在请求头中增加 if-none-match 字段,这个值就是上一次服务器端给到的 ETag 值,服务器会拿这个值与最新的ETag对比,如果一样说明文件是最新的直接返回304状态码,反之返回最新的文件以及最新的 ETag

上代码

if (pathname === '/images/01.jpeg') {
      const data = fs.readFileSync('./images/01.jpeg')

      // 通过 etag库 生成etag字符串
      const etagContent = etag(data)
      // 上一次服务器端给到的etag字符串
      const ifNoneMatch = req.headers['if-none-match']
      // 通过对比当前文件的etag 和 客户端上一次获取到的 etag来判断文件是否被修改
      if (ifNoneMatch === etagContent) {
          res.statusCode = 304
          res.end()
          return
      }
      res.setHeader('etag', etagContent)
      res.setHeader('Cache-Control', 'no-cache')

      res.end(data)
    }

第一次请求

image-20220218111612373.png

再次请求

image-20220218111639942.png

ETag的不足

ETag 本质上并不是新的协商缓存的方案,他只是 last-modified 的一个补充方案,因此他依旧存在一些不足。

  • 服务器端在比对ETag的时候需要付出额外的计算开销(比对ETag时需要通过hash计算出ETag再对比),如果资源的尺寸比较大,数量较多,且修改频繁,那么生成 ETag 的过程会对服务器造成性能影响。

  • 另一方面 ETag 字段值的生成分为强验证和弱验证性

    • 强验证会文集资源内容生成,能够保证每个字节都相同
    • 弱验证则根据资源的部分属性值来生成,生成速度快但无法保证每个字节都相同

    并且,在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率,所以恰当的方式是根据具体的资源使用场景去选择恰当的缓存校验方式。

缓存策略树

image-20220124164746830.png

缓存策略

在实际开发中,我们既希望缓存能在客户端保存的尽可能久,有希望它能在资源发生修改时进行及时更新。这是两个互斥的优化,因此我们对不同的资源类型要进行不同的缓存策略。

HTML:为保持内容发生改变时及时更新建议采用协商缓存,既设置 cache-controlno-cache

IMAGE:因为图片都修改都是更换修改,所有采用强制缓存且过期时间不宜过长,既设置 cache-controlmax-age: 86400

CSS/JS: 在现代的前端工程化中 css/js 文件名都会根据内容生成hash值来命名,也就是当内容更新后文件的url也会发生变化,因此建议使用强制缓存其过期时间可适当延长到一年,既设置 cache-controlmax-age: 31536000