一个小demo带你看懂http缓存策略

154 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

明明线上环境更新了tag,但是代码却没有生效?明明我这台机器上看是最新代码,为什么测试那边还是老代码?揭秘http缓存的神秘面纱,带你看懂静态资源请求背后的故事

用我们熟悉的nodejs,先起一个本地服务器,浏览器访问localhost:9001

const http = require('http');

const httpCtx = http.createServer((req, res) => {
    if (req.url.indexOf('/favicon.ico') > -1) {
        // 网站icon响应拦截
        res.end();
        return;
    }
    res.writeHead(200, {
        'Content-Type': 'application/json;charset=utf-8',
    });
    res.end('111');
    console.log('请求打到服务器❤');
});

httpCtx.listen(9001, () => {
    console.log('服务开启');
});

此时我们每次刷新浏览器,都会发起请求,即控制台都会打印'请求打到服务器❤',证明接收到了浏览器过来的请求,这种就是无缓存的情况。接着,从上面那段代码出发,让我们来进入缓存的世界吧

强缓存

顾名思义,强制缓存,意味着定义的有效缓存时效内,浏览器只使用本地缓存文件(当有本地缓存文件的前提下),不会向资源服务器发起http请求。

配置强缓存的header字段有两个:

Expires <http 1.0>

Expires配置非常简单,接受一个参数,即请求资源主体过期的日期时间,值为GMT格式的标准时间(js通过toUTCString获取)

我们来改造下我们的响应头

res.writeHead(200, {
    'Content-Type': 'application/json;charset=utf-8',
    // 配置Expires字段,值为绝对时间
    Expires: new Date('2077-01-01').toUTCString(), // 当前年份2022
});

改造之后,我们先请求一次资源,此时响应头返回Expires字段

image.png

接着我们新开窗口(为什么要新开,后面再解释),重新访问9001端口

image.png 此时控制台并没有打印'请求打到服务器❤',说明请求并未实际发送,并且from disk cache告诉我们资源来自磁盘缓存,强制缓存生效。

  • 我们可以看到,Expires字段的配置简单易用,兼容性好(http1.0/1.1版本均可使用),那么,它又有哪些不足呢:
  1. 因为设置的是绝对时间,由于用户本地时间和服务器时间的误差,缓存结果会受影响;
  2. 由于是一锤定音的配置关系,在绝对时间内更新迭代,除非用户手动清除本地缓存,否则永远无法更新资源;

所以,http1.1引入了Cache-Control

Cache-Control <http 1.1>

Cache-Control字段支持的配置指令就比较多了,为了介绍强缓存,这里以max-age指令做示例。指令用法是max-age=[秒],来定义缓存时长,如果写0,则表示不使用缓存。

我们来改造下我们的请求头:

res.writeHead(200, {
    'Content-Type': 'application/json;charset=utf-8',
    // 配置Cache-Control字段
    'Cache-Control': 'max-age=20',
});

重新刷新页面,发现响应头是有配置该字段的

image.png

此时我们新开窗口重新访问,命中缓存,并没有向服务端发起请求,由于设置的时间是20,所以20s之后,新开窗口,由于已经过了缓存有效期,所以会再次重新发起请求。你会发现Expires配置也还存在呢,怎么没有生效了?是的,这是因为首部Cache-Control字段的配置优先级更高。

image.png

  • Cache-Control用相对时间解决了Expires端对端时差导致缓存失效的问题,同时支持的配置更加丰富,开发人员可以按需配置。同样的,由于http1.1才开始支持,所以不适用1.0版本,同时相对时间内的资源变更,同样无法同步到客户端,除非用户手动清除本地缓存。

协商缓存

与强制缓存不同,协商缓存即将缓存的标识校验,交给服务器维护,具体涉及以下字段

Etag - If-None-Match

如果服务端响应首部有Etag字段,那么当浏览器再次请求该资源时,请求头会携带上If-None-Match(Etag的值)字段。

直接改造我们的响应头:

res.writeHead(200, {
    'Content-Type': 'application/json;charset=utf-8',
    // 配置Etag
    Etag: 'jimous_is_cool',
});

当我们第一次请求资源的时候,浏览器响应接收到Etag字段

image.png

当我们再次请求资源,此时请求头会带上对应协商字段:

image.png

此时服务端从请求头获取该字段,并进行缓存校验,简单逻辑实现如下:

if (req.headers['if-none-match']) {
    res.writeHead(304, {
        'Content-Type': 'application/json;charset=utf-8',
        'Last-Modified': new Date().getTime(),
    });
    // 直接结束响应,不需要返回具体值
    res.end();
}

此时再次请求:(响应头自动添加了Last-Modified字段)

image.png

  • 到这里,一次协商缓存就完成了,此时解决了强制缓存有效期内的版本迭代发布无效问题,更加精确命中缓存规则,同时对Etag的计算维护也会带来一定消耗,同时分布式服务器储存Etag值要保证一致性。

Last-Modified - If-Modified-Since

与Etag表现类似,如果服务端响应首部有Last-Modified字段,那么当浏览器再次请求该资源时,请求头会携带上If-Modified-Since(客户端时间戳)字段。然后服务端对比当前请求时间戳和资源改动时间是否有变动来决定是否命中缓存。

<具体逻辑参考Etag即可,偷懒>

  • 用时间对比来决定缓存同样不存在强制缓存下的版本发布问题,但是粒度只能控制在秒级,如果单秒内有多次变更,无法更细化;同时如果资源没有实质性变化,但是时间有变动(比如只是周期内的单次代码变更,只是变更了标点符号),同样会重新返回资源给客户端。

浏览器刷新方式对缓存的影响

  • 以下操作会走强制缓存判断
  1. 新标签页面访问url
  2. 链接跳转
  3. 前进、后退
  4. 从收藏栏打开url
  5. window.open(url)
  • 刷新页面(F5,或左上角刷新),此时只会受协商缓存影响,强制缓存无效
  • 强制刷新,不走缓存

最后,对于缓存的维护可以交给上游服务器来处理,比如nginx配置缓存,网关缓存,并不会直接写到业务服务端。对于前端来说,能理解资源缓存命中问题,解决资源未命中,排查一般性问题,也已经足够了。有兴趣的,可以自行更加深入了解相关缓存配置。