Cache-Control 优先级高于 Expires,当二者同时存在时,浏览器仅遵循 Cache-Control,Expires 被完全忽略;这是 HTTP/1.1 协议强制规定,而非简单优先级高低。

Cache-Control 与 Expires 同时存在时谁生效
当响应头里同时出现 Cache-Control 和 Expires,浏览器只认 Cache-Control,Expires 被完全忽略。这不是“优先级高”,而是 HTTP/1.1 协议强制规定:只要 Cache-Control 存在(哪怕值是 no-cache),Expires 就失效。
常见冲突场景:
- 后端框架(如 Django、Spring Boot)默认加了
Expires,但反向代理(Nginx)又额外加了Cache-Control: public, max-age=3600→ 实际只有后者起作用,前者白配 - CDN 配置了
max-age=86400,源站又返回了Expires: yesterday→ 浏览器仍按 CDN 的Cache-Control执行,但部分中间代理可能误判 - 本地开发用
live-server,它默认设Expires但不设Cache-Control→ 在旧版 IE 或某些 WebView 中可能触发缓存,但在 Chrome/Firefox 中基本无效
ETag 和 Last-Modified 共存导致协商失败
两者可以共存,但浏览器只用其中一个发起条件请求:If-None-Match(对应 ETag)优先级高于 If-Modified-Since(对应 Last-Modified)。如果 ETag 生成逻辑不稳定(比如基于文件修改时间而非内容哈希),就可能出现“内容没变,ETag 变了”,导致本该 304 的请求变成 200。
典型问题表现:
立即学习“前端免费学习笔记(深入)”;
文章转信息图。将文章/笔记转化为手机可读的 HTML 信息图,自动匹配视觉风格。触发场景:文章转图、笔记转图、信息图、转小红书图、做张图、可视化这篇文章、文生图。
- Webpack 构建后输出的
main.js内容完全一致,但每次部署ETag都不同 → 用户反复收到完整响应,缓存命中率归零 - Nginx 默认开启
ETag,但后端 Node.js 服务也手动设置了Last-Modified,且两个时间戳来源不一致(一个用fs.stat,一个用构建时间)→ 协商结果不可预测 - CDN 节点重写了
ETag(比如转成内部 ID),而源站没关掉自己的ETag→ 客户端发If-None-Match,CDN 拦截后无法匹配源站响应
HTML 文件被强缓存却依赖动态资源
给 /index.html 配了 Cache-Control: public, max-age=3600,但页面里引用的 app.a1b2c3.js 是带 hash 的长期缓存——这本身没问题;但一旦 HTML 里还写了 <script src="/api/config.json"> 这类无 hash、无版本号的动态路径,就会出问题。
因为:
-
/index.html被强缓存 1 小时,期间用户始终加载旧 HTML - 但
/api/config.json若没设no-cache,也可能被中间代理缓存住,导致配置更新延迟甚至错乱 - 更隐蔽的是:HTML 里内联了
window.__API_BASE = "https://v1.api.example.com",这个值硬编码在缓存的 HTML 里,换域名后根本不会刷新
Service Worker 与 HTTP 缓存叠加引发的双重失效
Service Worker 的 fetch 事件会拦截所有网络请求,但它对缓存的控制和 HTTP 层是独立的。如果你既在响应头里设了 Cache-Control: max-age=600,又在 SW 里用 caches.match() 查找资源,就可能出现“HTTP 缓存已过期,但 SW 还拿着旧副本”或反过来的情况。
关键矛盾点:
- HTTP 缓存过期后,浏览器自动发条件请求(带
If-None-Match),但 SW 如果没透传这个 header,服务器收不到,直接返回 200 → 浪费带宽 - SW 缓存名没随版本变更(比如一直用
v1),而 HTML 文件又被 HTTP 强缓存 → 新 SW 安装后无法接管旧 HTML,用户持续运行老 JS - HTML 文件本身没被
cache.addAll()显式加入 SW 缓存,但 HTTP 层又给了长缓存 → 网络断开时白屏,且控制台连 fetch error 都不报
最常被忽略的一点:缓存冲突往往不出现在单个字段上,而出现在“多层缓存策略的时间窗口错位”——比如 Nginx 设了 1 年,CDN 设了 7 天,源站响应头又设了 1 小时,而 Service Worker install 时取的又是另一套逻辑。这时候不能只看某一个响应头,得用 curl -I 逐层抓包,确认最终到达浏览器的那组头到底是什么。


















