HTTP缓存
MDN官方文档对于Web开发技术部分,划分了几大块:
- 基础:HTML,CSS和HTTP
- Web脚本:JavaScript,WebAPI,事件和Web Components(新)
- 图形:Canvas,SVG和WebGL
- 其他:多媒体,WebAssembly(新),MathML,XSLT,EXSLT,XPath
从这样的划分中,我们不难发现,HTTP的重要程度可能远超Web脚本,这也是为什么很多前端面试题都是围绕着网络展开的,本文主要讨论老生常谈的HTTP缓存。
HTTP—超文本传输协议
应用层协议(网络基础这里暂时不会具体学习),为Web浏览器与Web服务器设计,所以HTTP是典型的客户端-服务端模型,请求-响应模式。HTTP是典型的无状态协议,双端在不同的请求之间不保留数据(尽管现在已经不完全是)。
MDN官方文档对与HTTP的划分:
- 资源与URIs:标示互联网上的资源,资源定位标示符,MIME等。
- HTTP简介:基础、概览、消息、会话和协议升级机制(101状态码)
- HTTP安全:内容安全策略,公钥,严格传输安全,Cookie安全,XSS,X-Content-Type-Options等。
- HTTP CORS 跨域资源共享(这个也会专门开一章学习)
- HTTP 身份验证(如果你有拒绝访问403等相关问题,可以看看这一章节)
- HTTP 缓存,本文所关注的主要内容,缓存类型和缓存头的含义
- HTTP 数据压缩
- HTTP 条件请求(If-*头)缓存更新会与其相关
- HTTP 内容协商,Accept-*头,300状态码等相关内容
- HTTP Cookies 补足HTTP无状态的问题
- HTTP 请求范围
- HTTP 重定向 或者叫URL转发,3XX状态码相关
HTTP 缓存
请求HTML页面并渲染出来往往需要请求很多资源,简单来看请求脚本资源和样式表资源都会使用大量的HTTP请求,而建立链接的过程有时比实际的传输过程更消耗时间(具体可以参见相关网络内容),HTTP缓存的目的就是为了重用已经获取过的资源来减少不必要的请求,减少网络拥塞的可能。
HTTP 缓存类型
首先我们要明确什么是缓存技术:保存资源副本,下次使用时可直接使用副本的技术。当然这是简单的定义,储存哪些资源的副本,如何存储这些副本等,都需要具体的讨论。使用缓存保留某些资源的副本,对于Web服务器来说可能减少处理请求的压力,根据缓存的位置不同,处理速度也会更快。而缓存的核心问题,一个是讨论缓存的内容,如果所有资源都缓存,空间压力是需要考虑的问题,对于哪些动态数据来说缓存是无效的;还有就是缓存的更新问题,虽然缓存保存的是哪些相对稳定的数据,但是随着时间的推移缓存可能过去,请求条件的变更缓存可能也需要更新,要确保缓存不能缓存过期的数据,否则就会产生逻辑上的错误,而不只是性能问题。
我们先讨论浏览器缓存和代理缓存(网关缓存、CDN、反向代理缓存和负载均衡等偏重于服务端缓存,针对前端的话先不了解这些)。
浏览器缓存从大分类上属于私有缓存,用户通过浏览器下载的各种文档,缓存在浏览器管理的存储空间中。代理缓存属于共享缓存,针对部分网络用户架设的web代理服务器缓存热门资源,这种缓存对于浏览器的用户来说基本上是不可见且无感的,而浏览器缓存用户可以手动清除,可以通过网络监视查看哪些数据走了浏览器缓存。
缓存操作的目标
介绍缓存的功能时,主要是为了减少网络拥塞,提高用户访问速度,但是似乎没有缓存整个过程也应当是正常的,所以缓存的有无都不应当影响访问的正确性和完整性。浏览器在设计缓存时,充分考虑了用户使用web站点的习惯和需求,对于GET请求的文档(URI相同)进行缓存,比如html文档,脚本文件和样式表文件。对于用户的异步请求来说,没有必有进行缓存,大部分都是灵活的数据。
这里说一下URI和URL。URI是统一资源标示符,URL是统一资源定位符,URN是统一资源名称。理论上URI包含URL和URN,URI比URL更抽象,URL用定位的方式实现URI(我们可以说URL和URI完全不是一个东西)。浏览器输入网址访问HTML资源,其实就是提供了一个定位进行访问资源,如果这个资源在特定的环境下,能够用其他方式唯一表示(唯一编号等),且不包含定位信息,那么这个表示就是该资源的URI。不用过于纠结URI和URL具体怎么区分,知道URL是URI的子集,URL往往包含“定位信息”即可。
先来看一些具体的缓存情况:
- 一个GET请求,响应码200,包含HTML文档,图片等,被缓存下来
- 永久重定向:一个请求的响应为301,表示老得URL对应的资源已经换到新的URL下,所以浏览器要缓存这一个变更,下载访问老URL的时候,就会用从新URL拿来的数据使用。新URL是从响应的Location响应头字段获取。
- 错误响应:对于404的页面
- 不完全响应:响应状态码206。206成功状态表示请求成功,主体包含所请求的数据区间,请求首部包含Range时。
- 除了所有GET请求之外的,包含了特定cache头的请求
缓存控制
下面我们来研究一下那些老生常谈的用于控制缓存的请求首部:
Cache-Control头:从HTTP/1.1开始,表达是否支持缓存,请求首部和响应首部都有。
- no-store:禁止进行缓存!表示不得保存任何请求和响应的内容,客户端发起带这样的头部的请求时,都是一个完整的请求。
- no-cache:强制确认缓存!带有该内容的请求,会携带着本地缓存相关的验证字段,向服务器发送请求,服务端则可以根据懈怠的验证字段判断缓存是不是过期,如果没有过期我们就会得到熟悉的304,从而使用浏览器本地的缓存。
- public/private:私有/公共缓存!如果是public则表示可以被任何中间人进行缓存,比如CDN等;private相反表示中间人不能缓存,只能在浏览器私有缓存。
- max-age=<seconds>:缓存过期!直接指明缓存能够持续的最大时间,超过这个时间后,如果没有更新缓存,那么这条缓存将失效。
- must-revalidate:缓存验证确认!使用缓存时必须要验证,验证不过将无法使用。具体后面有介绍。
Pragma头:从HTTP/1.0开始,与Cache-Control:no-cache效果相同,响应头中没有这个字段,这只是一个兼容性的字段,知道是啥就行。
新鲜度
缓存是为了复用那些已经获取过的数据,但是这些数据往往不是永久的,随着时间的推移,这些数据可能已经失去的时效性,今天看的数据,也许明天看没变,后天看没变,但是大后天它会变,比如周期举行的比赛的结果。初次之外,缓存通常使用的空间不大,毕竟大部分数据不会是长久有效的,同时浏览的页面不断增加,占用的空间也越来越大,某天心血来潮使用的网页,也许以后很久都不会访问,还占用空间就很不合理了,所以混存通常会设计周期性的删除策略,方便那些真正经常使用的数据享用资源。
一个很重要的因素,HTTP是客户端-服务器模型,请求-响应模式,服务器更新数据,如果客户端没有请求,是无法知道数据的变化的。所以客户端和服务端之间会制定一个策略,比如约定过期时间,即使没有请求,一段时间后也会自动弃用缓存,释放空间,且下一次请求能够保证最新数据。驱逐算法用于保证缓存中的数据的新鲜度,将陈旧的资源替换成新鲜的资源,但不是直接清除,而是将请求附加If-None-Match请求头,发送给服务器后可以得到关于新鲜度的信息。304 Not Modified响应不带有实体,但是表达了新鲜度检查结果,所以就用缓存副本。
从官方网站提供的这样示意图,基本可以看出缓存的工作过程。一开始缓存中没有数据,所以请求会发送到服务器,正确响应后缓存看到响应头中Cache-Control字段,max-age=100表示最大有效时间100s。10s再次访问相同的文档,发现缓存中有副本,且max-age还没过认为是新鲜的,于是直接返回到,这次访问并没有实际的请求到服务器。而100s后再次访问,缓存中有副本,但是max-age已过,请求会被附带If-None-Match特殊首部字段发送到服务器,服务器返回304表示你的副本还没有过期,那么缓存max-age被刷新,可继续使用。
对于Cache-Control:max-age=N这样的响应头,缓存的寿命就会被设定为N。另外还有一个你见过的字段Expires,这个字段指明的是一个具体的过期时间点,和Date字段进行比较,判断是否过期。再有就是Last-Modified字段,缓存寿命=[Date-(Last-Modified)]*10%。
理论上设置尽可能长的过期时间,能够更多的利用缓存,但对于长期不更新的数据,如果需要进行更新,则会比较麻烦。比如HTML中的脚本和样式表,有的时候会通过HTML中script标签和style标签的src,更改其url(改变queryString之类的)拉取新的资源,但是这样也会造成不必要的缓存浪费。这种方式被称作revving技术。在高频变动的资源文件作入口的前提下,修改低频变动资源的链接,从而达到使用更新后的资源的目的。例如我们老的HTML中使用jQuery.js?v=1.1,新的HTML则使用的新版本jQuery.js?v=1.2,这时理论上缓存中两个脚本文件都有,这样还有一个好处就是如果HTML也被缓存了,新旧可能都有访问,这样连接关系没变,依赖关系得到了保证。
我又无耻的弄了官网的图,很好的解释了资源相关连,HTML变更与依赖资源变更的缓存使用情况,所以这样的缓存机制保留了依赖关系,新旧版本都能正常使用。现在前端项目多用脚手架工具进行打包,很少关注这样的依赖关系的变化产生的影响,因此我们也经常看到用哈希码作文件名维持关系并缓存的。
缓存验证
当用户访问某个网站的地址时,浏览器就开始作缓存验证的工作了。前面提到的Cache-Control字段决定了如何使用缓存,比如遇到must-revalidate,就会强制进行验证。当然浏览器也提供了选项。一般来说浏览器只会检查要访问的资源是不是过期,must-revalidate则要跟服务器进行确认,然后会给用户返回对应的资源。
ETage响应头是对UA不透明的值,意思就是浏览器其实并不认识这个头,也不知道含义是什么。但是这个字段会在验证时将其赋予If-None-Match字段进行发送,这是一个强校验器。
相应的Last-Modified响应头是一种弱校验器,会被带到If-Modified-Since进行缓存验证。
浏览器向服务器发送验证请求,服务器返回200或者304,都表明浏览器可以信任缓存数据,另外304将会指导浏览器刷新缓存的过期时间。
带Vary头的响应
Vary 是一个HTTP响应头部信息,它决定了对于未来的一个请求头,应该用一个缓存的回复(response)还是向源服务器请求一个新的回复。它被服务器用来表明在 content negotiation algorithm(内容协商算法)中选择一个资源代表的时候应该使用哪些头部信息(headers)
怎么说,Vary响应头会影响后续的请求。我又要偷官网的图了:
第一次请求,带有请求头Accept-Encoding(告诉服务器我接受怎样的编码),没有缓存内容,发送到服务端并得到200响应。这个响应包含Content-Encoded(告诉客户端是怎么内容编码的)和Vary:Content-Encoding字段。这个Vary字段就指明了下次请求时,如果服务器要求的Content-Encoding对不上,那么就不认为命中了缓存。于是缓存时用文档的URL+Content-Encoding的值作为缓存键,保存下这次的内容。
第二次请求,Accept-Encoding携带了br,由于上一次请求的Vary字段,缓存中对应的文档要求gzip因此无法命中,请求依旧到达了服务端,并且正常返回了Content-Encoded:br的响应,也带有Vary字段,这时缓存就会扩大命中的范围(注意是更新,而不是新增)。
第三次请求,Accept-Encoding仍然是br,但是上次响应已经扩大了范围,所以这次命中缓存并返回了。
这样做有什么意义呢?Vary可以携带User-Agent,区分移动端浏览器和桌面端浏览器。
HTTP 条件请求
由于之前看到了很多If-*的请求头,这里有必要介绍一下HTTP条件请求。
在 HTTP 协议中有一个“条件式请求”的概念,在这类请求中,请求的结果,甚至请求成功的状态,都会随着验证器与受影响资源的比较结果的变化而变化。这类请求可以用来验证缓存的有效性,省去不必要的控制手段,以及验证文件的完整性,例如在断点续传的场景下或者在上传或者修改服务器端的文件的时候避免更新丢失问题。
验证器这个概念之前应该是提到过了,不仅可以验证缓存有效性,还能验证完整性。这里貌似有出现了另外一个前端常考的题目—断点续传,这个不在本文的学习范畴内。
简单来说,就是请求带有特定的首部,会影响响应结果。也就是说通过请求的首部来给这次的请求-响应一个限定条件,根据这些条件的匹配情况响应会有一些不同。
比如安全方法(不动服务器状态的方法)GET通常用于请求文件,条件请求可以让服务器进行判断,是否应当返回。而类似PUT方法在上传文件时,可以对待上传的文件进行版本匹配之类的判断,满足条件才上传。当然这些逻辑貌似现在都在服务端应用中处理了。
验证器
之前我们提到过ETag强校验器。条件请求首目的都是为了在服务端进行匹配用的,比如请求某项资源时,如何确定缓存中的数据和服务端数据是否没有变化,完全比较的话你还得把缓存内容也发出去,这显然更浪费资源了,于是有了一些校验器字段,比较验证器的值还是靠谱的。
ETag是强验证器,也就是说要进行完全匹配的,一一对应,断点续传时可能通过该字段进行确认。ETag的值通常使用MD5或其他类似算法,计算资源的散列值,就像我们平时用散列值判断文件是否有改动一样。注意:ETag值通常是一个字符串,如果以“w/”开头,表示将不采用强验证。
Last-Modified是弱验证器,因为需要和Date字段进行联合计算,还有10%的容忍度(或者说是精度),所以并不那么精确。
条件首部
- If-Match:该请求首部的值是一个曾经响应首部的ETag值,用于在服务端比较对应资源是否变更。If-Match语意上表示匹配的情况下才返回资源,所以更新缓存谁一般用的是If-None-Match。
- If-None-Match:和If-Match类似,只是在比较为不匹配时返回资源(也就是传输中的返回新鲜资源)。
- If-Modified-Since:该请求首部携带之前响应首部的Last-Modified值,如果服务端资源的Last-Modified字段已经比该请求首部给的值晚(也就是别更新过),则会返回这个资源。
- If-Unmodified-Since:和If-Modified-Since相反。
- If-Range:和If-Match和If-Modified-Since类似,包含一个tag或者时间,如果能匹配则正常返回206,否则的返回200,对应的是完整的资源而不是部分。
条件请求与缓存
缓存的验证需要用的ETage和Last-Modified,用于判断缓存是否过期,其实就是用条件请求进行更新。
首先:没有缓存的情况下,请求正常被处理返回200 OK。这是响应头都会带着Last-Modified和ETag,或者说验证器随着响应回到浏览器中,跟着资源一同被缓存起来,这也是为了后面的缓存验证发起条件请求时可以使用。
有Cache-Control首部控制的缓存有效性,采取特定的动作。可以回去复习一下Cache-Control字段的含义即对应的情况,最常用的应该就是no-cache,也就是强制使用确认缓存的方式,用验证器发起条件请求来确定是否使用缓存。如果缓存没有失效,比如没有超过max-age的情况下,是不会发起请求的。
常见的304 Not Modified也都是条件请求经常返回的,客户端会根据这个响应来刷新缓存的新鲜度。所以请求还是发出去了,但是304响应不带body,所以传输消耗还是来的比较小,且相对固定缓存来说更容易实现更新。
更新:条件请求的本质含义是满足条件的请求可以得到响应,反之则会给一些提示而不是响应。比如GET /doc HTTP/1.1
在没有缓存的情况下,服务器正常返回200,并且携带Last-Modified:date
和Etag:“xyz”
的响应首部,这两个信息和具体资源都被缓存。下次再请求/doc
时,会带上相应的条件头部,Last-Modified:date
对应If-Modified-Since:date
表达的是如果服务器的资源的最新修改时间,在date之后,请给我这个资源;Etag:“xyz”
对应If-None-Match:"xyz"
表达的是如果服务器的资源tag不是xyz了,请给我这个资源。
这时假设服务器资源没有变动,那么这两个条件都不满足,肯定不会返回200,更不会给客户端这个资源;相反的,如果资源变动,这两个条件都满足,会给一个200响应,并提供最新资源,再提供新的Last-Modified:newDate
和Etag:“xyz1”
新的头部用于告知客户端新的资源是怎样的。
注意:以上内容对于web开发来说是透明的,也无法控制的,但是分析网站性能是可能会用到。
总结
面试题中经常遇到缓存问题,你知道哪些与缓存相关的头部信息,304和200,强缓存和协商缓存之类的问题。304 Not Modified可能都知道是因为资源没变所以返回这个码来告诉浏览器可以用缓存,而为什么返回304可能还需要知道条件请求,条件请求与If-*相关,这些头的值可能是Last-Modified字段和ETag字段的值,满足条件就200,不满足条件就304。当前强缓存和协商缓存并没有在官方的文档中出现,但是你看缓存你就会了解期核心字段Cache-Control,不同的值有不同的效果,no-cache最常用,可以认为这就是协商缓存的标志。而max-age=<seconds>直接限定过期时间,认为是强制缓存。相应的,你会看到Expires头,Pragma头,这些理论上都没有Cache-Control头重要,但是他们也能作为缓存的依据。Expries头需要和Date头搭配,而Pragma头是一个兼容性的头,也是一个协商缓存的标志。
附赠:其他的条件请求场景
- 增量下载的完整性:增量下载的是HTTP协议规定的一项功能,允许恢复之前的操作,响应头信息Accept-Ranges:bytes表明下载是可恢复的。浏览器知道了服务器支持,下次请求就可以带上Ranges首部进行端点续传了。比如“Ranges:23783-”表示获取某一字节后的数据。那跟条件请求有什么关系,假设在两次请求之间资源发生了改变,可以利用If-Modified-Since和If-Match首部来判断,用于告知客户端是否需要重新开始下载。不难发现这样其实在继续之前增加了一个请求,所以有了一个If-Range首部。
- 使用乐观锁避免更新丢失问题:针对一些内容管理系统,在进行远程文件更新时,需要用乐观锁来保证版本一直问题。这里用PUT上传文件为例,客户端读取源文件,修改,然后上传。一般来说正常情况下PUT成功会返回204 No Content表示成功更新。但是如果有两人同时针对资源在进行修改,先后提交,在没有保护的状态下会出现覆盖的情况,且被覆盖的人无法得知,而最终使用谁的更新无法确定,这种根据谁先提交谁的修改就会丢失的情况,也称为竞争状态。所以要解决的是在竞争时不要丢失更改。无法通知已经提交成功的人,那就通知更新会被拒绝的人。那么提交的时候增加条件请求,通过乐观锁(即版本号控制并发的一种方式)模式,判断“版本号”。有一个人更新成功,“版本号”或者说ETag更新,下一个人提交时条件请求失败得到412 Precondition Failed响应,就知道自己修改的过程中已经有人提交成功了。当然后面的工作可能就需要自己的逻辑了。
- 资源首次上传问题:和上面的问题类似,两个人几乎同时上传同一个文件,在服务端要创建文件也会产生竞争状态。解决方案也是使用条件请求,If-None-Match首部提供为“*”,表示有这个实体在没有的情况下才能上传,否则也会返回412哟。
注意!If-None-Match首部有兼容问题,HTTP/1.1以后才有,所以使用前请HEAD一下。