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响应从源服务器到用户浏览器经过了三层缓存:
- 源服务器:Date: 10:00:00, Cache-Control: max-age=3600
- CDN:收到响应时间是10:00:10,存储到10:30:00。响应Age: 1800(30分钟)
- 浏览器:收到时间是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中的引用。这意味着:
- 文件内容变化 → hash变化 → URL变化
- 旧的URL永远不会被再次请求
- 旧的缓存即使"过期"也无害,因为没有人会请求它
在这种模式下,使用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,就能获取受害者的私密数据。
预防措施:
- 所有动态内容必须明确设置
Cache-Control: no-store或private - 不要依赖URL后缀判断资源类型
- 定期审计缓存头部配置
浏览器实现的差异:从规范到现实的鸿沟
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
这是最危险的配置。假设:
- 用户在T=0访问页面,加载HTML、CSS、JS,都缓存5分钟
- 服务器在T=2更新了所有资源
- 用户在T=4刷新页面,HTML过期重新获取,但CSS和JS还在缓存期内
- 结果:新版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更安全。性能可以通过其他方式优化,但数据泄露或功能异常的代价,往往远超那几百毫秒的网络延迟。
参考文献:
- Fielding, R., et al. “RFC 2616: Hypertext Transfer Protocol – HTTP/1.1.” IETF, June 1999.
- Nottingham, M., & Fielding, R. “RFC 7234: Hypertext Transfer Protocol (HTTP/1.1): Caching.” IETF, June 2014.
- Nottingham, M. “RFC 5861: HTTP Cache-Control Extensions for Stale Content.” IETF, May 2010.
- Nottingham, M. “RFC 8246: HTTP Immutable Responses.” IETF, September 2017.
- Archibald, J. “Caching best practices & max-age gotchas.” jakearchibald.com, April 2016.
- Archibald, J. “The browser cache is Vary broken.” jakearchibald.com, March 2014.
- Nottingham, M. “The State of Browser Caching, Revisited.” mnot.net, March 2017.
- Calvano, P. “HTTP Heuristic Caching (Missing Cache-Control and Expires Headers) Explained.” paulcalvano.com, March 2018.
- “Collection of incidents resulting from caching issues.” GitHub, 2025.
- PortSwigger. “Web cache poisoning.” Web Security Academy.
- “HTTP caching.” MDN Web Docs, Mozilla.
- “Keeping things fresh with stale-while-revalidate.” web.dev, Google.
- HTTP Archive. “Caching | 2020 Web Almanac.”
- Fastly. “Best practices for using the Vary header.” August 2014.
- Alderson, J. “A complete guide to HTTP caching.” jonoalderson.com, August 2025.