之前做页面改版,踩过一个很常见但又很磨人的坑:后端接口已经上新,页面打包也发了,可一部分用户打开还是旧页面,清浏览器缓存才正常。一开始想当然,觉得只要给 js、css 资源设置好 max-age,再带上文件哈希,就稳了。
实际线上跑起来才发现,中间链路比想象的多。浏览器缓存、CDN 节点缓存、代理服务器缓存,每一层都可能截留资源。我们当时打包是带 contenthash 的,文件名会跟着内容变化,理论上内容一改文件名就变,不会命中旧缓存。但静态页面 index.html 没有加哈希,Cache-Control 又设置了较长的缓存时间。
这就造成一个局面:用户本地缓存了旧的 index.html,html 里面引用的还是旧哈希的 js、css 链接。就算新的资源包已经上传到 CDN,只要浏览器不去拉新的 html,页面永远加载旧版本。很多人容易把重心放在脚本和样式文件上,忽略入口 html 本身的缓存控制。
后面又遇到另一个反向问题:为了避免缓存,给所有静态资源都设成 no-cache。这个配置很多人会混淆,no-cache 不是不缓存,而是每次使用缓存前,都要去源站校验一次 etag 或者 last-modified。如果 CDN 或者源站响应慢,大量并发请求上来,静态资源都要回源校验,回源带宽直接被打高,CDN 的加速效果大打折扣。要是直接上 no-store,那每次都完整下载文件,用户侧加载速度会明显变差。
还有一个容易忽略的点,是资源的更新和回滚。假设线上版本出问题,需要立刻回滚到上一个版本。如果旧版本的静态资源已经被清理,或者 CDN 节点已经过期淘汰,就算切回旧的 html,里面的资源地址访问 404,页面直接白屏。所以我们现在会保留最近两三个版本的静态资源包,不会发布之后立刻删除历史文件。
浏览器的刷新行为也会带来差异。普通刷新、强制刷新、无痕窗口,缓存策略表现完全不一样。本地自测的时候,很多开发习惯强制刷新看效果,很难复现普通用户自然访问下的缓存问题,测试阶段容易漏掉。
梳理之后,我们调整了一套比较稳妥的做法。
入口 index.html 设置短缓存,或者用 no-cache,保证浏览器会主动校验最新页面;带哈希后缀的 js、css、图片这类资源,设置比较长的 max-age,依靠文件名哈希变化实现更新。同时配合 ETag 做弱校验,CDN 侧单独配置,区分 html 和静态资源的缓存规则,不要一刀切。
另外还加了兜底机制,版本发布之后,先预热 CDN 缓存;重大变更的时候,给页面加版本查询参数做临时兜底,不长期依赖。发布之后持续观察 404 和资源命中率指标,命中率突然下跌,大概率是缓存配置或者哈希规则出了问题。
缓存这件事看着简单,就是几个 HTTP 响应头。但它是多层级协同的,只要其中一层配置和预期不一致,就会出现一部分用户版本错乱的现象。很多线上静态资源相关的 bug,根源都不是打包工具,而是对多层缓存的行为理解不到位。


