17 _ 客户端缓存是如何帮助服务器分担流量的? 你好,我是周志明。这节课,我们继续来讨论透明多级分流系统中,最靠近用户一侧的分流部件:浏览器的客户端缓存。

当万维网刚刚出现的时候,浏览器的缓存机制差不多就已经存在了。在HTTP协议设计之初,人们便确定了服务端与客户端之间“无状态”(Stateless)的交互原则,即要求客户端的每次请求是独立的,每次请求无法感知、也不能依赖另一个请求的存在,这既简化了HTTP服务器的设计,也为它的水平扩展能力留下了广阔的空间。

但无状态并不是只有好的一面。因为客户端的每次请求都是独立的,服务端不会保存之前请求的状态和资源,所以也不可避免地导致它会携带重复的数据,造成网络性能的降低。

那么,HTTP协议针对这个问题的解决方案,就是客户端缓存。从HTTP/1.0到1.1、再到2.0版本的演进中,逐步形成了现在被称为“状态缓存”、“强制缓存”(或简称为“强缓存”)和“协商缓存”这三种HTTP缓存机制。

这其中的状态缓存,是指不经过服务器,客户端直接根据缓存信息来判断目标网站的状态。以前只有301/Moved Permanently(永久重定向)这一种;后来在RFC6797中增加了HSTS(HTTP Strict Transport Security)机制,用来避免依赖301/302跳转HTTPS时,可能产生的降级中间人劫持问题(在第28、29讲中,我还会展开讲解这个问题),这也属于另一种状态缓存。

因为状态缓存涉及的内容只有这么一点,所以后面,我们就只聚焦在强制缓存与协商缓存这两种机制的探讨上。

下面我们就先来看看强制缓存的实现机制吧。

实现强制缓存机制的两类Headers

就像“强制缓存”这个名字一样,它对一致性问题的处理策略十分直接:假设在某个时间点内,比如服务器收到响应后的10分钟内,资源的内容和状态一定不会被改变,因此客户端可以不需要经过任何请求,在该时间点到来之前一直持有和使用该资源的本地缓存副本。

根据约定,在浏览器的地址输入、页面链接跳转、新开窗口、前进和后退中,强制缓存都可以生效,但在用户主动刷新页面时应当自动失效。

在HTTP协议中,设置了两类可以实现强制缓存的Headers(标头):Expires和Cache-Control。

第一类:Expires

Expires是HTTP/1.0协议中开始提供的Header,后面跟随了一个截止时间参数。当服务器返回某个资源时,如果带有该Header的话,就意味着服务器承诺在截止时间之前,资源不会发生变动,浏览器可直接缓存该数据,不再重新发请求。我们直接来看一个Expires头的示例程序: HTTP/1.1 200 OK Expires: Wed, 8 Apr 2020 07:28:00 GMT

那么,你能看到,Expires设计得非常直观易懂,但它考虑得其实并不周全。我给你简单举几个例子。

  • 受限于客户端的本地时间

比如,在收到响应后,客户端修改了本地时间,将时间点前后调整了几分钟,这就可能会造成缓存提前失效或超期持有。

  • 无法处理涉及到用户身份的私有资源

比如,合理的做法是,某些资源被登录用户缓存在了自己的浏览器上。但如果被代理服务器或者内容分发网络(CDN)缓存起来,就可能会被其他未认证的用户获取。

  • 无法描述“不缓存”的语义

比如,一般浏览器为了提高性能,往往会自动在当次会话中缓存某些MINE类型的资源,这会造成设计者不希望缓存的资源无法被及时更新。而在HTTP/1.0的设计中,Expires并没有考虑这方面的需求,导致无法强制浏览器不允许缓存某个资源。

所以,以前为了实现这类功能,我们通常不得不使用脚本,或者手工在资源后面增加时间戳(如“xx.js?t=1586359920”“xx.jpg?t=1586359350”)来保证每次资源都会重新获取。

不过,关于“不缓存”的语义,在HTTP/1.0中其实是预留了“Pragma: no-cache”来表达的,但在HTTP/1.0中,并没有确切地描述Pragma参数的具体行为,随后它就被HTTP/1.1中出现过的Cache-Control给替代了。

现在,尽管主流的浏览器也通常都会支持Pragma,但它的行为仍然是不确定的,实际上并没有什么使用价值。而Cache-Control的出现,进一步压缩了Pragma的生存空间。所以接下来,我们就一起来看看,它是如何支持强制缓存机制的实现的。

第二类:Cache-Control

Cache-Control是HTTP/1.1协议中定义的强制缓存Header,它的语义比起Expires来说就丰富了很多。而如果Cache-Control和Expires同时存在,并且语义存在冲突(比如Expires与max-age / s-maxage冲突)的话,IETF规定必须以Cache-Control为准。

同样这里,我们也看看Cache-Control的使用示例: HTTP/1.1 200 OK Cache-Control: max-age=600

那么,你能看到,这里的示例中使用的参数是max-age。实际上,在客户端的请求Header或服务器的响应Header中,Cache-Control都可以存在,它定义了一系列的参数,并且允许自行扩展(即不在标准RFC协议中,由浏览器自行支持的参数)。Cache-Control标准的参数主要包括6种,下面我就带你一一了解下。

  • max-age和s-maxage

在前面的示例中,你会发现max-age后面跟随了一个数字,它是以秒为单位的,表明相对于请求时间(在Date Header中会注明请求时间)多少秒以内,缓存是有效的,资源不需要重新从服务器中获取。这个相对时间,就避免了Expires中,采用的绝对时间可能受客户端时钟影响的问题。

另一个类似的参数是s-maxage,其中的“s”是“Share”的缩写,意味着“共享缓存”的有效时间,即允许被CDN、代理等持有的缓存有效时间,这个参数主要是用来提示CDN这类服务器如何对缓存进行失效。

  • public和private

这一类参数表明了是否涉及到用户身份的私有资源。如果是public,就意味着资源可以被代理、CDN等缓存;如果是private,就意味着只能由用户的客户端进行私有缓存。

  • no-cache和no-store

no-cache表明该资源不应该被缓存,哪怕是同一个会话中对同一个URL地址的请求,也必须从服务端获取,从而令强制缓存完全失效(但此时的协商缓存机制依然是生效的);no-store不强制会话中是否重复获取相同的URL资源,但它禁止浏览器、CDN等以任何形式保存该资源。

  • no-transform

no-transform禁止资源以任何形式被修改。比如,某些CDN、透明代理支持自动GZip压缩图片或文本,以提升网络性能,而no-transform就禁止了这样的行为,它要求Content-Encoding、Content-Range、Content-Type均不允许进行任何形式的修改。

  • min-fresh和only-if-cached

这两个参数是仅用于客户端的请求Header。min-fresh后续跟随了一个以秒为单位的数字,用于建议服务器能返回一个不少于该时间的缓存资源(即包含max-age且不少于min-fresh的数字);only-if-cached表示服务器希望客户端不要发送请求,只使用缓存来进行响应,若缓存不能命中,就直接返回503/Service Unavailable错误。

  • must-revalidate和proxy-revalidate

must-revalidate表示在资源过期后,一定要从服务器中进行获取,即超过了max-age的时间后,就等同于no-cache的行为;proxy-revalidate用于提示代理、CDN等设备资源过期后的缓存行为,除对象不同外,语义与must-revalidate完全一致。

好了,现在你应该就已经理解了强制缓存的实现机制了。但是,强制缓存是基于时效性的,无论是人还是服务器,在大多数情况下,其实都没有什么把握去承诺某项资源多久不会发生变化。

所以,接下来我就要给你介绍另一种基于变化检测的缓存机制,也就是协商缓存。它在处理一致性问题上,比强制缓存会有更好的表现。不过它需要一次变化检测的交互开销,在性能上就会略差一些。

协商缓存的两种变动检查机制

那么,在开始了解协商缓存的实现机制之前,你要先注意一个地方,就是在HTTP中,协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的。

比如说,当强制缓存存在时,客户端可以直接从强制缓存中返回资源,无需进行变动检查;而当强制缓存超过时效,或者被禁止(no-cache / must-revalidate),协商缓存也仍然可以正常工作。

协商缓存有两种变动检查机制,一种是根据资源的修改时间进行检查,另一种是根据资源唯一标识是否发生变化来进行检查。它们都是靠一组成对出现的请求、响应Header来实现的。

根据资源的修改时间进行检查

我们先来看看根据资源的修改时间进行检查的协商缓存机制。它的语义中包含了两种标准参数:Last-Modified和If-Modified-Since。

Last-Modified是服务器的响应Header,用来告诉客户端这个资源的最后修改时间。

而对于带有这个Header的资源,当客户端需要再次请求时,会通过If-Modified-Since,把之前收到的资源最后修改时间发送回服务端。

如果此时,服务端发现资源在该时间后没有被修改过,就只要返回一个304/Not Modified的响应即可,无需附带消息体,从而达到了节省流量的目的: HTTP/1.1 304 Not Modified Cache-Control: public, max-age=600 Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT

而如果此时,服务端发现资源在该时间之后有变动,就会返回200/OK的完整响应,在消息体中包含最新的资源。

HTTP/1.1 200 OK Cache-Control: public, max-age=600 Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT Content

根据资源唯一标识是否发生变化来进行检查

好,我们再来看看“根据资源唯一标识是否发生变化来进行检查”的协商缓存机制。它的语义中也包含了两种标准参数:Etag和If-None-Match。

Etag是服务器的响应Header,用于告诉客户端这个资源的唯一标识。HTTP服务器可以根据自己的意愿,来选择如何生成这个标识,比如Apache服务器的Etag值,就默认是对文件的索引节点(INode)、大小和最后修改时间进行哈希计算后而得到的。

然后,对于带有这个Header的资源,当客户端需要再次请求时,就会通过If-None-Match,把之前收到的资源唯一标识发送回服务端。

如果此时,服务端计算后发现资源的唯一标识与上传回来的一致,就说明资源没有被修改过,同样也只需要返回一个304/Not Modified的响应即可,无需附带消息体,达到节省流量的目的: HTTP/1.1 304 Not Modified Cache-Control: public, max-age=600 ETag: “28c3f612-ceb0-4ddc-ae35-791ca840c5fa”

而如果此时,服务端发现资源的唯一标识有变动,也一样会返回200/OK的完整响应,在消息体中包含最新的资源。

HTTP/1.1 200 OK Cache-Control: public, max-age=600 ETag: “28c3f612-ceb0-4ddc-ae35-791ca840c5fa” Content

另外,我还想强调的是,Etag是HTTP中一致性最强的缓存机制。

为什么会这么说呢?我直接给你举个例子。

前面我提到的Last-Modified参数,它标注的最后修改只能精确到秒级,而如果某些文件在一秒钟以内被修改多次的话,它就不能准确标注文件的修改时间了;又或者,如果某些文件会被定期生成,可能内容上并没有任何变化,但Last-Modified却改变了,导致文件无法有效使用缓存。而这些情况,Last-Modified都有可能产生资源一致性的问题,只能使用Etag解决。

但是,Etag又是HTTP中性能最差的缓存机制。这个“最差”体现在每次请求时,服务端都必须对资源进行哈希计算,这比起简单获取一下修改时间,开销要大了很多。

所以,Etag和Last-Modified是允许一起使用的,服务器会优先验证Etag,在Etag一致的情况下,再去对比Last-Modified,这是为了防止有一些HTTP服务器没有把文件修改日期纳入哈希范围内。

HTTP的内容协商机制

那到这里为止,HTTP的协商缓存机制,就已经能很好地处理通过URL获取单个资源的场景了。不过你可能要问了:为什么要强调“单个资源”呢?

我们知道,在HTTP协议的设计中,一个URL地址是有可能提供多份不同版本的资源的,比如说,一段文字的不同语言版本,一个文件的不同编码格式版本,一份数据的不同压缩方式版本,等等。因此针对请求的缓存机制,也必须能够提供对应的支持。

所以,针对这种情况,HTTP协议设计了以Accept/(Accept、Accept-Language、Accept-Charset、Accept-Encoding)开头的一套请求Header,以及对应的以Content-/(Content-Language、Content-Type、Content-Encoding)开头的响应Header。这些Headers被称为HTTP的内容协商机制。

那么,与之对应的,对于一个URL能够获取多个资源的场景中,缓存同样也需要有明确的标识来获知,它要根据什么内容来对同一个URL返回给用户正确的资源。这个就是Vary Header的作用,Vary后面应该跟随一组其他Header的名字,比如说: HTTP/1.1 200 OK Vary: Accept, User-Agent

这里你要知道,这个响应的含义是应该根据MINE类型和浏览器类型来缓存资源,另外服务端在获取资源时,也需要根据请求Header中对应的字段,来筛选出适合的资源版本。

根据约定,协商缓存不仅可以在浏览器的地址输入、页面链接跳转、新开窗口、前进、后退中生效,而且在用户主动刷新页面(F5)时也同样是生效的。只有用户强制刷新(Ctrl+F5)或者明确禁用缓存(比如在DevTools中设定)时才会失效,此时客户端向服务端发出的请求会自动带有“Cache-Control: no-cache”。

小结

HTTP协议以“无状态”作为基本的交互原则,那么由此而来的资源重复访问问题,就需要通过网络链路中的缓存来解决了。现在你也已经知道,客户端缓存具体包括了“状态缓存”、“强制缓存”和“协商缓存”三类。

这节课,我们详细分析了强制缓存和协商缓存的工作原理。利用好客户端的缓存,能够节省大量网络流量,这是为后端系统分流,以实现更高并发的第一步。

一课一思

除了在HTTP协议中规定的缓存机制,你还使用过其他客户端的缓存方式吗?比如说,浏览器WebAPI中的Cache Storage和Application Cache,或者你是否通过LocalStorage、SessionStorage、IndexedDB、WebSQL等等去缓存信息?

欢迎在留言区分享你的做法和见解。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/17%20_%20%e5%ae%a2%e6%88%b7%e7%ab%af%e7%bc%93%e5%ad%98%e6%98%af%e5%a6%82%e4%bd%95%e5%b8%ae%e5%8a%a9%e6%9c%8d%e5%8a%a1%e5%99%a8%e5%88%86%e6%8b%85%e6%b5%81%e9%87%8f%e7%9a%84%ef%bc%9f.md