Published on
2752

深入理解 HTTP 启发式缓存:浏览器如何在没有 Cache-Control 时做决策

Authors
  • avatar
    Name
    小辉辉
    Twitter

一个让人困惑的场景

某天,你在 Chrome DevTools 的 Network 面板中看到了这样的响应头:

accept-ranges: bytes
content-length: 544043
content-type: image/png
date: Wed, 13 May 2026 07:06:26 GMT
etag: "ef64c8265e86ea52f7e5cc6feabff5d4"
last-modified: Sun, 10 May 2026 16:18:01 GMT

没有 Cache-Control,没有 Expires。按照常理,浏览器应该每次都会发起 If-None-MatchIf-Modified-Since 的协商缓存请求,对吗?

但实际情况是:刷新页面后,请求显示 200 OK (from memory cache) —— 浏览器直接使用了缓存,根本没有发起任何请求!

这就是 启发式缓存(Heuristic Caching)在起作用。


什么是启发式缓存?

启发式缓存是 HTTP/1.1 规范(RFC 7234)中定义的一种后备机制。当响应缺少明确的缓存指令(Cache-ControlExpires)时,缓存(包括浏览器、代理服务器、CDN)可以根据已有信息推测一个合理的缓存过期时间

RFC 7234 第 4.2.2 节的原话是:

If no explicit expiration time is present in the response, a heuristic expiration time might be computed.

翻译过来就是:没有明确过期时间,就可以推测一个出来

⚠️ 重要限制:RFC 规定,启发式计算的时间不能超过从响应产生开始计算的 24 小时(即 Date 头之后 24 小时)。但在实际实现中,各浏览器执行得并不严格。


浏览器如何计算启发式缓存时间?

Chrome 的实现算法

Chrome 使用了一个相对简单且激进的算法:

启发式过期时间 = (响应时间 - Last-Modified 时间) × 10%

其中:

  • 响应时间 = Date 头的值(或收到响应的时间)
  • Last-Modified = 资源最后修改时间

一个真实案例的演算

假设响应头:

  • Last-Modified: Sun, 10 May 2026 16:18:01 GMT
  • Date: Wed, 13 May 2026 07:06:26 GMT

计算步骤:

  1. 时间差 = 2026-05-13 07:06:26 - 2026-05-10 16:18:01 ≈ 2.62 天
  2. 转换为秒 ≈ 226,368 秒
  3. 应用公式:226,368 × 10% ≈ 22,637 秒
  4. 转换为小时:22,637 ÷ 3600 ≈ 6.29 小时

结论:这张图片在 Chrome 中的启发式缓存时长约为 6.3 小时。在此期间,刷新页面会直接从 (memory cache)(disk cache) 读取,完全不会向服务器发送任何请求。

为什么是 10%?

这个比例背后有一种假设:如果一个资源很久没有修改,那么它未来也不太可能很快被修改

举个例子:

  • 一张图片上次修改是 100 天前 → 启发式缓存时长 ≈ 10 天
  • 一个 CSS 文件 1 小时前刚改过 → 启发式缓存时长 ≈ 6 分钟

从统计学角度看,这个启发式规则在某些场景下是合理的,但它并不适用于所有情况。


不同浏览器的行为差异

Chrome(最激进)

  • 始终应用启发式缓存(只要有 Last-Modified
  • 使用公式:(Date - Last-Modified) × 10%
  • 遵循 24 小时上限(但不是非常严格)
  • 这也是为什么很多开发者“惊喜地”发现缓存没有按预期失效

Firefox(更保守)

  • 也支持启发式缓存,但上限更严格
  • 默认启发式上限 = 24 小时(符合 RFC 建议)
  • 在某些版本中,首选的启发式缓存时长 = (Date - Last-Modified) × 10%,但不超过 24 小时

Safari(最保守)

  • 启发式缓存时间通常更短
  • 更倾向于发起协商缓存请求(If-Modified-Since
  • 对非静态资源的启发式行为较弱

缓存类型对比

缓存类型是否发请求响应码场景
from memory cache❌ 否200(显示灰色)启发式/强缓存 + 内存充足
from disk cache❌ 否200(灰色)启发式/强缓存 + 磁盘
304 Not Modified✅ 是(验证)304协商缓存命中
200 OK✅ 是(完整)200缓存未命中

启发式缓存的潜在风险

1. 内容更新后用户看不到最新版本

假设你更新了一个 CSS 文件:

  • 旧文件 Last-Modified: 2026-05-10
  • 今天(2026-05-13)用户首次访问,浏览器计算出 6.3 小时的启发式缓存
  • 你在 1 小时后部署了新版本
  • 用户在部署后的 5 小时再访问 → 仍然看到旧样式

2. 不同浏览器行为不一致

同一个网站,Chrome 用户可能看到“新鲜”的启发式缓存,而 Safari 用户每次都发验证请求。这会导致:

  • 难以调试缓存问题
  • 用户看到的内容不一致

3. 浪费存储空间

如果启发式缓存时间设置得过长(比如 10 天),而你的资源实际上可能在 1 天后就变了,那么缓存既占用了用户磁盘空间,又提供了过时的内容。

4. 对 CDN 和中间代理的影响

很多 CDN 也有自己的启发式缓存逻辑,可能与浏览器不同,导致:浏览器从磁盘缓存读取旧内容 → CDN 缓存也可能旧 → 多级缓存都不可控。


如何控制或禁用启发式缓存?

最佳方案是:不要依赖启发式缓存,而是提供明确的缓存策略

方案一:明确的强缓存(推荐)

Cache-Control: max-age=3600
# 或者更完整的写法
Cache-Control: public, max-age=86400, immutable

方案二:每次都验证(禁用强制缓存)

Cache-Control: no-cache
# 或者
Cache-Control: max-age=0, must-revalidate

注意:no-cache 不是不缓存,而是要求每次使用缓存前都去服务器验证。no-store 才是真正的"不缓存"。

方案三:彻底阻止启发式计算

虽然不能在所有浏览器中完全"禁用"启发式逻辑,但你可以通过以下方式确保启发式缓存时间尽可能短:

Cache-Control: max-age=0
Expires: 0

或者在响应中添加 Cache-Control: private 和简短的最大有效期。

一个更保险的组合头

Cache-Control: max-age=3600, must-revalidate
Expires: Wed, 13 May 2026 08:06:26 GMT
Last-Modified: Sun, 10 May 2026 16:18:01 GMT
ETag: "ef64c8265e86ea52f7e5cc6feabff5d4"

这样:

  • 支持 HTTP/1.1 的客户端优先使用 Cache-Control: max-age=3600
  • 不支持 HTTP/1.1 的老客户端使用 Expires
  • 如果 max-age 过期,则通过 ETag/Last-Modified 做协商缓存
  • 启发式缓存不再被需要

如何检测你的网站是否正在使用启发式缓存?

Chrome DevTools 方法

  1. 打开 Network 面板
  2. 勾选 Disable cache(这是为了测试,正常用户不勾选)
  3. 刷新页面,观察资源的 Size
    • 显示 (memory cache)(disk cache) 但状态码为 200 → 可能是启发式缓存
  4. 点击某个资源 → Headers 标签页
    • 如果看到 Status: 200 (from disk cache) 但根本没有发送请求(Time 显示 0 ms 或 < 2 ms),那就是启发式缓存/强缓存

判断窍门

  • Cache-Control: max-age → 强缓存(预期行为)
  • 没有 Cache-Control,但有 Last-Modified → 很可能是启发式缓存
  • 多个页面会话后,同一个资源仍然直接从缓存读取 → 启发式缓存(没有过期)
  • 使用 Chrome 的 Clear site data 清除缓存后再加载 → 如果再次看到直接缓存命中,说明启发式逻辑再次生效

实际案例:为什么 Nginx 默认配置如此危险?

很多人的 Nginx 或 Node.js(express.static)静态服务器配置如下:

location ~* \.(jpg|png|css|js)$ {
    expires off;   # 没有 Expires 头
    # 也没有 add_header Cache-Control ...
    add_header ETag on;
    # 只有 Last-Modified 和 ETag
}

结果:

  • 浏览器会使用启发式缓存
  • 文件更新后很长时间(几小时到几天)用户看不到变化
  • 用户反馈“清除了缓存也没用” → 因为启发式缓存时间较长

解决方案:明确配置 Cache-Control

location ~* \.(css|js)$ {
    expires 1h;   # 等效于 Cache-Control: max-age=3600
    add_header Cache-Control "public";
}

或针对频繁变化的文件:

location ~* \.(html)$ {
    add_header Cache-Control "no-cache";
}

启发式缓存的历史与未来

历史背景

启发式缓存诞生于互联网早期(1990 年代),那时很多服务器不发送缓存头。设计者希望即使服务器配置不完善,浏览器也能合理且高效地利用缓存。

当时的主流模式是:

  • 静态资源往往很少变化
  • 带宽和性能昂贵
  • 宁愿长期缓存也不愿意多一次请求

现在的情况

  • 现代网站资源更新频繁(持续部署、热更新)
  • 带宽更便宜但等待时间更敏感
  • 缓存策略已成为前端性能优化的核心

未来趋势

  1. HTTP/3 + Cache Digests:允许客户端和服务端更高效地协商缓存状态
  2. Stale-While-Revalidate:提供在后台更新缓存的同时返回旧内容的能力
  3. 更严格的浏览器实现:Chrome 可能在将来降低启发式缓存的默认时长(目前已经有了限制 24 小时)
  4. 推荐开发者主动控制:Lighthouse 会警告“缺少明确的缓存策略”

值得遵守的最佳实践

1. 总是提供明确的 Cache-Control

# 不会变化的静态资源(带哈希值)
Cache-Control: public, max-age=31536000, immutable

# 会变化的资源
Cache-Control: public, max-age=3600

# HTML 等入口文件
Cache-Control: no-cache

2. 使用文件名哈希(Content Hashing)

不要依赖 Last-Modified 或启发式缓存进行版本控制。更好的方法:

<!-- 坏 -->
<link href="/style.css" />

<!-- 好 -->
<link href="/style.a3d8f2.css" />

配合 max-age=31536000(一年)。

3. 主动测试缓存行为

使用 curl 查看完整响应头:

curl -I https://example.com/image.png

确保看到明确的 Cache-ControlExpires

4. 记录和监控

如果你的网站仍依赖启发式缓存,请:

  • 在文档中指出
  • 定期审查旧资源的启发式缓存时长
  • 考虑添加 Cache-Control 来替代它

总结

项目说明
触发条件响应没有 Cache-ControlExpires,但有 Last-Modified
Chrome 算法(Date - Last-Modified) × 10%
实际表现资源直接从 memory cache / disk cache 读取,不发任何请求
最大限制24 小时(RFC 建议但浏览器实现不一)
风险内容更新后用户看不到最新版本,浏览器行为不一致
最佳方案主动提供明确的 Cache-Control,不要依赖启发式缓存

一句话总结

启发式缓存是浏览器在没有明确指令时的一种“聪明猜测”,但它不够可靠,生产环境应该总是提供明确的 Cache-Control 策略覆盖它。

检查清单

  • 响应中有 Cache-Control 吗?
  • 静态资源使用了文件名哈希吗?
  • 使用 curl 验证了缓存头?
  • 测试过浏览器刷新和硬刷新行为?
  • CDN 或中间代理的缓存策略与浏览器一致?

参考资源


你的网站还在依赖启发式缓存吗? 打开 Chrome DevTools 看看 Network 面板,说不定就会发现有资源显示了 (memory cache),而它的 Cache-Control 其实是缺失的。是的,浏览器比你想象的更“聪明”,但也因此更容易出错。