HTTP的缓存为什么这么设计?

作为前端开发,缓存是整天接触的概念,面试必问、工作中也频繁接触到,可能大家对缓存的 header 记的比较熟了,可是大家有没有思考过为什么 HTTP 的缓存控制要这么设计呢?.

首先,为什么要有缓存?

网页中的代码和资源都是从服务器下载的,如果服务器和用户的浏览器离得比较远,那下载过程会比较耗时,网页打开也就比较慢。下次再访问这个网页的时候,又要重新再下载一次,如果资源没有啥变动的话,那这样的重新下载就很没必要。所以,HTTP 设计了缓存的功能,可以把下载的资源保存起来,再打开网页的时候直接读缓存,速度自然会快很多。

而且,每个请求都要服务端做相应的处理,比如解析 url,读取文件,返回响应等,而服务器能同时处理的请求是有上限的,也就是负载是有上限的,所以如果能通过缓存减少没必要的资源的请求,就能解放服务器,让它去处理一些更有意义的请求。

综上,为了提高网页打开速度,降低服务器的负担,HTTP 设计了缓存的功能。

那 HTTP 是怎么设计的缓存功能呢?

如果让大家设计 HTTP 的缓存功能,大家会怎么设计呢?

最容易想到的就是指定一个时间点了,到这个时间之前都直接用缓存,过期之后才去下载新的。

HTTP 1.0 的时候也是这么设计的,也就是 Expires 的 header,它可以指定资源过期时间,到这个时间之前不去请求服务器,直接拿上次下载好被缓存起来的内容,

Expires: Wed, 21 Oct 2021 07:28:00 GMT

但是这种设计有个 bug,不知道大家能猜出来不。

首先这个时间是指 GMT 时间,也就是会转化为格林尼治那个时区的时间,不存在时区问题。

服务端会把当地的时间转化为 GMT 时间,比如当前是某个时间点 xxx,想缓存的时间为 yyy,那 Expires 就设置为 xxx + yyy 的时间。

如果浏览器的时间是准确的,那转化为 GMT 时间后应该也是 xxx,所以缓存的时间就是 yyy。

这是理想中的情况。

但万一浏览器的时间不准呢?转化为 GMT 时间之后就不是 xxx,那具体缓存的时间也就不是 yyy 了,这就是问题。

所以,这个过期时间不能让服务端来算,应该让浏览器自己算。

这也是为什么在 HTTP 1.1 里面改为了 max-age 的方式:

Cache-Control: max-age=600

上面就代表资源缓存 600 秒,也就是 10 分钟。

让浏览器来自己算啥时候过期,也就没有 Expires 的问题了。(这也是为什么同时存在 max-age 和 Expires 会用 max-age 的原因)

当然,不同的资源会有不同的 max-age,比如打开 b 站首页你会看到不同资源的 max-age 是不同的:

HTTP的缓存为什么这么设计?

 

比如一些库的 js 文件就设置了 31536000,也就是 1 年后过期,因为一般也不会变,以年为单位没啥问题。

 

而业务的 js 文件设置了 600,也就是 10 分钟过期,业务代码经常会变动嘛。

细心的同学可能会发现之前都是 key: value 形式的 header,现在咋变成了 key: k1=v1,k2=v2 的形式了呢?

没错,这也是 HTTP 1.1 做的设计,他们想把缓存相关的 header 都集中到一起,所以就包了一层,都放在 Cache-Control 的 header 里。

所以名字上也就不一样了,Expires: xxx 这种叫做消息头(header),而 Cache-Control: max-age=xxx 里面的 max-age 叫做指令(directive)。

好了,改成 max-age 之后,浏览器就会在本地计算出的过期时间就去下载新的资源了。

但是这样就行了么?

只是到了过期时间,但是资源并不一定有变化呀,那再下载一次同样的内容还是很没必要。

所以要和服务端确认下是否内容真的变了,变了的话就重新下载,否则的话就不用再下载了,有这样一个协商的过程。

所以 HTTP 1.1 又设计了协商缓存的 header。

我们说到资源过期了,浏览器要和服务端确认下是否有更新,怎么判断资源过期呢?

比较容易想到可以通过文件内容的 hash,也可以通过最后修改时间,这俩分别叫 Etag 和 Last-Modified:

HTTP的缓存为什么这么设计?

 

服务端返回资源的时候就会带上这俩 header。

 

那在 max-age 时间到了的时候,就可以带上 etag 和 last-modified 就请求服务器,问下是否资源有更新。

带上 etag 的 header 叫做 If-None-Match:

If-None-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"

带上 last-modified 时间的叫做 If-Modified-Since:

If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT

服务端判断下如果资源有变化,那就返回 200,并在响应体带上新的内容,浏览器就用这份新下载的资源。

如果没有变化,那就返回 304,响应体是空的,浏览器会直接读缓存。

这样多了一个协商的阶段,那在本地缓存过期但是服务端改资源没有变化的时候就能避免重复的下载。

那如果文件确定不会变,不需要协商的话,怎么告诉浏览器呢?可以用 immutable 的 directive 来告诉浏览器,这个资源就是不变的,不用协商了。这样就算缓存过期了也不会发验证的 header(If-None-Match 和 If-Modified-Since):

Cache-control: immutable

我们前面讲了 HTTP 1.1 改成了 Cache-control: k1=v1,k2=v2 的形式,那除了 max-age 还有啥其他的 directive 呢?

前面我们讲的都是浏览器的缓存控制,但请求从浏览器到服务器的过程中,中间可能经过很多层代理。

代理服务器的缓存怎么控制?

浏览器里的缓存都是用户自己的,叫做私有缓存,而代理服务器上的缓存大家都可以访问,叫做公有缓存。

如果这个资源只想浏览器里缓存,不想代理服务器上缓存,那就设置 private,否则设置 public:

比如这样设置就是资源可以在代理服务器缓存,缓存时间一年(代理服务器的 max-age 用 s-maxage 设置),浏览器里缓存时间 10 分钟:

Cache-control:public, max-age=600,s-maxage:31536000

这样设置就是只有浏览器可以缓存:

Cache-control: private, max-age=31536000

而且,缓存过期了就完全不能用了么?

不是的,其实也想用过期的资源也是可以的,有这样的指令:

Cache-control: max-stale=600

stale 是不新鲜的意思。请求里带上 max-stale 设置 600s,也就是说过期 10 分钟的话还是可以用的,但是再长就不行了。

Cache-control: stale-while-revalidate=600

也可以设置 stale-while-revalidate,也就是说在和浏览器协商还没结束的时候,就先用着过期的缓存吧。

Cache-control: stale-if-error=600

或者设置 stale-if-error,也就是说协商失败了的话,也先用着过期的缓存吧。

所以说,max-age 的过期时间也不是完全强制的,是可以允许过期后用一段时间的。

那如果我想强制在缓存还没协商完的时候不用过期的缓存怎么办呢?

用这个指令 must-revalidate:

Cache-Control: max-age=31536000, must-revalidate

名字上就可以看出来,就是缓存失效了的话,必须等验证结束,中间不能用过期的缓存。

可能有的同学会有疑问,缓存不都是自己设置的么,咋还一个允许过期,一个禁止过期呢?

自己会同时用这两种和自己玩么?

自己肯定不会,但是 CDN 厂商可能会呀,想禁止这种用过期缓存的行为,就可以设置这个 must-revalidate 指令。

最后,HTTP 当然也支持禁止缓存,也就是这样:

Cache-control: no-store

设置了 no-store 的指令就不会缓存文件了,也就没有过期时间和之后的协商过程。

如果允许缓存,但是需要每次都协商下的话就用 no-cache:

Cache-control: no-cache

可能有的同学对 no-cache 和 must-revalidate 的区别比较迷糊,我们理一下:

no-cache 相当于禁掉了强缓存,每次都要协商下,而 must-revalidate 只是在强缓存过期之后,禁止掉了用过期的缓存的过程,强制必须协商。

至此,http 的缓存设置我们就讲完了,来总结一下:

总结

缓存能加快也面的打开速度,也能减轻服务器压力,所以 HTTP 设计了缓存机制。

HTTP 1.0 的时候是使用 Expires 的 header 来控制的,指定一个 GMT 的过期时间,但是当浏览器时间不准的时候就有问题了。

HTTP 1.1 的时候改为了 max-age 的方式来设置过期时间,让浏览器自己计算。并且把所有的缓存相关的控制都放到了 Cache-control 的 header 里,像 max-age 等叫做指令。

缓存过期后,HTTP 1.1 还设计了个协商阶段,会分别通过 If-None-Match 和 If-Modified-Since 的 header 带资源的 Etag 和 Last-Modied 到服务端问下是否过期了,过期了的话就返回 200 带上新的内容,否则返回 304,让浏览器拿缓存。

除了 max-age 的指令外,我们还学了这些指令:

  • public:允许代理服务器缓存资源
  • s-maxage:代理服务器的资源过期时间
  • private:不允许代理服务器缓存资源,只有浏览器可以缓存
  • immutable:就算过期了也不用协商,资源就是不变的
  • max-stale:过期了一段时间的话,资源也能用
  • stale-while-revalidate:在验证(协商)期间,返回过期的资源
  • stale-if-error:验证(协商)出错的话,返回过期的资源
  • must-revalidate:不允许过期了还用过期资源,必须等协商结束
  • no-store:禁止缓存和协商
  • no-cache:允许缓存,但每次都要协商

虽然 HTTP 缓存相关的指令还是挺多的,但是都是围绕 max-age 和过期后的协商来设计的,思路理清的话,还是很容易就能记住的。