“There are only two hard things in Computer Science: cache invalidation and naming things.”
Prologue
一些计算机操作的开销是昂贵的。在许多情况下,我们会尽量访问缓存,因为在合理的缓存设计下,系统中保存的内容大多数资源都足够满足我们的需求,并且能够有效减少昂贵操作所带来的开销的同时提高系统的运行效率。
缓存存在于计算机的每一个角落。如计算机 CPU 需要经常访问的核心指令有 L1、L2、L3 缓存,对于计算机的内存,有 SWAP 机制来使用硬盘针对内存中不活跃的内存数据进行缓存。同样的,缓存存在于网络的每个节点,比如 DNS 寻址时,会优先读取浏览器缓存的 IP 地址,然后是公共DNS服务器。如果未命中,将从根 DNS 服务器开始,然后到顶级 DNS 服务器,最后到权威 DNS 服务器。最终,一系列IP地址被返回给浏览器。
一次 HTTP 请求的开销也是昂贵的。因此,对于 HTTP 的数据交互,我们依旧会尽量采取缓存策略。通常来说,缓存可以分为如下几个方向:
- 客户端缓存(也就是浏览器的缓存)
- 代理服务器缓存
- 源服务器缓存
浏览器与源服务器间的缓存机制
服务器的缓存机制
Cache-Control
响应头
如果开发者编辑了网页,客户端则需要展示最新的最新的内容。但是浏览器并不清楚是否需要更新内容,仅仅只有持有资源的服务器才知道客户端显示的内容是否过时。通过 Cache-Control
响应头,服务器可以决定浏览器的处理策略,比如直接使用缓存或者请求新的资源。
Cache-Control
有许多可能的值。其中这四个值是最常见的:
no-store
: 禁止浏览器存储任何缓存,通常用于经常需要变化的资源;no-cache
: 这个值的名字比较具有误导性,时间上,no-cache
所代表的含义是允许缓存的,但是在使用之前需要强制与源服务器进行验证;max-age
: 帮助浏览器判断缓存资源是否是陈旧资源;must-revalidate
: 当浏览器重用缓存的陈旧资源前,必须重新由服务器进行验证。缓存资源是否过期则由max-age
字段确定;
与 Cookie 中的 Expires
字段不同,max-age
是基于在服务器生成响应的时间,而这个时间在 Date 响应头中声明。假如服务器将 max-age
设置为 10 秒,而将资源传递到浏览器花费了 1 秒,则剩下的 9 秒则是资源过期时间。相应的,如果服务器设置 max-age
为 0 秒,则资源到达浏览器的时候,就直接是陈旧资源了。
浏览器的缓存机制
Cache-Control
请求头
HTTP 事关服务端与客户端两者之间的协商,而浏览器作为客户端也有确定自己需求的灵活性。比如在浏览器同样可以通过在请求头设置 Cache-Control: no-cache
来不使用缓存,直接向服务器发起网络请求。当我们强制刷新一个网页的时候(通常是 Cmd + Shift + R
或者是 Ctrl + Shift + R
),浏览器就会在请求头中添加 Cache-Control: no-cache
来直接请求。
浏览器使用缓存的场景也很常见,当我们在浏览网页的时,点击浏览器标签页中的 “前进” 或者是 “后退”,浏览器会直接初始化一个没有 Cache-Control
头部的请求,并得到缓存后的资源。因此,在网页的 “前进” 或者 “后退” 时,浏览器的响应十分快。
那么如果浏览器在使用缓存前需要验证缓存呢?
HTTP HEAD method
浏览器可以通过发送 HEAD 请求来验证缓存资源,服务器收到请求后,返回与使用 GET 方法请求资源相同的 Header。接下来,浏览器就可以使用收到的响应头来判断缓存是否过期,来决定是否需要再次发送请求来获得最新的资源。
正如我们在 Prologue 中所说,“HTTP 请求的开销是昂贵的”,而验证缓存需要发送的两次请求,看起来有些让人无法接受。那么,我们有什么优化的空间吗?
HTTP 条件请求
在 HTTP 协议中有一个 “条件式请求” 的概念,浏览器与服务器之间通过发送一些特定的头部字段,来验证缓存的有效性与文件的完整性。因此,这种方法也被称作“协商缓存”。条件请求的请求头有一个共同的特点,它们都以 If-
为前缀。在这些请求头中比较值得讨论的两个是 If-Modified-Since
与 If-None-Match
。
If-Modified-Since
请求头与 Last-Modified
响应头
If-Modified-Since
请求头的还是比较语义化的,从名字上很容易看出来它是关于过期时间的。完整的 If-Modified-Since
的验证流程如下:
- 浏览器初次请求资源;
- 服务器返回带有
Last-Modified
响应头的资源; - 当浏览器再次请求资源的时候,将自动附加
If-Modified-Since
请求头; - 服务器验证时间,并发现期间没有修改;
- 由于没有修改,服务器直接返回 HTTP 状态码 304 与其他头部响应,通知浏览器使用缓存;
- 浏览器收到 HTTP 状态码 304,安心的使用缓存;
那么 If-Modified-Since
就可以完美控制浏览器缓存了吗?并不是,因为 If-Modified-Since
的时间精度是以 秒 为单位。
如果一个资源在一秒钟内发生了多次更改,服务器将不会用最新的资源响应。此时,浏览器则会错误的使用缓存。相反,如果一些服务器做出的修改并没有影响内容本身,而仅仅只是更新了修改时间,浏览器可能会误以为内容发生了变化,因为它通常使用最后修改时间来判断资源是否被修改过。因此,即使实际内容没有变化,浏览器也可能会发送一次请求,以验证资源是否仍然有效。这可能导致在不必要的情况下从服务器重新获取资源,而不是使用缓存中的内容。
为了解决这些问题,HTTP/1.1 引入了另一种机制,即 ETag
和 If-None-Match
。
If-None-Match
请求头与 ETag
请求头
通常来说,请求接口返回一般是一个较大的文件或是字符串,如果直接对两者进行比较,开销是较为昂贵的。因此,通常来说我们针对文件资源生成一个较短的字符串作为唯一标识符,然后将这个唯一标识符设置在名为 ETag
的响应头中。完整的 ETag
与 If-None-Match
验证流程如下:
- 浏览器初次请求资源;
- 服务器返回带有
ETag
响应头的资源; - 当浏览器再次请求相同资源的时候,将自动附加一个
If-None-Match
头部,其值为前一次响应收到的ETag
值; - 服务器验证
ETag
以检查资源是否有变动; - 如果资源保持不变,服务器直接返回 HTTP 状态码 304 与其他头部响应,通知浏览器使用缓存;
- 浏览器收到 HTTP 状态码 304,安心的使用缓存;
ETag
可以分为两种类型:
ETag: W/"<etag_value>"
ETag: "<etag_value>"
第一个 ETag
的 W/
(大小写敏感)标识使用弱验证器(Weak validation),弱验证器只需要确认资源内容相同即可,即便是有细微差别也可以接受,比如显示的广告不同,或者是页脚的时间不同。
第二个的 ETag
表示使用了强验证器(Strong validation),强验证器会针对每个字节进行校验,确保文件完全一致,比如大文件的断点续传时会用到。相比较而言,强验证器对于生成 ETag
的算法要求更严格,并且生成新的 ETag
会比生成弱验证器的 ETag
消耗更多的资源。例如,我们耳熟能详的 MD5 就可以作为强验证器 ETag
的一种实现。
浏览器、源服务器、与代理服务器间的缓存机制
Dive Deeper into HTTP Cache Strategy
“感谢浏览器与源服务器二位为我们带来精彩的对口相声,接下来,请欣赏浏览器、源服务器、代理服务器三位为我们带来的群口相声《三者缓存一台戏》!”
下面,让我们更深入 HTTP 缓存,并请出本篇的最后一位嘉宾:代理服务器。我们不禁发问:“代理服务器在 HTTP 缓存中扮演一个什么样的角色呢?”
代理服务器可以看作一个客户端,因为它向源服务器发送请求;代理服务器也可以看作一个服务端,因为它向浏览器发送响应数据;因此,所有的源服务器与浏览器可用的缓存控制头都可以在代理服务器上使用,此外,作为夹在浏览器与源服务器中间人,代理服务器还有一些自己独特的头部。
代理服务器的缓存策略
我们将引入更多的 Cache-Control
字段来引导一个代理服务器如何缓存。由于代理服务器有可能需要响应多个浏览器,带有 public
标识的 Cache-Control
字段标识该资源可以被所有客户端访问。相反,带有 private
字段则表示资源是私有的,只对特定的客户端可见。客户端可以缓存资源,但是作为代理服务器不应该。
比如使用本站举例,使用了 Nginx 作为浏览器与源服务器之间的代理服务器。网站的网站的 CSS 文件与一些其他的配置文件属于是公共资源:
而登录时的请求的接口的 Set-Cookie
头部中的登录令牌则是私有的,代理不应该将令牌发送给其他的浏览器:
对于公共资源,proxy-revalidate
响应头要求代理服务器在公共资源的缓存过期时验证缓存。s-maxage
响应头则是为公共资源设置过期时间,这个响应头是代理服务器专有的。客户端应当依然按照 max-age
头管理缓存。no-transform
则是用来禁止代理服务器修改响应体。是的,代理服务器有可能会编辑响应体,比如,用来提供一些轻量级的替代方案,比如 webp 格式的图片,来优化图像资源。
浏览器的缓存策略
浏览器也引入了一些新的字段来与代理服务器协商缓存控制。除了 no-transform
外,我们还有三个新字段:
only-if-cached
:浏览器表示客户端只接受来自代理服务器的缓存,而不接受原始服务器的请求。此时,如果代理服务器的缓存变为过期时,服务器应该返回 HTTP 状态码 504(Gateway Timeout),表明来自原始服务器的超时。max-stale
:扩展缓存的生命周期。当浏览器发起对某个资源的请求时,可以通过设置max-stale
指令来告知服务器,即使该资源在客户端缓存中已经过期,服务器仍然可以将过期的资源发送给客户端。这允许服务器在某些情况下返回过期的资源,而无需重新生成或刷新。min-fresh
:减少缓存的生命周期。浏览器希望获取的资源应该保持新鲜,即在一定的时间内不过期。如果服务器无法提供新鲜度大于等于min-fresh
所指定的时间的资源,服务器应该返回 HTTP 状态码 504。
Overall
当我们输入了一个 URL 的时候,浏览器会先检查资源是否允许被缓存,如果允许并且缓存还未过期,那么将返回缓存的资源。否则,浏览器将需要请求刷新。
请求的过程从 DNS 解析开始,浏览器首先会检查本地的 DNS 缓存,然后是公共的 DNS 服务器,与一系列向上查询的过程,最终,浏览器将得到相应的 IP 地址,并根据各种标准,从所有 IP 地址里面选择一个合适出来。
接下来,它将创建并发送一个带有 Cache-Control
请求头的请求。代理服务器收到请求后,如果缓存命中,并且允许缓存还没有过期。那么代理服务器将直接把缓存的资源返回给浏览器。否则,代理服务器需要从源服务器请求更新。
代理服务器与浏览器一样,需要创建一个带有适当 Cache-Control
请求头的请求,发送给源服务器。
源服务器收到请求后检查资源在距离上次请求期间是否被修改。如果没有修改,则返回 HTTP 状态码 304,如果资源期间被修改,则创建一个带有 Cache-Control
响应头与其他响应头(例如 ETag
或 Last-Modified
)的响应。并根据是否需要重定向来返回代表永久重定向(HTTP Code 301)或临时重定向(HTTP Code 302)的状态码。在这些准备工作都做完后,源服务器发送响应。
代理服务器收到响应,如果是允许缓存,并且是公共缓存,则代理服务器将缓存相应的内容。否则,响应的资源将直接发送给浏览器。当然,正如前面所提到的,在缓存之前,代理服务器也有可能对资源进行优化,并加上相应的响应头。
下一步,浏览器收到响应内容,并根据响应的 HTTP 状态码进行下一步逻辑,比如接收到状态码 304,浏览器将使用自己的缓存并更新与缓存相关的变量,如 max-age
等;如果接收到状态码 301,任何未来对该资源的请求都应该使用新的 URL。浏览器会自动将请求重定向到新的 URL;如果接收到状态码 302,请求的资源现在位于新的URL,但未来的请求仍应使用原始的URL。浏览器会自动将请求重定向到新的URL。
最后,如果资源是新的,浏览器将进入渲染流程,并更新缓存。
Epilogue
这次分别从源服务器与客户端、源服务器和代理服务器与客户端两个角度,讲解了 HTTP 的缓存策略。其实还有许多较为有趣的知识想在这里分享,受制于篇幅所限,部分内容只能简略带过,比如 HTTP 条件请求中,缓存更新时还有一些竞态条件(Race Condition)与互斥锁(Mutex)的问题,这里完全没有提到。在此,先把更详细的解释链接贴在这里,以供有兴趣的网上邻居们阅读。
For Further Reading: