起因

最近团队里有一个项目需要实现资源的预加载,因为之前我维护过这个项目,所以负责实现的同学在出了方案之后,跟我说了一下。但是他在开发的过程中发现了一个问题:

我们有一个环境,这个环境的资源访问会打到路由对应的容器内,而容器内是一个 nginx,服务了特定路径下的所有静态资源,如 ⬇️ 图:

静态资源访问.png

容器里的 nginx 配置比较简陋,只配置了协商缓存的响应头(Last-Modified、ETag),没有配置强缓存的响应头(Cache-Control、Expires)。

而这个配置下的表现是:

在这个环境发布之后,页面请求 a/javascripts/xxx1.js,第一次会走 200,短时间内再次刷新,这个资源却走了 memory/disk cache,状态码 200,再过一段时间刷新,这个资源走了协商缓存,服务端返回 304,之后再次刷新,又走了 memory/disk cache,像下面这样:

访问时间线.png

第一次理解

我不理解啊!This is not right!

协商缓存之所以叫协商缓存,就是因为客户端在拿到资源之后,可以缓存,但是需要在每次使用缓存之前和服务端做一次沟通,确定缓存是否有效,如果有效,才可以使用。

协商协商,我(🐷🐔)没有给你(💻)强缓存的响应头,打算让你每次都和我协商,那你为何不与我协商了呢?

猫猫问号.png

复现问题

无奈,这个环境配置略微复杂,先尝试造一个最小可复现案例,最终造出了一个这样的结构:

.
├── app1.js
├── app2.js
├── index.html
└── server.js

index.html

<!DOCTYPE html>
<html lang="en">

<body>
    <script src="./app1.js"></script>
    <script src="./app2.js"></script>
</body>
</html>

server.js

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const Koa = require('koa');

// 获取文件路径的 hash 作为 tag
function getHash(f) {
    const hash = crypto.createHash('sha256');
    hash.update(f);
    return hash.digest('hex');
}

const app = new Koa();

// 对 js 请求发送 Last-Modified 和 ETag 响应头,其余请求 fallback 到 index.html
app.use((ctx, next) => {
    const filePath = path.join(__dirname, ctx.path);
    if (filePath.endsWith('.js')) {
        ctx.res.setHeader('Last-Modified', 'Thu, 25 Nov 2021 06:55:09 GMT')
        ctx.res.setHeader('ETag', getHash(filePath).slice(0, 12))
    }
    const isIllegalPath = !fs.existsSync(filePath) || filePath.endsWith('/')
    if (!isIllegalPath) {
        const content = fs.readFileSync(filePath, 'utf-8')
        ctx.body = content;
    } else {
        const content = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8')
        ctx.body = content;
    }
    return next();
});

app.listen(8080, () => console.log('server is listening on 8080...'));

在这个配置下,浏览器的行为与我们特殊环境的表现完全一致。而如果在 JavaScript 请求之后加上:

ctx.res.setHeader('Cache-Control', 'max-age=0')

那浏览器每次都会走协商缓存,这才是(我们以为的)正确的表现。

翻阅资料

复现了问题,但又完全不知道问题在哪儿,这个时候最好的办法就是上网翻文档。

关键词:

  • 协商缓存 200
  • 协商缓存 返回 200
  • 协商缓存 memory cache
  • 协商缓存 返回 memory cache
  • Cache-Control
  • negotiate cache but memory cache is used(忽略错词)
  • strong cache header is not present
  • ......

cat keyboard meme.gif

一顿操作猛如虎,结果什么也没搜到,脑子也迷糊了。

后退一步

🧘‍♂️ 静下心来仔细想想,现在 Chrome 表现是这样,不如换 Safari 试试,如果 Safari 表现与 Chrome

  1. 不一致,那可能就是 Chrome 的问题
  2. 一致,说明浏览器实现是相同的,可以去看标准里有没有相关的文档

试了一下发现,Safari 和 Chrome 的表现一致,那就说明 2 可能是正确的,于是去 MDN 上找文档。

柳暗花明

果然在 Caching 相关的文档里找到了一条貌似可疑的条目:Heuristic freshness checking,中文为启发式新鲜度检测。

文档里是这么写的:

heuristic freshness checking.png

简单翻译一下:

如果服务端没有通过 Cache-ControlExpires 等方式指定缓存有效时间,那么浏览器就去看是否存在 Last-Modified 响应头,如果存在,就根据以下公式:

// responseTime: 响应时间
// freshnessLifetime: 缓存有效时间,为 (响应头 Date - 响应头 Last-Midified) / 10
// currentAge: 当前时间
expirationTime = responseTime + freshnessLifetime - currentAge

算出缓存过期时间,并对这个资源应用这个过期时间。

that is interesting.jpeg

按照这个公式,如果服务端不设置强缓存响应头,那么缓存过期时间大约等于资源修改时间到访问时间的 10%,意思就是浏览器可以认为,如果在我访问到这个资源的时候,你已经有 n 长时间没有修改过资源了,那么你大概率在 n 的 1.1 倍的时间内也不会修改。

完美解释了我们这个环境的访问行为

文档里还给出了 HTTP 协议规范的地址:rfc7234#section-4.2.2,有兴趣的可以读一下,跟 MDN 里描述得差不多。

chromium 实现

突然想起来我本地有 chromium 的源码,既然如此,就去翻翻,看 chromium 里是不是真的这样。

net/http/http_response_headers.cc 文件里翻到了对应的实现:

http_response_header_cc.png

这里还备注了 implement a smarter heuristic,不知道 Darin 同学还有没有想起来这个事情。

总结

这个行为可以理解吗?其实仔细想想是可以的。

如果服务端没有告诉浏览器缓存的有效时间,那浏览器就可以根据服务端返回的资源状态向前推一个合理时间(10%),在这段时间之内应用强缓存。如果短时间内用户访问频繁的话,对网站来说节约了很多流量和非必要请求,对客户来说网站的加载速度更快了,是一个很好的低成本高收益的案例。

参考