恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
HTTP缓存控制实践:强缓存与协商缓存的核心原理与配置
首页
资讯中心
/
HTTP缓存控制实践:强缓存与协商缓存的核心原理与配置
HTTP缓存控制实践:强缓存与协商缓存的核心原理与配置
发布时间:2026/9/28 12:17:23
常在河边走哪有不湿鞋做Web开发这么多年我见过太多次这样的场景后端通宵改完一个Bug上线后产品经理在群里喊大家清一下缓存再看结果用户那边的页面还是旧版最后背锅的往往是缓存两个字。不少开发同学对缓存的理解停留在Cache-Control 就是控制缓存的Expires 就是过期时间这个层面真到排查问题的时候连请求是走了强缓存还是协商缓存都说不清楚。这篇内容就是冲着这个痛点来的。我会把 Cache-Control、Expires 这两个头字段掰开揉碎讲清楚结合我实际排查过的线上事故案例给出不同场景下可以直接抄的配置方案。不管你是前端、后端还是运维只要你的服务要过浏览器、过 CDN、过网关这篇文章都值得花十分钟读完。1. 一个刷新也没用的经典场景先搞清楚缓存是怎么判定的很多年前我接手过一个内部系统用户反复反馈页面上的按钮点了没反应后来发现是前端改版后代码根本没更新到用户浏览器里。我当时的处理方式很粗暴让用户强制刷新CtrlF5然后问题暂时消失但第二天又复发。那时候我对缓存的认知确实不够体系化直到真正理解了浏览器的完整判定链路才发现问题其实是一套可以被精确控制的逻辑。1.1 从响应到下发的三次判断一个资源从服务端返回后能不能被浏览器缓存、缓存多久、过期之后怎么做是有固定判定顺序的。抛开内存和磁盘缓存这类物理存储的差异只看逻辑层面大致是这么三步判断能不能存响应头里有Cache-Control: no-store那什么都别谈这个响应不允许被任何地方保存副本。判断新不新鲜如果允许存缓存系统会记录这个资源存下来的时间然后根据max-age、Expires这些字段计算它还能不能直接用。如果还在有效期内直接返回副本连网络请求都不发。判断要不要验证如果已经过期了浏览器会带着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-age0类似的标识强制让资源进入协商环节。也就是说你测试时看到的刷新就走网络不代表缓存配置失效了而是浏览器自己的行为在干预。2. Cache-Control 指令逐个拆每个字段背后的真实意图Cache-Control 是现在最核心的缓存控制字段但它不是一个简单的开或关开关而是一组指令的集合用逗号分隔可以同时出现。比如Cache-Control: public, max-age31536000, immutable就是三个指令的组合。每个指令其实都在回答缓存系统的一个具体问题谁能存我能存多久过期了怎么办2.1 max-age 与 s-maxage新鲜期的两种尺度max-age600表示这个响应从生成时刻起600秒10分钟内是新鲜的。注意单位是秒不是毫秒。很多从Expires时代过来的人会习惯性地写绝对时间而max-age给的是相对时长这也是它更科学的核心原因——不依赖客户端时钟。max-age是针对接收这个响应的那一方说的包括浏览器和CDN等所有缓存。但有时候我想让浏览器缓存短一点、让CDN缓存长一点怎么办两个头的确不够这时候就用s-maxage。它专门用于共享缓存CDN、代理服务器如果响应里同时出现max-age60, s-maxage3600浏览器按60秒算新鲜期CDN按3600秒算。浏览器不理解s-maxage会直接忽略它。这个机制在前后端分离的项目里非常实用。接口数据我可以让用户浏览器缓存1分钟但让CDN缓存1小时既能保证用户端的更新频率又能大幅降低源站压力。2.2 public、private 与 no-store谁能拥有这个副本public并不意味着资源是公开的、谁都能看它只是在声明所有缓存都可以存储这个响应。private也不是加密它表示只能存到浏览器这种私有缓存里CDN和中间代理不许存。真正容易踩坑的组合是public和包含用户隐私的数据。假设一个订单接口返回了当前用户的订单列表如果响应头是Cache-Control: public, max-age300那么理论上任何中间代理都可以把这个响应缓存下来。当另一个用户用相同的 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-age0、no-cache、max-stale、min-fresh。比如浏览器按 F5 时自动带上Cache-Control: max-age0就是在告诉缓存系统我不要强缓存给我走验证流程。另一个请求头指令max-stale600表示客户端允许接收过期不超过600秒的响应。这在弱网环境下很有用比如移动端离线状态下可以继续使用过期缓存而不是直接白屏。不过这类指令在实际业务中主要靠客户端尤其是原生App主动发送浏览器默认行为不太一样。3. Expires 和 PragmaHTTP/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-ControlExpires 其实可以忽略。但它作为兜底字段在对接一些老旧的嵌入式设备、老版本客户端时仍有价值。我个人的建议是新项目统一用 Cache-Control 做缓存控制Expires 能不写就不写写了也是被忽略还给排查问题增加干扰项。如果你非要兼容老环境那也应该是Cache-Control: max-age31536000与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-age31536000, immutable一年有效期配合 immutable 让浏览器连刷新时的验证请求都省略。注意这里的关键前提是文件名真的随内容变化。如果你没有用 hash 命名只是固定路径比如/js/main.js那就绝对不能用这种配置否则内容更新后用户端会一直拿到老文件。我在实际项目中见过一个反面教材有的团队懒得上构建工具的 hash 功能直接把整个静态目录都配成了max-age31536000结果上线后用户端一周都是旧代码只能半夜紧急清 CDN。这个教训很简单——immutable 与 max-age 一年只配给永远不变的资源不要贪方便全局套用。4.2 SPA 入口 HTMLno-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-age60让 CDN 也能缓存有效减轻源站压力。用户相关但非敏感比如用户的收藏列表、浏览记录可以用private, max-age120。浏览器可以缓存两分钟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-age60, s-maxage3600浏览器按 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-age86400并且这个响应头作用在整个 HTML 上。再往下看HTML 引用的main.js是固定路径没有 hash同样被缓存了一天。这下根因清楚了入口 HTML 被强缓存了一天里面引用的 JS 又是固定路径等于整条链路的更新入口被堵死了。解法分两步走第一步index.html改为no-cache, must-revalidate同时配上 ETag让入口每次回源验证第二步前端构建产物改用带 hash 的文件名静态资源才能安全地配置长缓存。这两步缺一不可入口不放开下面资源改啥都白搭。5.2 事故二接口返回了另一个用户的余额这是一次线上 P0 事故。某内部系统用户反馈偶尔看到别人的余额数据测试人员凌晨三点打电话给我我当场就警觉了——八成是共享缓存穿透了用户隔离。排查过程打开浏览器找到那个接口响应头赫然写着Cache-Control: public, max-age300。这是某员工早期写代码时为了加快接口响应加的但它完全忽略了一个事实这个接口的 URL 是固定的 GET 请求没有把用户身份作为 URL 的一部分。中间一层公司内部网关开了透明缓存于是用户 A 的余额响应被缓存到网关用户 B 用同一个 URL 请求时网关直接把 A 的数据给了 B。处理方案也很直接接口响应头立刻改成Cache-Control: no-store并移除所有可能触发协商的ETag、Last-Modified网关侧将这条路径加入不缓存名单同时巡检所有 GET 接口是否还有类似问题。这个案例给所有人的教训是只要接口响应里含有个性化数据就不要幻想中间代理会老老实实遵守private直接 no-store别给缓存留任何机会。5.3 排查缓存问题的三件套排查缓存问题我常用的三个手段Chrome DevTools Network 面板直接看某个请求的 Status、Size 列。from disk cache和from memory cache是强缓存命中304 Not Modified是协商缓存命中200才是真实网络传输。curl 看响应头curl -sI https://example.com/app.js能直接看到服务器返回的完整响应头这是确认服务端配置最快的方式。想手动模拟条件请求可以加-H If-None-Match: xxx测试 ETag 是否生效。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 就上 ETagLast-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-age0新鲜期为 0使用前必然进入验证流程以为等价于 no-store把这些字段搞清楚之后再回头看刷新还是旧版本的问题思路就清晰多了先判断是强缓存还是协商缓存命中再确认入口文件与静态资源的缓存头配置是否匹配最后看是否存在固定路径 长缓存的组合雷区。我始终觉得缓存控制不是配个头就完事它更像一套需要根据资源特性动态调整的策略配错了轻则浪费带宽重则泄露数据。希望这篇内容能帮你把这块短板补上。