2015年3月,一家北欧银行的服务器集群遭遇了诡异的问题:部分用户看到的是昨天的股票价格,而另一部分用户则完全无法加载页面。运维团队排查了数据库、应用服务器、负载均衡器,都没发现问题。最终发现,罪魁祸首是Expires头部中设置的过期时间戳。

服务器的时钟比客户端快了15分钟。当浏览器按照Expires标记的时间判断缓存过期时,服务器认为资源还很新鲜。这个微小的时间偏差,在CDN和浏览器的多层缓存链路中被放大,最终导致了大规模的内容不一致。

这个问题并非孤例。在HTTP缓存的二十年演进史中,类似的"时钟炸弹"困扰了无数开发者。而解决方案——Cache-Control头部的max-age指令——早在1997年就被写入了RFC 2068。但直到今天,仍有大量网站在使用Expires,或者误用Cache-Control的各种指令。

从绝对时间到相对时间:一个看似简单的设计变革

HTTP/1.0时代,Expires头部是控制缓存过期的唯一方式。它的语法简单直接:

Expires: Wed, 21 Oct 2026 07:28:00 GMT

这个设计有一个致命缺陷:它依赖服务器和客户端的时钟同步。如果用户的设备时间不准——无论是手动设置错误,还是时区配置问题,甚至是系统时钟漂移——缓存的行为就会变得不可预测。

1997年,Roy Fielding等人在HTTP/1.1规范(RFC 2068,后来被RFC 2616取代)中引入了一个新方案:Cache-Control: max-age=N。这里的N是相对秒数,表示"从响应生成时刻起,N秒内资源是新鲜的"。

Cache-Control: max-age=3600

这个设计的精妙之处在于:它不再依赖任何绝对时间点。浏览器只需要记录响应的生成时间,然后计算当前时间 - 生成时间 < max-age即可判断新鲜度。即使客户端时钟与服务器有偏差,也不会影响缓存逻辑。

优先级规则:当Expires和max-age同时存在

RFC 7234明确规定了优先级:如果同时存在Cache-Control: max-age和Expires,浏览器必须忽略Expires。这是一个重要的向后兼容设计——开发者可以同时设置两者,让支持HTTP/1.1的浏览器使用max-age,而老旧的HTTP/1.0客户端仍能理解Expires。

但现实往往更复杂。一些代理服务器和CDN在解析缓存头部时行为不一致。2014年,Cloudflare的技术博客曾报告过一个案例:某些代理服务器在max-age=0时仍然使用了Expires的值,导致本应立即过期的缓存被长时间保留。

no-cache的命名陷阱:这不是"不缓存"

HTTP缓存头部中最容易被误解的,莫过于no-cache这个指令。从名字上看,它似乎意味着"不要缓存"。但RFC 7234的定义完全不同:

The no-cache response directive indicates that the response can be stored in caches, but the response must be validated with the origin server before each reuse.

也就是说,no-cache的实际含义是:“可以缓存,但每次使用前必须向服务器验证”。这个命名误导了无数开发者,导致很多网站错误地使用了no-store——真正的"不缓存"指令。

两个指令的本质区别

Cache-Control: no-cache

  • 浏览器存储响应到缓存
  • 后续请求时,浏览器发送条件请求(If-None-Match或If-Modified-Since)
  • 如果服务器返回304 Not Modified,浏览器使用缓存的副本
  • 优势:节省带宽,同时保证内容新鲜度

Cache-Control: no-store

  • 浏览器完全放弃存储
  • 每次请求都从服务器获取完整内容
  • 适用场景:敏感数据(银行账户、个人隐私信息)
  • 劣势:每次请求都消耗完整带宽

Jake Archibald在2016年的博客文章中举了一个典型案例:一个新闻网站为了"确保内容新鲜",在所有页面上都使用了no-store。结果是,用户每次访问都需要重新下载所有HTML、CSS、JS,即使这些资源根本没有变化。而正确的做法应该是no-cache,让浏览器存储内容但每次验证,只有在内容真正变化时才重新下载。

Age头部的隐藏逻辑:缓存链中的时间累积

当响应经过多个缓存层(浏览器 → CDN → 反向代理 → 源服务器)时,每一层都可能添加Age头部。这个头部表示"这个响应已经存在了多少秒"。

RFC 7234定义了年龄计算公式:

age_value = Age header value (if present)
apparent_age = response_time - Date_header_value
corrected_age_value = max(age_value, apparent_age)
resident_time = current_time - response_time
current_age = corrected_age_value + resident_time

这个公式看起来复杂,但核心逻辑很简单:缓存的年龄是"上游传递的年龄"和"本地观察到的年龄"中的较大值,加上"在本层停留的时间"。

一个实际案例

假设一个API响应从源服务器到用户浏览器经过了三层缓存:

  1. 源服务器:Date: 10:00:00, Cache-Control: max-age=3600
  2. CDN:收到响应时间是10:00:10,存储到10:30:00。响应Age: 1800(30分钟)
  3. 浏览器:收到时间是10:30:05,Date头部显示10:00:00

浏览器计算:

  • apparent_age = 10:30:05 - 10:00:00 = 1805秒
  • corrected_age = max(1800, 1805) = 1805秒
  • 如果用户在10:31:00再次请求,resident_time = 55秒
  • current_age = 1805 + 55 = 1860秒 > max-age=3600? 不,仍然新鲜

但如果Age头部被中间层错误计算或缺失,浏览器就会做出错误的新鲜度判断。2017年,Mark Nottingham在博客"The State of Browser Caching, Revisited"中测试了各大浏览器的实现,发现Chrome和Firefox在处理Age头部时存在细微差异,而Safari在某些情况下会忽略Age头部。

Vary头部:缓存键爆炸的隐形杀手

Vary头部的设计初衷是支持内容协商:让同一URL根据请求头部的不同返回不同的内容。最常见的例子:

Vary: Accept-Encoding

这告诉缓存:“同一个URL,但Accept-Encoding不同的请求,应该分别缓存”。gzip版本和brotli版本各有独立的缓存条目。

问题出在哪里?

Jake Archibald在2014年的文章"The browser cache is Vary broken"中揭示了浏览器实现的一个致命缺陷:浏览器使用URL作为缓存键的主维度,Vary只是验证条件,不是独立的存储维度。

这意味着:

请求1: GET /resource, Accept-Language: en
响应1: Vary: Accept-Language, Cache-Control: max-age=3600

请求2: GET /resource, Accept-Language: zh
响应2: Vary: Accept-Language, Cache-Control: max-age=3600

理想情况下,浏览器应该存储两个独立的缓存条目。但实际行为是:存储响应1,然后请求2到来时,发现缓存的响应1的Vary条件不匹配,于是从网络获取响应2,然后覆盖掉响应1。

这就是"缓存键爆炸"问题的本质:不同变体的响应会互相覆盖,导致缓存命中率极低。

更糟糕的是,一些开发者误用了Vary:

  • Vary: User-Agent:User-Agent字符串有上千种变体,每个都会生成独立的缓存条目,但内容可能完全相同
  • Vary: Cookie:每个用户的cookie值都不同,会导致缓存完全失效
  • Vary: *:等同于"不可缓存"

Fastly的技术博客"Best practices for using the Vary header"中提供了一个经验法则:只有当请求头部的变化确实会导致响应内容本质不同时,才应该使用Vary。否则,应该通过URL路径或查询参数来区分内容,而不是依赖Vary。

启发式缓存:当服务器什么都不说时

如果响应既没有Cache-Control,也没有Expires,浏览器还能缓存吗?答案是可以,这叫"启发式缓存"(Heuristic Caching)。

RFC 7234 Section 4.2.2定义了启发式新鲜度的计算方法:

If the response has a Last-Modified header, the heuristic freshness lifetime is 10% of the time since the Last-Modified date.

Date: Fri, 06 Mar 2026 12:00:00 GMT
Last-Modified: Thu, 27 Feb 2026 12:00:00 GMT

计算:响应生成时间距离最后修改时间是7天(604800秒),启发式新鲜度 = 604800 × 10% = 60480秒(约16.8小时)。

如果没有Last-Modified头部,浏览器可能会使用默认值(通常10-30分钟),或者完全放弃缓存。

Paul Calvano在2018年的文章"HTTP Heuristic Caching (Missing Cache-Control and Expires Headers) Explained"中分析了Alexa Top 10000网站,发现超过30%的资源缺乏明确的缓存头部。这些资源被浏览器启发式缓存,行为不可预测,容易导致内容更新不及时或缓存污染。

immutable指令:为版本化资源而生

2017年,HTTP工作组发布了RFC 8246"HTTP Immutable Responses",定义了immutable指令:

Cache-Control: max-age=31536000, immutable

这个指令的语义是:“在这个max-age期间,资源永远不会改变”。即使是用户强制刷新(Ctrl+F5),浏览器也不会发送条件请求验证。

这个设计的背景是现代Web开发的"缓存爆破"(cache busting)模式:

<script src="/app.9f2d1a3c.js"></script>
<link rel="stylesheet" href="/styles.8e7b2f4a.css">

每次构建时,工具会根据文件内容生成hash值,并更新HTML中的引用。这意味着:

  1. 文件内容变化 → hash变化 → URL变化
  2. 旧的URL永远不会被再次请求
  3. 旧的缓存即使"过期"也无害,因为没有人会请求它

在这种模式下,使用immutable可以避免不必要的条件请求,特别是在用户频繁刷新页面时。HTTP Archive的数据显示,截至2020年,只有约15%的网站使用了immutable,但它对性能提升的效果显著。

stale-while-revalidate和stale-if-error:RFC 5861的现代扩展

2010年,Mark Nottingham提交了RFC 5861"HTTP Cache-Control Extensions for Stale Content",定义了两个扩展指令:

stale-while-revalidate:异步验证

Cache-Control: max-age=600, stale-while-revalidate=30

这个配置的含义:

  • 响应在生成后600秒内是新鲜的
  • 600-630秒之间,浏览器可以立即返回过期的缓存,同时在后台异步验证
  • 如果后台验证成功,缓存被更新;如果失败,缓存保持过期状态
  • 超过630秒,必须同步验证

这个机制解决了一个核心矛盾:“即时性"和"新鲜性”。用户总是能立即得到响应(即使稍微过期),同时后台默默更新,保证下次请求是新鲜的。

Google的web.dev博客提供了一个典型案例:一个显示"当前时间(分钟)“的API:

Cache-Control: max-age=60, stale-while-revalidate=900
  • 0-60秒:使用缓存,不验证
  • 60-960秒:使用缓存,后台验证
  • 超过960秒:必须同步验证

这样,用户每次请求都能在1秒内得到响应,而数据的新鲜度最多延迟15分钟。

stale-if-error:容错机制

Cache-Control: max-age=600, stale-if-error=86400

含义:

  • 响应新鲜期为600秒
  • 如果在验证时源服务器返回5xx错误,缓存可以继续使用过期的响应,最长86400秒

这个机制是系统可用性的最后一道防线。当源服务器宕机、网络分区或过载时,CDN和浏览器可以继续服务过期的内容,而不是向用户显示错误页面。

AWS CloudFront在2023年5月宣布支持这两个指令,文档中特别强调:“stale-if-error增强了用户体验和可用性,通过在源服务器返回错误时提供过期的内容。”

生产事故:缓存配置错误的代价

GitHub用户0xdabbad00维护了一个仓库"security_incidents_from_caching”,记录了因缓存配置问题导致的重大事故:

日期 公司 问题描述
2025年3月9日 DNB银行 用户看到其他用户的账户信息
2025年2月11日 Nordnet 股票交易数据混乱
2024年5月1日 Qantas航空 用户登录后看到其他用户的预订信息
2024年2月16日 Wyze摄像头 用户看到其他用户的摄像头画面
2023年9月8日 Wyze摄像头 再次发生同类事故
2023年3月21日 ChatGPT 用户看到其他用户的对话历史
2023年2月 SAS航空 用户个人信息泄露

这些事故的共同原因:使用了Cache-Control: public或忘记了private指令,导致包含用户私密信息的响应被存储在共享缓存(CDN、代理服务器)中,然后被其他用户获取。

Web Cache Deception:更隐蔽的风险

即使正确使用了private,还有一种攻击叫"Web Cache Deception"。攻击者诱导用户访问一个看起来像静态资源的URL:

GET /api/user/profile.jpg

如果服务器忽略了.jpg后缀,仍然返回用户个人信息(但使用了默认的Cache-Control: public, max-age=3600),那么这个响应就会被CDN缓存。攻击者之后请求同样的URL,就能获取受害者的私密数据。

预防措施:

  1. 所有动态内容必须明确设置Cache-Control: no-storeprivate
  2. 不要依赖URL后缀判断资源类型
  3. 定期审计缓存头部配置

浏览器实现的差异:从规范到现实的鸿沟

Mark Nottingham在2017年的博客"The State of Browser Caching, Revisited"中测试了主流浏览器的缓存行为:

Chrome:

  • 正确实现启发式缓存(Last-Modified的10%)
  • 支持immutable、stale-while-revalidate
  • 对Vary头部的处理:使用URL作为主键,Vary作为验证条件

Firefox:

  • 行为与Chrome基本一致
  • 对Age头部的计算更严格
  • 在Service Worker与HTTP缓存的交互上有更好的隔离

Safari:

  • 某些版本忽略Age头部
  • 对启发式缓存的计算方式不同
  • 在处理Vary头部时存在bug(已在后续版本修复)

IE/Edge:

  • 旧版本IE在有Vary头部时忽略max-age
  • 对no-cache和must-revalidate的语义理解有偏差

这些差异意味着,开发者不能假设"设置了Cache-Control就万事大吉"。必须在不同浏览器中测试实际行为,特别是涉及Vary、Age、启发式缓存等复杂场景时。

最佳实践:基于场景的缓存策略

Jake Archibald在"Caching best practices & max-age gotchas"中总结了两种核心模式:

模式一:不可变内容 + 长期max-age

Cache-Control: max-age=31536000, immutable

适用场景:

  • 版本化的静态资源(通过hash或版本号命名)
  • 字体文件
  • 图片等媒体资源

关键点:内容不会变化,变化时URL也会变化。这是最安全、性能最优的模式。

模式二:可变内容 + 服务器验证

Cache-Control: no-cache

配合ETag或Last-Modified:

请求: If-None-Match: "abc123"
响应: 304 Not Modified

适用场景:

  • HTML文档
  • API响应
  • 任何URL不变但内容会变化的资源

关键点:每次使用前都验证,确保新鲜度,同时节省带宽(304响应比完整内容小得多)。

避免的模式:短期max-age + 可变内容

Cache-Control: max-age=300

这是最危险的配置。假设:

  1. 用户在T=0访问页面,加载HTML、CSS、JS,都缓存5分钟
  2. 服务器在T=2更新了所有资源
  3. 用户在T=4刷新页面,HTML过期重新获取,但CSS和JS还在缓存期内
  4. 结果:新版HTML + 旧版CSS + 旧版JS = 功能异常

这种"资源版本不一致"的问题在生产环境中极难调试,因为:

  • 只在特定时间窗口发生
  • 不同用户可能处于不同的缓存状态
  • 刷新页面(强制重新验证)会"意外"修复问题,给用户"不稳定"的印象

CDN的额外复杂性

当CDN介入后,缓存策略变得更加复杂:

s-maxage:只对共享缓存(CDN、代理)生效,覆盖max-age

Cache-Control: max-age=300, s-maxage=3600

含义:浏览器缓存5分钟,CDN缓存1小时。这样CDN可以承担更多流量,而浏览器保持较高的新鲜度。

proxy-revalidate:类似于must-revalidate,但只对共享缓存生效

Cache-Control: max-age=3600, proxy-revalidate

含义:CDN上的缓存在过期后必须重新验证,而浏览器可以更灵活地处理过期缓存。

Vary头部的CDN行为:

  • Cloudflare:会解析Vary头部,但某些版本会忽略特定的Vary值
  • Fastly:完全支持Vary,并提供自定义缓存键的功能
  • AWS CloudFront:支持Vary,但在请求折叠(request collapsing)时可能引发问题

2022年,Hono框架曝出了一个安全漏洞(CVE-2024-21502):Cache中间件忽略了Cache-Control: private指令,导致本应只存储在浏览器缓存中的响应被存储在服务器端缓存中。这是典型的"规范理解不一致"导致的安全问题。

从HTTP/1.1到HTTP/3:缓存机制的变化

HTTP/2和HTTP/3主要在传输层进行了优化,对缓存机制的影响有限:

HTTP/2:

  • 引入了服务器推送(Server Push),可以预先推送资源到客户端缓存
  • 推送的资源共享HTTP缓存规则,但需要通过Cache-Control控制生命周期
  • 推送缓存的失效机制与普通缓存相同

HTTP/3:

  • 基于QUIC协议,但HTTP语义不变
  • 缓存头部(Cache-Control、ETag等)的处理逻辑与HTTP/2完全相同
  • 0-RTT连接中,可能使用缓存的响应快速渲染页面,但存在重放攻击风险

核心不变的是:无论协议如何演进,HTTP缓存的核心概念——新鲜度、验证、缓存键——都没有变化。变化的是传输效率和连接管理。

调试缓存的工具和方法

Chrome DevTools:

  • Network面板显示缓存状态:(from disk cache)、(from memory cache)、(from service worker)
  • 禁用缓存:在DevTools打开时,可以在Network面板勾选"Disable cache"
  • 强制刷新:Ctrl+Shift+R(macOS: Cmd+Shift+R)强制重新验证所有资源

curl命令行测试:

# 查看响应头部
curl -I https://example.com/resource

# 发送条件请求
curl -H "If-None-Match: \"abc123\"" -I https://example.com/resource

# 模拟不同的Accept-Encoding
curl -H "Accept-Encoding: br" -I https://example.com/resource

在线工具:

  • HTTP Archive:分析全球网站的缓存配置趋势
  • WebPageTest:测试真实浏览器的缓存行为
  • RedBot:分析HTTP缓存配置的正确性

未来的演进方向

Cache-Status头部(RFC 9211,2022年发布):

Cache-Status: ExampleCache; hit; ttl=300

这个头部让缓存明确报告自己的决策过程,解决"为什么这个响应被缓存/不被缓存"的黑盒问题。目前主要CDN(Cloudflare、Fastly、Akamai)都已支持。

No-Vary-Search(实验性):

No-Vary-Search: params=("utm_source" "fbclid")

告诉缓存:查询参数utm_source和fbclid不影响响应内容,不应该作为缓存键的一部分。这可以避免分析跟踪参数导致的缓存碎片化。

Service Worker与HTTP缓存的协作:

现代Web应用可以通过Service Worker完全控制缓存策略,但最佳实践仍然是"与HTTP缓存协作,而不是对抗"。Jake Archibald的建议:

// 正确:利用HTTP缓存
caches.open('v1').then(cache => {
  cache.addAll([
    '/',
    '/app.9f2d1a3c.js',
    '/styles.8e7b2f4a.css'
  ]);
});

// 错误:绕过HTTP缓存
caches.open('v1').then(cache => {
  cache.addAll([
    new Request('/', { cache: 'no-cache' })
  ]);
});

第一种方式,浏览器可以利用已有的HTTP缓存,减少网络请求。第二种方式强制每次都从网络获取,浪费了HTTP缓存的优化。

结语:理解权衡,而非追求完美

HTTP缓存的设计,本质上是"一致性"、“可用性”、“性能"三者之间的权衡:

  • 强一致性(每次都验证):保证新鲜度,但增加了延迟和服务器负载
  • 弱一致性(长时间缓存):提升性能,但可能导致内容过期
  • 最终一致性(stale-while-revalidate):平衡即时性和新鲜度,但增加了实现复杂度

没有完美的策略,只有最适合特定场景的权衡。理解每个头部、每个指令的真实语义,以及浏览器、CDN、代理服务器的实际行为,才能设计出既高性能又可靠的缓存策略。

Expires时代的问题——时钟同步——已经被max-age解决。但新的问题——Vary爆炸、启发式缓存不可预测、CDN配置不一致——仍然困扰着开发者。缓存投毒、Web Cache Deception等安全风险,提醒我们:缓存不仅是性能优化工具,也是安全边界的一部分。

最后的建议:如果你不确定某个资源的缓存策略,宁可保守一点。使用no-cache总是比错误的max-age更安全。性能可以通过其他方式优化,但数据泄露或功能异常的代价,往往远超那几百毫秒的网络延迟。


参考文献:

  1. Fielding, R., et al. “RFC 2616: Hypertext Transfer Protocol – HTTP/1.1.” IETF, June 1999.
  2. Nottingham, M., & Fielding, R. “RFC 7234: Hypertext Transfer Protocol (HTTP/1.1): Caching.” IETF, June 2014.
  3. Nottingham, M. “RFC 5861: HTTP Cache-Control Extensions for Stale Content.” IETF, May 2010.
  4. Nottingham, M. “RFC 8246: HTTP Immutable Responses.” IETF, September 2017.
  5. Archibald, J. “Caching best practices & max-age gotchas.” jakearchibald.com, April 2016.
  6. Archibald, J. “The browser cache is Vary broken.” jakearchibald.com, March 2014.
  7. Nottingham, M. “The State of Browser Caching, Revisited.” mnot.net, March 2017.
  8. Calvano, P. “HTTP Heuristic Caching (Missing Cache-Control and Expires Headers) Explained.” paulcalvano.com, March 2018.
  9. “Collection of incidents resulting from caching issues.” GitHub, 2025.
  10. PortSwigger. “Web cache poisoning.” Web Security Academy.
  11. “HTTP caching.” MDN Web Docs, Mozilla.
  12. “Keeping things fresh with stale-while-revalidate.” web.dev, Google.
  13. HTTP Archive. “Caching | 2020 Web Almanac.”
  14. Fastly. “Best practices for using the Vary header.” August 2014.
  15. Alderson, J. “A complete guide to HTTP caching.” jonoalderson.com, August 2025.