本文同步发表在我的个人博客静态资源缓存机制
个人博客的评论系统加了邮件通知,有什么想和作者说的建议在个人博客留言哦
后续若有变化,将只在个人博客更新,恕此处不再修改
背景
重用已获取的资源能够有效的提升网站与应用的性能。Web 缓存能够减少延迟与网络阻塞,进而减少显示某个资源所用的时间。借助 HTTP 缓存,Web 站点变得更具有响应性。缓存是提升 Web 性能的重要手段之一,作为前端,有必要对它进行深入理解。
一般情况下,缓存机制失效或者设置不正确,可能只是影响用户体验,但是极端情况下,会打挂你的服务器甚至CDN。我曾经参与了公司一个重要项目,涉及几十个团队上千人的大规模协作,结果静态资源的缓存设置错误,导致不停的从CDN请求静态资源,所幸在上线前发现了这个问题并及时修复了,否则一旦项目上线,整个公司所有服务都会受到影响(会被祭天的那种影响)。
在整个Web系统中,会有作用在不同阶段的多种缓存机制,例如网关缓存、CDN、反向代理缓存和负载均衡器等部署在服务器上的缓存方式,本文主要探讨和前端关系比较密切的浏览器中的静态资源缓存。
前言
缓存的目的
当我们请求某个资源时候,如果这个资源曾经下载过,那么在它过期之前是没有必要再从服务器下载,直接从本地缓存中读取即可,这么做有减轻服务器的压力、节约带宽等好处。
从缓存的目的出发,我们可以得到这个问题有几个关键点:
- 如何控制某个资源是否需要缓存?
- 如何判断某个资源是否过期?
- 如果过期了怎么办?
我认为缓存机制的设计基本上是围绕这三个问题展开的,让我们带着这三个问题去分析浏览器中的静态资源缓存机制。
静态资源缓存主要通过HTTP header中相关字段完成
我们知道HTTP 消息头允许客户端和服务器通过 request Header和 response Header传递附加信息。缓存机制的实现也是通过HTTP header中的一些字段完成。
首次请求时候,本地肯定没有缓存,所以请求会到服务端。服务端返回资源的时候会在请求的响应头的相关字段里标记此资源的缓存信息。浏览器收到这个资源后会根据响应头里的相关信息缓存资源,注意,这些用来控制缓存策略的字段会和资源本身一起被缓存在客户端,下次请求资源的时候会带着其中一些信息去发起请求。正是通过这样的前后端交互,配合实现了静态资源的缓存机制。
浏览器端发起请求时判断资源是否命中强缓存
当某次请求命中了强缓存策略,那么这个请求就不会被发送到服务器了,而是直接由浏览器将缓存的资源返回。如图所示:
在chrome的控制台中,我们可以看到当一个资源有disk cache
标记且状态码也是200时候,说明资源是来自本地的缓存,这时候你看请求的响应头,会显示这个请求被关闭了:
Provisional headers are shown
如何命中强缓存呢?有两个条件:
- 本地缓存没有过期
- 没有其它要求必须到后端验证资源是否过期的指令
很多文章里只写了第一条,即本地缓存未过期问题,而忽略了第二条。强缓存是通过下面两个请求头字段实现的:
Cache-Control
HTTP/1.1定义的 Cache-Control 头用来区分对缓存机制的支持情况, 请求头和响应头都支持这个属性。通过它提供的不同的值来定义缓存策略。
注意:Cache-Control有多个可能的值,并且可以组合使用。
其中一个最重要的取值是 "max-age=
还有一个类似的取值是s-maxage=
简单的说,这个字段是用来标记资源从发起请求的时间开始,多少秒之后过期,如果过期了,请求一定会到服务端。
注意,这个max-age的时间是从请求的Date字段计算,不是从浏览器端时间开始计算,这样是为了避免前后端时间不一致的问题。
但是,但是,但是!!!没过期就一定会命中强缓存吗?答案是否定的。这个字段还有其它可能和 max-age 一起出现的值。如下:
Cache-Control: no-store
禁止进行缓存
缓存中不得存储任何关于客户端请求和服务端响应的内容。每次由客户端发起的请求都会下载完整的响应内容。
带有no-store的响应不会被缓存到任意的磁盘或者内存里
Cache-Control: no-cache
强制确认缓存
客户端会把带有 no-cache 的响应缓存下来,只不过每次不会直接用缓存,而得先到服务端验证一下。
每次有请求发出时,缓存会将此请求发到服务器(该请求应该会带有与本地缓存相关的验证字段),服务器端会验证请求中所描述的缓存是否过期,若未过期(返回304),则缓存才使用本地缓存副本。
Cache-Control: private
私有缓存
"private" 则表示该响应是专用于某单个用户的,中间人不能缓存此响应,该响应只能应用于浏览器私有缓存中。
Cache-Control: public
公共缓存
"public" 指令表示该响应可以被任何中间人(译者注:比如中间代理、CDN等)缓存。若指定了"public",则一些通常不被中间人缓存的页面(译者注:因为默认是private)(比如 带有HTTP验证信息(帐号密码)的页面 或 某些特定状态码的页面),将会被其缓存。
如果携带了 public ,且未过期,就会命中强制缓存。
Cache-Control: must-revalidate
缓存验证确认
当使用了 "must-revalidate" 指令,那就意味着缓存在考虑使用一个陈旧的资源时,必须先验证它的状态,已过期的缓存将不被使用。
注意:这个属性有点奇怪。
这个属性对于浏览器基本没什么用(在chrome下有一点点奇怪的用,其它浏览器会忽略它)。在缓存服务器上一些特殊场景下可以允许返回过期的资源。这部分有兴趣的可以看看这篇文章:
可能是最被误用的 HTTP 响应头之一 Cache-Control: must-revalidate
Expires
上面提到的Cache-Control 响应头中的 max-age指令是HTTP 1.1中的内容。对于不含这个属性的请求则会去查看是否包含Expires(HTTP 1.0)属性。
这个属性的值是一个具体的时间点,通过比较Expires的值和头里面Date属性的值来判断是否缓存还有效。
Last-Modified
如果max-age和expires属性都没有,找找头里的Last-Modified信息。如果有,缓存的寿命就等于头里面Date的值减去Last-Modified的值除以10。这种缓存方式也叫做启发式缓存。
值得注意的是,这个计算方法是规范中推荐的计算方法,并不是强制需求。 Firefox 中就算规则就不是这样。
Pragma
这个也是HTTP 1.0时代的产物,现在通常为了兼容而保留,不用太关心它。
未命中强缓存则发请求到服务端验证资源是否命中协商缓存
当本地设置的过期时间到了,就发个请求到后端问问:这个资源有变化吗?没变化我就接着用缓存了。这时候请求响应返回的http状态为304并且会显示一个Not Modified
的字符串,这就是协商缓存。它有两种实现方式:
Last-Modified与If-Modified-Since
服务端返回的时候,会带着一个响应头叫Last-Modified
,意思是这个资源最后的修改时间。浏览器发请求时候会在If-Modified-Since
字段中携带上次返回的Last-Modified
值。这样服务器就能知道自从上次请求后这个文件有没有修改了。
ETag与If-None-Match
服务端返回的时候,会带着一个响应头叫Etag
,它是资源的特定版本的标识符。浏览器发请求时候会在If-None-Match
字段中携带上次返回的Etag
值。这样服务器就能通过对比请求中的值与当前资源的 Etag 值来判断这个文件有没有修改了。
注意,标准中并没有指定 Etag 的生成方法,它可以是对资源内容使用抗碰撞散列函数,也可以是用最近修改的时间戳的哈希值,甚至可以是一个版本号。
Etag 的 机制优先级比 Last-Modified 要高,其实Etag要也是为了解决 Last-Modified 的一些问题出现的:
- 文件内容没变只是修改时间变了
- 不能十分精确的得到文件修改时间(服务器端时间戳一般精确到s,如果你文件变化是毫秒级别的呢)
没有命中协商缓存则从服务器端加载资源
如果没有命中上述缓存策略,则会从服务端拉取资源
惊天大迷案
按照本文前面的结论,当你首次请求的响应头是这样的时候,在600s刷新页面,按道理应该命中强制缓存,也就是不发起请求,直接从本地缓存拿数据。
然而天真的我发现事实却是这样的:
什么?为什么没有走到强制缓存的逻辑里?怎么还是走到了304的逻辑里(向后端发送了请求)?而且请求中国年的max-age=0
?为什么?为什么?为什么?
答案是,现代浏览器(我测试了firefox和chrome),当你在当前浏览器tab刷新的时候,即使没到过期时间,浏览器觉得你刷新了,应该是想问问服务器有没有资源更新,所以把 max-age 置为0(强制过期)。
不信?不信你新建一个tab,打开相同页面就会发现它命中了强制缓存。
如果更深入的研究会发现,下面的操作都会发请求给后端:
- F5
- 点击工具栏中的刷新按钮
- 右键菜单重新加载操作
但是 Ctl+F5 会完全不走本地缓存,不但要发送HTTP request给Server,会把协商缓存的东西清空,这样服务端就不会返回304(无法验证资源是否变更,就像首次请求一样),而是把整个资源重新返回一份,这样,Ctrl+F5引发的传输时间变长了,自然网页Refresh的也慢一些。我们可以看到该操作返回了200,并刷新了相关的缓存控制时间。
不得不说,浏览器也太牛逼了!!!