用疑问的方式,从HTTP发展谈到HTTP缓存

285 阅读17分钟

缓存

http发展史

我们先从http的发展史来谈谈浏览器缓存的诞生。(PS.本文只是简单梳理,详细内容参考http的发展

  • htttp 0.9 1991年互联网刚开始普及,网速带宽低,当时候网页的内容只是文字内容。 (PS.只是文字,那怎么行啊,我还要发表情包(doge))

  • http1.0 1996年发布,新增了很多内容,请求中增加了Post命令和HEAD命令,同时,支持发送任意格式的内容,这样就可以传输文字,图像,视频,二进制文件。 (PS.这时候就有了TCP请求,但是还有问题,也就是后面谈到的,每次请求的内容都是相同的,这怎么处理)

  • http1.1 1997年一月发布,进一步完善了HTTP协议。

    • 优化了缓存处理。在HTTP 1.0 中主要使用 header 里的If-Modified-Since,Expires来做为缓存判断的标准,HTTP 1.1则引入了更多的缓存控制策略例如 Entity tagIf-Unmodified-Since, If-Match,If-None-Match等更多可供选择的缓存头来控制缓存策略。

    • 带宽优化和网络连接的使用。针对网络开销大的问题,HTTP 1.1 在请求头引入了range头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。

    • 错误通知的管理。在HTTP1.1中新增了24个错误状态响应码。

    • 长链接。HTTP/1.1 加入 Connection:keep-alive可以复用一部分连接,在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟。

    • PS:http1.1后面也就暴露出了一些局限性:

      • 虽然加入了keep-alive可以复用一部分连接,但是域名分片的情况下,仍然需要建立多个连接,也会耗费资源,给服务器带来压力。
      • 协议开销大。HTTP/1 在使用时,header 里携带的内容过大,在一定程度上增加了传输的成本,并且每次请求 header 基本不怎么变化,尤其在移动端增加用户流量。
  • HTTP2.0 后来,谷歌为了解决传输数据慢的问题。自研了SPDY协议。目的是最小化网络延迟,提高网络速度,解决了网络效率低的问题。SPDY协议是基于TCP协议之上,与HTTP/1相比,HTTP/2采用二进制格式传输数据,解析起来更高效,同时支持对Header压缩,减少头部包体积的大小。2009年谷歌公开了SPDY协议。2012年发布了HTTP2.0.

    • 加入了多路复用的技术。解决了浏览器限制同一个域名下的请求数量的问题,同时也能实现全速传输。
  • HTTP3.0针对TCP的局限性,基于UDP协议研发了一种名为QUIC(全称是“快速UDP互联网连接”)的实验性网络协议,并且使用运用在 Chrome 浏览器上。 (ps:如果想体验的同学,打开Chrome浏览器,在浏览器的地址栏输入chrome://flags/,在Experiment中找到Exprimental QUIC protocol属性值改为Enable。)

而从前端的发展中看,一开始都是静态资源html,后面慢慢发展,静态资源分为了javascript css html,同时打开网页都需要请求网站。

打开方式比较简单,但是稍加分析就会发现,每次请求的内容都是相同的,那么对于带宽,首屏性能都有影响。

这就是HTTP1.1解决的痛点。

这时候就要谈到HTTP的浏览器缓存

而HTTP缓存又分为了协商缓存(也称为弱缓存)和强缓存(也称为本地缓存)

协商缓存简单理解

是向服务器发送请求,服务器会根据这个请求的Request Header的一些参数来判断是否命中协商缓存,如果命中了,则会返回304状态码,并且带着Responce Header通知浏览器从缓存中读取资源。

PS.同时在HTTP1.0中,也有协商缓存的身影,Last-Modified(Response Header)与 If-Modified-Since(Request Header)

简而言之,协商缓存帮我们做了一件什么事情呢?

判断浏览器缓存中是否有这部分存储,如果有则直接从浏览器中取,否则请求获得。

强缓存简单理解

向服务器发送请求,服务器根据Request Header的参数来判断是否命中强缓存,如果命中则直接从缓存中取,状态码为200 OK。如果没命中呢?

抛出疑问:

  • 这个命中是怎么判断命中的?这两个这样理解不是都是一样的吗?
  • 没命中还要请求吗?
  • 那么协商缓存和强缓存的优先级怎么体现?谁又优于谁呢?
  • 而且需要缓存我们能理解,那么为什么需要强缓存和协商缓存呢?
  • 如果只有一个强缓存或者只有一个协商缓存可以吗?

解答:

先解答最后两个问题,实际上,协商缓存可以理解为强缓存的补充。这是两种运行机制,其实也可以统称为缓存。

如果只有强缓存,那么意味着没有命中强缓存就直接请求数据了,强缓存中只是记录了过期的时间,和最大过期时间,有些时候过期了,但是实际上缓存依然存在,并且页面没有改变,这就造成了带宽浪费。 如果只有协商缓存,协商缓存则还是有一次和服务器的通信,这个强缓存就有优势,具体可以看下面。

强缓存的理解:

先来看浏览器第一次请求的流程图:

image-20211019173912106.png 再来看下浏览器第二次请求的流程图:

image-20211018144752983.png

这下就明白了命中是如何判断的,没有命中的处理流程, 优先级是强缓存优于协商缓存,那么又有疑问了:

  • 为什么强缓存优先于协商缓存?
  • 凭什么说你画的图就是正确的?

好!我们访问百度次,因为首次会先请求后存到缓存,第二次我截图:

image-20211018184022242.png

这个图中既有协商缓存的标志,也有强缓存的标志,从状态码中我们也能看出这个是强缓存读取的。所以优先级为强缓存优先于协商缓存。

Expires
  • 定义: 缓存过期的时间(相对服务器时间和浏览器的时间),如果超过了这个时间点就代表资源过期
  • 缺点:服务器和客户端的时间可以任意修改
  • HTTP/1.0的标准
Cache-Control
  • 可以用于请求/响应(request/response)由多个字段组合而成,大小写不区分,建议写小写的Cache-control,这里只举了部分属性,全部属性

    • max-age 指定一个时间长度,在这个时间段内缓存是有效的,单位是s(相对请求的时间)
    • max-stale 表明客户端愿意接收一个已经过期的资源,可以设置一个可选的秒数,表示响应不能已经过时,超过给定时间。
    • no-store 禁止缓存,每次请求都要向服务器重新获取数据
    • no-cache : max-age = 0仍然缓存,但是走协商缓存
    • public 表明响应可以被任何对象(发送请求的客户端、代理服务器等等)Public 设置我们可以将 Http 响应数据存储到本地,但此时并不意味着后续浏览器会直接从缓存中读取数据并使用- 无法确定本地缓存的数据是否可用(可能已经失效)需要配合使用
    • private 表明响应只能被单个用户(可能是操作系统用户、浏览器用户)缓存,是非共享的,不能被代理服务器缓存
  • ps.缓存数据标记为已过期只是告诉客户端不能再直接从本地读取缓存了,还可能被再次用到(协商缓存)

    • 在HTTP1.0中还有一个通用首部Pragma,是用于HTTP/1.0中的规定通用首部,Pragma: no-cache等同于Cache-Control: no-cache,当时Cache-Control还没有出来
  • 注意: 如果既有expries又有Cache-control的max-age,Cache-control会覆盖expires,这个在RFC2616中提到了。

If a response includes both an Expires header and a max-age directive, the max-age directive overrides the Expires header, even if the Expires header is more restrictive.

  • cache-control的流程图可以理解为这样

htXr84PI8YR0lhgLPiqZ.png 中文翻译后:

image-20211018171204634.png

协商缓存的理解:

Last-Modified
  • Last-Modified(Response Header)与 If-Modified-Since(Request Header)是一对报文头

  • HTTP1.0的标准

  • If-Modified-Since 是一个请求首部字段,并且只能用在GET或者HEAD请求中。Last-Modified是一个响应首部字段,包含服务器认定的资源作出修改的日期及时间。当带着 If-Modified-Since 头访问服务器请求资源时,服务器会检查 Last-Modified,如果 Last-Modified 的时间小于等于 If-Modified-Since 则会返回一个不带主体的 304 响应,否则将重新返回资源。

image-20211018181027547.png

PS.在 Chromedevtools 中勾选Disable cache选项后,发送的请求会去掉 If-Modified-Since 这个 Header

小结:

那么这就又有疑问了。

既然HTTP1.0中已经有了协商缓存Last-Modified,为什么还要有Etag?

Etag
  • EtagIf-None-Match是一对报文头

  • HTTP1.1的标准

  • ETag是一个响应首部字段,根据实体内容生成一段hash字符串,标识资源的状态,由服务器生成,If-None-Match是一个条件式的请求头部。

    • 如果请求资源时在请求首部加上这个字段,值为之前服务器端返回的资源上的ETag,则当且仅当服务器上没有任何资源的 ETag属性值与这个首部中列出的相同的时候,服务器才会返回带有所请求资源实体的 200 响应,否则服务器会返回不带实体的 304 响应。

Etag能解决什么问题呢?也就是为什么还要有Etag?

  • Last-Modified标注的最后修改只能精确到秒级,如果某些文件在 1 秒钟以内,被修改多次的话,它将不能准确标注文件的新鲜度;
  • 某些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),但Last-Modified却改变了,导致文件没法使用缓存;
  • 有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形。
  • 但另一方面:Etag的值的计算时服务器计算且计算复杂的,会消耗性能,结果视情况而定。
  • 优先级:ETag优先级比Last-Modified高,同时存在时会以ETag为准。

其实还有一个问题:

不知道大伙在浏览器中调试中有没有注意到一个问题,在调试的过程中我们能在状态码后看到200 from memory cache200 from disk cache,而这些都是什么呢?

这就是我们接下来要谈到的:

缓存位置(策略)

浏览器可以在内存、硬盘中开辟一个空间用于保存请求资源副本。我们经常调试时在 DevTools Network 里看到Memory Cache(內存缓存)和Disk Cache(硬盘缓存),指的就是缓存所在的位置。请求一个资源时,会按照优先级(Service Worker ->Memory Cache -> Disk Cache -> Push Cache)依次查找缓存,如果命中则使用缓存,否则发起请求。

Memory Cache

顾名思义,这个就是内存缓存,那么什么样的资源会存储在内存中,在操作系统中常理:那自然是先读内存,再读硬盘,而且在浏览器打开的过程中,也会占用内存资源。

浏览器将缓存资源存到内存中,但是不会将所有的缓存都存在内存,一方面是因为内存的大小相对小。另一方面是因为浏览器占用的内存不能无限扩大,所以只能是“短期存储”。一般情况下,浏览器的标签页(Tag页)关闭后,内存中的缓存就释放了。一般存储一些相对小的资源。

特点

  • 快速读取:内存缓存会将编译解析后的文件,直接存入该进程的内存中,占据该进程一定的内存资源,以方便下次运行使用时的快速读取。
  • 时效性:一旦该进程关闭,则该进程的内存则会清空。
  • 从memory cache中获取内容时,浏览器会忽略头部信息,例如max-age=0这种信息。 因此可能会出现,页面上存在几个相同 src 的图片,即便它们可能被设置为不缓存,但依然会从 memory cache 中读取。如果想要确实不进入缓存,短期也不行,需要使用no-store,这样memory cache 也不会存储,也就不会从中读取了。

ps.还有两个比较重要的点preloaderpreload.

  • preloader在浏览器打开网页的过程中,会先请求HTML然后解析。之后如果浏览器发现了js,css等需要解析和执行的资源时,它会使用 CPU 资源对它们进行解析和执行。在古老的年代(大约 2007 年以前),“请求 js/css - 解析执行 - 请求下一个 js/css - 解析执行下一个 js/css” 这样的“串行”操作模式在每次打开页面之前进行着。很明显在解析执行的时候,网络请求是空闲的,这就有了发挥的空间:我们能不能一边解析执行 js/css,一边去请求下一个(或下一批)资源呢?详情可以参阅这篇文章。 这就是 preloader 要做的事情。不过 preloader 没有一个官方标准,所以每个浏览器的处理都略有区别。例如有些浏览器还会下载 css 中的 @import 内容或者 <video>poster等。 而这些被 preloader 请求过来的资源就会被放入 memory cache 中,供之后的解析执行操作使用。
  • preload实际上这个大家应该更加熟悉一些,例如 <link rel="preload">。这些显式指定的预加载资源,也会被放入memory cache中。

memory cache 机制保证了一个页面中如果有两个相同的请求 (例如两个 src 相同的 <img>,两个 href 相同的 <link>) 都实际只会被请求最多一次,避免浪费。

Disk Cache

而这个就是存储在硬盘中的缓存,也叫HTTP cache,虽然读取速度慢,但能容纳的很多,它是持久存储的,实际存在于文件系统。而且它允许相同的资源在跨会话,甚至跨站点的情况下使用,例如两个站点都使用了同一张图片。

disk cache会严格根据 HTTP 头信息中的各类字段来判定哪些资源可以缓存,哪些资源不可以缓存;哪些资源是仍然可用的,哪些资源是过时需要重新请求的。当命中缓存之后,浏览器会从硬盘中读取资源,虽然比起从内存中读取慢了一些,但比起网络请求还是快了不少的。绝大部分的缓存都来自 disk cache

读取缓存需要对硬盘存储的文件进行I/O操作,然后重新解析该缓存内容,读取也相对复杂,速度比内存缓存慢。

特点:

  • 容量大
  • 速度相对慢
  • 根据http头信息各类字段(强缓存或者协商缓存)
  • 同时,disk cache这种持久化存储都会遇到容量增长的问题,每个浏览器的处理逻辑都是把最老的或者最可能过期的资源删除,但是处理的算法可能不尽相同。
尝试验证

我们可以这样尝试验证一下

首次打开掘金,按F12打开开发者工具。

image-20211019142849577.png

我们可以看到都是新的请求,然后我们关闭tag,再次打开掘金。

image-20211019143100802.png

可以看到这时,很多请求都从disk cache取出,因为关闭了tag,所以没有从memory cache中取,但是 disk cache 是持久的,于是所有资源来自 disk cache。然后我们F5刷新再试试。

image-20211019143210561.png

我们这样就可以看到很多是从memory cache中取出,因为我们没有关闭 Tag,所以浏览器把缓存的应用加到了 memory cache。而有些还是从disk cache中取出,这时候,我们就能得出结论了。

在浏览器中,浏览器会在js和图片等文件解析执行后直接存入内存缓存中,那么当刷新页面时只需直接从内存缓存中读取(from memory cache);而css文件则会存入硬盘文件中,所以每次渲染页面都需要从硬盘读取缓存(from disk cache)。

PS.后续查了相关的文档

Chrome employs two caches — an on-disk cache and a very fast in-memory cache. The lifetime of an in-memory cache is attached to the lifetime of a render process, which roughly corresponds to a tab. Requests that are answered from the in-memory cache are invisible to the web request API. If a request handler changes its behavior (for example, the behavior according to which requests are blocked), a simple page refresh might not respect this changed behavior. To make sure the behavior change goes through, call handlerBehaviorChanged() to flush the in-memory cache. But don't do it often; flushing the cache is a very expensive operation. You don't need to call handlerBehaviorChanged() after registering or unregistering an event listener.

意思大致是说浏览器的内存缓存与浏览器的渲染进程相绑定,大多数情况都是指代浏览器的Tag页。

也许有的同学会好奇,那这个硬盘缓存存在哪里呢?答案是:

image-20211019171249846.png

一般存在谷歌目录下的User Data的Default中,点击Cache文件夹就能看到很多的缓存文件了

浏览器刷新方式的影响

浏览器中常见两种刷新场景:

  • 当 F5 刷新网页时,跳过强缓存,但是会检查协商缓存;
  • 当 Ctrl + F5 强制刷新页面时,直接从服务器加载,跳过强缓存和协商缓存
整体流程:

2021-10-19_163116.png

小结:

  • 四种缓存的优先级:cache-control > expires > etag > last-modified
  • cache-control:设置过期的时间长度(秒),在这个时间范围内,浏览器请求都会直接读缓存
  • expires:在 http 头中设置一个过期时间,在这个过期时间之前,浏览器的请求都不会发出,而是自动从缓存中读取文件,除非缓存被清空,或者强制刷新
  • etag / if-none-match:服务器端返回资源时,如果头部带上了 etag,那么资源下次请求时就会把值加入到请求头 if-none-match
  • last-modified / if-modified-since:服务器端返回资源时,如果头部带上了 last-modified,那么资源下次请求时就会把值加入到请求头 if-modified-since

补:还有一个缓存策略

Service Worker

之前谈到的缓存位置(策略)都是浏览器的内部判断执行的,只能通过设置响应头的字段,告诉浏览器,而Service Worker则是能够操作的缓存,我们可以从 Chrome 的 F12 中,Application ->Cache Storage找到这个单独的“小金库”。而这个缓存是永久性的,关闭Tag或者浏览器,这个缓存依然存在,有两种情况会导致这个缓存中的资源被清除:手动调用 API cache.delete(resource) 或者容量超过限制,被浏览器全部清空。

如果Service Worker没能命中缓存,一般情况会使用 fetch() 方法继续获取资源。这时候,浏览器就去 memory cache 或者 disk cache 进行下一次找缓存的工作了。注意:经过 Service Worker 的 fetch() 方法获取的资源,即便它并没有命中 Service Worker 缓存,甚至实际走了网络请求,也会标注为 from ServiceWorker。详情使用可以查看链接

所以优先级是:(由上到下寻找,找到即返回;找不到则继续)

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. 网络请求

参考文档:

developer.mozilla.org/zh-CN/docs/…

developer.mozilla.org/zh-CN/docs/…

datatracker.ietf.org/doc/html/rf…

web.dev/http-cache/

datatracker.ietf.org/doc/html/rf…

datatracker.ietf.org/doc/html/rf…

zhuanlan.zhihu.com/p/44789005

calendar.perfplanet.com/2013/big-ba…

developer.mozilla.org/zh-CN/docs/…

www.chromium.org/developers/…