HTTP缓存流程

HTTP缓存流程
也可以通过这个图来看:
HTTP缓存流程


从上图的第一次请求可以看出,当服务器返回HTTP响应头给浏览器时,浏览器是通过响应头中的Cache-Control字段来设置是否缓存该资源。通常,我们还需要为这个资源设置一个缓存过期时长,而这个时长是通过Cache-Control中的Max-age参数来设置的,比如上图设置的缓存过期时间是2000秒。

  1. Cache-Control:Max-age=2000

这也就意味着,在该缓存资源还未过期的情况下, 如果再次请求该资源,会直接返回缓存中的资源给浏览器。
但如果缓存过期了,浏览器则会继续发起网络请求。
如果上次服务返回的资源响应头里带的是Etag,

  1. ETag之间的比较使用的是强比较算法,即只有在每一个字节都相同的情况下,才可以认为两个文件是相同的。在 ETag 前面添加
  2. W/ 前缀表示可以采用相对宽松的算法
  3. ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
  4. ETag: W/"0815"
  5. // W 表示使用弱验证器。 弱验证器很容易生成,但不利于比较。 强验证器是比较的理想选择,但很难有效地生成。 相同资源的两个弱Etag值可能语义等同,但不是每个字节都相同

则在HTTP请求头中带上:

  1. If-None-Match:"33a64df551425fcc55e4d42a148795d9f25f89d4"
  2. 如果没有改变,则返回304告知浏览器使用客户端缓存
  3. ETag还可以配合 If-Match条件请求来避免一些资源冲突 (比如同时编辑更新一份资源)

If-Match

如果上次服务返回的资源响应头里带的是Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT , 则在HTTP请求头里带上:

  1. If-Modified-Since: Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
  2. 只可用于GET HEAD请求中。如果如 If-Node-Match 一同出现时,它会被忽略,除非服务器不支持If-Node-Match
  3. // Last-Modify 是个备用机制,它精确到秒级,精度不如ETag。 而且即使是重新编译出的同样内容文件,通过If-Modified-Since 访问,也会返回200并把该资源返回。而不是返回304.


浏览器端发起HTTP请求流程

构建请求

首先浏览器构建请求行信息,构建好之后,浏览器准备发起网络请求:

  1. GET /index.html HTTP 1.1

查找缓存

在真正发起网络请求之前,浏览器会现在浏览器缓存中查询是否有要请求的文件。其中,浏览器缓存是一种在本地保存资源副本以供下次请求时直接使用的技术。
如果浏览器发现了所请求资源的副本,它会拦截请求,返回该资源副本,并直接结束请求,而不去服务器重新下载。这时候可以在Netwrok里看到 200 ok (from memory cache / from disk cache)
这样做的好处是:

  • 缓解服务器压力。
  • 获取资源的时间减少,页面渲染更快,缓存是实现快速资源加载的重要组成部分

如果缓存查找失败,就会进入网络请求过程。

准备IP地址和端口

浏览器使用HTTP协议作为应用层协议,用来封装请求的文本信息。 使用TCP/IP作为传输层协议将文本信息发到网络上。
所以在HTTP工作开始之前,浏览器需要通过TCP和服务器简历连接。也就是说HTTP的内容是通过TCP的传输数据阶段来实现的。

准备IP地址和端口
那么就有一些问题:

  • HTTP网络请求的第一步是做什么呢? 是和服务器建立TCP连接。 (三次握手)
  • 建立连接的信息都有了吗? 建立TCP连接的第一步是需要准备IP地址和端口号。
  • 那么怎么获取IP和端口号呢?

如果我们只有一个URL地址,那就需要请求DNS服务器,获取域名和ip的映射关系,浏览器缓存DNS。而端口号如果url没有特别指明端口号,http默认端口80, https 默认端口443。

等待TCP队列

现在已经把端口和IP地址都准备好了,那么下一步是不是可以建立TCP连接了呢?
答案依然是“不行”。Chrome有个机制,同一个域名同时最多只能建立6个TCP连接,如果在同一个域名下同时有10个请求发生,那么其中4个请求会进入排队等待状态,直至进行中的请求完成。
如果当前请求数量少于6,会直接进入下一步,建立TCP连接。

建立TCP连接

排队等待结束之后,终于可以快乐地和服务器握手了,在HTTP工作开始之前,浏览器通过TCP与服务器建立连接。而TCP的工作方式,阅读这里

发送HTTP请求

一旦建立了TCP连接,浏览器就可以和服务器进行通信了。而HTTP中的数据正是在这个通信过程中传输的。
你可以结合下图来理解,浏览器是如何发送请求信息给服务器的。

发送HTTP请求

首先浏览器会相服务器发送请求行,包括了请求方法,请求URI 和 HTTP版本。
发送请求行,就是告诉服务器 浏览器需要什么资源,常用如GET、POST 等
在浏览器发送请求行命令之后,还要以请求头形式发送其他一些信息,把浏览器的一些基础信息告诉服务器。比如包含了浏览器使用的操作系统、内核信息、当前域名、浏览器端cookie等。

服务器处理HTTP请求流程

HTTP请求信息终于被送达到服务器,接下来,服务器会根据浏览器的请求信息来准备相应的内容。

返回请求

一旦服务器处理结束,便可以返回数据给浏览器了。
比如我们在命令行

  1. curl -i https://x.yupoo.com
  2. // 这里加上了-i是为了返回响应行、响应头和响应体的数据

返回请求

额外小知识: Connection: keep-alive 我们知道HTTP协议采用“请求-应答”模式,当使用普通模式,即非KeepAlive模式时,每个请求/应答客户和服务器都要新建一个连接,完成之后立即断开连接(HTTP协议为无连接的协议);当使用Keep-Alive模式(又称持久连接、连接重用)时,Keep-Alive功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive功能避免了建立或者重新建立连接。 http 1.0中默认是关闭的,需要在http头加入”Connection: Keep-Alive”,才能启用Keep-Alive;http 1.1中默认启用Keep-Alive,如果加入”Connection: close “,才关闭。目前大部分浏览器都是用http1.1协议,也就是说默认都会发起Keep-Alive的连接请求了,所以是否能完成一个完整的Keep-Alive连接就看服务器设置情况。

Q: Keep-Alive模式,客户端如何判断请求所得到的响应数据已经接收完成(或者说如何知道服务器已经发生完了数据)? A: 使用响应头字段: content-length, 表示实体内容长度。 Q: 如果响应头中没有content-length 呢? 什么情况下会没有 content-length 呢? A: 当客户端向服务器请求一个静态页面或者一张图片时,服务器可以很清楚的指导内容大小。然后通过content-length告诉客户端需要接受多少数据。但是如果是动态页面,服务器无法预先知道内容大小。这时可以使用Transfer-Encoding: chunk 模式来传输数据。即如果要一边产生,一边发送给客户端,服务器就需要使用 Transfer-Encoding: chunked 的方式来代替 content-length。 chunk编码将数据分成一块一块的发生。Chunked编码将使用若干个Chunk串连而成,由一个标明长度为0的chunk标示结束。

额外小知识:Vary vary 这个响应头可以帮助缓存服务器(代理服务器如CDN)来判断后续的请求是获取新资源还是使用缓存文件。比如上面这个请求,返回的是 vary: Accept-Encoding, 那么这份资源就会将URL和 Content-Encoding (压缩方式,比如gzip或者br,假如这次浏览器返回的压缩方式是gzip)作为key存入缓存中。一段时间后请求同一份资源,但是请求头中携带者 Accept-Encoding: br 时,那么之前的缓存就无法被匹配,从而请求新的资源。然后这个 URL + br 也会作为key,标记着这份资源并缓存下来。 同样的,vary还可以搭配 Vary: User-Agent ,来通过UA判断是否使用缓存的页面。 Vary字段让代理服务器的缓存命中有更多的决定因子,而不仅仅是依据请求 URL 和请求方法来决定是否命中。 更多的参考这里

首先服务器会返回响应行,包括协议版本和状态码。
但并不是所有的请求都可以呗服务器处理,一些无法处理或者处理出错的信息,服务器会通过请求行的的状态码来告知浏览器。如5xx、4xx等。

随后,正如浏览器会随同请求发送请求头一样,服务器也会随同响应浏览器发送响应头。响应头包含了服务器自身的一些信息。比如服务器生成返回数据的时间、返回的数据类型(JSON、HTML、流媒体等类型),以及服务器要在客户端保存的cookie等信息。
发送完响应头后,服务器就可以继续发送响应体的数据,通常响应体包含了HTML的实际内容。

我所理解的点: 这里要说明的是,前面这里面写了 发送完响应头后,服务器继续发送响应体数据等,并不意味着服务器是发了数次请求。如果返回的以太网数据帧字节没有超过网卡的mtu (一般1500字节长度), 则会一次将状态行,响应头,响应体一次性一起返回。如果较多,则可能会分成多个以太网数据帧。但这仍是底层的传输机制,在http的应用层,应该看起来都是一次性的。浏览器通过content-length 或者 Transfer-Encoding: chunk 可以得知什么时候请求全部结束,既而关闭TCP链接。

断开连接

通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭TCP连接。不过如果浏览器或者服务器在其头信息中加入了

  1. Connect: Keep-Alive

那么TCP连接在发送后仍然保持打开状态,这样浏览器就可以继续通过同一个TCP连接发送请求。保持TCP连接可以省去下次请求时建立连接的时间,提升资源的加载速度。比如一个web页面中内嵌的图片都来自同一个web站点,如果初始化一个持久连接,就可以复用该连接,请求其他资源,而不用建立新的连接。

重定向

到这里请求似乎已经结束了,但是仍有一种情况需要注意,重定向操作。
例如我们

  1. curl -I www.yupoo.com // -I 返回状态行,响应头,不返回响应体
  2. HTTP/1.1 301 Moved Permanently
  3. Date: Mon, 16 Aug 2021 01:37:52 GMT
  4. Content-Type: text/html
  5. Content-Length: 191
  6. Connection: keep-alive
  7. Location: https://x.yupoo.com

可以看到 返回状态码 301, 301就是告诉浏览器,需要重定向到另外一个网址,并且重定向的网址就在响应头的location中,并使用改地址重新导航。

为什么很多站点第二次打开速度会很快?

第二次页面打开很快,主要原因是在第一次加载的过程中,缓存了一些数据。
从上面的分析可以看出,DNS缓存页面资源缓存这两块数据是被浏览器缓存的。其中DNS缓存比较简单,就是在浏览器本地把ip和域名对应起来。
而页面缓存的处理过程就是一开始那个图中所表述的那样。

登录状态是如何保持的?

用户在登录框输入账户密码,经过post提交给服务器,验证无误的话,服务器会生成一段表示用户身份的字符串,并把该字符串写在响应头的set-cookie中。

  1. Set-Cookie: UID=XXXXXXXXX

浏览器收到并响应头,遇到set-cookie,会将UID=XXXXXXX写入本地 (http-only, 浏览器无法修改)。之后的请求则都会带上这个cookie作为身份判断的依据。

当然这只是一种方式。
也可以在登录成功后,服务器返回身份token, 这个token作为之后请求的请求头中的一个属性从而帮助服务器鉴权。

总结

总结
从图中可以发现,浏览器的HTTP请求从开始到结束一共经历了如下九个个阶段:

  1. 构建请求
  2. 查找缓存
  3. 准备IP和端口
  4. 等待TCP队列
  5. TCP建立连接
  6. 发起HTTP请求
  7. 服务器处理请求
  8. 服务器响应请求
  9. 断开TCP链接

额外

http获取资源的的优先级顺序是
Service Worker -> Memory Cache -> Disk Cache -> 网络请求
一旦找到,则不再继续往下。
那么, 什么算 Memory Cache , 什么又属于 DIsk Cache 呢?我们前面讲到的那些http请求的缓存又属于哪种?

memory cache

memory cache 是内存中的缓存,按照操作系统的一般情况,先读内存,再读硬盘。几乎所有的网络请求资源都会被自动加入到memory cache中。但是也正是因为数量很大但是浏览器占用的内用是有限的,所以memory cache 注定是个 短暂存储。一般情况下,浏览器的TAB关闭后,该次浏览器的 memroy cache 就会失效。如果在极端情况下,一个tab页缓存占据了大量内存,可能在这个tab关闭之前,排在前面的缓存已经失效了。
几乎所有的请求资源都能进入memory cache,主要分为两块:

  1. prelaoder 熟悉浏览器页面渲染流程应该了解这个机制。简单说就是浏览器会开启一个额外的线程,解析html里的外链js和css, 并提前下载,以加快页面渲染出页面的机制。这些被preloader请求过来的资源就会被放进 memory cache中,供之后的解析执行操作使用。
  2. preload <link rel="preload">, 显式的指定预加载资源,也会被放进memory cache中。

memory cache 机制保证了再一个页面中如果有两个相同的请求,则只会最多请求一次。

不过在匹配缓存时,除了匹配完全相同的 URL 之外,还会比对他们的类型,CORS 中的域名规则等。因此一个作为脚本 (script) 类型被缓存的资源是不能用在图片 (image) 类型的请求中的,即便他们 src 相等。

在memory cache 获取缓存的时候, 浏览器会忽略max-age=0, no-cache 等头部配置。因为memory cache只是短期使用,大部分情况下生命周期只有一次浏览二期。而max-age=0
在语义上普遍被解读为 不要在下次浏览时使用, 和 memory cache 并不冲突。

如果真不想让一个资源进入缓存,即使短期也不行,就需要使用 **no-store** , 使用这个头部配置的话,即使是 memeory cache也不会存储。

disk cache

我们前面讲到的HTTP请求的缓存,属于 disk cache。平时所说的 强制缓存、协商缓存、以及cache-control 都属于disk cache。
disk cache 又叫 HTTP cache (因为它遵守htt协议头中的字段),即存储在硬盘上的缓存。是持久储存的,存在于文件系统中。而且它允许相同的资源在跨会话,甚至在跨站点的情况下使用,例如两个站点使用了同一张图片。
disk cache 会根据HTTP头信息中的各类字段来判断哪些资源是可以缓存,哪些资源是不可以缓存的。哪些仍然可用,哪些是过期需要重新请求的。当命中缓存时,则直接从硬盘中读取,虽然比从内存中读取慢一些,但是比网络请求还是快不少的。大部分的缓存都来自disk cache.
凡是持久化缓存,都会面临容量增长的问题,disk cache也不例外。在浏览器自动清理时,会采用一些策略将最古老的或最可能过时的资源删除。

service wroker

memory cachedisk cache 都是由浏览器内部判断和进行的。
memory cache犹如黑盒,不受开发者控制,也不受请求头约束。disk cache 也只能通过请求头告诉浏览器需要缓存,而不能自己操作。
而service wroker 的出现,给予了开发者更多缓存资源的主动权,我们可以决定哪些资源缓存,哪些不缓存。而且这个缓存是永久的,即使关闭浏览器,下次进入仍然存在。除非我们手动删除 cache.delete(resouce) 或者容量超过限制而被浏览器清空。
如果 service wroker 没能命中缓存,则一般会调用fetch等方法获取资源,这时候就开始去memory cache 或disk cache 中寻找缓存了。
注意: 经过 Service Worker 的 fetch() 方法获取的资源,即便它并没有命中 Service Worker 缓存,甚至实际走了网络请求,也会标注为 from ServiceWorker, 这个在前面的文章里我已经专门写过了,详见这里

强制缓存

强制缓存的含义是,当客户端请求后,会先访问缓存数据库看缓存是否存在。如果存在则直接返回;不存在则请求真的服务器,响应后再写入缓存。
强制缓存直接减少请求数,是提升最大的缓存策略。
可以造成强制缓存的字段是 Cache-controlExpires

  1. Expires 用于 HTTP 1.0阶段,表示缓存到期时间,是一个绝对时间。

    1. Expires: Thu, 10 Nov 2021 08:00:00 GMT

    但是因为两个缺点而被逐渐放弃:

    1. 由于是绝对时间,用户可以通过修改客户端本地时间来导致浏览器判断缓存失效。也可能因为时差、误差因素导致客户端和服务端时间不一致,从而缓存失效。
    2. 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致非法属性从而设置失效。
  2. cache-control, 用于 HTTP/1.1中,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求。

    1. Cache-control: max-age=2592000 // 相对时间

    cache-control 常用值有:

    1. max-age // 最大有效时间
    2. must-revalidate // 超过了max-age时间,必须想服务器发送请求,验证资源是否有效
    3. no-cache // 字面意思不要缓存,实际上还是要求客户端缓存的,只是是否使用这个缓存由后续的对比来决定
    4. no-store // 真正的不要缓存,所有内容不走缓存
    5. public // 所有内容均可被缓存,包括客户端和代理服务器,如CDN缓存一些资源。
    6. private // 所有内容只有客户端才可以缓存,代理服务器不能缓存。默认值。

    这些值可以混合使用,例如 Cache-control:public, max-age=2592000。当然字段直接也是有优先级的。详见这里

协商缓存

当强制缓存失效(超过规定时间)时,就需要使用对比缓存,由服务器决定缓存内容是否失效。
流程上,浏览器先请求缓存数据库,返回一个缓存标识(之前存进来的)。之后拿这个标识和浏览器通讯。如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存;如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。

协商缓存在请求数上和没有缓存是一样的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此 在响应体体积上的节省是它的优化点。它的优化覆盖了文章开头提到过的请求数据的三个步骤中的最后一个:“响应”。通过减少响应体体积,来缩短网络传输时间。所以和强制缓存相比提升幅度较小,但总比没有缓存好。

实现协商缓存的两组字段分别是

Last-Modified & If-Modified-Since Etag & If-None-Match

前面已经讲过不再赘述。

额外总结

那么加上service wroker后,在缓存这一小部分里的流程应该是:

  1. 调用 Service Worker 的 fetch 事件响应
  2. 查看 memory cache
  3. 查看 disk cache。这里又细分:
    1. 如果有强制缓存且未失效,则使用强制缓存,不请求服务器。这时的状态码全部是 200
    2. 如果有强制缓存但已失效,使用对比缓存,比较后确定 304 还是 200
  4. 发送网络请求,等待网络响应
  5. 把响应内容存入 disk cache (如果 HTTP 头信息配置可以存的话)
  6. 把响应内容 的引用 存入 memory cache (无视 HTTP 头信息的配置)
  7. 把响应内容存入 Service Worker 的 Cache Storage (如果 Service Worker 的脚本调用了 cache.put())

参考文献

前端缓存
HTTP缓存
Prevent unnecessary network requests with the HTTP Cache
http请求流程
响应报文中的 Vary 头字段的作用