☰
HTTP缓存控制实践:强缓存与协商缓存的核心原理与配置
2026/9/28 12:17:12 网站建设 项目流程

"常在河边走哪有不湿鞋",做Web开发这么多年,我见过太多次这样的场景:后端通宵改完一个Bug,上线后产品经理在群里喊"大家清一下缓存再看",结果用户那边的页面还是旧版,最后背锅的往往是"缓存"两个字。不少开发同学对缓存的理解停留在"Cache-Control 就是控制缓存的,Expires 就是过期时间"这个层面,真到排查问题的时候,连请求是走了强缓存还是协商缓存都说不清楚。

这篇内容就是冲着这个痛点来的。我会把 Cache-Control、Expires 这两个头字段掰开揉碎讲清楚,结合我实际排查过的线上事故案例,给出不同场景下可以直接抄的配置方案。不管你是前端、后端还是运维,只要你的服务要过浏览器、过 CDN、过网关,这篇文章都值得花十分钟读完。

1. 一个"刷新也没用"的经典场景,先搞清楚缓存是怎么判定的

很多年前我接手过一个内部系统,用户反复反馈"页面上的按钮点了没反应",后来发现是前端改版后代码根本没更新到用户浏览器里。我当时的处理方式很粗暴:让用户强制刷新(Ctrl+F5),然后问题暂时消失,但第二天又复发。那时候我对缓存的认知确实不够体系化,直到真正理解了浏览器的完整判定链路,才发现问题其实是一套可以被精确控制的逻辑。

1.1 从响应到下发的三次判断

一个资源从服务端返回后,能不能被浏览器缓存、缓存多久、过期之后怎么做,是有固定判定顺序的。抛开内存和磁盘缓存这类物理存储的差异,只看逻辑层面,大致是这么三步:

  1. 判断能不能存:响应头里有Cache-Control: no-store,那什么都别谈,这个响应不允许被任何地方保存副本。
  2. 判断新不新鲜:如果允许存,缓存系统会记录这个资源存下来的时间,然后根据max-age、Expires这些字段计算它还能不能直接用。如果还在有效期内,直接返回副本,连网络请求都不发。
  3. 判断要不要验证:如果已经过期了,浏览器会带着If-None-Match或If-Modified-Since去问服务器"这个资源变没变",服务器返回304 Not Modified就继续用旧副本,返回200就更新数据。

我见过很多人把这三步搅成一锅粥,尤其是把"过期"和"不能用"划等号。其实在 Web 缓存的语境里,资源过期只是表示"不能直接用了",它还有一次通过协商缓存"续命"的机会,如果服务器的校验结果说"内容没变",那这次网络请求的开销只有几百字节的响应头。

1.2 强缓存和协商缓存的本质区别

"强缓存"指的是第2步命中的情况,浏览器直接使用本地副本,Status 显示为200 (from disk cache)或200 (from memory cache),网络面板里这一条请求的时间几乎为 0。

"协商缓存"指的是第3步命中的情况,浏览器确实向服务器发了一个条件请求,服务器返回304 Not Modified,传输大小可能只有几百字节。相比强缓存它多了一次网络往返,但相比重新传输完整资源,它仍然是巨大的性能提升。

这里有个容易让人困惑的点:很多人手动按 F5 刷新页面,发现"明明设置了 max-age,怎么还是每次都会发请求?" 原因在于浏览器对"普通导航"和"用户手动刷新"的处理是不同的。普通导航时,浏览器会尽可能使用强缓存;用户按 F5 时,浏览器通常会在请求头里加上Cache-Control: max-age=0类似的标识,强制让资源进入协商环节。也就是说,你测试时看到的"刷新就走网络",不代表缓存配置失效了,而是浏览器自己的行为在干预。

2. Cache-Control 指令逐个拆,每个字段背后的真实意图

Cache-Control 是现在最核心的缓存控制字段,但它不是一个简单的"开"或"关"开关,而是一组指令的集合,用逗号分隔,可以同时出现。比如Cache-Control: public, max-age=31536000, immutable就是三个指令的组合。每个指令其实都在回答缓存系统的一个具体问题:谁能存我?能存多久?过期了怎么办?

2.1 max-age 与 s-maxage:新鲜期的两种尺度

max-age=600表示这个响应从生成时刻起,600秒(10分钟)内是新鲜的。注意单位是秒,不是毫秒。很多从Expires时代过来的人会习惯性地写绝对时间,而max-age给的是相对时长,这也是它更科学的核心原因——不依赖客户端时钟。

max-age是针对"接收这个响应的那一方"说的,包括浏览器和CDN等所有缓存。但有时候我想让浏览器缓存短一点、让CDN缓存长一点,怎么办?两个头的确不够,这时候就用s-maxage。它专门用于共享缓存(CDN、代理服务器),如果响应里同时出现max-age=60, s-maxage=3600,浏览器按60秒算新鲜期,CDN按3600秒算。浏览器不理解s-maxage,会直接忽略它。

这个机制在前后端分离的项目里非常实用。接口数据我可以让用户浏览器缓存1分钟,但让CDN缓存1小时,既能保证用户端的更新频率,又能大幅降低源站压力。

2.2 public、private 与 no-store:谁能拥有这个副本

public并不意味着资源是公开的、谁都能看,它只是在声明"所有缓存都可以存储这个响应"。private也不是加密,它表示"只能存到浏览器这种私有缓存里,CDN和中间代理不许存"。

真正容易踩坑的组合是public和包含用户隐私的数据。假设一个订单接口返回了当前用户的订单列表,如果响应头是Cache-Control: public, max-age=300,那么理论上任何中间代理都可以把这个响应缓存下来。当另一个用户用相同的 URL(比如不带用户标识的 GET 请求)访问时,就可能拿到前一个人的数据。这就是事故级别的问题。所以public只用在不含任何用户个性化信息的公共数据上,比如公司官网的 logo、公共活动页的配置等。

no-store是这三者里最"绝情"的,它直接禁止缓存系统保存响应副本。注意它和no-cache不是一回事,no-cache是"可以存,但用之前必须验证",no-store才是真正的"别存"。对于登录接口、支付接口、用户余额这种数据,没有任何理由让任何缓存碰它,直接上no-store。

2.3 no-cache、must-revalidate 与 immutable:过期后的处理策略

no-cache这个名字太有迷惑性了,我第一次接触时以为它等于"不缓存"。实际上它的准确含义是"每次使用前必须向服务器验证"。可以理解为缓存副本仍然存在本地,但每次读取都要先问一下服务器:"这个还能用吗?" 服务器说能用,返回304,浏览器再拿着旧副本去渲染。这种策略适合对实时性要求较高的 HTML 页面,它能保证内容永远不过期缓存,同时又能省下重复下载完整资源的带宽。

must-revalidate通常和max-age搭配,表示"一旦过期,必须回源验证;如果源站不可达,宁可报错也不能继续使用旧数据"。这个指令的潜台词是"数据一致性优先于可用性"。

immutable是我比较推荐的进阶配置,它告诉浏览器"这个资源在新鲜期内永远不会变,你不用每次刷新都来问我"。浏览器收到这个提示后,即使用户手动刷新页面,也可以直接跳过对这批资源的验证请求。它必须搭配max-age使用才有意义,而且只用在一类资源上——文件名带内容 hash 的静态资源。

2.4 请求头里也能出现 Cache-Control

很多人不知道Cache-Control既可以出现在响应头里,也可以出现在请求头里。请求头里常见的指令有max-age=0、no-cache、max-stale、min-fresh。比如浏览器按 F5 时自动带上Cache-Control: max-age=0,就是在告诉缓存系统"我不要强缓存,给我走验证流程"。

另一个请求头指令max-stale=600表示客户端允许接收过期不超过600秒的响应。这在弱网环境下很有用,比如移动端离线状态下可以继续使用过期缓存,而不是直接白屏。不过这类指令在实际业务中主要靠客户端(尤其是原生App)主动发送,浏览器默认行为不太一样。

3. Expires 和 Pragma:HTTP/1.0 时代的老朋友,为什么还没退休

聊完 Cache-Control,再回头看 Expires 这个老前辈。Expires: Wed, 21 Oct 2025 07:28:00 GMT用的是绝对时间,它告诉客户端"在这个时刻之前,可以直接使用缓存副本,无需验证"。这个字段从 HTTP/1.0 就有了,很多老系统里还在用,也确实还在工作,但它的设计有几个硬伤。

3.1 Expires 的三个硬伤

第一个硬伤是依赖客户端时钟。如果用户本机时间被改得不对,缓存行为就会变得不可预测。内部办公系统里经常有员工电脑时间不同步的情况,你明明设了缓存一天,他那台电脑可能两小时后就去回源了。

第二个硬伤是绝对时间无法表达"相对时长"。对服务器来说,生成一个资源时想表达的是"从生成起一小时内有效",用 Expires 就得动态计算那个绝对时刻。如果资源是静态的、由 CDN 返回的,那 Expires 的生成时刻和缓存存储时刻往往对不上,极易造成缓存提前过期或迟迟不失效。

第三个硬伤是 HTTP/1.1 引入 Cache-Control 之后,两者的优先级是明确的:同时存在时,必须使用 Cache-Control,忽略 Expires。这意味着 Expires 在现代浏览器里基本是"退居二线"的状态,它的存在主要是为了兼容那些只认 HTTP/1.0 的极端老旧的代理或爬虫。

3.2 现在的服务器还发 Expires 吗

说实话,现在的大部分主流框架和服务器都已经不再主动生成 Expires 了。Nginx 的expires指令在设置时间的时候,会自动同时输出 Expires 和 Cache-Control: max-age。如果你手动配置了 Cache-Control,Expires 其实可以忽略。但它作为兜底字段,在对接一些老旧的嵌入式设备、老版本客户端时仍有价值。

我个人的建议是:新项目统一用 Cache-Control 做缓存控制,Expires 能不写就不写,写了也是被忽略,还给排查问题增加干扰项。如果你非要兼容老环境,那也应该是Cache-Control: max-age=31536000与Expires: 一年后的某个时间同时出现,让新系统用前者就行。

顺带提一下 Pragma。Pragma: no-cache是 HTTP/1.0 时代的产物,作用类似今天的Cache-Control: no-cache,但它只能出现在请求头里,语义也比较模糊。现在除了在极老系统的兼容层里能看到它,基本可以当它不存在。

4. 五大场景的缓存头配置,可以直接抄的作业

讲完原理,到了最实用的环节。我按实际工作里最常见的五类资源,分别给出我验证过、踩过坑之后沉淀下来的配置方案。这些配置没有绝对标准,但它能让你在面对同类需求时少走弯路。

4.1 带 hash 指纹的静态资源:一年缓存不为过

前端构建工具(Webpack、Vite 这类的)会生成形如style.ab3c9f.css、app.8d7e22.js的文件名,hash 值会根据文件内容变化。文件名一变,URL 就变,旧文件永远不会再被引用。针对这类资源,最优配置是:

Cache-Control: public, max-age=31536000, immutable

一年有效期,配合 immutable 让浏览器连刷新时的验证请求都省略。注意,这里的关键前提是"文件名真的随内容变化"。如果你没有用 hash 命名,只是固定路径比如/js/main.js,那就绝对不能用这种配置,否则内容更新后用户端会一直拿到老文件。

我在实际项目中见过一个反面教材:有的团队懒得上构建工具的 hash 功能,直接把整个静态目录都配成了max-age=31536000,结果上线后用户端一周都是旧代码,只能半夜紧急清 CDN。这个教训很简单——immutable 与 max-age 一年只配给"永远不变"的资源,不要贪方便全局套用。

4.2 SPA 入口 HTML:no-cache 加 ETag 是标准答案

单页应用的index.html是所有资源的入口,它引用的 JavaScript、CSS 路径都是带 hash 的。这个 HTML 文件本身很小,但它必须保持最新,否则用户拿到的可能是引用旧资源的入口。

最合理的配置是:

Cache-Control: no-cache, must-revalidate

配合服务端自动生成的ETag。每次用户访问都会回源验证一次,文件没变就返回 304,变了就返回完整新 HTML。对 SPA 来说这个策略兼顾了实时性和带宽成本,几乎是行业标准做法。

有同学可能会想:"那我干脆直接 no-store,岂不是每次都拿最新的?" 不是不行,但对性能不友好。每次进入网站都要完整下载一遍 HTML,虽然体积不大,但多一次完整响应总归比 304 要慢;更重要的是,很多服务端渲染框架如果发现输出是 no-store,可能连页面级别的缓存策略都会受影响。所以 no-cache + ETag 才是正解。

4.3 业务 API:按数据归属分三档

API 的缓存策略不能一刀切,核心判断维度是"这份数据属于谁"。

  • 完全公开、无个性化:比如热点新闻列表、公共配置项,可以用public, max-age=60,让 CDN 也能缓存,有效减轻源站压力。
  • 用户相关但非敏感:比如用户的收藏列表、浏览记录,可以用private, max-age=120。浏览器可以缓存两分钟,CDN 不能缓存,避免串数据。
  • 实时性要求高的数据:比如库存、价格、订单状态,用no-cache, must-revalidate加 ETag,每次请求都验证,确保拿到的永远是最新值。

这里有个容易忽略的细节:有些团队在网关层配置了统一的缓存规则,导致后端明明没设缓存头,网关自己把 GET 请求缓存了。所以上 API 网关时,后端显式返回private或no-store这些头,也是一种防止中间层"自作主张"的手段。

4.4 敏感接口的保命配置

登录、退出、支付、余额查询、用户信息修改这五类接口,我强烈建议统一使用:

Cache-Control: no-store

不要加ETag、Last-Modified这些会引发协商的字段,也不要试图配private了事。private只能阻止共享缓存,但有些老旧的中间代理实现不规范,遇到private也可能违规缓存。只有no-store才能把"缓存"这个念头从源头掐掉。

注意:no-store不是加密,它只是禁止缓存。数据在传输过程中的加密要靠 TLS/HTTPS,缓存控制管不到那一层。

4.5 接 CDN 后如何让浏览器与 CDN 各缓存各的

接入 CDN 之后,你可以用s-maxage和max-age的组合实现"浏览器缓存与 CDN 缓存时长分离":

Cache-Control: public, max-age=60, s-maxage=3600

浏览器按 60 秒新鲜期执行,CDN 按 1 小时缓存。也就是说,CDN 会在用户第一次请求后缓存这个资源 1 小时,期间回源次数降为零;浏览器则最多直接使用该资源 1 分钟,之后会回源验证一次(此时命中的是 CDN 的缓存)。这套组合非常实用,尤其适合内容型站点。

需要注意的是,CDN 缓存时长不是越长越好,越长代表展示端更新越慢,你要在源站侧做好主动刷新预案:发布新版本时调用 CDN 的刷新 API,让关键资源立即失效,而不是等着 max-age 自然过期。

5. 两个真实事故的完整排查链路:从现象到根因

光讲配置不给案例等于没讲。下面这两个事故都是我现实中遇到过的,为了叙述方便做了脱敏简化,但排查思路完全保留。

5.1 事故一:发布后 24 小时用户端不更新

某业务前端上线新版本,开发自测一切正常,但用户反馈"24 小时了还是旧页面"。远程看了一眼用户电脑,打开 Network 面板,发现index.html这个请求的 Size 列显示from disk cache,而且状态是 200。

沿着这条线索往下查,我让用户把该请求的响应头截图发过来,看到关键信息:Cache-Control: max-age=86400,并且这个响应头作用在整个 HTML 上。再往下看,HTML 引用的main.js是固定路径,没有 hash,同样被缓存了一天。这下根因清楚了:入口 HTML 被强缓存了一天,里面引用的 JS 又是固定路径,等于整条链路的更新入口被堵死了。

解法分两步走:第一步,index.html改为no-cache, must-revalidate,同时配上 ETag,让入口每次回源验证;第二步,前端构建产物改用带 hash 的文件名,静态资源才能安全地配置长缓存。这两步缺一不可,入口不放开,下面资源改啥都白搭。

5.2 事故二:接口返回了另一个用户的余额

这是一次线上 P0 事故。某内部系统用户反馈偶尔看到别人的余额数据,测试人员凌晨三点打电话给我,我当场就警觉了——八成是共享缓存穿透了用户隔离。

排查过程:打开浏览器找到那个接口,响应头赫然写着Cache-Control: public, max-age=300。这是某员工早期写代码时为了"加快接口响应"加的,但它完全忽略了一个事实:这个接口的 URL 是固定的 GET 请求,没有把用户身份作为 URL 的一部分。中间一层公司内部网关开了透明缓存,于是用户 A 的余额响应被缓存到网关,用户 B 用同一个 URL 请求时,网关直接把 A 的数据给了 B。

处理方案也很直接:接口响应头立刻改成Cache-Control: no-store,并移除所有可能触发协商的ETag、Last-Modified;网关侧将这条路径加入不缓存名单,同时巡检所有 GET 接口是否还有类似问题。

这个案例给所有人的教训是:只要接口响应里含有个性化数据,就不要幻想中间代理会老老实实遵守private,直接 no-store,别给缓存留任何机会。

5.3 排查缓存问题的三件套

排查缓存问题,我常用的三个手段:

  1. Chrome DevTools Network 面板:直接看某个请求的 Status、Size 列。from disk cache和from memory cache是强缓存命中;304 Not Modified是协商缓存命中;200才是真实网络传输。
  2. curl 看响应头:curl -sI https://example.com/app.js能直接看到服务器返回的完整响应头,这是确认服务端配置最快的方式。想手动模拟条件请求,可以加-H 'If-None-Match: "xxx"'测试 ETag 是否生效。
  3. CDN 日志与 Age 字段:通过 CDN 平台的日志看 hit/miss 的比例,再结合响应头里的Age字段,能算出资源实际在 CDN 里存了多久、离过期还有多久。

6. 容易被忽略的关联字段与组合细节

最后再补几个跟前端缓存强相关但经常被忽略的字段和细节,这些内容我在排查事故时没少被坑过。

6.1 ETag 与 Last-Modified 怎么选

Last-Modified是服务端给出的修改时间,精度只有秒级。问题在于:文件重新生成但内容没变,会导致"假修改";有些服务器会自动更新时间戳,但内容其实没变,于是白白触发一次资源重传。

ETag是基于内容生成的指纹,精度更高,支持强弱校验,条件请求同时带If-None-Match和If-Modified-Since时,服务器以If-None-Match为准。所以我的习惯是:能上 ETag 就上 ETag,Last-Modified 当兜底。

6.2 Vary 是个隐形变量

Vary: Accept-Encoding是在告诉缓存系统"这个资源的响应会根据请求头Accept-Encoding的不同而变化",所以缓存的 key 必须把该请求头纳入计算。最常见的场景是同一个 URL 对支持 gzip 的客户端和不支持的客户端返回不同内容。

这个字段的坑在于:如果后端响应里写了 Vary,但缓存系统没按这个字段区分缓存 key,就可能给用户返回错误版本。反过来,Vary 写多了也会降低缓存命中率,因为每个变化因素都会把缓存拆成一个更小的碎片。我的原则是能用Accept-Encoding就只用它,不要轻易加User-Agent之类变化频繁的头。

6.3 Age、Date 与时间语义

Age字段通常由共享缓存(CDN)返回,表示这个资源已经在缓存里存活了多少秒。用max-age减去Age,就能算出它还剩下多少新鲜时间。排查"为什么 CDN 缓存这么快就过期"时,这个字段是第一手证据。另外,Date字段表示响应生成的时刻,是计算 max-age 起点的重要依据。

6.4 一组容易记错的头字段对照

头字段实际含义常见误解
Cache-Control: no-cache缓存副本可以被存储,但每次使用前必须验证以为等于不缓存
Cache-Control: no-store禁止存储任何副本与 no-cache 混淆
Cache-Control: private只能被浏览器私有缓存存储以为代表加密/数据私密
Cache-Control: public所有中间缓存都可以存储以为代表资源公开可见
must-revalidate过期后必须回源验证,源站不可达时不得用旧数据以为只是"重新验证一次"
max-age=0新鲜期为 0,使用前必然进入验证流程以为等价于 no-store

把这些字段搞清楚之后,再回头看"刷新还是旧版本"的问题,思路就清晰多了:先判断是强缓存还是协商缓存命中,再确认入口文件与静态资源的缓存头配置是否匹配,最后看是否存在"固定路径 + 长缓存"的组合雷区。我始终觉得,缓存控制不是"配个头就完事",它更像一套需要根据资源特性动态调整的策略,配错了轻则浪费带宽,重则泄露数据。希望这篇内容能帮你把这块短板补上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询