1. 从一次接口联调说起:为什么 304 值得单独拎出来讲前段时间帮一个朋友排查线上问题,前端反馈说某个列表接口“有时候返回数据、有时候返回空”,抓包一看,状态码是 304,响应体确实是空的。前端同学第一反应是“后端挂了”,后端同学第一反应是“你请求头带得不对”,两边扯了半天,最后发现是浏览器缓存策略和接口的缓存头没对齐。这种场景我遇到过太多次了,304 这个状态码本身不复杂,但它牵扯出来的强缓存和协商缓存这套机制,是很多前后端协作里最容易互相甩锅的地方。这篇文章想做的事情很明确:把 304 到底是什么、它和强缓存/协商缓存的关系、浏览器在什么条件下会走缓存、服务端该怎么配合设置响应头,从头到尾讲清楚。不管你是刚接触 HTTP 的前端新人,还是写了几年接口但一直没系统梳理过缓存的后端,或者是做性能优化时被缓存问题卡住的同学,看完应该都能对这套机制有个完整的认知,并且能直接拿去排查线上问题。我尽量不写成教科书,而是按照我实际排查问题的思路来组织:先搞清楚 304 出现的完整链路,再拆解强缓存和协商缓存各自的分工,然后落到具体的请求头/响应头配置,最后给一份排查清单。中间会穿插一些我踩过的坑,比如Cache-Control和Expires同时存在时谁说了算、ETag和Last-Modified到底该用哪个、为什么有时候改了文件浏览器还是拿旧的。2. 304 到底是什么:一次完整的缓存命中链路2.1 304 的字面含义和它在状态码家族里的位置304 的官方名称叫Not Modified,中文一般翻译成“未修改”。它属于 HTTP 状态码里的3xx 重定向类,但和 301、302 那种“让你去另一个地址”的重定向不一样,304 的语义是:你请求的这个资源,从你上次拿到之后到现在,没有发生变化,所以你本地那份还能继续用,我这次就不给你发新内容了。这里有个关键点很多人会忽略:304 响应是没有响应体的。也就是说,服务端只回了响应头,body 是空的。这就是为什么前面那个前端同学看到“返回空”会慌——其实不是接口挂了,是浏览器和服务端协商之后确认“不用重传”,浏览器会自己从本地缓存里把之前存的那份拿出来用。从状态码分类上看,1xx 是信息类,2xx 是成功,3xx 是重定向/缓存相关,4xx 是客户端错误,5xx 是服务端错误。304 被归在 3xx 里,是因为它本质上是一种“资源位置/内容不需要重新传输”的指示,而不是一次全新的成功响应。理解这一点,对后面理解缓存流程很重要。2.2 浏览器发起请求到拿到 304 的完整流程我把一次典型的缓存命中流程拆成几个阶段,这样你能看清楚每一步浏览器在做什么决策。第一阶段,浏览器准备发起请求。它会先检查本地缓存里有没有这个资源的记录,以及这条记录是否还在“新鲜期”内。这个“新鲜期”就是强缓存要管的事情。第二阶段,如果强缓存判定“还新鲜”,浏览器根本不会发请求,直接用本地缓存,这时候你在 Network 面板里看到的是200 (from disk cache)或者200 (from memory cache),注意这里状态码还是 200,不是 304。很多人以为走缓存就是 304,这是个常见误解。第三阶段,如果强缓存判定“过期了”或者压根没设置强缓存,浏览器会发一个带条件的请求给服务端。这个“条件”就是协商缓存的核心——浏览器会带上If-None-Match或If-Modified-Since这样的请求头,把上次拿到的ETag或Last-Modified值回传给服务端。第四阶段,服务端收到请求后,拿浏览器传来的标识和自己当前资源的标识做对比。如果一致,说明资源没变,返回 304,不带 body;如果不一致,说明资源变了,返回 200 加上新的完整内容和新的缓存标识。第五阶段,浏览器收到 304 后,从本地缓存取出之前存的内容渲染,同时用响应头里的新缓存信息更新本地记录的“新鲜期”。整个链路里,强缓存决定“要不要发请求”,协商缓存决定“发了请求之后服务端要不要给新内容”。这两层是串联关系,不是二选一。搞清楚这个分工,后面所有的配置和排查都会顺很多。2.3 为什么要有 304:省的不是带宽那么简单有人可能会问,既然协商缓存还是要发一次请求到服务端,那省了什么?直接返回 200 不也一样吗?省的东西比想象中多。第一是响应体传输,一个几百 KB 的 JS 文件或者一张图片,304 只回几十字节的响应头,差距是数量级的。第二是服务端渲染/查询成本,如果服务端能通过 ETag 快速判断资源没变,就不用重新走一遍数据库查询、模板渲染、序列化这些流程。第三是客户端解析成本,浏览器拿到 304 直接用本地缓存,省去了重新解析、重新执行的开销。在移动端弱网环境下,这个收益尤其明显。我做过一个粗略的对比,一个首屏依赖十几个静态资源的页面,如果全部走协商缓存命中 304,相比全部 200 重传,首屏时间能差出好几百毫秒。所以 304 不是“锦上添花”,而是性能优化里性价比很高的一环。3. 强缓存:浏览器连请求都不发的底气从哪来3.1 强缓存的两个关键响应头:Expires 和 Cache-Control强缓存的判定完全由响应头里的字段决定,浏览器自己算时间,不和服务端商量。历史上先后出现过两个字段。Expires是 HTTP/1.0 时代的产物,值是一个绝对时间,比如Expires: Wed, 21 Oct 2026 07:28:00 GMT。浏览器拿本地时间和这个时间比,没到就认为新鲜。它的问题很明显:依赖客户端本地时间,如果用户设备时间不准,缓存判定就乱了;而且这是个绝对时间,服务端和客户端时区不一致也容易出问题。Cache-Control是 HTTP/1.1 引入的,用相对时间表达,比如Cache-Control: max-age=3600表示从响应生成那一刻起 3600 秒内都新鲜。它不依赖客户端时钟,语义也更丰富,支持no-cache、no-store、public、private、must-revalidate等一堆指令。实际项目里,两个字段经常同时出现,这时候Cache-Control 优先级高于 Expires。我见过不少老项目只配了 Expires,结果用户改系统时间就能“骗”过缓存,这种坑现在基本可以靠 Cache-Control 规避。3.2 max-age、no-cache、no-store 到底怎么选这几个指令是配置时最容易搞混的,我一个个说清楚。max-age=N是最常用的,表示 N 秒内直接走强缓存,不发请求。静态资源比如带 hash 的 JS/CSS,通常配很长的 max-age,比如一年。no-cache这个名字有迷惑性,它不是“不缓存”,而是“可以缓存,但每次使用前必须去服务端协商”。也就是说它其实是在强制走协商缓存。很多人把它和 no-store 搞混,结果配了个 no-cache 以为不缓存了,实际上浏览器还是存了,只是每次都发请求验证。no-store才是真正的“完全不缓存”,任何环节都不许存,每次都得完整请求。涉及敏感信息的接口,比如账户余额、个人资料,一般用这个。must-revalidate表示缓存过期后必须去服务端验证,不能拿过期的凑合用。这个在需要强一致性的场景下有用。我一般的配置习惯是这样:带内容 hash 的静态资源用max-age=31536000, immutable;HTML 入口文件用no-cache,保证每次都能拿到最新的资源引用;接口数据根据业务实时性要求,要么no-store,要么配一个较短的 max-age 加协商缓存兜底。3.3 强缓存命中时为什么状态码还是 200这是新手最容易困惑的点。强缓存命中时,浏览器压根没发请求,Network 面板里显示的200 (from disk cache)或者200 (from memory cache),这个 200 是浏览器“模拟”出来的,表示“这次请求的结果是成功的”,但它并不是服务端返回的。from memory cache和from disk cache的区别在于缓存存放位置。内存缓存读取快,但进程关闭就没了,一般用于当前页面会话内频繁访问的资源;磁盘缓存持久化,跨会话可用,但读取稍慢。浏览器自己决定放哪,开发者一般不用干预。理解这一点很重要,因为排查问题时如果你只盯着“状态码是不是 304”,会漏掉强缓存这条路径。正确的排查姿势是同时看状态码和后面的(from cache)标记。4. 协商缓存:服务端说了算的“内容比对”4.1 Last-Modified 和 If-Modified-Since 的配对逻辑协商缓存的第一套方案是基于修改时间的。服务端在响应头里返回Last-Modified,值是资源最后修改的时间,精确到秒。浏览器把这个值存下来,下次请求同一个资源时,在请求头里带上If-Modified-Since,值就是上次拿到的Last-Modified。服务端收到后,拿这个时间和当前资源的实际修改时间比。如果当前资源的修改时间没有晚于If-Modified-Since,说明没变,返回 304;如果晚了,说明变了,返回 200 加新内容。这套方案的问题在于:第一,时间精度只到秒,如果一秒内改了多次,可能判断不准;第二,如果文件内容没变但修改时间变了(比如重新部署导致时间戳更新),会误判为“变了”,白白重传;第三,分布式环境下多台机器的文件时间可能不一致。4.2 ETag 和 If-None-Match 为什么更可靠ETag 是服务端给资源生成的一个唯一标识,通常是对内容做哈希得到的字符串,比如ETag: "abc123def456"。内容变,ETag 就变;内容不变,ETag 就不变,和修改时间无关。浏览器下次请求时带上If-None-Match,值是上次的 ETag。服务端比对当前资源的 ETag 和请求头里的值,一致就 304,不一致就 200 加新内容。ETag 解决了 Last-Modified 的几个痛点:精度问题没了,因为它是内容哈希;内容没变但时间变了也不会误判;分布式环境下只要内容一致,ETag 就一致。但 ETag 也有代价:服务端每次都要计算哈希,对大文件来说有 CPU 开销。所以有些场景会用“弱 ETag”,格式是W/"abc123",表示“语义上等价即可”,不要求字节级完全一致。弱 ETag 在比对时只要语义没变就算命中,计算成本更低。4.3 两套方案同时存在时谁优先如果响应头里同时有ETag和Last-Modified,浏览器会两个都带上,请求头里同时有If-None-Match和If-Modified-Since。服务端比对时,ETag 优先——只要 ETag 一致就返回 304,不管 Last-Modified 怎么样。这个优先级是有道理的,因为 ETag 更精确。实际配置时我建议两个都返回,让服务端有更充分的判断依据,同时兼容一些只认 Last-Modified 的老客户端。这里有个坑要注意:不同服务端框架生成 ETag 的规则不一样。有的用文件 inode 加大小加时间,有的用内容哈希。如果用了负载均衡,多台机器生成的 ETag 规则不一致,会导致缓存频繁失效。这种情况要么统一 ETag 生成规则,要么干脆只用 Last-Modified。5. 从请求头到响应头:一次协商缓存的完整报文拆解5.1 首次请求:服务端如何埋下缓存标识假设浏览器第一次请求/api/user/profile,请求头里没有任何缓存相关字段。服务端处理完业务逻辑后,返回:HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-cache ETag: "v1-abc123" Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT {"name": "某用户", "level": 3}这里Cache-Control: no-cache表示“可以存,但每次用之前要协商”。ETag和Last-Modified就是给浏览器下次协商用的“凭证”。浏览器把响应内容、ETag、Last-Modified 一起存进缓存。5.2 二次请求:浏览器带上什么去协商用户刷新页面或者再次访问同一接口时,浏览器从缓存里找到这条记录,发现no-cache要求协商,于是发请求:GET /api/user/profile If-None-Match: "v1-abc123" If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT注意这两个请求头的值,就是上次响应里存下来的。服务端收到后开始比对。5.3 服务端比对:304 和 200 的分叉点服务端拿到If-None-Match的值"v1-abc123",和当前资源的 ETag 比对。如果当前资源还是"v1-abc123",说明没变,返回:HTTP/1.1 304 Not Modified ETag: "v1-abc123" Cache-Control: no-cache没有 body。浏览器收到后,从本地缓存取出之前存的 JSON 数据交给业务代码使用。如果资源变了,当前 ETag 是"v2-def456",服务端返回:HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-cache ETag: "v2-def456" Last-Modified: Thu, 22 Oct 2026 09:15:00 GMT {"name": "某用户", "level": 4}浏览器用新内容替换缓存,同时更新 ETag 和 Last-Modified。5.4 用 curl 手动验证缓存行为排查缓存问题时,用 curl 手动带条件请求头是最直接的方式。比如:curl -I -H 'If-None-Match: "v1-abc123"' https://example.com/api/user/profile-I表示只发 HEAD 请求看响应头。如果返回 304,说明 ETag 匹配;如果返回 200,说明资源变了或者 ETag 生成规则不一致。再比如验证 Last-Modified:curl -I -H 'If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT' https://example.com/static/app.js这种方式能快速定位是服务端没返回缓存头,还是返回了但比对逻辑有问题。我排查线上缓存问题时,第一步基本都是先用 curl 确认服务端行为,再去浏览器里看实际表现,这样能快速区分是服务端问题还是浏览器行为差异。6. 实战配置:不同资源类型该怎么设缓存头6.1 带 hash 的静态资源:一年强缓存加 immutable现代前端构建工具打包出来的 JS/CSS 文件名通常带内容 hash,比如app.a1b2c3.js。内容一变,文件名就变,所以可以放心配长缓存:Cache-Control: public, max-age=31536000, immutableimmutable是个补充指令,告诉浏览器“这个资源在 max-age 内绝对不会变,用户刷新页面也不用去协商”。没有它的话,用户按 F5 刷新时浏览器可能会发协商请求,有了它就能省掉这次请求。这类资源不需要 ETag 和 Last-Modified,因为文件名本身就是版本标识,配了反而增加服务端计算开销。6.2 HTML 入口文件:no-cache 保证拿到最新引用HTML 文件不能配长缓存,因为它里面引用的 JS/CSS 文件名会变。如果 HTML 被缓存了,用户可能一直加载旧的资源引用。所以 HTML 一般配:Cache-Control: no-cache这样每次都会协商,服务端可以通过 ETag 快速判断 HTML 有没有变。如果没变返回 304,变了返回新的 HTML 和新的资源引用。有些团队会用max-age=0, must-revalidate,效果类似,但no-cache语义更清晰。6.3 接口数据:根据实时性分档配置接口的缓存策略要按业务实时性来分。我一般分三档:实时性要求极高的,比如支付状态、订单详情,直接Cache-Control: no-store,完全不缓存。实时性要求中等、但变化不频繁的,比如用户资料、配置项,用no-cache加 ETag,每次协商,没变就 304。实时性要求低、可以容忍几分钟延迟的,比如排行榜、统计报表,用max-age=60加协商缓存兜底,一分钟内直接走强缓存。这里要注意,接口返回的Cache-Control如果设成public,可能被中间层缓存,涉及用户私有数据的接口一定要用private或者no-store,否则可能出现 A 用户看到 B 用户数据的严重问题。6.4 一张表看清各类资源的推荐配置资源类型Cache-ControlETag/Last-Modified理由带 hash 的 JS/CSSpublic, max-age=31536000, immutable不需要文件名即版本,内容变文件名变HTML 入口no-cache需要保证拿到最新资源引用图片/字体(带 hash)public, max-age=31536000不需要同静态资源用户私有接口private, no-cache需要每次协商,避免数据串号公开配置接口public, max-age=300可选容忍 5 分钟延迟敏感数据接口no-store不需要完全不缓存这张表是我自己在项目里总结的,实际用的时候还要结合 CDN 的行为调整。比如 CDN 可能会覆盖源站的 Cache-Control,需要在 CDN 配置里单独设置。7. 排查实录:那些年踩过的缓存坑7.1 改了文件浏览器还是拿旧的:缓存没失效的几种原因这是最高频的问题。我遇到过的情况大概有这么几类。第一类是 HTML 被强缓存了。用户访问过一次,HTML 存了 max-age=3600,一小时内再访问都是旧的,里面引用的还是旧 JS。解决办法就是 HTML 配 no-cache。第二类是 CDN 缓存没刷新。源站文件更新了,但 CDN 边缘节点还存着旧的,用户拿到的还是旧内容。这种情况要么等 CDN 缓存过期,要么主动刷新 CDN。第三类是 Service Worker 拦截了请求。有些 PWA 项目里 Service Worker 有自己的缓存策略,可能覆盖了 HTTP 缓存的行为。排查时要看 Service Worker 的 fetch 事件处理逻辑。第四类是浏览器磁盘缓存没清。有时候强制刷新(Ctrl+Shift+R)能解决,但普通刷新不行,说明是强缓存命中。这种情况要检查响应头里的 max-age 是不是设太长了。7.2 304 响应体为空导致前端报错:一个典型的协作问题前面提到的那个案例,前端拿到 304 后响应体为空,如果前端代码里直接JSON.parse(response.body)就会报错。这其实是前端代码没处理好 304 的情况。正确的做法是,前端用 fetch 或 axios 时,浏览器会自动处理 304——它会从缓存取内容填充到 response 里,业务代码拿到的应该是完整数据。如果拿到空,可能是用了底层 API 没走浏览器的缓存处理,或者服务端错误地对 304 也返回了空 body 但前端没做兼容。我一般的建议是:前端不要自己判断状态码是不是 304,交给浏览器处理;服务端确保 304 响应不带 body,这是规范要求。两边都按规范来,就不会出问题。7.3 ETag 不一致导致缓存频繁失效:分布式环境的坑有次线上发现某个接口的缓存命中率特别低,几乎每次都返回 200。排查发现是负载均衡后面有多台机器,每台机器生成的 ETag 规则不一样——有的用文件修改时间,有的用内容哈希,还有的用了包含机器标识的随机值。结果请求打到不同机器上,ETag 对不上,缓存就失效了。解决办法是统一 ETag 生成规则,所有机器用同一套内容哈希算法。如果做不到,就干脆不用 ETag,只用 Last-Modified,或者把缓存策略改成基于时间的强缓存。这个坑在容器化部署的环境里特别常见,因为容器重建后文件时间戳会变,如果 ETag 依赖时间戳就会失效。所以我现在更倾向于用内容哈希生成 ETag。7.4 常见问题速查表现象可能原因排查方向一直返回 200,没有 304服务端没返回 ETag/Last-Modified检查响应头304 但内容不对缓存标识生成规则有问题检查 ETag 生成逻辑强缓存不生效Cache-Control 被覆盖或没设置检查响应头和 CDN 配置缓存该失效时不失效max-age 设太长或 CDN 未刷新检查缓存时长和 CDN不同用户拿到相同数据私有接口配了 public改成 private 或 no-store刷新页面才更新强缓存命中,普通刷新不走协商检查 max-age 和 immutable8. 几个容易混淆的概念澄清8.1 304 和 200 from cache 的区别这两个都是“用了缓存”,但路径完全不同。200 from cache 是强缓存命中,浏览器没发请求;304 是协商缓存命中,浏览器发了请求但服务端说不用重传。前者省了一次网络往返,后者省了响应体传输。从性能上看,强缓存更优,但强缓存无法保证内容实时性,所以两者要配合使用。8.2 no-cache 和 no-store 的真实语义再强调一遍,no-cache不是不缓存,是“缓存但每次协商”。no-store才是完全不缓存。我见过有同学为了“不缓存”配了 no-cache,结果发现浏览器还是存了,只是每次发请求验证,误以为配置没生效。搞清楚这两个的语义,能避免很多无效排查。8.3 强缓存和协商缓存不是二选一很多人以为一个资源要么走强缓存要么走协商缓存,其实它们是串联的。一个资源可以同时配置强缓存和协商缓存:max-age 内走强缓存,max-age 过期后走协商缓存。这样既保证了新鲜期内的性能,又保证了过期后的正确性。实际项目里我基本都会这么配,除非是 no-store 的敏感接口。9. 我个人的一些实操体会缓存这块东西,看文档觉得简单,真到线上排查起来坑特别多。我自己总结下来,有几个习惯挺有用。第一,新项目上线前一定要用 curl 或者浏览器 DevTools 确认每个资源的缓存头符合预期,别等出问题了再查。特别是 HTML 和接口,配错了影响面很大。第二,缓存策略要写进项目文档,前后端一起对齐。我遇到过前端以为接口不缓存、后端以为前端会处理的情况,最后两边都没管,缓存行为完全靠浏览器默认,结果不可控。第三,CDN 的缓存配置要单独确认,它可能覆盖源站的 Cache-Control。有些 CDN 默认会缓存所有 GET 请求,如果不显式配置,私有接口可能被缓存,这是很危险的。第四,排查缓存问题时,先用无痕窗口排除本地缓存干扰,再用 curl 确认服务端行为,最后看浏览器实际表现。这个顺序能快速定位问题在哪一层。最后分享一个小技巧:Chrome DevTools 的 Network 面板里,把 Size 列打开,能看到(from disk cache)、(from memory cache)或者实际传输大小。配合 Status 列一起看,一眼就能区分是强缓存、协商缓存还是完整请求。这个组合我用了好几年,排查缓存问题基本够用了。
1. 从一次接口联调说起:为什么 304 值得单独拎出来讲前段时间帮一个朋友排查线上问题,前端反馈说某个列表接口“有时候返回数据、有时候返回空”,抓包一看,状态码是 304,响应体确实是空的。前端同学第一反应是“后端挂了”,后端同学第一反应是“你请求头带得不对”,两边扯了半天,最后发现是浏览器缓存策略和接口的缓存头没对齐。这种场景我遇到过太多次了,304 这个状态码本身不复杂,但它牵扯出来的强缓存和协商缓存这套机制,是很多前后端协作里最容易互相甩锅的地方。这篇文章想做的事情很明确:把 304 到底是什么、它和强缓存/协商缓存的关系、浏览器在什么条件下会走缓存、服务端该怎么配合设置响应头,从头到尾讲清楚。不管你是刚接触 HTTP 的前端新人,还是写了几年接口但一直没系统梳理过缓存的后端,或者是做性能优化时被缓存问题卡住的同学,看完应该都能对这套机制有个完整的认知,并且能直接拿去排查线上问题。我尽量不写成教科书,而是按照我实际排查问题的思路来组织:先搞清楚 304 出现的完整链路,再拆解强缓存和协商缓存各自的分工,然后落到具体的请求头/响应头配置,最后给一份排查清单。中间会穿插一些我踩过的坑,比如Cache-Control和Expires同时存在时谁说了算、ETag和Last-Modified到底该用哪个、为什么有时候改了文件浏览器还是拿旧的。2. 304 到底是什么:一次完整的缓存命中链路2.1 304 的字面含义和它在状态码家族里的位置304 的官方名称叫Not Modified,中文一般翻译成“未修改”。它属于 HTTP 状态码里的3xx 重定向类,但和 301、302 那种“让你去另一个地址”的重定向不一样,304 的语义是:你请求的这个资源,从你上次拿到之后到现在,没有发生变化,所以你本地那份还能继续用,我这次就不给你发新内容了。这里有个关键点很多人会忽略:304 响应是没有响应体的。也就是说,服务端只回了响应头,body 是空的。这就是为什么前面那个前端同学看到“返回空”会慌——其实不是接口挂了,是浏览器和服务端协商之后确认“不用重传”,浏览器会自己从本地缓存里把之前存的那份拿出来用。从状态码分类上看,1xx 是信息类,2xx 是成功,3xx 是重定向/缓存相关,4xx 是客户端错误,5xx 是服务端错误。304 被归在 3xx 里,是因为它本质上是一种“资源位置/内容不需要重新传输”的指示,而不是一次全新的成功响应。理解这一点,对后面理解缓存流程很重要。2.2 浏览器发起请求到拿到 304 的完整流程我把一次典型的缓存命中流程拆成几个阶段,这样你能看清楚每一步浏览器在做什么决策。第一阶段,浏览器准备发起请求。它会先检查本地缓存里有没有这个资源的记录,以及这条记录是否还在“新鲜期”内。这个“新鲜期”就是强缓存要管的事情。第二阶段,如果强缓存判定“还新鲜”,浏览器根本不会发请求,直接用本地缓存,这时候你在 Network 面板里看到的是200 (from disk cache)或者200 (from memory cache),注意这里状态码还是 200,不是 304。很多人以为走缓存就是 304,这是个常见误解。第三阶段,如果强缓存判定“过期了”或者压根没设置强缓存,浏览器会发一个带条件的请求给服务端。这个“条件”就是协商缓存的核心——浏览器会带上If-None-Match或If-Modified-Since这样的请求头,把上次拿到的ETag或Last-Modified值回传给服务端。第四阶段,服务端收到请求后,拿浏览器传来的标识和自己当前资源的标识做对比。如果一致,说明资源没变,返回 304,不带 body;如果不一致,说明资源变了,返回 200 加上新的完整内容和新的缓存标识。第五阶段,浏览器收到 304 后,从本地缓存取出之前存的内容渲染,同时用响应头里的新缓存信息更新本地记录的“新鲜期”。整个链路里,强缓存决定“要不要发请求”,协商缓存决定“发了请求之后服务端要不要给新内容”。这两层是串联关系,不是二选一。搞清楚这个分工,后面所有的配置和排查都会顺很多。2.3 为什么要有 304:省的不是带宽那么简单有人可能会问,既然协商缓存还是要发一次请求到服务端,那省了什么?直接返回 200 不也一样吗?省的东西比想象中多。第一是响应体传输,一个几百 KB 的 JS 文件或者一张图片,304 只回几十字节的响应头,差距是数量级的。第二是服务端渲染/查询成本,如果服务端能通过 ETag 快速判断资源没变,就不用重新走一遍数据库查询、模板渲染、序列化这些流程。第三是客户端解析成本,浏览器拿到 304 直接用本地缓存,省去了重新解析、重新执行的开销。在移动端弱网环境下,这个收益尤其明显。我做过一个粗略的对比,一个首屏依赖十几个静态资源的页面,如果全部走协商缓存命中 304,相比全部 200 重传,首屏时间能差出好几百毫秒。所以 304 不是“锦上添花”,而是性能优化里性价比很高的一环。3. 强缓存:浏览器连请求都不发的底气从哪来3.1 强缓存的两个关键响应头:Expires 和 Cache-Control强缓存的判定完全由响应头里的字段决定,浏览器自己算时间,不和服务端商量。历史上先后出现过两个字段。Expires是 HTTP/1.0 时代的产物,值是一个绝对时间,比如Expires: Wed, 21 Oct 2026 07:28:00 GMT。浏览器拿本地时间和这个时间比,没到就认为新鲜。它的问题很明显:依赖客户端本地时间,如果用户设备时间不准,缓存判定就乱了;而且这是个绝对时间,服务端和客户端时区不一致也容易出问题。Cache-Control是 HTTP/1.1 引入的,用相对时间表达,比如Cache-Control: max-age=3600表示从响应生成那一刻起 3600 秒内都新鲜。它不依赖客户端时钟,语义也更丰富,支持no-cache、no-store、public、private、must-revalidate等一堆指令。实际项目里,两个字段经常同时出现,这时候Cache-Control 优先级高于 Expires。我见过不少老项目只配了 Expires,结果用户改系统时间就能“骗”过缓存,这种坑现在基本可以靠 Cache-Control 规避。3.2 max-age、no-cache、no-store 到底怎么选这几个指令是配置时最容易搞混的,我一个个说清楚。max-age=N是最常用的,表示 N 秒内直接走强缓存,不发请求。静态资源比如带 hash 的 JS/CSS,通常配很长的 max-age,比如一年。no-cache这个名字有迷惑性,它不是“不缓存”,而是“可以缓存,但每次使用前必须去服务端协商”。也就是说它其实是在强制走协商缓存。很多人把它和 no-store 搞混,结果配了个 no-cache 以为不缓存了,实际上浏览器还是存了,只是每次都发请求验证。no-store才是真正的“完全不缓存”,任何环节都不许存,每次都得完整请求。涉及敏感信息的接口,比如账户余额、个人资料,一般用这个。must-revalidate表示缓存过期后必须去服务端验证,不能拿过期的凑合用。这个在需要强一致性的场景下有用。我一般的配置习惯是这样:带内容 hash 的静态资源用max-age=31536000, immutable;HTML 入口文件用no-cache,保证每次都能拿到最新的资源引用;接口数据根据业务实时性要求,要么no-store,要么配一个较短的 max-age 加协商缓存兜底。3.3 强缓存命中时为什么状态码还是 200这是新手最容易困惑的点。强缓存命中时,浏览器压根没发请求,Network 面板里显示的200 (from disk cache)或者200 (from memory cache),这个 200 是浏览器“模拟”出来的,表示“这次请求的结果是成功的”,但它并不是服务端返回的。from memory cache和from disk cache的区别在于缓存存放位置。内存缓存读取快,但进程关闭就没了,一般用于当前页面会话内频繁访问的资源;磁盘缓存持久化,跨会话可用,但读取稍慢。浏览器自己决定放哪,开发者一般不用干预。理解这一点很重要,因为排查问题时如果你只盯着“状态码是不是 304”,会漏掉强缓存这条路径。正确的排查姿势是同时看状态码和后面的(from cache)标记。4. 协商缓存:服务端说了算的“内容比对”4.1 Last-Modified 和 If-Modified-Since 的配对逻辑协商缓存的第一套方案是基于修改时间的。服务端在响应头里返回Last-Modified,值是资源最后修改的时间,精确到秒。浏览器把这个值存下来,下次请求同一个资源时,在请求头里带上If-Modified-Since,值就是上次拿到的Last-Modified。服务端收到后,拿这个时间和当前资源的实际修改时间比。如果当前资源的修改时间没有晚于If-Modified-Since,说明没变,返回 304;如果晚了,说明变了,返回 200 加新内容。这套方案的问题在于:第一,时间精度只到秒,如果一秒内改了多次,可能判断不准;第二,如果文件内容没变但修改时间变了(比如重新部署导致时间戳更新),会误判为“变了”,白白重传;第三,分布式环境下多台机器的文件时间可能不一致。4.2 ETag 和 If-None-Match 为什么更可靠ETag 是服务端给资源生成的一个唯一标识,通常是对内容做哈希得到的字符串,比如ETag: "abc123def456"。内容变,ETag 就变;内容不变,ETag 就不变,和修改时间无关。浏览器下次请求时带上If-None-Match,值是上次的 ETag。服务端比对当前资源的 ETag 和请求头里的值,一致就 304,不一致就 200 加新内容。ETag 解决了 Last-Modified 的几个痛点:精度问题没了,因为它是内容哈希;内容没变但时间变了也不会误判;分布式环境下只要内容一致,ETag 就一致。但 ETag 也有代价:服务端每次都要计算哈希,对大文件来说有 CPU 开销。所以有些场景会用“弱 ETag”,格式是W/"abc123",表示“语义上等价即可”,不要求字节级完全一致。弱 ETag 在比对时只要语义没变就算命中,计算成本更低。4.3 两套方案同时存在时谁优先如果响应头里同时有ETag和Last-Modified,浏览器会两个都带上,请求头里同时有If-None-Match和If-Modified-Since。服务端比对时,ETag 优先——只要 ETag 一致就返回 304,不管 Last-Modified 怎么样。这个优先级是有道理的,因为 ETag 更精确。实际配置时我建议两个都返回,让服务端有更充分的判断依据,同时兼容一些只认 Last-Modified 的老客户端。这里有个坑要注意:不同服务端框架生成 ETag 的规则不一样。有的用文件 inode 加大小加时间,有的用内容哈希。如果用了负载均衡,多台机器生成的 ETag 规则不一致,会导致缓存频繁失效。这种情况要么统一 ETag 生成规则,要么干脆只用 Last-Modified。5. 从请求头到响应头:一次协商缓存的完整报文拆解5.1 首次请求:服务端如何埋下缓存标识假设浏览器第一次请求/api/user/profile,请求头里没有任何缓存相关字段。服务端处理完业务逻辑后,返回:HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-cache ETag: "v1-abc123" Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT {"name": "某用户", "level": 3}这里Cache-Control: no-cache表示“可以存,但每次用之前要协商”。ETag和Last-Modified就是给浏览器下次协商用的“凭证”。浏览器把响应内容、ETag、Last-Modified 一起存进缓存。5.2 二次请求:浏览器带上什么去协商用户刷新页面或者再次访问同一接口时,浏览器从缓存里找到这条记录,发现no-cache要求协商,于是发请求:GET /api/user/profile If-None-Match: "v1-abc123" If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT注意这两个请求头的值,就是上次响应里存下来的。服务端收到后开始比对。5.3 服务端比对:304 和 200 的分叉点服务端拿到If-None-Match的值"v1-abc123",和当前资源的 ETag 比对。如果当前资源还是"v1-abc123",说明没变,返回:HTTP/1.1 304 Not Modified ETag: "v1-abc123" Cache-Control: no-cache没有 body。浏览器收到后,从本地缓存取出之前存的 JSON 数据交给业务代码使用。如果资源变了,当前 ETag 是"v2-def456",服务端返回:HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-cache ETag: "v2-def456" Last-Modified: Thu, 22 Oct 2026 09:15:00 GMT {"name": "某用户", "level": 4}浏览器用新内容替换缓存,同时更新 ETag 和 Last-Modified。5.4 用 curl 手动验证缓存行为排查缓存问题时,用 curl 手动带条件请求头是最直接的方式。比如:curl -I -H 'If-None-Match: "v1-abc123"' https://example.com/api/user/profile-I表示只发 HEAD 请求看响应头。如果返回 304,说明 ETag 匹配;如果返回 200,说明资源变了或者 ETag 生成规则不一致。再比如验证 Last-Modified:curl -I -H 'If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT' https://example.com/static/app.js这种方式能快速定位是服务端没返回缓存头,还是返回了但比对逻辑有问题。我排查线上缓存问题时,第一步基本都是先用 curl 确认服务端行为,再去浏览器里看实际表现,这样能快速区分是服务端问题还是浏览器行为差异。6. 实战配置:不同资源类型该怎么设缓存头6.1 带 hash 的静态资源:一年强缓存加 immutable现代前端构建工具打包出来的 JS/CSS 文件名通常带内容 hash,比如app.a1b2c3.js。内容一变,文件名就变,所以可以放心配长缓存:Cache-Control: public, max-age=31536000, immutableimmutable是个补充指令,告诉浏览器“这个资源在 max-age 内绝对不会变,用户刷新页面也不用去协商”。没有它的话,用户按 F5 刷新时浏览器可能会发协商请求,有了它就能省掉这次请求。这类资源不需要 ETag 和 Last-Modified,因为文件名本身就是版本标识,配了反而增加服务端计算开销。6.2 HTML 入口文件:no-cache 保证拿到最新引用HTML 文件不能配长缓存,因为它里面引用的 JS/CSS 文件名会变。如果 HTML 被缓存了,用户可能一直加载旧的资源引用。所以 HTML 一般配:Cache-Control: no-cache这样每次都会协商,服务端可以通过 ETag 快速判断 HTML 有没有变。如果没变返回 304,变了返回新的 HTML 和新的资源引用。有些团队会用max-age=0, must-revalidate,效果类似,但no-cache语义更清晰。6.3 接口数据:根据实时性分档配置接口的缓存策略要按业务实时性来分。我一般分三档:实时性要求极高的,比如支付状态、订单详情,直接Cache-Control: no-store,完全不缓存。实时性要求中等、但变化不频繁的,比如用户资料、配置项,用no-cache加 ETag,每次协商,没变就 304。实时性要求低、可以容忍几分钟延迟的,比如排行榜、统计报表,用max-age=60加协商缓存兜底,一分钟内直接走强缓存。这里要注意,接口返回的Cache-Control如果设成public,可能被中间层缓存,涉及用户私有数据的接口一定要用private或者no-store,否则可能出现 A 用户看到 B 用户数据的严重问题。6.4 一张表看清各类资源的推荐配置资源类型Cache-ControlETag/Last-Modified理由带 hash 的 JS/CSSpublic, max-age=31536000, immutable不需要文件名即版本,内容变文件名变HTML 入口no-cache需要保证拿到最新资源引用图片/字体(带 hash)public, max-age=31536000不需要同静态资源用户私有接口private, no-cache需要每次协商,避免数据串号公开配置接口public, max-age=300可选容忍 5 分钟延迟敏感数据接口no-store不需要完全不缓存这张表是我自己在项目里总结的,实际用的时候还要结合 CDN 的行为调整。比如 CDN 可能会覆盖源站的 Cache-Control,需要在 CDN 配置里单独设置。7. 排查实录:那些年踩过的缓存坑7.1 改了文件浏览器还是拿旧的:缓存没失效的几种原因这是最高频的问题。我遇到过的情况大概有这么几类。第一类是 HTML 被强缓存了。用户访问过一次,HTML 存了 max-age=3600,一小时内再访问都是旧的,里面引用的还是旧 JS。解决办法就是 HTML 配 no-cache。第二类是 CDN 缓存没刷新。源站文件更新了,但 CDN 边缘节点还存着旧的,用户拿到的还是旧内容。这种情况要么等 CDN 缓存过期,要么主动刷新 CDN。第三类是 Service Worker 拦截了请求。有些 PWA 项目里 Service Worker 有自己的缓存策略,可能覆盖了 HTTP 缓存的行为。排查时要看 Service Worker 的 fetch 事件处理逻辑。第四类是浏览器磁盘缓存没清。有时候强制刷新(Ctrl+Shift+R)能解决,但普通刷新不行,说明是强缓存命中。这种情况要检查响应头里的 max-age 是不是设太长了。7.2 304 响应体为空导致前端报错:一个典型的协作问题前面提到的那个案例,前端拿到 304 后响应体为空,如果前端代码里直接JSON.parse(response.body)就会报错。这其实是前端代码没处理好 304 的情况。正确的做法是,前端用 fetch 或 axios 时,浏览器会自动处理 304——它会从缓存取内容填充到 response 里,业务代码拿到的应该是完整数据。如果拿到空,可能是用了底层 API 没走浏览器的缓存处理,或者服务端错误地对 304 也返回了空 body 但前端没做兼容。我一般的建议是:前端不要自己判断状态码是不是 304,交给浏览器处理;服务端确保 304 响应不带 body,这是规范要求。两边都按规范来,就不会出问题。7.3 ETag 不一致导致缓存频繁失效:分布式环境的坑有次线上发现某个接口的缓存命中率特别低,几乎每次都返回 200。排查发现是负载均衡后面有多台机器,每台机器生成的 ETag 规则不一样——有的用文件修改时间,有的用内容哈希,还有的用了包含机器标识的随机值。结果请求打到不同机器上,ETag 对不上,缓存就失效了。解决办法是统一 ETag 生成规则,所有机器用同一套内容哈希算法。如果做不到,就干脆不用 ETag,只用 Last-Modified,或者把缓存策略改成基于时间的强缓存。这个坑在容器化部署的环境里特别常见,因为容器重建后文件时间戳会变,如果 ETag 依赖时间戳就会失效。所以我现在更倾向于用内容哈希生成 ETag。7.4 常见问题速查表现象可能原因排查方向一直返回 200,没有 304服务端没返回 ETag/Last-Modified检查响应头304 但内容不对缓存标识生成规则有问题检查 ETag 生成逻辑强缓存不生效Cache-Control 被覆盖或没设置检查响应头和 CDN 配置缓存该失效时不失效max-age 设太长或 CDN 未刷新检查缓存时长和 CDN不同用户拿到相同数据私有接口配了 public改成 private 或 no-store刷新页面才更新强缓存命中,普通刷新不走协商检查 max-age 和 immutable8. 几个容易混淆的概念澄清8.1 304 和 200 from cache 的区别这两个都是“用了缓存”,但路径完全不同。200 from cache 是强缓存命中,浏览器没发请求;304 是协商缓存命中,浏览器发了请求但服务端说不用重传。前者省了一次网络往返,后者省了响应体传输。从性能上看,强缓存更优,但强缓存无法保证内容实时性,所以两者要配合使用。8.2 no-cache 和 no-store 的真实语义再强调一遍,no-cache不是不缓存,是“缓存但每次协商”。no-store才是完全不缓存。我见过有同学为了“不缓存”配了 no-cache,结果发现浏览器还是存了,只是每次发请求验证,误以为配置没生效。搞清楚这两个的语义,能避免很多无效排查。8.3 强缓存和协商缓存不是二选一很多人以为一个资源要么走强缓存要么走协商缓存,其实它们是串联的。一个资源可以同时配置强缓存和协商缓存:max-age 内走强缓存,max-age 过期后走协商缓存。这样既保证了新鲜期内的性能,又保证了过期后的正确性。实际项目里我基本都会这么配,除非是 no-store 的敏感接口。9. 我个人的一些实操体会缓存这块东西,看文档觉得简单,真到线上排查起来坑特别多。我自己总结下来,有几个习惯挺有用。第一,新项目上线前一定要用 curl 或者浏览器 DevTools 确认每个资源的缓存头符合预期,别等出问题了再查。特别是 HTML 和接口,配错了影响面很大。第二,缓存策略要写进项目文档,前后端一起对齐。我遇到过前端以为接口不缓存、后端以为前端会处理的情况,最后两边都没管,缓存行为完全靠浏览器默认,结果不可控。第三,CDN 的缓存配置要单独确认,它可能覆盖源站的 Cache-Control。有些 CDN 默认会缓存所有 GET 请求,如果不显式配置,私有接口可能被缓存,这是很危险的。第四,排查缓存问题时,先用无痕窗口排除本地缓存干扰,再用 curl 确认服务端行为,最后看浏览器实际表现。这个顺序能快速定位问题在哪一层。最后分享一个小技巧:Chrome DevTools 的 Network 面板里,把 Size 列打开,能看到(from disk cache)、(from memory cache)或者实际传输大小。配合 Status 列一起看,一眼就能区分是强缓存、协商缓存还是完整请求。这个组合我用了好几年,排查缓存问题基本够用了。