本文使用 Zhihu On VSCode 创作并发布
引言
说到浏览器的文件缓存,就绕不开 HTTP 协议中的相关内容,群脉在这方面积累了一些经验,在这里做一个梳理,分享出来供大家参考。
条件式请求(Conditional Requests)
HTTP 中的条件式请求,指的是请求的响应会因特定请求 header 的值以及有关资源的状态的不同而不同。这些 header 实际上就是请求的前置条件,故会影响请求的最终响应。
- 对于安全方法(如
GET
、HEAD
),条件请求可以用来限定仅在满足某些条件的情况下才返回文件,可以减少不必要的重复传输,节省带宽。 - 对于非安全方法(如
PUT
),条件请求可以用来限定仅在满足文件的初始版本与服务器版本相同时才将其上传,防止例如上传了不同版本的不同部分之类的问题。
具体例子会在后面介绍。
验证器(Validators)
所有的验证器都会试图去检测服务器上存储的资源是否与某一特定版本相匹配。它们都是用来描述这一版本的值,分两大类:
- 文件的最后修改时间:
Last-Modified
。 - 指代唯一版本的“实体标签”:
ETag
。
条件式请求的一般过程
![cd55cf6bc6707e509953c6bef18175b2.png](https://i-blog.csdnimg.cn/blog_migrate/6767d022d3cc68654b5e2bdb59ce20ed.png)
没有魔法般神秘的实现或过程,就是正常的、无状态、多带了一些 header 和预制实现的服务器处理逻辑的 HTTP 访问。
当然条件请求所返回的 Code 不止上图所示的三种。
条件 Header(Conditional Headers)
If-Match <ETag>, <ETag>, ... or *
:若远端资源的实体标签与此处列出值有相同的,则匹配成功。If-None-Match <ETag>, <ETag>, ... or *
: 若远端资源的实体标签与此处列出值均不相同,则匹配成功。If-Modified-Since <HTTP time>
:若远端资源的Last-Modified
标识日期比该列出值晚,则匹配成功。If-Unmodified-Since <HTTP time>
:若远端资源的Last-Modified
标识日期比该列出值早,或与之相等,则匹配成功。If-Range <ETag> or <HTTP time>
:若远端资源的实体标签与此处列出值相同或Last-Modified
标识日期比该列出值早或与之相等,则匹配成功。匹配成功后才能使 header 字段Range
生效,并返回206
。匹配失败则试图返回整个文件和200
。
应用场景
缓存更新
一开始缓存为空:
conditional-requests-refresh-cache-1.png 当然这里的验证器
ETag
和Last-Modified
可以只返回其一。通过
Cache-Control
和其他浏览器策略判断缓存是否失效。若失效,则向服务器验证资源。若资源未变化,返回
304 Not Modified
,客户端则继续使用被缓存的资源。虽然会产生一次网络访问,但是比重新下载整个资源高效的多:conditional-requests-refresh-cache-2.png 若资源变化了,即条件 header 被匹配,则返回
200 OK
和新的资源:conditional-requests-refresh-cache-3.png
增量下载的完整性
如果文件很大,则需要用到 HTTP 提供的增量下载功能。
客户端一开始当然不知道文件需要增量下载,正常发出
GET
请求,对方则会用Accept-Ranges
提示客户端自己有增量下载的能力:conditional-requests-partial-download-1.png 此后客户端通过发送 header 字段
Ranges
进行断点续传。这里用到两个条件 header 确认下载过程中服务器上的这个文件没有被更改过:conditional-requests-partial-download-2.png 其中请求响应中的
Content-Range
结构为<start>-<end>/<total>
。如果下载期间文件发生更改,若不加以任何处理,势必会引起客户端收到一个错误的、两个版本强行拼凑起来的版本。此时我们加上的条件 header 会让服务器返回错误提示:
conditional-requests-partial-download-3.png 我们还可以用
If-Range
,避免上图中的过程/1/
和/2/
,减少客户端处理异常和反复通信的开销:conditional-requests-partial-download-4.png
使用乐观锁避免更新丢失问题
假设现在有一个 wiki 网站,远程更新文件是一个常见操作,这个过程一般如下:
![bcc681858e12d7494e2c3a40d89e0c71.png](https://i-blog.csdnimg.cn/blog_migrate/3915d632eee999179e7a3e355e9dbe7f.jpeg)
但如果考虑并发,就会出现两个用户下载了相同版本的资源,却分别上传了自己修改后的版本。若不做任何验证,后被处理的一方很有可能会将前者的改动覆盖掉,对于 HTTP 来说,这几乎是无法处理的:
![8ebf06af0f28a52b166a1ac47b28fffe.png](https://i-blog.csdnimg.cn/blog_migrate/cdeaabc1c9c9d79970c652b03e3bc994.jpeg)
所谓乐观锁算法,在这里实际上就是:
- 允许所有客户端获得资源副本,使大家能同时在本地编辑。
- 只允许第一个客户端成功提交。
- 所有基于已过期版本操作的提交均会被拒绝。
不存在解决这一问题而不打扰任意一方的办法。然而,更新丢失问题以及竞态条件是需要避免的。我们希望获得可预测的结果,并且希望在更新操作被拒绝的时候客户端可以得到反馈。
在这里我们使用 If-Match
或 If-Unmodified-Since
就可以方便的实现乐观锁:
![3c27a1307b711c45e1580037be297485.png](https://i-blog.csdnimg.cn/blog_migrate/55c67498540c218e6a8a555537d36f9c.jpeg)
收到错误后我们就可以有机会为客户提供其他的业务逻辑来完成基于最新版本的修改。
处理创建资源的冲突问题
类似上面文件更新的竞态,创建资源也可能形成竞态。我们可以添加 If-None-Match: *
,来确保只有第一个创建资源的请求会被处理:
![567b11b4d167dbb843f16815533e5f08.png](https://i-blog.csdnimg.cn/blog_migrate/2352f2dabccf50b646b96fba75dea73a.jpeg)
缓存控制
首先我们来澄清两个已经不推荐使用的 header:
expires <HTTP time>
:在所指示的时间之后,消息对象过期。优先级低于Cache-Control
中的max-age
和s-max-age
。pragma: no cache
:行为和Cache-Control: no-cache
相同。
在 HTTP/1.1 中,我们完全可以只使用 Cache-Control
完成缓存控制。
Cache-Control Header
禁止进行缓存
Cache-Control: no-store
:缓存中不得存储任何关于客户端请求和服务端响应的内容。每次客户端发起的请求都会下载完整的资源内容。
强制确认缓存
Cache-Control: no-cache
:每次请求发出时,缓存器都会携带相关字段向服务器进行验证。如前文所述,未过期时服务器返回 304
,缓存器使用缓存版本;反之服务器返回完整文件。
私有缓存和公共缓存
Cache-Control: public
:该响应可以被任何中间人(比如中间代理、CDN 等)缓存,当然同时浏览器也会缓存。Cache-Control: private
:该响应只能被浏览器的私有缓存器缓存,比如账号密码订单信息等敏感页面。
缓存过期机制
过期机制中最重要的指令就是 max-age=<seconds>
,表示该资源在多少秒内可以看作“新鲜的”。s-max-age
与其作用相似,但只作用于公共缓存中。其中所谓的 age,实际上就是 HTTP header 中的 Age 字段。
缓存可用机制
除 max-age=<seconds>
外,max-stable=<seconds>
和 min-fresh=<seconds>
也会影响缓存资源的可用性。一个资源是否可用,大致遵循以下规则:
age + min-fresh < max-age
:缓存未过期。age + min-fresh >= max-age && age + min-fresh < max-age + max-stable
:缓存过期但仍可用。age + min-fresh > max-age + max-stable
:缓存过期且不可用
缓存验证确认
使用 Cache-Control: must-revalidate
指令,会使得缓存器对所有过期的资源都必须向服务器验证它的状态。实际上就是跳过了上文“缓存过期但仍可用”这一阶段,只要过期就算作不可用。
缓存控制的一般过程
![c1ba02d595fef8b1ec496132d3577265.png](https://i-blog.csdnimg.cn/blog_migrate/72a2890ca6b3e2819450453b5cdbbf23.jpeg)
注意上图中的 Cache 实质上是指一个类似缓存代理的服务器。当然浏览器缓存器的控制过程也类似。
废弃或更新缓存
考虑如下这种情况,客户端在无缓存的情况下向服务器请求了 HTML 文件 /page
,其中引用了文件 /style.css
、/script.js
和 /photo.jpg
。为了降低带宽开销,这些文件都声明 max-age=<86400>
(一天)。
一个用户在 0800 下载了这个网页,1300 时开发者上传了新版本,但是因为 max-age
,客户打开的网页读取了本地缓存,仍然是旧的。
这样显然不好,就产生了一个问题,如何才能鱼和熊掌兼得:客户端缓存和快速更新?
这就引出了现在非常常见的处理方式,文件指纹:
![9c531e3a1558a4bc0cc8aa3e7b884c88.png](https://i-blog.csdnimg.cn/blog_migrate/cadf9402914108da34824a3cf7e77a3a.png)
如果我们声明 /page
是 no-cache
的,那么客户端每次请求这个文件都会去向服务器验证。这样既可以保证页面没有更新时不用重复下载,也可以保证页面一旦产生更新就能拿到最新的版本,同时其引用的三个文件的文件名也会得到及时的更新。
Vary
这一 header 字段会在另一篇关于“HTTP 内容协商机制”的文章中进行讨论。在这里我们可以简单的这么认为:
- 内容协商相关的 header 字段不同,可以将同一个 URL 对应到内容不同的资源文件上。它们可能是因不同的文件类型、字符集、编码方式和语言(
Accept
、Accept-Charset
、Accept-Encoding
、Accept-Language
)而区分开的。 - HTTP 缓存机制可以区分不同的内容协商资源版本,分开缓存、分开查验新鲜度、分开向服务器重验证。
Vary
是由服务器发送给客户端的,它能向客户端说明,这次传输的资源应当用什么内容协商头字段做版本区分。
制定缓存策略的策略
缓存策略没有银弹,需要根据通信模式、提供的数据类型以及应用特定的数据更新要求,为每个资源定义和配置合适的设置,以及整体的“缓存层次结构”。这意味着,定义合适的缓存策略,需要综合考虑自身的业务需要、构建过程、终端用户的体验等等方面。
通用的策略,可以参考 Google Web Fundamentals - 缓存检查清单。
我们的缓存策略
群脉有数量众多的企业客户,我们向这些客户提供了丰富的定制化功能。这意味着除了规模庞大的基础功能,还有数量众多但规模较小的子功能。我们将这些定制化的功能称为“模块”(modules)。
为了提高终端用户的访问速度,同时控制整个系统的复杂度,我们将所有的前端页面分模块静态化,构建后直接上传到阿里云 OSS,前面挡阿里云 CDN,实现了无服务器化部署。
为保证缓存的可用性,我们统一的维护这些模块。这样可以保证各个模块都可以访问同一个域名下的公共文件,比如各种通用的库、图片等。另外,我们也接受用户独立部署自己的所有业务、页面和资源,对于此类客户缓存自然只在该客户的域名范围内生效。
综合以上背景考虑,我们制定了如下的构建结果文件目录结构:
├── libs/ # 通用库
└── modules/
└── ${module1}/
├── ${buildId1}/ # 版本 1
| ├── css/
| ├── js/
| ├── libs/
| └── images/
├── ${buildId2}/ # 版本 2
| ├── css/
| ├── js/
| ├── libs/
| └── images/
└── index.html # 其中的资源引用指向最新的版本
上传策略:
每次构建,我们会基于当前时间生成
buildId
,并将 JS、CSS、图片等需要长期缓存的文件存放在该模块的${buildId}/
目录下。同时构建新的 HTML 文件(每个 module 都是基于 Vue.js 的独立单页应用,所有有且只有一个入口 HTML),其中的资源引用路径会指向此次的${buildId}/
目录,然后将之存放在模块根目录下。上传文件时,首先上传 JS、CSS、图片,最后上传 HTML 文件替换旧的,这样可以保证在上传过程中不会出现不可用:
- HTML 上传完成前,客户端访问到的是老的 HTML,其引用的也是老的
${buildId}/
目录下的资源,没有任何问题。 - HTML 上传完成后,客户端访问到的是新的 HTML,其引用的新的
${buildId}/
目录下的资源,因为它们已经先于 HTML 上传完成了,所以也不会有问题。
- HTML 上传完成前,客户端访问到的是老的 HTML,其引用的也是老的
缓存策略:
- JS、CSS、图片等资源是永不过期的(
Cache-Control: max-age=${100 * 365 * 24 * 60 * 60}
),如此客户端可直接使用本地缓存,无需发起任何网络请求。那么需要更新时怎么办?上面说了,每次构建buildId
会变的,也就是资源路径会变,之前的缓存自然就失效了,虽然这可能导致一些没有变化的文件的缓存也失效了,但是构建不会很频繁,所以可接受。 - 入口 HTML 文件是不允许客户端缓存的(
Cache-Control: no-cache
),这样可以保证客户端每次都能请求到最新的 HTML,同时如果服务端 HTML 没有变化则可以直接响应 304,无需响应整个文件,如此可节省流量、加快客户端加载速度。此外还要额外考虑 CDN 的刷新,阿里云 OSS 是支持在文件变化时自动刷新 CDN 的,配置一下即可。
- JS、CSS、图片等资源是永不过期的(
定时清理:
通过
${buildId}/
目录区分文件版本,还有一个好处是可以方便的进行旧资源的清理,只需定时清理buildId
小于某个值的所有目录即可,这样做可以有效控制阿里云 OSS 的存储成本。
参考资料
- Google Web Fundamentals - HTTP 缓存
- MDN web docs HTTP 缓存
- MDN web docs HTTP 条件请求
- .Net 基于时间的缓存策略 (微软果然与众不同)
- baitouwei App 缓存方案:Http 缓存
- zhanglun 浅谈 HTTP 缓存
关于
- 作者:张乐萌(Ian Zhang),群脉交付团队工程师。
- 编辑:顾卫海(Rob Gu),群脉交付团队架构师;张璇晨(Sara Zhang),群脉产品团队工程师;王永浩(Aaron Wang),群脉首席架构师。