你刚把首页标题从“春季活动”改成“夏季活动”,线上服务器也确认部署成功了。自己打开页面,看到的是新标题;同事刷新后,却仍然看到旧标题。更奇怪的是,他在开发者工具里明明能看到一次请求,响应体却是空的,页面照样完整显示了出来。
这通常不是服务器“随机回滚”,而是同一个 URL 的响应可能已经被放在多个位置:浏览器的私有缓存、公司代理、CDN 的共享缓存,甚至 Service Worker 自己管理的 Cache Storage。刷新时也不一定重新下载正文。缓存可能直接交出一份仍然新鲜的副本,也可能只拿着一个版本标识去服务器核对,得到 304 Not Modified 后继续使用本地正文。
所以,理解缓存不能只问“有没有缓存”。真正要问的是四件事:这份响应能不能存、谁可以存、存下后多久能直接使用、过期后怎样确认它还能不能用。

页面“没有重新下载”至少有三种可能:新鲜的 HTTP 缓存直接命中;过期副本通过 304 验证后被复用;浏览器从历史快照或 Service Worker 恢复页面。它们的处理规则不同,排查时不要混成一个“浏览器缓存问题”。
缓存不是收到响应后就无条件保存,也不是保存后就永远可以直接返回。一次正常的 HTTP 缓存决策,可以拆成三个动作。
缓存先检查请求方法、响应状态、认证信息和响应头。常见的 GET 响应最容易被缓存;200、301、404 等部分状态在满足条件时也可以缓存。POST 并非协议上绝对不能缓存,但需要明确的新鲜度信息,而且实际实现支持有限,业务代码不应默认它会命中缓存。
如果响应里有 Cache-Control: no-store,缓存不应存储这次请求和响应。这个指令针对的是“从现在起不要存”,并不负责删除同一 URL 以前已经存下的副本。
存下来了,不代表下一个请求一定能用。缓存会先找“匹配的缓存键”。最基本的缓存键来自请求方法和目标 URI;如果响应含有 Vary,还要比较被列出的请求头。比如一个 URL 会按 Accept-Language 返回中文或英文,缓存就必须把语言差异算进匹配条件。
找到候选副本后,还要判断它是新鲜还是过期。新鲜副本通常可以直接返回,源站甚至看不到这次请求;过期副本通常要验证,或在明确允许的过期窗口内暂时使用。
浏览器 HTTP 缓存是私有缓存,只服务于当前用户环境。代理和 CDN 是共享缓存,一份副本可能服务很多用户。两者都理解 HTTP 缓存字段,但承担的风险不同:浏览器存下一份个人资料,通常只是这个用户再次看到自己的资料;共享缓存误存个人资料,可能把甲的内容交给乙。
private 用来阻止共享缓存存储完整响应,但仍允许浏览器存储;public 明确表示共享缓存可以存储响应,即使它在其他条件下通常不会被共享缓存接受。这里的 public 不是“页面对公众开放”的权限声明,它只是在描述缓存资格。
Cache-Control 看起来像一串逗号分隔的单词,其实每个指令回答的问题不同。把它们按“存储范围、直接使用时间、过期后的动作”分类,比背诵一长串定义更可靠。
no-store:不要存储这次请求和响应。适合密码找回链接、一次性令牌、极敏感账户数据。private:完整响应只允许私有缓存存储,不能进入共享缓存。适合个性化页面。public:允许共享缓存存储响应。它常用于明确开放共享缓存,但不能替代正确的授权设计。max-age=秒数:响应从生成或成功验证后,可以保持新鲜的时间。浏览器和共享缓存都能使用。s-maxage=秒数:只对共享缓存生效,并覆盖共享缓存看到的 max-age 和 Expires。浏览器忽略它。它同时带有 proxy-revalidate 的约束:一旦过期,共享缓存必须成功验证后才能复用。immutable:在响应仍然新鲜时,告诉客户端这个 URL 对应的内容不会变化,连用户刷新时的多余验证也可以省掉。它应和内容哈希 URL 一起使用,不要给会原地更新的文件贴上这个标签。Expires:用绝对日期描述过期时间。若同时存在适用的 max-age,后者优先,因为相对时间更不容易受时钟差异影响。no-cache:可以存,但每次复用前都必须先向源站成功验证。这个名字最容易骗人,它不是“不要缓存”。must-revalidate:新鲜时仍可直接使用;一旦过期,必须成功验证后才能复用。无法联系源站时,也不能自行拿旧内容顶上。proxy-revalidate:只约束共享缓存,含义与 must-revalidate 对应。stale-while-revalidate=秒数:过期后的指定时间内,可以先返回旧副本,同时在后台更新。stale-if-error=秒数:过期后的指定时间内,如果联系源站或验证时遇到错误,可以退回旧副本。
下面这行配置不是“先缓存一分钟,再公开,再验证”的程序:
Cache-Control: public, max-age=60, stale-while-revalidate=30它表达的是一组同时成立的约束:浏览器和共享缓存的新鲜期都是 60 秒,支持相应扩展的缓存还可以在过期后的 30 秒内一边返回旧内容一边更新。
若指令冲突,不能靠书写顺序解决。缓存应采用更严格的含义。例如 max-age=600, no-cache 不能让响应直接使用十分钟,因为 no-cache 要求复用前验证。s-maxage 对共享缓存同时意味着过期后必须验证,因此不应再期待共享缓存执行 stale-while-revalidate 或 stale-if-error。重复、非法或彼此冲突的新鲜度值,也可能让实现把响应直接视为过期。
这些只是起点。库存、价格、权限变化能容忍几秒延迟,策略就可以更积极;支付结果、权限校验、一次性凭证不能接受旧值,就应牺牲一部分缓存收益。
假设浏览器最终收到:
HTTP/1.1 200 OK
Date: Fri, 14 Aug 2026 04:00:00 GMT
Cache-Control: public, max-age=120, s-maxage=600
Age: 90
Content-Type: text/html; charset=utf-8浏览器看见 max-age=120,共享缓存看见 s-maxage=600。Age: 90 表示这份响应从源站生成或验证后,估计已经过去 90 秒;它不是“刚到浏览器,所以年龄从零开始”。经过多层缓存时,之前驻留的时间和网络传输时间都要算进去。
缓存的核心判断可以简化为:
新鲜 = 当前年龄 < 新鲜寿命“新鲜”只表示服务器承诺的直接复用时间还没用完,不表示缓存知道源站此刻有没有改。服务器若在第 10 秒改了内容,而浏览器拿到的副本仍有 110 秒新鲜期,浏览器完全可能继续展示旧内容。这不是缓存违反配置,恰恰是配置在正常工作。
缓存确定新鲜寿命时,按以下顺序选第一个适用值:
s-maxage。max-age。Expires 与 Date 的差值。
没有 Cache-Control 不等于“肯定不缓存”。对于本身允许启发式缓存的响应,一些缓存会参考 Last-Modified 推测内容能保持多久。常见实现可能取“从最后修改到现在这段时间”的一小部分,例如约一成,但这个比例不是协议规定的统一算法。
这会带来一个很不舒服的结果:你没写缓存策略,系统却替你猜了一套,而且浏览器、代理、CDN 的猜法未必一致。对更新节奏有要求的内容,应该明确发送 Cache-Control,不要把一致性预算交给启发式算法。
缓存过期只说明“不能再不问服务器就直接用”,并不等于旧正文已经作废。如果服务器给过验证器,缓存可以发一个条件请求:先问版本是否变化,再决定要不要下载完整内容。
ETag 是服务器为某个资源表示生成的版本标识。它是一个不透明字符串,客户端不应猜它是哈希、数据库版本号还是发布时间。
第一次响应可能是:
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "article-8f32"
Content-Type: text/html; charset=utf-8
Content-Length: 42810下次复用前,浏览器带上旧标识:
GET /article HTTP/1.1
Host: example.test
If-None-Match: "article-8f32"内容没变时,服务器只回答:
HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "article-8f32"304 没有消息正文。浏览器把缓存里的 42810 字节正文拿出来,结合这次响应更新相关元数据,然后显示页面。这就是“Network 面板里有请求,却根本没下载正文”的常见原因。
Last-Modified 用资源的修改时间充当验证器。缓存会在条件请求里发送 If-Modified-Since。如果资源在该时间之后没有修改,服务器也可以返回 304。
它容易生成,但时间精度有限。一个资源在同一秒内连续变化,或者多台服务器的修改时间不一致,都可能让判断不够可靠。ETag 可以直接表达业务版本,通常更精确。
服务器可以同时提供两者。如果请求同时带有 If-None-Match 和 If-Modified-Since,缓存验证应优先依据 If-None-Match;不能在 ETag 已经变化时,又因为修改时间没变而错误返回 304。
强 ETag 表示两个表示在字节层面一致;弱 ETag 以 W/ 开头,表示语义等价但字节未必完全相同。缓存用 If-None-Match 验证是否需要重新传输时,可以使用弱比较。断点续传、并发更新保护等要求字节级一致性的场景,则不能把弱 ETag 当作强验证器。

下面的示例只使用 Node.js 内置模块。它按 Accept-Language 生成中文或英文表示,发送对应的 ETag,并正确处理两个验证器的优先级。
// cache-server.mjs
import http from 'node:http';
import { createHash } from 'node:crypto';
const port = 3000;
const lastModified = new Date('2026-08-14T04:00:00Z');
function createETag(body) {
const digest = createHash('sha256').update(body).digest('hex'
保存为 cache-server.mjs 后运行:
node cache-server.mjs
curl -i \
-H 'Accept-Language: zh-CN' \
http://127.0.0.1:3000/article
curl -i \
-H 'Accept-Language: zh-CN' \
-H 'If-None-Match: "把第一次响应中的 ETag 填在这里"' \
http://127.0.0.1:3000/article第二次请求的 ETag 匹配时会得到 304。把语言改为 en 后,正文和 ETag 都会变化;这也正好引出下一个容易漏掉的字段。
假设 /article 会读取 Accept-Language。第一位用户请求中文,CDN 缓存了中文响应;第二位用户请求英文。如果缓存键里只有 URL,第二位用户也会收到中文。
服务器需要明确告诉缓存,选择响应时用到了哪些请求头:
Vary: Accept-Language从此,缓存会把不同的语言请求看成不同变体。压缩通常对应 Vary: Accept-Encoding,跨域响应如果随请求的 Origin 改变,则需要正确处理 Vary: Origin。

把 Cookie 放进 Vary,理论上能按整段 Cookie 区分缓存条目,实践中却很容易制造海量缓存键,让命中率快速下降。更严重的是,只要响应逻辑还有一个遗漏的个性化维度,就可能串数据。
用户资料、购物车、账户页面应优先使用 Cache-Control: private 阻止共享缓存,而不是试图用 Vary: Cookie 枚举每位用户。授权仍然必须在后端执行,缓存键不能充当权限系统。
Vary: * 表示响应取决于无法用一组请求头描述的因素。这样的已存响应不能在不验证的情况下匹配后续请求,通常会让缓存收益接近消失。Vary 列出的字段越多,变体数量也越多;只列真正影响响应内容的字段。
我们通常把“旧内容”理解成错误,但现实里还要问一句:旧多久,发生在什么业务里?一篇文章晚 20 秒更新,可能比让所有读者等待源站恢复更好;库存和支付状态晚 20 秒,就可能直接造成业务事故。
Cache-Control: public, max-age=60, stale-while-revalidate=30前 60 秒,缓存副本是新鲜的。接下来的 30 秒内,支持该指令的缓存可以立即返回旧副本,并在后台发起验证。后台更新完成后,后续请求再得到新版本。
代价很明确:用户可能在 30 秒窗口内看到旧内容。好处是第一个撞上过期时刻的请求不用承担完整回源延迟,热门资源也不容易在同一秒把大量验证请求压到源站。缓存实现还可能合并同时到来的验证请求,但应用不能假定所有产品都会用完全相同的方式处理并发更新。
Cache-Control: public, max-age=60, stale-if-error=86400副本过期后,如果回源遇到网络故障或服务器错误,支持该指令的缓存可以在允许的 86400 秒窗口内交付旧副本。它很适合文档、公共内容页和变化不频繁的目录数据。
这不是“发生任何问题都吞掉”。不同缓存产品对错误类型和后台更新方式的支持可能不同。协议层面,must-revalidate、proxy-revalidate 以及适用于共享缓存的 s-maxage 都明确禁止过期副本在未成功验证时复用,因此不要把它们和 stale 指令配在一起期待“严格验证”和“返回旧内容”同时发生。部署前还要按实际 CDN 和代理行为测试,不能只看响应头觉得配置已经生效。

权限、余额、库存扣减、支付状态等内容不适合随意使用过期副本。缓存提高可用性的代价,是主动接受一段时间的不一致。先写清业务能容忍的最长旧值,再决定 stale 窗口,而不是反过来从某个常见配置抄一个秒数。
最棘手的情况是:资源还在新鲜期内,服务器却必须立刻发布新版本。对已经进入用户浏览器的响应,源站无法主动敲门要求删除。解决办法通常分成两类。
HTML、API 列表这类 URL 通常要原地更新。它们适合短 max-age,或使用 no-cache 搭配 ETag、Last-Modified。这样每次使用前可以确认版本,内容没变时只付出一次往返和少量头部,内容变了才下载完整响应。
CDN 可以提供 purge 或 invalidate 接口,主动删除边缘节点中的缓存条目。但一次清理是否覆盖所有区域、所有变体和所有缓存层,取决于产品配置。更重要的是,CDN 清理通常触碰不到用户浏览器里仍然新鲜的副本。
构建产物更适合把内容哈希放进文件名:
/assets/app.4f83c1a2.js
/assets/site.91b76e40.css这些 URL 一旦发布就不再原地修改,可以放心使用:
Cache-Control: public, max-age=31536000, immutable新版本上线时,HTML 改为引用新的哈希 URL。浏览器看到的是一个从未请求过的地址,自然会下载新文件;旧文件继续留在缓存里也不会污染新页面。

哈希 URL 解决了失效问题,也带来部署约束。正确顺序通常是先上传新静态资源,再发布引用这些资源的新 HTML,并让旧资源保留一段时间。若先发布 HTML,边缘节点拿到新页面时,新 JS 可能还不存在;若部署完成后立刻删除旧资源,仍缓存着旧 HTML 的用户会请求已经消失的文件。
查询参数也能形成不同 URI,例如 /app.js?v=42,但中间缓存是否忽略、保留或规范化查询参数可能受产品策略影响。内容哈希文件名更容易看出“这个 URL 是否不可变”,也更适合自动化构建。
一次成功的 POST、PUT 或 DELETE 可能让处理该请求的缓存失效相关 URI,但它不会自动广播到所有浏览器、所有 CDN 节点和应用内部缓存。业务上的“数据已更新”,不能等同于“全球所有副本已删除”。
缓存事故里最严重的通常不是“页面旧了”,而是“用户看到了别人的页面”。这种问题常出现在 CDN 被强制缓存所有 200 响应,或者后端把个性化响应错误标成 public。
响应里有 Set-Cookie,请求里有 Cookie,都不应被当成可靠的“自动私有化”策略。不同缓存产品还能通过规则覆盖源站头部。只要响应与用户身份有关,就明确发送 private;如果内容不应落入 HTTP 缓存,再加 no-store。
Cache-Control: private, no-storeno-store 也不是完整的安全方案。它不能撤回已经泄露的响应,不能替代 HTTPS,不能修复日志里记录的令牌,也不能阻止应用代码把数据写进其他存储。缓存策略只负责缓存边界内的行为。
与其默认缓存所有动态路由,再逐个排除敏感页面,更稳妥的做法是只允许经过审查的公共路由进入共享缓存。审查时至少确认:响应是否随身份变化、是否包含权限相关数据、缓存键是否覆盖所有表示维度、旧值最多能容忍多久、出错时是否允许返回旧副本。
如果 CDN 规则会覆盖 Cache-Control,就把规则也纳入代码评审和发布检查。源站响应头正确,不代表边缘最终执行的策略一定正确。
缓存问题之所以难查,是因为一次请求可能在浏览器、Service Worker、公司代理、CDN 或源站中的任何一层结束。按下面的顺序观察,通常比反复清空一切更快找到责任层。
打开浏览器 Network 面板,保留日志并重新访问目标 URL。观察状态码、Transferred Size 和响应来源提示:
304,说明请求到达了某个能够验证的服务器,但正文来自本地缓存。200 也不能证明到达源站,CDN 完全可以用缓存副本返回 200。开发者工具里的“Disable cache”通常只在工具打开时禁用浏览器 HTTP 缓存,用它可以模拟首次访问。但它不会替你删除 CDN 副本,也不能自动绕过 Service Worker 自己的缓存逻辑。
先看这些标准字段:
Cache-Control
Age
Date
ETag
Last-Modified
Vary
Expires
Via再看 CDN 自己的命中状态头和请求追踪 ID。不同供应商的字段名不一样,但通常会告诉你是 HIT、MISS、BYPASS、STALE 还是正在更新。
可以用命令行把浏览器因素暂时移开:
curl -i http://127.0.0.1:3000/article
curl -i \
-H 'Cache-Control: no-cache' \
http://127.0.0.1:3000/article
curl -i \
-H 'If-None-Match: "article-8f32"' \
http://127.0.0.1:3000/article请求中的 Cache-Control: no-cache 表示客户端希望沿途缓存先成功验证再回答,但中间产品配置仍可能有自己的约束。若要定位 CDN,可以对比正常域名、明确的源站测试入口以及不同节点的响应;不要在生产环境随意暴露可绕过网关的源站地址。
浏览器 HTTP 缓存、Service Worker Cache Storage、历史快照、应用内数据缓存和 CDN 缓存是不同系统。清空浏览器缓存后问题仍在,说明线索可能在更上游;禁用 CDN 后仍旧出现,可能是 Service Worker 或应用状态。
强制刷新适合诊断,不适合成为发布流程。一个版本必须依靠用户“多按几次刷新”才能生效,说明 URL 版本化、缓存头或失效流程仍有缺口。
缓存真正做的事情,是用一段可接受的不一致换取更少的延迟、带宽和源站压力。max-age 决定你愿意多久不问源站,验证器决定过期后能否只核对版本,Vary 决定哪些请求可以共享副本,stale 指令决定源站变慢或出错时能否暂时相信旧值。
设计时可以从一个具体问题开始:如果这个响应旧了 10 秒,最坏会发生什么?答案只是文章晚更新,就可以大胆利用共享缓存;答案涉及越权、超卖或重复支付,就应缩短甚至取消复用窗口。
当浏览器、CDN、网关和应用缓存一起出现时,每一层都成了系统的集成点。下一步要关注的已经不只是“这一层能不能命中”,而是这些组件之间如何传递身份、版本、错误和失效信号。缓存配置最终是否可靠,就取决于这些边界能不能对得上。