其实关于浏览器缓存的内容网上已经不胜枚举,之所以产出本文的目的是,遇到缓存相关的问题后,在网上看到的所有相关内容大都雷同对细节的描述都不够全面。因此阅读了缓存相关的RFC文档及浏览器内核的实现文档等对缓存相关内容进行整理。
在了解浏览器缓存之前,我们不妨先谈谈缓存的意义。这里引用RFC文档上的一句话:缓存如果不能用以提升性能,那么它就毫无用处。以HTTP缓存为例,如果缓存未过期那么就减少了网络请求,如果缓存通过验证那么就减少了传输资源大小。而关于过期与验证机制的讲解将在下文中展开。
顺便一提,本文详细的给出了参考链接以便阅读者对其中任何一个部分感兴趣时可以找到更加详细的参考资料。
浏览器缓存可以从多个维度进行抽象分类。在广义上来讲无论是memory cache、service worker、push cache、http cache都属于浏览器缓存的概念,而大部分时候我们提到浏览器缓存的概念往往是指http cache。其实对于浏览器而言还有一种回退缓存(page cache),
以下我们来关注几种浏览器可能会发生缓存的场景:
以上缓存的读取顺序为: (Memory Cache/Preload Cache) -> Service Worker -> (Disk Cache/HTTP cache) -> Push Cache
而本文主要以Http Cache的描述为主,关于Service worker以及Server Push如果感兴趣可以通过参考链接进行过了解。
缓存的目标是通过重用先前的响应消息以满足当前请求,来显着提高性能。
让我们来看一个小例子以便于理解:
这天浏览器请求一个叫做海绵宝宝.jpg的资源,服务器给了浏览器一张图片。当浏览器再一次请求服务器海绵宝宝.jpg时,
服务器说:大哥,未来30天图都不会变,你就不能存起来下次别来管我要了吗?我太累了。并在响应里写到,这个图30天都不变。
于是浏览器在这30天里遇到这张图的请求都会使用缓存的图片以响应。
第31天时,浏览器又遇到了海绵宝宝.jpg的请求。于是他问服务器:海绵宝宝.jpg变了吗
服务器答道:没变
又过了一段时间,遇到这个请求时浏览器又去问服务器
服务器说:变了。并给了浏览器一张图片。
浏览器这次就用新的图片响应了请求。
记住本文的主角:浏览器和海绵宝宝.jpg,我们将在后文多处看到他们。(是的,服务器在本文只是配角)
前情提要:在后面我们会讲述:
简单的来说当我们请求一个请求一个本地存在响应缓存的资源时,浏览器并不会立即发起网络请求。而是对缓存的新鲜度(freshness)进行一个判定,如果该响应是可以使用的,那么就会直接使用缓存资源以减少延迟和网络开销)。
如果缓存资源已经陈旧了,那么就会对缓存资源进行验证。如果验证通过,那么浏览器仍然可以复用资源,以减少网络传输的资源大小。如果没有通过,则源服务器应当在验证请求中返回资源,而不是仅仅告诉浏览器该缓存不可使用。
强缓存与协商缓存:现在的许多资料中都将未过期可直接使用的缓存称为强制缓存。过期了需要验证的缓存称为协商缓存。但是实际上RFC文档中并未给出这样的定义。也就是说这两个概念属于理解性的概念而非规范性的概念
为了简单理解可以先参考下面这张图。但是这里隐去很多细节,随着后文对内容的不断扩充,我们会完善这张图。
以上简述,描述了网络资源请求使用缓存的一个大致过程。以下将详细描述过期与验证机制。
还记得上面海绵宝宝图片的例子吗,我们现在需要来解决第一个问题,即服务器如何告知图片资源海绵宝宝.jpg的有效期。为了解决这个问题,则需要一种规范来明确定义如何说明进行资源缓存机制。这种规范必须是双方都可以理解的。在HTTP1.1中,可以使用Cache-Control的缓存指令,以实现缓存机制。
在使用浏览器决定对一个内容进行缓存之前,他将会判定内容是否为可以缓存的。
当资源缓存之后,则在重用时需要判定资源是否过期。max-age被用以设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。
对于共享缓存来说(比如各个代理),s-maxage将覆盖max-age或者Expires头,私有缓存会忽略它。
因此服务器如果想要告知资是30天过期时间,则需要设置:
Cache-Control: max-age=2592000
现在服务器成功的设置了过期时间,我们来到第二个问题,浏览器如何计算资源过期了?
其实只需要保证资源的可缓存时间大于资源的存在时间,那么缓存就没有过期。反之,则缓存则过期了。以下我们将讨论可缓存时间(freshness LifeTime)与存在周期(Age)的具体算法。
freshness LifeTime的算法:
还记得,我们之前讨论的问题:如果没有约定缓存相关的内容,那么还会缓存吗?
答案是:不一定。一般来说如果既没有过期说明,也没有明确进行协商验证。那么不缓存。但不禁止缓存。可由浏览器自由发挥。一般这种自由发挥被称之为启发式缓存。
关于启发式缓存的算法,通常采用Last-Modified与Date时间差的1/10来作为freshness LifeTime。关于启发式缓存我们有两点需要注意。
Age算法:
Age首部字段被用于描述一个缓存接收到响应消息的估算时长(Age)。Age 字段的值是指消息被源服务器创建或者验证之后以来缓存的秒数估算值。
重要的是,Age值是响应沿源服务器的路径驻留在每个缓存中的时间的总和,并需要加上在网络路径中的传输时间
以下数据被用于计算age
响应的age可以以两种完全独立的方式计算
这里简单说下,为什么http1.1需要使用request时间进行校正。因为http1.1的存储最大周期时间是相对于请求的时间的。
apparent_age = max(0, response_time - date_value);
response_delay = response_time - request_time;
corrected_age_value = age_value + response_delay
合并为
corrected_initial_age = max(apparent_age, corrected_age_value)
如果缓存对Age首部字段的值置信(例如,没有HTTP / 1.0 hops存在于Via首部字段中),则在这种情况下,corrected_age_value可以用作corrected_initial_age
存储响应时间可以通添加存储响应最后一次被源服务器验证(以秒为单位)与corrected_initial_age的和值来计算
resident_time = now - response_time;
current_age = corrected_initial_age + resident_time
看到这,可能会令人头秃。简单的总结一下:
Age 消息头里包含对象在缓存代理中存贮的时长,以秒为单位。这里描述了Age首部字段的算法。
而缓存存在周期的算法则是:上一次收到服务器答复距离现在的时间的差值和在代理服务器中存贮的时间之和。即缓存在浏览器存在的时长+缓存在代理服务器路径上存在的时长。
关于过期机制的三个首部字段分别为:
Cache-Cotrol:Cache-Control是缓存控制的重要字段,如果要系统的了解它的各项指令,那么最好的方式是读RFC文档,或者MDN文档。本文不会详解cache-control的每个指令,而会去一些容易混淆的点击进行概述。
Cache-Control:public, max-age=31536000
包含了资源的可缓存性以及过期特性。Expires:该字段提供了一个日期,在该日期之后的资源被认为是过期的。关于Expires需要注意的是:
Pragma:Pragma 是一个在 HTTP/1.0 中规定的通用首部,这个首部的效果依赖于不同的实现,所以在“请求-响应”链中可能会有不同的效果。它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器,那时候 HTTP/1.1 协议中的 Cache-Control 还没有出来。
根据以上的过期机制,如果缓存被判定为未过期的,则可以在不与源服务器连接的情况下直接使用缓存响应消息。否则则应当使用验证机制。以下将展开讲述验证机制。
上节中讲到了过期机制的判定。当一个资源过期,或者初始时就被设置为强制验证等原因导致缓存无法提供响应时,它可以使用条件请求机制在转发请求到源服务器以选择一个有效响应。这个过程被称为验证。关于条件请求机制如果你感兴趣可以参阅条件请求和分布式创作及版本控制
还记得上文海绵宝宝的例子吗?下来我们将讨论第三个问题服务器如何验证缓存资源。值得一提的是在这个部分服务器化身主角了。
如果需要服务器能够快速验证本地资源相对于缓存资源的变更,我们需要有一个标示帮助服务器进行快速比对。如果每次有效更新这个值都会变更,反之则不会变更,那么服务器就能快速判断本地资源相对于缓存资源是否有变更了。我们将可以帮助我们验证的方式为验证器
在正式介绍验证器之前我们不妨想想什么样的标示可以用于判断资源变更比对。
如果文件内容变更了,因此内容散列也会变更,反正内容散列则不会变更。因此我们可以使用内容散列作为验证器,记录内容散列的字段是ETag。
而另一种比较简单粗暴的方式则是判定文件的最后一次修改时间。如果文件的最后一次修改时间变更了,我们认为文件变更了。反之,则认为没有变更。记录文件最后一次变更时间的字段是Last-Modified。
强验证器与弱验证器: 我们将验证器分为两种:强验证器与弱验证器。弱验证器是易于生成,但对验证来熟存在许多限制甚至缺陷。强验证器是比较的理想选择,但可能非常困难(并且有时是不可能的)以高效地生成。Last-Modified是显式弱验证器除非能证明是强选择器。而ETag默认为强验证器,但我们可以显示的将其指为弱验证器。
当然了相比于内容散列,使用最后一次修改时间会有一些缺陷,所以通常作为候补方案来使用。下面我们将详细介绍这两种验证器:
Last-Modified:其中包含源头服务器认定的资源做出修改的日期及时间。 它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。由于精确度比 ETag 要低,所以这是一个备用机制。
这里我们给出一个示例:
Last-Modified: Tue, 15 Nov 1994 12:45:26 GMT
对应条件请求机制:请求可以在请求首部If-Modified-Since中携带上需要验证的响应用于响应验证。服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为200 。如果请求的资源从那时起未经修改,那么返回一个不带有消息主体的304响应。
下面我们做一个总结:
ETag:ETag响应头是资源的特定版本的标识符。其用法如下:
ETag: W/""
ETag: ""
空中碰撞: 想象有这样一种场景。你正在编辑一个文档,文档现在的版本是v1.0。因此你目前的变更时基于v1.0的。但等你提交的时候,由于小明比你先提交,所以服务器的版本已经变成小明提交的v1.1。如果你成功提交,则小明编辑的内容就会消失。这种情况称为空中碰撞。为了检测到这种情况,浏览器会提交If-Match或者If-Unmodified-Since进行条件请求,如果条件符合,则可以成功提交,否则返回412前提条件失败。如果对此感兴趣,可参阅:https://www.w3.org/1999/04/Editing/
还记得海绵宝宝.jpg那里我们提出的问题吗?通过上述章节的介绍我们可以来试试回答了。当然你也可以不往下翻而是回去看看那些问题,并帮助浏览器解决问题。
下面我们来揭晓答案:
1、通过过期机制。就缓存时长而言,通常是max-age指令与Expires首部字段。
详细内容可以参阅过期机制章节。
2、通过比对freshness lifetiime与age来判定。
3、通过验证机制。详细内容可以参阅验证机制章节。
4、这是一个不确定的答案,或许我们要看浏览器本身的意愿。通常不会,不过浏览器自身可以采用启发式过期周期计算。
5、这道题在上文中并没有提到,所以我们似乎还不能做出解答
6、同样,这也是我们目前了解到的内容无法解决的问题。
那么我们需要继续深入一些细节,以帮助浏览器解决所有的问题。
缓存如何在浏览器内进行存储
如果要标示缓存资源那么最直接的方式就是以url以及请求方法作为主键进行存储。实际上RFC的规范也确实如此。但是鉴于,实际上请求方法往往被限制为get,因此可以只使用url作为主键。关于浏览器具体实现可以参阅:https://www.chromium.org/developers/design-documents/network-stack/disk-cache
是否可能同一资源对应多条缓存
如果请求目标受内容协商影响,则其缓存记录可能包含多个响应存储内容,每个存储响应由原始请求选择标题字段的值作为辅助密钥来进行区分。用Vary首部字段来实现。当缓存收到了一个可以被带有Vary首部字段的存储,除非可以满足Vary字段中所有选择的首部字段,否则不应当使用该响应。
而如果有多条缓存都可以满足条件,缓存将需要选择其中的一个进行使用。如果存在一个选择首部字段拥有一种已知机制可以进行择优(例如,Accept中的qvalues值,以及相似的请求首部字段),那么该机制就可能用作选择更优的响应。如果没有这样的机制,将会通过Date首部字段根据最近日期选择一个最近期的响应。
到了这里,我们的旅程就结束了。期间,我们帮助浏览器完成了他关于海绵宝宝.jpg的缓存使命。相信这将是一次难忘的旅程。:)
在后文中将附上一些容易出现的误解和在这个过程中参阅的资料。如果对于这趟旅程的细节你还想了解更多,不妨继续阅读下去!
下面来看看关于缓存容易混淆的点:
强缓存与弱缓存概念:
缓存概念并不区分强弱。是缓存验证机制中的验证器分为强验证器与弱验证器。
可参阅RFC7232第2.1节
强制缓存与协商缓存:
从便于理解的角度来讲没有问题,但这不是规范中的概念。实际上IETF中关于HTTP的Cache规范主要从过期机制与验证机制来描述缓存。可参阅RFC7234全文
有了ETag就不需要Last-Modified:
事实上,这两种都应该存在。因为你不能保证路径上都是HTTP1.1协议。对于不能理解ETag的协议来说,缓存将失效。而假如都有,那对于可以理解ETag的则会忽略last-Modified,因此有益无害。Cache-Control与Expires同理。可参阅RFC7232第2.4节
memory cache和disk cache是http缓存的两个位置:
仔细看network就会发现size那一栏有时会出现disk cache有时候会出现memory cache。所以http缓存会根据一定规则决定存进内存还是硬存?
并不是这样,memory cache和http cache是并列的缓存类型,没有包含关系。http cache作为持久化存储一定会进入disk的,所以disk cache和http cache是一种存储方式。
要证明memory cache不是http cache的一部分是很简单的。因为开发者可以在开发中工具的network里禁用缓存。首先我们直接加载一次请求
可以看到资源有的从memory cache加载,有的从disk cache加载。现在打开禁用缓存。
当显示的禁用缓存后,从disk加载的已经直接请求了。memory cache的依然从memory cache去读取。可见资源的可缓存性不影响memory cache的行为。
在前面的讲述中我们已经知道memory cache优先级大于disk cache。设若一个资源是不可长期缓存的,例如设置了no-store,但是并不会影响其内存是否缓存的行为。反之如果memory cache的资源如果被设定为可存储他最终一定也是会进入硬存持久化存储的。
我们考虑一个内容有什么用很难,我们反向思考一下没有memory cache会发生什么。我们加载了一张图用做头像框。整个页面有10个头像要加载。如果这张图被服务器标不可以缓存,浏览器真的不缓存他就要把同一张图加载10遍。这合理吗?这不合理,所以memory cache不属于http缓存的一种形式,不受协议影响,是一种短效快捷存储。
mozilla提供了关于内存缓存关闭的选项。其中对默认缓存内容进行了描述。其默认是开启的。可参见mozilla的memory cache配置。不同浏览器实现可能存在差异。
关于缓存需要了解的首部字段
字段名称 | 参考文档 | 字段类型 | 字段描述 |
---|---|---|---|
Age | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Age | 响应首部 | Age 消息头里包含对象在缓存代理中存贮的时长,以秒为单位。. |
Pragma | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Pragma | 通用首部 | 它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器 |
Date | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Date | 通用首部 | 包含了报文创建的日期和时间。 |
Vary | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Vary | 响应首部 | 它被服务器用来表明在 内容协商算法中选择一个资源代表的时候应该使用哪些头部信息 |
Last-Modified | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Last-Modified | 响应首部 | 包含源头服务器认定的资源做出修改的日期及时间。 |
If-Modified-Since | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-Modified-Since | 请求首部 | 服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回。 |
If-Unmodified-Since | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-Unmodified-Since | 请求首部 | 如果所请求的资源在指定的时间之后发生了修改,那么会返回 412 (Precondition Failed) 错误。 |
ETag | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/ETag | 请求首部 | ETagHTTP响应头是资源的特定版本的标识符。 |
If-Match | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-Match | 请求首部 | 服务器仅在请求的资源满足此首部列出的 ETag值时才会返回资源 |
If-None-Match | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-None-Match | 请求首部 | 当且仅当服务器上没有任何资源的 ETag 属性值与这个首部中列出的相匹配的时候,服务器端会才返回所请求的资源 |
If-Range | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-Range | 请求首部 | If-Range字段用来使得Range头字段在一定条件下起作用:当字段值中的条件得到满足时,Range 头字段才会起作用,同时服务器回复206 部分内容状态码 |
Expires | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Expires | 实体首部 | Expires 响应头包含日期/时间, 即在此时候之后,响应过期。 |
cache-control | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Cache-Control | 通用首部 | 用于在http请求和响应中,通过指定指令来实现缓存机制 |