
1. 从一次接口联调说起为什么 304 总让人心里没底做前端或者做客户端开发的人几乎都遇到过这样一个场景打开浏览器开发者工具Network 面板里一堆请求状态码那一列有的显示 200有的显示 304。200 大家都熟请求成功、数据回来了。可 304 是什么它没有响应体Size 那一栏经常显示 (from disk cache) 或者 (from memory cache)看起来像是没请求成功但页面又确实正常显示了。我第一次认真对待 304是在一次接口联调里。后端同学信誓旦旦说接口改了字段我刷新页面死活看不到新数据抓包一看状态码 304。当时我的第一反应是缓存没清干净于是 CtrlF5 强刷果然好了。但问题来了为什么普通刷新会命中 304强刷就不会304 到底是浏览器的问题还是服务器的问题它和 200 之间到底差了什么后来把 HTTP 缓存这一块彻底捋了一遍才明白304 不是一个孤立的数字它是HTTP 缓存机制里协商缓存这一环的产物。要真正搞懂 304必须把强缓存和协商缓存放在一起看因为它们是一套配合使用的机制而不是两个独立的知识点。这篇内容我打算按我自己当初学习的路径来写先把缓存这件事的动机讲清楚再拆强缓存和协商缓存各自靠什么字段工作然后重点讲 304 是怎么产生的、它和 200 的区别在哪、实际开发中怎么控制它最后把我踩过的几个坑完整复盘一遍。适合前端、后端、测试、运维以及任何需要和 HTTP 打交道的人。看完之后你应该能做到看到 304 不再慌知道它是好是坏并且能主动控制它什么时候出现、什么时候不出现。2. 缓存这件事的动机不是省流量是省时间2.1 一次完整请求的成本到底花在哪很多人对缓存的理解停留在省流量这个说法对但不够本质。真正驱动缓存存在的是延迟。一次完整的 HTTP 请求从浏览器发起到拿到响应中间要经历DNS 解析如果没缓存、TCP 三次握手、TLS 握手HTTPS、发送请求、服务器处理、返回响应。这里面 DNS、TCP、TLS 这几步每一次都要消耗几十到几百毫秒。哪怕服务器处理只花 1 毫秒光是建立连接的开销就够呛。而一个现代网页动辄几十上百个静态资源JS、CSS、图片、字体。如果每个资源都要走一遍完整流程页面加载时间会非常难看。缓存的核心价值就是让第二次及以后的请求尽量少走甚至不走这些昂贵的步骤。这里有个关键区分缓存省的是重新获取资源的成本但不同缓存策略省的程度不一样。强缓存能省掉整个请求协商缓存只能省掉响应体传输。这个区别是理解 304 的钥匙。2.2 强缓存和协商缓存一个管要不要问一个管问什么把浏览器想象成一个仓库管理员服务器是供货商。强缓存的逻辑是管理员手里有一张表写着这个货在某个日期之前都有效。只要没过期有人来要货管理员直接从自己仓库拿根本不联系供货商。这就是强缓存——浏览器直接用本地副本连请求都不发。协商缓存的逻辑是管理员不确定手里的货还是不是最新的于是给供货商打个电话问一句我这儿有个货标识是 XXX你那边还是这个版本吗供货商一看说还是这个版本管理员就放心用本地那份如果说变了供货商就把新货发过来。这个打电话问的过程就是协商缓存而服务器回答还是这个版本时返回的状态码就是304。所以 304 的本质是请求发出去了服务器也处理了但服务器告诉浏览器你本地那份还能用我不重复发数据了。它省掉的是响应体的传输但请求-响应这个来回还是发生了。理解了这一层就能明白为什么 304 的响应没有 body——因为服务器认为没必要再传一遍相同的内容。2.3 为什么需要两套机制配合而不是只用一套有人会问既然强缓存连请求都不发那不是更省吗为什么还要协商缓存问题在于时效性。强缓存靠的是过期时间在过期之前浏览器完全信任本地副本哪怕服务器上的文件已经改了浏览器也不知道。如果所有资源都用强缓存那服务器更新了文件用户可能几天都看不到新版本。协商缓存则相反它每次都要问服务器时效性最好但每次都有一个请求往返的开销。所以真实世界的策略是组合使用用强缓存扛住大部分请求等强缓存过期了再用协商缓存去确认一下。这样既省了大部分请求又保证了更新能及时生效。这就是为什么你会在 Network 面板里同时看到 200、304、from cache 这几种状态——它们对应的是资源在不同缓存阶段的表现。3. 强缓存的两个开关Expires 和 Cache-Control3.1 Expires用绝对时间坑在绝对两个字Expires是 HTTP/1.0 时代的产物它的值是一个绝对时间比如Expires: Wed, 21 Oct 2026 07:28:00 GMT浏览器拿到这个响应后会把这个时间记下来。下次请求同一个资源时先看当前时间有没有超过这个 Expires 时间没超过就直接用本地缓存不发请求。听起来简单但坑就在绝对时间上。这个时间是服务器的时间而判断是否过期用的是客户端的时间。如果客户端的时间不准——比如用户手动改了系统时间或者时区设置有问题——那缓存判断就会出错。客户端时间比服务器慢缓存可能提前失效客户端时间比服务器快缓存可能该失效了还在用。我在实际项目里就遇到过某台测试机的系统时间被改到了几个月后结果所有强缓存全部失效页面加载慢得离谱排查了半天才发现是机器时间的问题。这种问题在 Expires 时代很常见所以现在基本不单独用它了。3.2 Cache-Control用相对时间语义更丰富Cache-Control是 HTTP/1.1 引入的它用相对时间秒数来表达比如Cache-Control: max-age3600意思是这个资源在 3600 秒内有效。浏览器收到后会记录下收到响应的时刻然后加上 3600 秒算出过期时间。这样就不依赖客户端的绝对时间了只依赖时间间隔可靠性高很多。Cache-Control的指令不止max-age一个常用的还有指令含义典型场景max-age秒资源在多少秒内有效静态资源缓存no-cache可以缓存但每次都要协商验证HTML 入口文件no-store完全不缓存每次都要重新获取敏感数据接口public允许中间代理缓存CDN 场景private只允许浏览器缓存不允许代理缓存用户私有数据must-revalidate过期后必须验证不能用过期副本强一致性要求这里要特别澄清一个高频误解no-cache不是不缓存。它的真实含义是可以缓存但每次使用前必须向服务器验证。真正不缓存的是no-store。这两个词坑了无数人包括当年的我。3.3 两者同时存在时谁说了算如果响应里同时有Expires和Cache-Control浏览器会优先使用 Cache-Control忽略 Expires。这是 HTTP/1.1 的明确规定目的是向后兼容——老客户端认 Expires新客户端认 Cache-Control。所以现在的最佳实践是只设置 Cache-Control不设置 Expires。Expires 留着只是为了兼容极老的客户端现代项目里基本可以忽略。提示如果你在响应头里看到Cache-Control: max-age0它的效果接近于每次都要验证但和no-cache还是有细微差别。max-age0表示缓存立即过期需要重新验证no-cache是明确要求每次验证。实际效果类似但语义上no-cache更清晰。4. 协商缓存的两次对暗号Last-Modified 和 ETag4.1 Last-Modified / If-Modified-Since按修改时间对暗号协商缓存的第一种方式靠的是资源的最后修改时间。流程是这样的浏览器第一次请求资源服务器返回资源并在响应头里带上Last-Modified表示这个文件最后修改的时间。浏览器把这个时间和资源一起缓存起来。下次请求同一个资源时浏览器在请求头里带上If-Modified-Since值就是上次拿到的Last-Modified。服务器收到后比较这个时间和文件当前的修改时间。如果文件没变返回304不带响应体如果变了返回200带上新资源。用代码表示这个判断逻辑大概是// 服务器端伪代码 const ifModifiedSince req.headers[if-modified-since]; const lastModified getFileLastModified(file); if (ifModifiedSince new Date(ifModifiedSince) lastModified) { // 文件没变返回 304 res.status(304).end(); } else { // 文件变了返回新内容 res.setHeader(Last-Modified, lastModified.toUTCString()); res.status(200).send(fileContent); }Last-Modified的精度是秒级。这就带来一个问题如果一个文件在 1 秒内被修改了两次第二次修改的时间戳和第一次可能一样服务器就会误判为没变返回 304导致浏览器拿到旧内容。这在自动化构建、频繁写入的场景下是真实存在的坑。4.2 ETag / If-None-Match按内容指纹对暗号为了解决 Last-Modified 的精度和可靠性问题HTTP/1.1 引入了ETag。ETag是服务器为资源生成的一个唯一标识通常是对文件内容做哈希得到的。内容变了ETag 就变内容没变ETag 就不变。它不依赖时间所以精度问题不存在。流程和 Last-Modified 类似服务器返回资源时带上ETag: abc123。浏览器缓存这个 ETag。下次请求时带上If-None-Match: abc123。服务器比较 ETag相同则返回 304不同则返回 200 和新 ETag。// 服务器端伪代码 const ifNoneMatch req.headers[if-none-match]; const currentETag generateETag(fileContent); // 比如内容哈希 if (ifNoneMatch currentETag) { res.status(304).end(); } else { res.setHeader(ETag, currentETag); res.status(200).send(fileContent); }ETag 分强验证和弱验证两种。强 ETag 形如abc123要求内容字节级完全一致弱 ETag 形如W/abc123允许内容有语义等价但字节不同的情况比如只是格式调整。大多数场景用强 ETag 就够了。4.3 两个字段同时存在时ETag 优先如果响应里同时有Last-Modified和ETag浏览器会同时带上If-Modified-Since和If-None-Match。服务器判断时ETag 的优先级更高——只要 ETag 匹配就返回 304不管 Last-Modified 是什么。这个设计是合理的ETag 更精确应该以它为准。Last-Modified 更像是一个兜底方案在 ETag 不可用时比如某些动态生成的资源才发挥作用。实际项目中静态资源服务器如 Nginx默认会同时生成这两个头。你不需要手动配置但需要知道它们的优先级关系排查问题时才不会懵。5. 304 是怎么产生的一次完整的协商缓存往返5.1 从浏览器发起请求到收到 304 的完整链路把前面的知识串起来一次命中协商缓存的完整流程是这样的浏览器要请求某个资源先查强缓存。如果强缓存没过期直接用本地副本根本不发请求Network 面板显示(from disk cache)或(from memory cache)。如果强缓存过期了或者压根没设置强缓存浏览器发起请求并在请求头里带上If-None-Match来自上次的 ETag和If-Modified-Since来自上次的 Last-Modified。服务器收到请求先比较 ETag。如果匹配直接返回304 Not Modified响应头里可能还会带上新的 Cache-Control但没有响应体。浏览器收到 304知道本地副本还能用于是从缓存里取出资源渲染页面。注意第 3 步304 响应虽然没有 body但可以有响应头。服务器经常利用这一点在 304 里更新Cache-Control告诉浏览器这次再给你续个期。这是一个很实用但容易被忽略的细节。5.2 304 和 200 的本质区别不是成功与失败是要不要传数据很多人第一次看到 304 会紧张觉得是不是出错了。其实 304 是一个成功的响应它的语义是你请求的资源没有变化用你本地的就行。和 200 的区别在于对比项200 OK304 Not Modified响应体有包含完整资源无语义请求成功返回资源资源未变用缓存触发条件首次请求或资源已变协商缓存验证通过传输量完整资源大小仅响应头极小对用户正常显示正常显示用缓存从用户视角看200 和 304 的最终效果是一样的——页面正常显示。区别只在网络层面304 省掉了响应体的传输。对于一个大图片或大 JS 文件这个节省是很可观的。5.3 为什么有时候 304 反而更慢这里有个反直觉的点304 不一定比 200 快。因为 304 需要一次完整的请求-响应往返。如果资源本身很小比如一个 1KB 的图标那么发请求问一下的开销可能比直接传 1KB 数据还大。这种情况下304 反而增加了延迟。所以缓存策略不是越缓存越好而是要根据资源特点来定大文件、不常变强缓存拉长减少请求。小文件、常变可以考虑不缓存或者用很短的强缓存。入口 HTML用no-cache每次协商保证能拿到最新的资源引用。这个权衡思路比死记硬背字段配置重要得多。6. 实战怎么主动控制缓存和 304 的出现6.1 静态资源内容哈希 长强缓存现代前端构建工具Webpack、Vite 等的标配做法是给文件名加上内容哈希比如app.a3f8c2.js。内容变了文件名就变URL 就变浏览器会当成一个全新资源去请求。配合这个策略静态资源可以设置很长的强缓存Cache-Control: public, max-age31536000, immutablemax-age31536000是一年。immutable告诉浏览器这个资源在有效期内绝对不会变不用去验证。这样一年内这个文件都不会产生任何请求性能拉满。因为文件名带哈希更新时文件名会变用户自然会请求新文件不存在拿不到新版本的问题。这是目前最主流的静态资源缓存方案。6.2 HTML 入口no-cache ETagHTML 文件不能设长强缓存因为它里面引用了带哈希的 JS/CSS 文件名。如果 HTML 被缓存了用户就拿不到新的文件名引用整个更新链路就断了。所以 HTML 通常这样设置Cache-Control: no-cache ETag: html-version-123no-cache表示每次都要协商验证。服务器通过 ETag 判断 HTML 有没有变没变返回 304变了返回 200 和新内容。这样既保证了更新及时又省掉了没变时的传输。6.3 接口数据按业务敏感度分级接口的缓存策略要按数据性质来分完全实时如账户余额、订单状态Cache-Control: no-store不缓存。短时有效如商品列表、配置项Cache-Control: max-age60缓存一分钟。长期不变如字典数据、地区列表可以设较长的强缓存配合版本号。我见过不少项目图省事所有接口都设no-store结果页面每次刷新都重新拉全部数据体验很差。其实很多数据是可以缓存的关键是想清楚这个数据多久内不变是安全的。6.4 用 Nginx 配置一个完整的缓存策略下面是一个 Nginx 配置示例展示了静态资源和 HTML 的不同处理# HTML 文件协商缓存 location ~* \.html$ { add_header Cache-Control no-cache; etag on; } # 带哈希的静态资源长强缓存 location ~* \.[a-f0-9]{8}\.(js|css|png|jpg|woff2)$ { add_header Cache-Control public, max-age31536000, immutable; etag off; } # 其他静态资源中等强缓存 location ~* \.(js|css|png|jpg|gif|svg|woff2)$ { add_header Cache-Control public, max-age86400; etag on; }这个配置的核心思路是带哈希的资源用长强缓存不带哈希的用协商缓存。这样既最大化性能又保证更新可控。注意add_header在 Nginx 里有继承规则如果 location 里又写了 add_header父级的会被覆盖。配置时要注意层级否则可能出现设了 Cache-Control 但不生效的情况。这个坑我踩过排查时用curl -I看响应头最直接。7. 踩坑复盘那些年 304 给我上的课7.1 坑一改了文件但浏览器一直 304这是最经典的坑。后端改了接口返回前端刷新还是旧数据状态码 304。根因服务器生成的 ETag 或 Last-Modified 没有随内容更新。比如某些框架会根据文件路径生成 ETag而不是根据内容导致内容变了 ETag 没变。排查链路用curl -I直接请求接口看响应头里的 ETag 和 Last-Modified。改一次内容再请求一次对比 ETag 有没有变。如果 ETag 没变说明服务器的 ETag 生成逻辑有问题需要改成基于内容哈希。修复把 ETag 生成逻辑改成对响应体做哈希。如果是 Nginx默认的 ETag 是基于文件修改时间和大小的一般够用如果是应用层自己生成的要检查生成逻辑。7.2 坑二no-cache 被误解成 no-store有次同事说这个接口我设了 no-cache怎么还是被缓存了。一看代码他以为no-cache是不缓存结果浏览器确实每次都发请求但服务器返回 304浏览器还是用了缓存。根因no-cache的真实含义是可以缓存但每次要验证不是不缓存。要完全不缓存得用no-store。修复把no-cache改成no-store或者在响应里加上Pragma: no-cache兼容老客户端。改完之后每次请求都是 200拿到最新数据。这个坑的本质是术语误导。no-cache这个名字起得太容易让人误解了实际用的时候一定要记住它的准确语义。7.3 坑三强缓存时间设太长更新发不出去早期项目里我们把所有静态资源都设了max-age31536000但文件名没加哈希。结果有一次紧急修复改了 JS 文件用户死活拿不到新版本因为浏览器一年内都不会去请求。根因长强缓存 无哈希文件名 更新灾难。修复要么给文件名加哈希要么把强缓存时间缩短。我们最后上了构建工具的哈希方案彻底解决了这个问题。教训长强缓存和文件名哈希是绑定使用的缺一不可。只做其中一个迟早出问题。7.4 坑四CDN 缓存和浏览器缓存打架有次更新了资源浏览器强刷能看到新的但普通用户还是旧的。排查发现是 CDN 层缓存了旧资源CDN 的缓存时间比浏览器还长。根因缓存是分层的浏览器一层CDN 一层可能还有反向代理一层。每层都有自己的缓存策略任何一层没刷新用户就可能拿到旧内容。修复更新资源时除了浏览器缓存策略还要考虑 CDN 的刷新。通常做法是文件名带哈希这样 CDN 也会当成新资源或者主动调用 CDN 的刷新接口。经验排查缓存问题时一定要逐层排查。用curl直接请求源站再请求 CDN对比响应头才能定位是哪一层的问题。8. 几个容易被忽略的细节和我的个人习惯8.1 304 响应可以更新缓存策略前面提过304 响应虽然没有 body但可以有响应头。服务器可以借这个机会更新Cache-Control。比如HTTP/1.1 304 Not Modified Cache-Control: max-age3600 ETag: abc123浏览器收到后会把新的max-age应用到本地缓存上相当于续了个期。这个技巧在需要动态调整缓存时间的场景下很有用。8.2 内存缓存和磁盘缓存的区别Network 面板里经常看到(from memory cache)和(from disk cache)它们都是强缓存命中但来源不同内存缓存存在内存里读取极快但页面关闭就没了。通常用于当前页面刚加载过的资源。磁盘缓存存在硬盘里读取稍慢但持久。用于跨页面、跨会话的资源。浏览器自己决定把资源放内存还是磁盘开发者一般不用干预。但知道这个区别能帮你理解为什么有时候刷新很快、有时候稍慢。8.3 我个人的排查习惯遇到缓存相关的问题我的固定动作是先看 Network 面板的状态码和 Size 列判断是强缓存、协商缓存还是没缓存。用curl -I看响应头确认 Cache-Control、ETag、Last-Modified 的实际值。对比两次请求的请求头看 If-None-Match 和 If-Modified-Since 有没有正确带上。逐层排查从浏览器到 CDN 到源站一层层看响应头。这套流程走下来90% 的缓存问题都能定位。剩下的 10%通常是服务器框架的默认行为在作怪需要去看框架文档。8.4 一个实用的小技巧如果你在调试时想临时禁用缓存除了用浏览器的 Disable cache 选项还可以在请求 URL 后面加一个随机参数比如?t123456。这样每次 URL 都不同浏览器会当成新资源请求绕过所有缓存。这个方法在调试接口时特别好用但记得不要带到生产环境否则缓存就白设了。缓存这块知识看一遍文档容易真正理解透需要在实际项目里踩几次坑。我自己的体会是不要死记字段要理解每一层缓存在解决什么问题。强缓存解决要不要发请求协商缓存解决发了请求要不要传数据304 就是后者的产物。把这个逻辑理顺了具体配置怎么设自然就有判断依据了。