在阅读本文前推荐你先阅读我的前两篇文章《 扼杀 304,Cache-Control: immutable》和《关于缓存和 Chrome 的“新版刷新”》;下面要说的两个问题是在淘宝(包括天猫等等)任意主流页面中都存在的,所以你可以随便打开一个页面进行测试;这两个问题我去年在微博上都简单提到过,这里做一下梳理总结。
一. 部分图片文件始终 304,无法直接读取缓存
淘宝网站上什么类型的请求最多?当然是图片了。拿淘宝首页举例,在 Chrome 的新标签页中先打开开发者工具,再打开淘宝首页,然后滚动到页面最底部,在开发者工具的网络面板中点击 Img 筛选条件后能够看到左下角有类似如下的数字:
有高达 80% 的请求数都是图片,淘宝的其它主流页面也有同样的规律。然而在这些图片中,有一部分图片无法直接读取浏览器缓存,即便已经被下载过,浏览器也要再发个条件请求,在收到 CDN 返回的空的 304 响应后再读取缓存:
<div><a href="javascript:location+=''">点击该链接,从而使当前页面重新加载,下面的两张图片应该直接读取缓存,不发起任何 HTTP 请求</a></div> <img width=100 src="http://images2015.cnblogs.com/blog/116671/201702/116671-20170222201116820-1249825884.png"> <img width=100 src="https://img.alicdn.com/imgextra/i2/272205633/TB2J1fpaurAQeBjSZFwXXa_RpXa_!!272205633.jpg">
上面这个 demo 中有两张图, 一张是存在淘宝 CDN 上的,另一张是我转存到博客园的。当你点击测试链接时会发现,博客园上的图片能够直接读取缓存(没发送请求),而淘宝 CDN 上的那张图产生了个 304 请求(响应码为 304 的请求):
这个视频演示有下面三个要关注的点:
1. 那个 304 请求的响应有 49 个字节。
2. 那个 304 请求的响应时间为十几毫秒到几百毫秒不等。
3. 页面中直接读取缓存的图片丝毫不动,而经过 304 后再读取缓存的图片有明显的闪动。
别看请求头加上响应头也就 100 个字节,但架不住多啊,这种图片的日 pv 我估计至少得用百亿做单位,浪费的日流量得用 T 作单位,这种小钱也许对淘宝这种大厂来说完全可以忽略,但用户体验却是没法忽略的,在理想的网络情况下图片展现都有明显的闪动,那在移动端或者网络环境较差的 PC 端,延时就会更加明显了。
原因是什么?让我们看看这张图片的响应头(已经删掉了 x- 开头的):
$ curl -I 'https://img.alicdn.com/imgextra/i2/272205633/TB2J1fpaurAQeBjSZFwXXa_RpXa_!!272205633.jpg' HTTP/1.1 200 OK Server: Tengine Content-Type: image/jpeg Content-Length: 94506 Connection: keep-alive Date: Mon, 31 Oct 2016 02:43:49 GMT last-modified: Fri, 09 Sep 2016 08:42:30 GMT Cache-Control: max-age=3600, s-maxage=31536000 Access-Control-Allow-Origin: * Via: cache64.l2et2[0,200-0,H], cache23.l2et2[21,0], cache4.cn395[0,200-0,H], cache3.cn395[0,0] Age: 9938377 Timing-Allow-Origin: * EagleId: 8ccd3b4314878202062633285e |
问题就出在标红的这两个头上,浏览器看一个缓存有没有过期是通过看 Date 头返回的时间点加上 Cache-Control 头中 max-age 字段指定的时间段算出的时间点有没有小于客户端的时间,也就是说看那个算出的时间是不是还没有到来。对于这张图片的话,过期时间可以通过如下的 JS 代码计算出来:
new Date(+new Date("Mon, 31 Oct 2016 02:43:49 GMT") + 3600) + "" // "Mon Oct 31 2016 10:43:52 GMT+0800 (CST)"
其实这个例子根本不需要笔算,口算都能算出来,2016 年 10 月份的某个时刻加上一个小时肯定还是 2016 年,小于我的客户端时间 2017 年,所以浏览器刚刚获取到这张图片就已经过期了。Firefox 有个内部调试工具可以看到每个缓存的过期时间,在 Firefox 中打开那张图片后再打开 about:cache-entry?storage=disk&context=&eid=&uri=https://img.alicdn.com/imgextra/i2/272205633/TB2J1fpaurAQeBjSZFwXXa_RpXa_!!272205633.jpg 页面:
expires 字段就是 Firefox 计算出来的过期时间,没有显示 2016 年是因为如果 Firefox 计算出的过期时间是过去的某个时间,会用当前时间来代替。
所以问题就是为什么 CDN 返回的 Date 会是 2016 年?是因为这张图片是在 2016 年回源的,回源的时候 CDN 缓存了当时图片源站返回的 Date 头,作为以后给浏览器返回的 Date 头,所以用户浏览器接受到的 Date 就固定在了 2016 年。
既然是 CDN 的问题,为什么 CDN 上的其它图片和文件没有同样的表现?是因为 Cache-Control。这类图片的 Cache-Control 有个特点,那就是 max-age 比 s-maxage 小,我们知道 max-age 是 CDN 给浏览器用的,而 s-maxage 是源站给 CDN 用的,max-age=3600, s-maxage=31536000 代表的含义就是浏览器只能缓存这张图片一小时,而 CDN 会缓存这张图片一年,所以只有等到了 Mon, 31 Oct 2017 02:43:49 GMT 年的时候,这张图片的 Date 响应头才会更新,也就是对于用户来说,一年中有 364 天 23 小时 访问这张图片都是直接过期的。
因此只要 max-age 不比 s-maxage 小,就不会有这种下载立刻过期的情况,比如 Cache-Control: max-age=2592000,s-maxage=3600,或者完全不指定 s-maxage,Cache-Control: max-age=31536000 都行。那 max-age 比 s-maxage 小就完全是错的吗?我觉的并不是,我猜测这么设置的理由是:这些图片有更新的需求,所以给浏览器设置的缓存时间是一小时,给 CDN 设置的缓存时间是一年是因为更新图片毕竟是小概率事件,不是大批量的,所以都是通过 CDN 提供的 purge 接口进行强制回源的,不需要 CDN 因资源过期发起大量主动回源。
所以我个人觉的 CDN 返回的 Date 响应头应该使用 CDN 服务器的当前时间,而不是用缓存的陈旧的源站时间。
二. 大部分 JS/CSS 文件在页面刷新时无法发送条件请求
也就是无法产生 304 响应。上篇文章已经说过了,Chrome 在页面刷新时已经不再为子资源文件发送条件请求了(直接读取缓存),但在国内,国产浏览器才是王道,尤其是移动端(没几个用 Chrome 的),目前这些浏览器仍然会在刷新时为页面中的 JS/CSS 文件发起条件请求。
如果你使用 Chrome 56(当前稳定版本)的话,需要把 chrome://flags/#enable-non-validating-reload-on-normal-reload 调成已停用(更高版本的 Chrome 已经没有这个选项,请换个浏览器),再运行下面的 demo:
<div>刷新当前页面,两个 JS 文件都应该是 304 响应</div> <script src="http://common.cnblogs.com/script/jquery.js"></script> <script src="https://g.alicdn.com/kissy/k/1.4.2/seed-min.js"></script>
上面这个 demo 中有两个 JS 文件,一个是淘宝 CDN 上的 KISSY,一个是博客园上的 jQuery。当你刷新页面时,会发现 jQuery 这个请求的确是 304 响应,而 KISSY 这个每次是 200,也就是像完全没缓存一样:
上面的视频演示中,我为了模拟较差的网络环境,故意将网速节流成了 3G 模式。在 waterfall 列里可以看到,获取完整响应的 200 请求比只拿响应头的 304 请求多了 100 多毫秒的加载时间(蓝色条部分)。考虑到现在的网页没有 JS/CSS 基本什么都展现不了,所以这个问题会让页面刷新后的白屏时间大幅增加。
原因是什么?我们看一下这个 JS 文件的响应头(已经删掉了 x- 开头的):
$ curl -I 'https://g.alicdn.com/kissy/k/1.4.2/seed-min.js' HTTP/1.1 200 OK Server: Tengine Content-Type: application/javascript Content-Length: 44971 Connection: keep-alive Date: Thu, 23 Feb 2017 05:50:47 GMT Vary: Accept-Encoding Accept-Ranges: bytes Cache-Control: max-age=2592000,s-maxage=3600 Access-Control-Allow-Origin: * Via: cache16.l2eu6-1[0,200-0,H], cache17.l2eu6-1[1,0], cache4.cn298[0,200-0,H], cache5.cn298[1,0] Age: 2605 Timing-Allow-Origin: * EagleId: 8ccd84cd14878316529776698e |
问题就在,响应头里没有 Last-Modified 和 ETag,因此浏览器没法生成 If-Modified-Since 和 If-None-Match 请求头,所以没法发送条件请求,只能发个普通的非条件请求。
刷新并不算是极端情况,比如移动端的下拉刷新,是很常见的,因此刷新的用户体验也是需要保障的。