浅谈浏览器http的缓存机制

575 查看

针对浏览器的http缓存的分析也算是老生常谈了,每隔一段时间就会冒出一篇不错的文章,其原理也是各大公司面试时几乎必考的问题。

之所以还写一篇这样的文章,是因为近期都在搞新技术,想“回归”下基础,也希望尽量总结的更详尽些。

那么你是否还需要阅读本篇文章呢?可以试着回答下面这个问题:

我们在访问百度首页的时候,会发现不管怎么刷新页面,静态资源基本都是返回 200(from cache)

随便点开一个静态资源是酱的:

哎哟有Response报头数据呢,看来服务器也正常返回了etag什么鬼的应有尽有,那状态200不是应该对应的非缓存状态么?要from cache的话不是应该返回304才合理么?

难道是度娘的服务器故障了吗?

如果你知道答案,那就可以忽略本文了。

http报文中与缓存相关的首部字段

我们先来瞅一眼RFC2616规定的47种http报文首部字段中与缓存相关的字段,事先了解一下能让咱在心里有个底:

1. 通用首部字段(就是请求报文和响应报文都能用上的字段)

2. 请求首部字段

3. 响应首部字段

4. 实体首部字段

后续大体也会依次介绍它们。

场景模拟

为方便模拟各种缓存效果,我们建个非常简单的场景。

1. 页面文件

我们建个非常简单的html页面,上面只有一个本地样式文件和图片:

2. 首部字段修改

有时候一些浏览器会自行给请求首部加上一些字段(如chrome使用F5会强制加上“cache-control:max-age=0”),会覆盖掉一些字段(比如pragma)的功能;另外有时候我们希望服务器能多/少返回一些响应字段。

这种情况我们就希望可以手动来修改请求或响应报文上的内容了。那么如何实现呢?这里我们使用Fiddler来完成任务。

在Fiddler中我们可以通过“bpu XXX”指令来拦截指定请求,然后手动修改请求内容再发给服务器、修改响应内容再发给客户端。

以我们的example为例,页面文件走nginx通过 http://localhost/ 可直接访问,所以我们直接执行“bpu localhost”拦截所有地址中带有该字样的请求:

点击被拦截的请求,可以在右栏直接修改报文内容(上半区域是请求报文,下半区域是响应报文),点击黄色的“Break on Response”按钮可以执行下一步(把请求发给服务器),点击绿色的按钮“Run to Completion”可以直接完成整个请求过程:

通过这个方法我们可以很轻松地模拟出各种http缓存场景。

3. 浏览器的强制策略

如上述,当下大多数浏览器在点击刷新按钮或按F5时会自行加上“Cache-Control:max-age=0”请求字段,所以我们先约定成俗——后文提及的“刷新”多指的是选中url地址栏并按回车键(这样不会被强行加上Cache-Control)

事实上有的浏览器还有一些更奇怪的行为,在后续我们回答文章开头问题的时候会提到。

石器时代的缓存方式

在 http1.0 时代,给客户端设定缓存方式可通过两个字段——“Pragma”和“Expires”来规范。虽然这两个字段早可抛弃,但为了做http协议的向下兼容,你还是可以看到很多网站依旧会带上这两个字段。

1. Pragma

当该字段值为“no-cache”的时候(事实上现在RFC中也仅标明该可选值),会知会客户端不要对该资源读缓存,即每次都得向服务器发一次请求才行。

Pragma属于通用首部字段,在客户端上使用时,常规要求我们往html上加上这段meta元标签(而且可能还得做些hack放到body后面去):

它告诉浏览器每次请求页面时都不要读缓存,都得往服务器发一次请求才行。

BUT!!! 事实上这种禁用缓存的形式用处很有限:

1. 仅有IE才能识别这段meta标签含义,其它主流浏览器仅能识别“Cache-Control: no-store”的meta标签(见出处
2. 在IE中识别到该meta标签含义,并不一定会在请求字段加上Pragma,但的确会让当前页面每次都发新请求(仅限页面,页面上的资源则不受影响)

做了测试后发现也的确如此,这种客户端定义Pragma的形式基本没起到多少作用。

不过如果是在响应报文上加上该字段就不一样了:

如上图红框部分是再次刷新页面时生成的请求,这说明禁用缓存生效,预计浏览器在收到服务器的Pragma字段后会对资源进行标记,禁用其缓存行为,进而后续每次刷新页面均能重新发出请求而不走缓存。

2. Expires

有了Pragma来禁用缓存,自然也需要有个东西来启用缓存和定义缓存时间,对http1.0而言,Expires就是做这件事的首部字段。

Expires的值对应一个GMT(格林尼治时间),比如“Mon, 22 Jul 2002 11:12:01 GMT”来告诉浏览器资源缓存过期时间,如果还没过该时间点则不发请求。

在客户端我们同样可以使用meta标签来知会IE(也仅有IE能识别)页面(同样也只对页面有效,对页面上的资源无效)缓存时间:

如果希望在IE下页面不走缓存,希望每次刷新页面都能发新请求,那么可以把“content”里的值写为“-1”或“0”。

注意的是该方式仅仅作为知会IE缓存时间的标记,你并不能在请求或响应报文中找到Expires字段。

如果是在服务端报头返回Expires字段,则在任何浏览器中都能正确设置资源缓存的时间:

在上图里,缓存时间设置为一个已过期的时间点(见红框),则刷新页面将重新发送请求(见蓝框)

那么如果Pragma和Expires一起上阵的话,听谁的?我们试一试就知道了:

我们通过Pragma禁用缓存,又给Expires定义一个还未到期的时间(红框),刷新页面时发现均发起了新请求(蓝框),这意味着Pragma字段的优先级会更高。

BUT,响应报文中Expires所定义的缓存时间是相对服务器上的时间而言的,如果客户端上的时间跟服务器上的时间不一致(特别是用户修改了自己电脑的系统时间),那缓存时间可能就没啥意义了。

Cache-Control

针对上述的“Expires时间是相对服务器而言,无法保证和客户端时间统一”的问题,http1.1新增了 Cache-Control 来定义缓存过期时间,若报文中同时出现了 Pragma、Expires 和 Cache-Control,会以 Cache-Control 为准。

Cache-Control也是一个通用首部字段,这意味着它能分别在请求报文和响应报文中使用。在RFC中规范了 Cache-Control 的格式为:

作为请求首部时,cache-directive 的可选值有:

作为响应首部时,cache-directive 的可选值有:

我们依旧可以在HTML页面加上meta标签来给请求报头加上 Cache-Control 字段:

另外 Cache-Control 允许自由组合可选值,例如:

它意味着该资源是从原服务器上取得的,且其缓存(新鲜度)的有效时间为一小时,在后续一小时内,用户重新访问该资源则无须发送请求。

当然这种组合的方式也会有些限制,比如 no-cache 就不能和 max-age、min-fresh、max-stale 一起搭配使用。

组合的形式还能做一些浏览器行为不一致的兼容处理。例如在IE我们可以使用 no-cache 来防止点击“后退”按钮时页面资源从缓存加载,但在 Firefox 中,需要使用 no-store 才能防止历史回退时浏览器不从缓存中去读取数据,故我们在响应报头加上如下组合值即可做兼容处理:

缓存校验字段

上述的首部字段均能让客户端决定是否向服务器发送请求,比如设置的缓存时间未过期,那么自然直接从本地缓存取数据即可(在chrome下表现为200 from cache),若缓存时间过期了或资源不该直接走缓存,则会发请求到服务器去。

我们现在要说的问题是,如果客户端向服务器发了请求,那么是否意味着一定要读取回该资源的整个实体内容呢?

我们试着这么想——客户端上某个资源保存的缓存时间过期了,但这时候其实服务器并没有更新过这个资源,如果这个资源数据量很大,客户端要求服务器再把这个东西重新发一遍过来,是否非常浪费带宽和时间呢?

答案是肯定的,那么是否有办法让服务器知道客户端现在存有的缓存文件,其实跟自己所有的文件是一致的,然后直接告诉客户端说“这东西你直接用缓存里的就可以了,我这边没更新过呢,就不再传一次过去了”。

为了让客户端与服务器之间能实现缓存文件是否更新的验证、提升缓存的复用率,Http1.1新增了几个首部字段来做这件事情。

1. Last-Modified

服务器将资源传递给客户端时,会将资源最后更改的时间以“Last-Modified: GMT”的形式加在实体首部上一起返回给客户端。

客户端会为资源标记上该信息,下次再次请求时,会把该信息附带在请求报文中一并带给服务器去做检查,若传递的时间值与服务器上该资源最终修改时间是一致的,则说明该资源没有被修改过,直接返回304状态码即可。

至于传递标记起来的最终修改时间的请求报文首部字段一共有两个:

⑴ If-Modified-Since: Last-Modified-value

该请求首部告诉服务器如果客户端传来的最后修改时间与服务器上的一致,则直接回送304 和响应报头即可。

当前各浏览器均是使用的该请求首部来向服务器传递保存的 Last-Modified 值。

 If-Unmodified-Since: Last-Modified-value

告诉服务器,若Last-Modified没有匹配上(资源在服务端的最后更新时间改变了),则应当返回412(Precondition Failed) 状态码给客户端。

当遇到下面情况时,If-Unmodified-Since 字段会被忽略: