写点什么

HTTP 缓存协议实战

  • 2022 年 2 月 16 日
  • 本文字数:8797 字

    阅读完需:约 29 分钟

一、什么是缓存


缓存,又称作 Cache,我们把临时存储数据的地方叫做缓存池,缓存池里面放的数据就叫做缓存。当用户需要使用这些数据,首先在缓存中寻找,如果找到了则直接使用。如果找不到,则再去其他数据源中查找。


二、为什么要使用缓存技术


缓存的本质就是用空间换时间,以临时存储的数据暂时代替数据源中读取最新的数据,这种方式带来的好处在不同的场景下是不一样的。


举个例子:


当我们需要喝水时,我们会拿出一个水杯,去水龙头接一杯水来喝。大家可以思考一下,为什么用杯子来喝水,而不是直接用嘴巴在水龙头接水喝。


用杯子喝水确实存在一些既有的问题,比如杯子里面的水容易变凉,而水龙头流出的水确是恒温的。我们可以想象一下,公司里的同事们排队在水龙头下面喝水的场面,确实有点滑稽,我们宁愿接受杯子里的水会变凉这个既有问题。


用杯子喝水有以下几个优势:


  • 用杯子喝水解决了总是要去找水龙头的问题,因为杯子可以一次接更多的水。

  • 用杯子喝水更不容易洒出来,不容易浪费水。

  • 用杯子喝水比趴在水龙头下喝水更优雅。


我们把杯子看成一个缓存池,杯中的水看成缓存,我们接受了杯中水会变凉的问题,相当于牺牲了数据的实时性。把这些优势换一个方式来描述,于是使用缓存的优势变成了下面几个:


  • 降低了系统压力;

  • 节省了资源消耗;

  • 优化用户体验。


三、HTTP 缓存的作用


网络的其中一个特点就是不稳定性,很多用户受到网速慢的困扰。


服务器在大量用户访问的场景下实时计算数据也很容易产生瓶颈,导致服务变慢。从缓存技术具备的优势来看,很适合解决网络服务不稳定的问题。


四、HTTP 缓存协议


协议是沟通过程中双方都遵守并且使用的一种规则。举个栗子,客户端和服务器两位大兄弟在新款机型问题上进行了几次沟通?


客户端:大哥,新款 nex 发布没?

服务器:老弟,还没发,你记住,别老来问我!


一周后......


客户端:大哥,我又来了,最新情况如何?

服务器:跟上次一样。


一个月后.....


客户端:大哥,这都一个月了,怎么样了啊?!

服务器:已经开售啦!


在这个例子里面,客户端与服务端沟通过程中就遵循某种规则,我们来看一下。


  • 数据部分:机型的内容;

  • 协议部分:1)别老来问我,2)最新情况如何,3)跟上次一样。


服务端说的这些话,客户端都能看懂并且明白这些话中所蕴含的意义,这就是客户端与服务端之间达成的某种通讯协议。


4.1 HTTP 消息头


在介绍 HTTP 缓存协议之前,我们先来了解一下 HTTP 消息头的基础知识。我们对 HTTP/HTTPS 的数据请求都比较熟悉,在 HTTP 的数据请求中有一种信息叫做“头部信息”。


头部信息是在客户端请求或者服务端响应是传递给对方的一种信息。我们来看一下 HTTP 协议的组成部分。


HTTP 请求的组成

状态行、请求头、消息主体三部分组成。


HTTP 响应的组成

状态行、响应头、响应正文。


其中,请求头和响应头就是我们这里说的“头部信息”或者又叫“消息头”。那么头部信息有什么作用呢?


4.2 请求头



如图所示:



4.3 响应头



如图所示:



我们今天要讲的缓存协议——Cache-Control, 也是放在消息头中进行控制的。


4.4 缓存协议


在第一节中,我们介绍了使用缓存技术的三个优势,在网络数据交换的过程中,使用缓存技术同样有这三个优势。


1)降低系统压力


使用 HTTP 缓存技术,可以有效的降低服务端的压力,服务端不需要实时计算数据并返回数据。


2)节省资源消耗


使用 HTTP 缓存技术,可以有效的避免大量的重复数据传输,降低流量消耗。


3)优化用户体验


使用 HTTP 缓存技术,本地缓存可以以较快的速度加载,减少用户等待时间。


在讲 HTTP 协议如何实现缓存之前,我们先来讲一下缓存类型。HTTP 缓存一般被分为两类,私有缓存和共享缓存。


4.4.1 私有缓存


缓存被存储在设备本地或者独立的账户体系下,仅供当前用户使用,他可以用来降低服务器压力,提高用户体验,甚至实现离线浏览。



4.4.2 共享缓存


共享缓存是在代理服务器或者其他中间服务器中进行二次缓存的数据,一般这里我们常见的是 CDN,这种缓存可以被多个用户访问,用来减少流量和延迟。



对于一次网络数据交互,本地缓存和共享缓存可以同时存在,HTTP 协议中规定了如何进行控制这些缓存的使用和更新。在 HTTP 中,控制缓存有两种字段:一个是 Pragma;另一个是 cache-control。


Pragma 是一个在 HTTP/1.0 中定义的字段,从 mozilla 官网文档上查询,Pragma 支持现有的几乎所有浏览器。


但是作为旧时代的产物,cache-control 正在逐步的替代它。cache-control 是从 HTTP/1.1 开始引入的协议。有些前端开发者会选择在 cache-control 的基础上增加 Pragma 来向下兼容,事实上 android 的 webview 即支持 Pragma 又支持 cache-control。


而当 Pragma 和 cache-control 同时出现时,Pragma 的优先级大于 cache-control 当然,这不是今天的重点,有兴趣的同学可以自行查阅相关资料。


下面我们就具体的来讲一下 cache-control 缓存协议的具体定义。HTTP 协议规定,服务端通过响应头中的 cache-control 将缓存方式通知给客户端,同时客户端也可以通过请求头中的 cache-control 来将自己的缓存需求通知给服务器。


4.4.3 响应头中的 cache-control


响应头中的 cache-control 一般有如下取值:

  • Cache-control: public

  • Cache-control: private

  • Cache-control: no-cache

  • Cache-control: no-store

  • Cache-control: no-transform

  • Cache-control: must-revalidate

  • Cache-control: proxy-revalidate

  • Cache-Control: max-age=

  • Cache-control: s-maxage=


4.4.4 请求头中的 cache-control


请求头中的 cache-control 一般有如下取值:

  • Cache-Control: max-age=

  • Cache-Control: max-stale[=]

  • Cache-Control: min-fresh=

  • Cache-control: no-cache

  • Cache-control: no-store

  • Cache-control: no-transform

  • Cache-control: only-if-cached


mozilla 开发者网站将这些取值分为如下几个类别进行描述。


4.4.5 可缓存性控制


public

表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容。(例如:1.该响应没有 max-age 指令或 Expires 消息头;2. 该响应对应的请求方法是 POST 。)


private

表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容,比如:对应用户的本地浏览器。


no-cache

在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证(协商缓存验证)。


no-store

缓存不应存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存。


4.4.6 缓存有效性控制


max-age=

设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与 Expires 相反,时间是相对于请求的时间。


s-maxage=

覆盖 max-age 或者 Expires 头,但是仅适用于共享缓存(比如各个代理),私有缓存会忽略它。


max-stale[=]

表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。


min-fresh=

表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。


stale-while-revalidate=

 表明客户端愿意接受陈旧的响应,同时在后台异步检查新的响应。秒值指示客户愿意接受陈旧响应的时间长度。


stale-if-error= 

表示如果新的检查失败,则客户愿意接受陈旧的响应。秒数值表示客户在初始到期后愿意接受陈旧响应的时间。


4.4.7 重新验证和重新加载


must-revalidate

一旦资源过期(比如已经超过 max-age),在成功向原始服务器验证之前,缓存不能用该资源响应后续请求。


proxy-revalidate

与 must-revalidate 作用相同,但它仅适用于共享缓存(例如代理),并被私有缓存忽略。


4.4.8 其他控制


no-transform

不得对资源进行转换或转变。Content-Encoding、Content-Range、Content-Type 等 HTTP 头不能由代理修改。例如,非透明代理或者如 Google's Light Mode 可能对图像格式进行转换,以便节省缓存空间或者减少缓慢链路上的流量。no-transform 指令不允许这样做。


only-if-cached

表明客户端只接受已缓存的响应,并且不要向原始服务器检查是否有更新的拷贝。


从这些描述以及分类中可以看出来,可缓存性控制+缓存有效性控制+其他控制 ,这几个控制维度是不冲突的,可以共同实现缓存的实现方式限定。


事实上 cache-control 确实是可以同时接受多个取值的,多个不同的指令可以搭配使用来对缓存进行控制。如果使用了相矛盾的多个指令取值,那么指令就会按照优先级进行缓存控制。


比如 no-store 和 max-age 这两种在行为上矛盾的指令取值放在一起下发,那么终端就只会按照 no-store 来进行缓存。


4.4.9 协议工作实战分析


专业的运维人员,一定很了解这些描述所表达的意思。然而作为客户端或者前端的我们,光是看这些专业术语,可能很难理解不同配置取值下实际的缓存效果。


因此为了搞明白取值对实际缓存效果的影响。我使用两台电脑,分别搭建了一个静态资源服务器(源服务器),一个代理服务器,通过模拟线上服务器的场景,来对常见的几种缓存控制模式进行验证。nginx 的安装比较简单,此处不在赘述。


静态资源服务器(源服务器)


windows+nginx,配置如下:



代理服务器


windows+nginx,配置如下:



服务器搭建完成后,我们逐个改变 cache-control 的取值,来模拟几种常见的缓存控制模式,来帮助大家理解这些取值,加深印象。在日常的使用过程中,cache-control 更多的是被放在响应头中来控制浏览的缓存行为,因此我们先来验证一下 cache-control 放在响应头中的情况。


场景:静态资源服务器(源服务器)的响应头中没有添加任何 cache-control 标识。没有添加标识,其实对应的就是 public 标识。


public 通常可以看成默认值,如果我们不在响应中添加任何有关 Cache-control 的 header,那么这次响应默认的处理逻辑就类似 Cache-control: public。


(这里使用"通常","类似"这种不确定的字眼,需要解释一下,如果服务器返回了 302 或者 307 这种重定向响应时,添加 Cache-control: public 会让浏览器把重定向响应也缓存起来,但是如果不添加 Cache-control,则不会缓存,也存在不同网络框架或者浏览器做不同处理的可能性)。


public 的意思是浏览器或者代理服务器都可以对静态资源服务器(源服务器)返回的资源进行缓存。使用浏览器直接访问静态资源服务器(不经过代理服务器)。


第一次访问



第一次访问,服务器返回了 200 状态并将静态 html 传回给客户端。同时,服务器还带上了 ETag 和 Last-Modified 两个字段,我们先继续往下看。此时客户端做了几件事情:


  • 缓存了静态资源的内容;

  • 记录了该内容的 ETag 和 Last-Modified。


点击浏览器刷新按钮



点击浏览器的刷新按钮后,客户端浏览器带上了第一次请求时返回的 ETag 和 Last-Modified 再次请求了服务器。服务端通过这两个参数认为客户端已经缓存了资源,服务器不需要再次返回资源了。于是服务器返回了 304。


那如果有代理服务器掺和进来又是一个什么样的场景呢?还记得我们之前配置的那台代理服务器吗,我们将代理服务的代理缓存时间设定在了 10 秒。


第一次访问



点击浏览器刷新按钮



点击浏览器的刷新按钮时,客户端浏览器带上了第一次请求时返回的 ETag 和 Last-Modified 再次请求了服务器。服务端通过这两个参数认为客户端已经缓存了资源,服务器不需要再次返回资源了,于是服务器返回了 304。


注意这次刷新时,ngiux-cache-status 的状态时 HIT 标识这次命中了代理服务器的缓存,这次的客户端缓存有效性判断是由代理服务器完成的。


10 秒后的第三次刷新



前面说了 代理服务器的缓存有效期,我们配置成了 10 秒。第三次刷新时服务器依然返回了 304,资源不需要更新。


但是这次刷新时,ngiux-cache-status 的状态是 EXPIRED,这标识代理服务器的缓存已经失效了,不能用来做有效性判断,  这个时候,代理服务器就会将这次的请求透传给静态资源服务器(源服务器),通过静态资源服务器(源服务器)完成的缓存的有效性判断。


在这个过程中,代理服务器又会对自己的缓存进行更新,于是有了下面第四次。


第四次刷新



逻辑图如下;



通过这四次请求,我们能够清晰的了解了整个的逻辑,代理服务器在某些情况下直接代替了静态资源服务器(源服务器)。因为 public 指令告诉代理服务器,可以缓存数据,于是代理服务器按照配置将数据缓存了 10 秒,超过 10 秒后就会重新将请求转发给静态资源服务器(源服务器),同时重新进行缓存。


这时候有的同学会问了,代理服务器有缓存的时间限制,在没有达到时间限制之前是不会重新请求静态资源服务器(源服务器)的,这时候就降低了静态资源服务器(源服务器)的压力。那为什么在上面的例子里面,浏览器一直在请求代理服务器呢?


这里要跟大家说明一下,在上述的案例中,我们其实一直在点击浏览器的刷新按钮,刷新按钮的意思就是让客户端浏览器重新请求服务器来验证缓存内容的有效性。


大家仔细看下所有截图中的 Request-Header 是不是都有一个 max-age = 0 ,这个指令就是浏览器在刷新请求时,告诉服务器——我本地的缓存可能到期了,你要帮我验证一下。如果你尝试将网址复制到浏览器的新窗口然后点击回车打开 url,而不是点击刷新按钮,这个时候就会像下图这样。



浏览器不会访问网络,注意看 Status Code 那里括号里面的备注,Status Code:  200 OK (from disk cache)   表示这次的响应数据,其实是从磁盘缓存里面拿的。


在 android 系统的 WebView 中,正常情况下是没有提供刷新按钮的(除非开发者自己写一个)那么这种场景下 webview 就不会请求网络,每次都从磁盘缓存中拿数据,对应在抓包时,就看不到网络请求。


了解了整个逻辑之后,我们再来看 mozilla 提供的描述,再结合上述的逻辑,是不是就已经有了初步的概念了。


4.4.10 在响应头中的可缓存性控制


public

表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容。(例如:1.该响应没有 max-age 指令或 Expires 消息头;2. 该响应对应的请求方法是 POST 。)这个其实就是我们刚刚验证的场景。


private

表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容,比如:对应用户的本地浏览器。


如果使用 private,代表着这个资源,可以被私有用户缓存,缓存不会被共享,实际测试,当标注为 private 时,浏览器可以进行缓存,但是代理服务器不会缓存这个资源。有些材料里面提到,private 是可以指定缓存的 user_id 的,这种属于比较复杂的配置了,有兴趣的同学可以研究下。


no-cache

强制要求缓存把请求提交给原始服务器进行验证(协商缓存验证)。


这是一个服务端经常使用的指令,也是一个比较容易与 no-store 混淆的指令,许多前端和客户端的同学都认为当服务端的响应中标注了 no-cache,那么客户端就不会进行缓存,每次都会请求服务器获取新的内容。其实只说对了一半。


在这种场景下,浏览器确实会每次都请求服务器,但是并不意味着浏览器不缓存资源,mozilla 的官方解释是“把请求提交给原始服务器进行验证”如果缓存没有问题,那么服务器就会返回 304,让浏览器继续使用自己本地的缓存”。


no-store

不应存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存。


这个指令就是完全不使用本地缓存,在这种模式下,客户端不会记录任何缓存,包括 Etag 等,每次都会重新发起请求,并且得到 200 响应和对应的数据。如果前端希望自己的网页完全不被缓存,那么可以试下这个指令。


以上指令解决了客户端以及代理服务器能不能缓存的问题,有的同学就会有疑问了,如果让客户端进行本地缓存,那么正常情况下如果不去手动刷新,客户端是不会请求服务器的,前端发新版后,客户端如何选择合适的时机请求服务器呢?


这个时候就要用到缓存有效性控制。浏览器和服务器之间的缓存校验是相互的 ,也就是说服务器可以告知浏览器 这个缓存你能用多久,能保留多久。


先来看下服务器是如何通知客户端缓存可以用多久的。缓存有效性控制指令一般会与可缓存性指令共同下发给客户端。



我们在 server 的 header 中增加 max-age 属性,同时,为了避免代理服务器提前将代理缓存置为无效,我们将代理服务器的缓存有效时间设置到 100 秒,超过静态资源服务器(源服务器)设置的 max-age = 20。


第一次请求



我们使用刷新功能刷新浏览器,在 20 秒内我们持续得到 HIT 的状态,说明命中了代理服务器的缓存。20 秒之后 代理服务器返回 EXPIRED 说明代理服务器响应了静态资源服务器(源服务器)的指示,让本地代理失效了,而代理服务器设置的 100 秒本地缓存时间,这个时候被忽略了。


这次我们依然使用了浏览器的刷新功能来强制浏览器去服务器校验缓存的有效性,也就是说其实在上面的测试中,浏览器每次都是自己忽略 max-age,去访问服务器的。


结论:新增的 max-age,控制了代理服务器保留的缓存时长,本地代理会忽略配置中的缓存时长直接使用静态资源服务器(源服务器)下发的 max-age 作为缓存时长。


下面为了测试浏览器如何使用本地缓存,我们用 android 上的 webview 来进行实验,因为 webview 是没有刷新按钮的(除非开发者自己造一个)。


第一次打开;



打开后在后面我们每隔两秒再打开一次;



可以看到 20 秒内,webview 都没有重复请求服务器下载站点的 index.html,在上面的截图中,每显示一个 favicon.ico 就是我打开一次站点链接,因为我没有在源服务器中配置 favicon.ico,所以每次打开,webview 都在找服务器下载这个资源。


超过 20 秒后,webview 发起了请求,此次服务器返回了 304,要求客户端继续使用缓存进行展示,这次 max-age 指令体现出来了。而 webview 在这次校验之后,会将本地的缓存再延长 20 秒的有效期,在下一个 20 秒后,webview 才会再次发起新的缓存验证请求。


总结:客户端 webview 会在 public 指令下缓存 index.html,然后在 max-age 要求限制的时间内,都不会发起任何网络请求来校验资源。


在官网商城的一个案例中,网站上线后,运维没有配置任何 cache-control 协议,在默认 public 的模式下,客户端 webview 一直使用本地缓存,开发人员发现前端发版后,客户端无法及时更新页面。于是在每一个打开的网址后面手动拼接了一个时间戳,来强制改变网址,让浏览器的缓存失效,其实只要使用 nocache 或者 max-age 作为 cache-control 协议就可以解决该问题。


除了 max-age,cache-control 在可缓存性控制指令的基础上还可以增加如下几个控制;


no-transform

源服务端告诉客户端,客户端在缓存数据的时候不可以对文件进行改变,比如压缩,格式修改等...


must-revalidate

源服务端告知客户端,一旦资源过期,在向静态资源服务器(源服务器)发起验证之前,该资源不得使用。


proxy-revalidate

与 must-revalidate 作用相同,仅仅适用于共享缓存(例如代理)。


max-age=

静态资源服务器(源服务器)告知客户端,X 秒内,客户端都不需要对缓存进行校验,可以直接使用。


s-maxage=

静态资源服务器(源服务器)告知代理服务器,代理服务器可以在 X 秒内使用该缓存,并且不需要进行校验,直接可以使用,但是客户端会忽略这个指令。


问题又来了,在验证的过程中,服务器是怎么判断浏览器的缓存是否有效的呢?


客户端浏览器在有机会访问服务器的时候就会告诉服务器,我的本地缓存是什么时候的数据(Last-Modified),数据内容是什么(ETag),这样服务端就能根据这两个值来判断客户端的缓存是否是有效的。


我们来模拟一次前端的发版操作,将 index.html 的内容进行修改;然后使用 android webview 进行请求。



这一次服务器毫不吝啬的返回了 200 和数据。大家仔细观察请求头和响应头;


  • 请求头中的 if-None-Match 其实就是保持的上次服务器返回的 ETag;

  • 请求头中的 if-Modified-Match 其实就是保持的上次服务器返回的 Last-Modified;


现在这两个值跟服务端的都对应不上了,所以服务器返回了最新的数据和 200 状态码,并且带上了最新的 Etag,Last-Modified。而客户端下一次请求时,就会带上最新的 Etag 和 Last-Modified。


在某些情况下,服务器返回的校验字段会不完整,比如缺失了 Etag 和 Last-Modified 中某一个,那么这种情况下的缓存校验就会存在风险。


在 PC 官网的一个案例中,源站点服务器返回了静态资源的 Etag 和 Last-Modified,但是代理服务器,也就是 CDN 厂商在返回时将 Etag 给清除了,导致缺少了 Etag 校验。在正常情况下,服务器只使用文件的最后一次修改时间来做缓存校验也没啥问题。但是有这么一个用户,他的浏览器内缓存的静态资源损坏了,浏览器每次读取出来的资源无法使用,也就无法正常渲染页面,但是在每次与服务器校验资源的时候,服务器依然会告知客户端 304(缓存可用)。这种场景下,只要源站点服务器不进行资源更新,也就是不变动这个 Last-Modified,那么用户将永远打不开这个文件。


讲完了这些,差不多整个缓存协议的下行及交互部分大家已经略知一二了。剩下的就是缓存协议的上行部分了,所谓上行部分就是将 cache-control 写在浏览器访问的请求头上面。


前面我们也提过,浏览器的刷新请求,其实就是在请求头里面加了一个 cache-control :max-age = 0 。这其实是告知服务器,客户端希望接收一个存在时间不大于 0 秒的缓存,一般的源服务器,特别是静态资源服务器,这个时候就会根据客户端的缓存情况返回 200 或者 304。


4.4.11 在请求头中的可缓存性控制


no-cache

告知代理服务器,不直接使用缓存,要求向源服务器发起请求。


no-store

所有的文件都不缓存到本地或者临时文件夹中。


max-age

告知服务器客户端希望接收一个存在时间不大于 X 秒的资源。


max-statle

告知服务器客户端愿意接受一个超过缓存时间的资源,时间为 X 秒。


min-fresh

告知服务器客户端希望接收一个在小于 X 秒内被更新过得资源。


no-transform

告知代理服务器,不允许代理服务器对资源进行压缩,转化,比如有些代理服务器会对图片进行压缩,格式转换。


only-if-cached

告知代理服务器如果代理服务器有缓存内容,就直接给,不用再找源服务器要。


请求头中的缓存控制因为用的比较少,我就不过多的去解读了,有兴趣的同学可以去研究下。


五、总结


HTTP 的 cache-control 协议规定了客户端,代理服务器,源服务器三者之间的缓存交互逻辑。做为客户端开发,经常出现一些与 cache 相关的问题在排查时无从下手,通过学习了解这部分内容,可以帮助快速的分析定位这部分问题。


前端同学熟悉 cache-control 的逻辑后,也可以根据业务的形态跟运维讨论自己缓存需求,有效的降低服务器的压力和用户的流量,提高网页打开速度。


作者:vivo 互联网客户端团队-Chen Long

发布于: 刚刚阅读数: 2
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
HTTP缓存协议实战