HTTP 301/302/303/304区别:重定向语义、请求方法与缓存机制 1. 先把这四个状态码放回它们各自的系列里这道题在面试里出现的频率高得有点不正常。面试官问“301、302、303、304 的区别”很多人第一反应是背四条定义背完就没了。但真正做过线上迁移、做过接口跳转、被浏览器缓存坑过的人会本能地从“浏览器拿到这个码之后干了什么”这个角度回答。差别就在这儿。HTTP 状态码 301、302、303、304 都属于 3xx 段也就是重定向与缓存协商这一大类。但它们的语义、对请求方法的处理、可缓存性、对搜索引擎的影响完全是四套逻辑。301 和 302 是“地址搬家”的两种说法303 是把 POST 强制转成 GET 的专用工具304 严格来说根本不是重定向它是协商缓存命中后服务端给的一个“你别下载了你本地那份还能用”的确认信号。把它和跳转混在一起讲是面试里最常见的翻车点。这篇内容适合两类人看一类是准备前端面试、需要把这道题答出层次的人另一类是日常写接口、配 Nginx、调 axios遇到过“明明改了跳转地址但浏览器还在往老地址跑”这类怪事的人。我会从语义定义一直聊到代码验证、线上排查和面试追问力争让看完的人不光是知道这四个数字而是知道什么场景下该用哪个、用错了会付出什么代价。1.1 3xx 这段到底在表达什么RFC 把 3xx 定义为“重定向”类但这个描述其实偏窄。更准确的理解是3xx 表示“你这个请求我没有直接用 200 回应但我要额外告诉你一些关于目标资源位置或缓存状态的信息”。这里的关键在于3xx 响应往往需要客户端再做一个动作要么去另一个地址取东西要么确认本地副本有效。所以 3xx 天然带有“客户端参与”的属性。服务端只负责发出指令真正执行的是浏览器、curl、axios 或者 Node 的 http 模块。而不同的客户端对同一条指令的执行细节并不完全一致尤其是历史上 HTTP/1.0 和 HTTP/1.1 对 302 的处理分歧直接催生了 303 和 307 这两个“补丁型”状态码。理解这个演化脉络比死记四个定义有用得多。还有一个容易被忽略的点3xx 的可缓存性差异巨大。301 默认是可缓存的浏览器甚至会把它写进本地缓存你在服务端把配置改了老用户依然会被跳到旧地址302 默认不可缓存只有带上明确的 Cache-Control 或 Expires 才会被缓存304 本身就是缓存机制的产物。这一条决定了这四个码在“出问题之后有多难修”这个维度上排名完全不同。1.2 为什么前端面试偏爱吃这道题面试官问这道题真正想考的往往不是记忆而是几件事的串联能力。第一你知不知道 HTTP 语义和浏览器行为的区别第二你有没有踩过缓存的坑能不能说出“301 缓存清不掉”这种只有实战才会遇到的现象第三你能不能把状态码和实际业务场景对上比如登录后跳转、老域名迁移、表单重复提交、静态资源版本更新。我在实际带人的过程中发现能把这四个码讲清楚的人通常对缓存体系、请求方法语义、浏览器调试面板都比较熟而只会背定义的人一追问“304 的响应里有 body 吗”“fetch 默认会不会跟重定向”就开始卡壳。所以这道题本质上是个探针探的是你对 HTTP 这一层的理解深度。后面的内容我会按“语义、方法、缓存、场景”这条线展开把这个探针能挖到的东西尽量挖干净。2. 301 与 302一条“永久”与“临时”的分界线先说不严谨但好记的一句话版本301 是“我以后就住这儿了别再找老地址”302 是“我这次临时在这儿下回还去老地址”。这句话抓住了核心但漏掉了两个要害请求方法会不会变以及这个响应会不会被浏览器记住。这两点才是实际开发中真正会咬人的地方。2.1 301 Moved Permanently发出去就很难收回301 的全称是 Moved Permanently语义是目标资源已经被永久性地迁移到了 Location 头指定的新地址。客户端以后再遇到这个老地址应该直接用新的。浏览器对这个的落实非常彻底它会把这条映射关系缓存下来后续请求根本不会再打到服务端直接从本地缓存里取出跳转规则执行。这个特性在域名迁移、站点改版时是巨大的优势因为老用户访问老地址会以最小的代价到达新地址同时搜索引擎也会把老 URL 的权重传递到新 URL。但反过来它也是事故高发区。我见过一个模拟项目测试环境把一个接口用 301 指到了一个临时地址上线后改回正确配置结果测试同事的浏览器一直往那个已经不存在的地址跑换了无痕窗口才恢复正常。原因就是他之前访问过301 被浏览器记住了。这里给一个实操上的提醒涉及 301 的配置变更验证时一定要用无痕窗口或者手动清一次该站点的缓存数据。Chrome 的 DevTools 里勾选 Network 面板的 Disable cache 只对强缓存类的资源请求生效对 301 这种导航级跳转的缓存不一定管用别指望它。注意很多团队在做线上跳转配置前会先在预发环境用 302 验证一整轮确认链路没问题再换成 301。这个习惯能省掉大量回滚成本。2.2 302 Found名字改过、语义吵过的一个码302 现在的正式名字是 Found早期的名字是 Moved Temporarily。改名这件事本身就说明它历史上有多混乱。最经典的争议是客户端收到 302 之后原来那个 POST 请求该不该原样重发到新地址HTTP/1.0 时代大量浏览器和客户端的实现是把 POST 改成 GET 再去访问 Location这在表单提交后跳转到结果页的场景下非常好用因为能避免用户刷新时重复提交。但这种“擅自改方法”的行为并不符合规范原意规范说的是方法不应改变。于是后面才有了 303 和 307 来把这件事说清楚303 明确说“请用 GET 去访问新地址”307 明确说“请保持原方法和请求体”。所以你在面试里如果只答“302 是临时重定向”大概率会被追问一句“那 302 和 303 有什么区别”。这个追问的答案就在方法处理上。另外补充一句302 默认不可缓存这点和 301 正好相反所以它在“改错了容易修”这个维度上比 301 友好得多。2.3 到底该选哪个看这个 URL 还会不会回来判断标准其实很朴素这个老地址以后还有没有可能重新承担原来的职责。如果是公司改名、站点整体搬迁、老接口永久下线用 301。如果是活动页面临时换地址、灰度期间把部分流量导到新服务、A/B 测试期间切换入口用 302。还有一个实践中的折中方案不确定的时候先用 302 跑一段时间观察访问量和业务指标确认新地址稳定承接之后再改成 301。这样即便中间出问题改回来的代价也很小因为 302 不会被浏览器长期记住。这个思路在模拟项目里验证过很多次尤其是那种“迁移后还要保留老地址入口”的场景先 302 后 301 几乎是标准动作。另外提一句 SEO 层面的事301 会把老 URL 的权重传递过去搜索引擎也会逐步把索引换成新地址302 则被认为只是临时状态老 URL 仍然保留在索引里。如果你的需求是“让搜索引擎尽快更新”那 301 才是对的如果只是短期导流用 301 反而会造成索引混乱。3. 303 与 304两个经常被误归类的主角这两个码被放在同一道题里问本身就有点“陷阱”的味道因为它们的定位完全不同。303 确实是重定向但它专治一种情况304 压根不是重定向它属于缓存协商。把它们并列考的是你能不能识别出这种分类上的错位。3.1 303 See Other专门用来把 POST 摁成 GET303 的语义非常明确请客户端用 GET 方法去访问 Location 指定的地址不管原请求是什么方法。它有一个很经典的应用模式叫 PRG也就是 Post/Redirect/Get。用户提交表单服务端处理完之后返回 303浏览器自动用 GET 去访问结果页。这样用户再刷新页面刷的是 GET 请求不会重复提交表单。这个模式在实际项目里价值很高。我处理过一个订单提交的模拟场景早期没做 PRG用户网络慢的时候习惯性点两下提交按钮直接产生两条记录。后来在提交接口上改成“处理完返回 303 跳到订单详情页”重复提交的问题基本消失因为浏览器地址栏已经变成详情页的 GET 地址了。顺带把这组补丁讲全303 是“换 GET”307 是“保持原方法和请求体”。307 在需要把整个 POST 请求原封不动转发到新地址时使用比如某些网关层的转发逻辑。而 308 则是 301 的“保持方法”版本。这四个码302、303、307、308经常被放在一起对比把它们按“是否保持方法”和“是否永久”两个维度列成一张表记忆负担会小很多。状态码语义请求方法是否保持是否可缓存301永久迁移规范上应保持实际有分歧默认可缓存302临时找到历史上常被改成 GET默认不缓存303参见其他强制改为 GET一般可缓存307临时重定向强制保持原方法和请求体默认不缓存308永久重定向强制保持原方法和请求体默认可缓存3.2 304 Not Modified它不是跳转是缓存确认304 是这几个码里唯一一个“不带响应体”的。它的产生流程是浏览器本地有一份缓存副本但不确定还有没有过期于是带上一组条件请求头发给服务端比如 If-None-Match 带上之前收到的 ETag或者 If-Modified-Since 带上之前收到的 Last-Modified。服务端一比对发现资源没变就回一个 304意思是“你本地那份继续用”。所以 304 的前提一定是条件请求。你用一个普通的 GET不带任何条件头服务端是不会回 304 的。这一点在面试里经常被追问“304 是怎么触发的”标准答案是客户端发起了带条件的请求服务端校验后判定资源未变更。再往下追一层就会聊到 ETag 和 Last-Modified 的优先级。ETag 是内容指纹精度更高Last-Modified 是时间戳精度只能到秒而且存在“内容没变但修改时间变了”的误判。规范上建议 ETag 优先实际实现里大多数服务端也是这么做的。如果你的项目里出现“明明文件内容改了浏览器还拿旧的”先去看是不是 Last-Modified 精度不够导致的误判或者 CDN 层把 ETag 吃掉了。3.3 304 报文里的几个硬性约束304 响应不能带消息体这一点必须记住。它的头部里通常会包含 Cache-Control、ETag、Date、Vary 等字段用来让客户端更新本地缓存条目的元信息。有些服务端框架在实现 304 时处理不干净还硬塞了一个空 body 或者错误的 Content-Length导致某些客户端解析异常。还有一个容易忽略的细节304 只能由 GET 或 HEAD 触发。你用 POST 去请求一个资源即便带了条件头也不应该得到 304。这个边界在写中间件的时候要注意别把条件判断逻辑套在所有方法上。在实际调试里Chrome DevTools 的 Network 面板会显示 304但响应体那一栏是空的Size 一栏会显示实际传输的字节数通常很小。如果你想区分“浏览器根本没发请求直接用的强缓存”和“发了请求、服务端回了 304”看 Size 那一列就能分辨前者显示的是 from disk cache 或 from memory cache后者显示的是一个具体的小数字。4. 动手验证把四个状态码跑一遍光看不练记不牢。这一段我搭一个最小实验环境用原生 Node 把四个状态码都造出来然后分别用浏览器、curl 和前端代码去观察把“理论”和“实际表现”对上。4.1 用几十行原生 Node 代码铺一个实验环境不引任何框架直接用 http 模块避免依赖干扰。const http require(http); const server http.createServer((req, res) { const url req.url.split(?)[0]; if (url /old-permanent) { // 301永久迁移 res.writeHead(301, { Location: /target }); res.end(); return; } if (url /old-temp) { // 302临时跳转 res.writeHead(302, { Location: /target }); res.end(); return; } if (url /submit) { // 303处理后转 GET res.writeHead(303, { Location: /result }); res.end(); return; } if (url /cached-asset) { const etag v1-abc123; const clientEtag req.headers[if-none-match]; if (clientEtag etag) { // 304协商缓存命中只回头部不带 body res.writeHead(304, { ETag: etag, Cache-Control: no-cache }); res.end(); return; } res.writeHead(200, { Content-Type: text/plain; charsetutf-8, ETag: etag, Cache-Control: no-cache, }); res.end(资源的完整内容); return; } if (url /result || url /target) { res.writeHead(200, { Content-Type: text/plain; charsetutf-8 }); res.end(到达目标页); return; } res.writeHead(404); res.end(not found); }); server.listen(8080, () { console.log(http://localhost:8080); });跑起来之后你依次访问/old-permanent、/old-temp、/submit、/cached-asset把 DevTools 的 Network 面板打开并勾上 Preserve log保留日志。这一步很关键因为跳转过程中页面会刷新不勾这个选项前一条 301 记录会被清掉你就只能看到最后那个 200。访问/cached-asset的时候要刷新两次才能看到 304第一次是 200 并把 ETag 带回来第二次浏览器带上 If-None-Match服务端比对命中返回 304。这里我特意用了Cache-Control: no-cache它的意思是“可以缓存但每次使用前必须跟服务端确认”也就是强制走协商缓存这样便于观察 304。如果写成no-store那是完全不缓存你永远看不到 304。4.2 命令行怎么观察重定向链路curl 是最直接的观察工具。默认情况下 curl 不会自动跟随重定向你只会看到第一个响应的状态码和 Location 头这反而方便你确认服务端到底发了什么。curl -i http://localhost:8080/old-permanent你会看到HTTP/1.1 301 Moved Permanently和一行Location: /target。想看完整链路就加-Lcurl -iL http://localhost:8080/old-permanent加上-L之后curl 会跟着跳最终输出目标页的内容。这里有个观察技巧加-v参数能看到每一步的请求方法。你拿/submit试一下用-X POST发出去再配合-v看第二步用的是 GET 还是 POST就能直观感受到 303 的“强制改 GET”到底是怎么回事。curl -v -L -X POST http://localhost:8080/submit4.3 fetch 的 redirect 选项前端拿到状态码的真实处境前端代码里有个坑必须说清楚fetch默认的redirect值是follow也就是说浏览器会自动帮你把跳转走完你的response.status拿到的是最终那个响应的状态码通常是 200。你根本看不到中间的 301 或 302。// 默认行为自动跟随拿不到中间状态码 const res await fetch(/old-permanent); console.log(res.status); // 200 console.log(res.redirected); // true console.log(res.url); // http://localhost:8080/targetres.redirected这个布尔值能告诉你“发生过跳转”但具体是 301 还是 302它不告诉你。想拿到原始状态码得把redirect设成manualconst res await fetch(/old-permanent, { redirect: manual }); console.log(res.status); // 0 console.log(res.type); // opaqueredirect这里会碰到一个跨域规范带来的限制redirect: manual返回的是一个 opaque-redirect 类型的响应status是 0headers也读不到你只能知道“发生了跳转”这件事本身。这是浏览器的安全策略不是 bug。如果你真的需要在业务代码里判断重定向类型通常的做法是让服务端在响应头里额外带一个自定义字段或者干脆用 XHR 配合服务端 CORS 配置来处理。提示如果你在项目里遇到“接口明明返回 302但 axios 里拿到的是 200”先别怀疑后端去看客户端是不是默认跟随了重定向。axios 在浏览器环境下跟随行为由浏览器决定在 Node 环境下由maxRedirects配置控制。5. 面试现场的答题结构和高频追问前面讲的都是“知识”这一段讲“怎么讲”。同一套知识组织方式不同面试官的评价差别很大。我的建议是按固定四步走语义、方法、缓存、场景。每一步一句话先把框架立起来再等追问往深处挖。5.1 一分钟版本四步把框架立住第一步说语义。301 是永久迁移302 是临时跳转303 是让客户端改用 GET 访问新地址304 不是跳转而是协商缓存命中。第二步说方法。301 和 302 在规范上不改变请求方法但历史实现里 302 常被改成 GET正因为这个歧义才补出了 303强制 GET和 307强制保持。304 只由 GET 或 HEAD 触发。第三步说缓存。301 默认可缓存且浏览器会长期记住302 默认不缓存303 一般可缓存304 本身就是缓存机制的产物且不带响应体。第四步说场景。域名迁移用 301临时导流用 302表单提交后防重复用 303静态资源版本控制配合 ETag 用 304。这一分钟讲完基本盘就稳了。接下来无论面试官从哪个角度切进来你都有地方接。5.2 追问一301 的缓存怎么清这个问题几乎必问因为它直接检验你有没有被坑过。标准回答分三层第一浏览器把 301 当作强缓存处理存在本地服务端改配置对它无效第二用户侧可以通过清除该站点的浏览数据来清除无痕窗口也是一个验证手段第三开发者侧如果已经上线了错误的 301只能靠引导用户清缓存或者在被跳转到的目标地址上做兼容处理没有服务端一键撤销的办法。这里可以补一句实操建议正式启用 301 之前先在预发环境用 302 跑通全链路再切 301把风险前置。这个习惯说出来面试官会知道你是有线上意识的。5.3 追问二307 和 308 是干什么的这两个是 302 和 301 的“保持方法”版本。302 因为历史上会改方法语义不清晰所以有了 307 来明确“保持原方法和请求体”301 同理308 是它的严格版本。回答时可以把 301、302、303、307、308 这五个按“是否保持方法”和“是否永久”两列排开说清楚它们的定位差异比单个背要清楚得多。顺带说一句307 在需要用户确认的场景下有额外行为某些客户端在跨域且带敏感信息时会弹确认框。这个细节不是必须答但提一句显得你读过规范。5.4 追问三强缓存和协商缓存怎么分这是从 304 延伸出去的问题。强缓存看Cache-Control的max-age和Expires命中时浏览器根本不发请求DevTools 里显示 from memory cache 或 from disk cache。协商缓存看ETag配If-None-Match、Last-Modified配If-Modified-Since命中时发请求、服务端回 304。两者的关键差别在于“要不要跟服务端确认”。强缓存是本地直接判定协商缓存是问一句再决定。回答时最好把“200 from cache”和“304”这两个在 DevTools 里的表现也说出来能体现你确实动手调过。5.5 答错率最高的三个点第一个是把 304 归到重定向里。它不带 Location不改变资源位置只是告诉客户端“本地那份还能用”。第二个是说 301 一定保持 POST 方法。规范原文的措辞是“用户代理可以改变请求方法”规范本身留了口子实践中不同客户端处理也不完全一致。严谨的说法是“301 在方法处理上存在历史分歧需要严格保持时用 308”。第三个是把 302 说成“一定不缓存”。准确说法是默认不可缓存但如果服务端显式设置了Cache-Control或Expires它也可以被缓存。规范里对可缓存性的描述是“只有明确指定时才可缓存”别说成绝对。6. 线上排查实录与速查清单这一段讲几个有味道的坑都是围绕这几个状态码发生的。我把现象、原因、处理方式整理出来遇到类似情况可以直接对号入座。6.1 三个真实味道的坑第一个坑是 301 改不回来。某个模拟项目做老接口迁移图省事直接上了 301。后来发现新地址的参数协议不兼容需要改回原地址结果测试同学的浏览器一直往新地址跑接口全部报错。最后的处理方式是让测试同学清一次该站点的浏览数据同时在文档里补了一条规范涉及 301 的配置变更必须先用 302 灰度验证。第二个坑是 302 循环。某个模拟网关在做灰度时把 A 路径 302 到 B 路径B 路径的配置里又写了一条兜底规则指回 A浏览器在两个地址之间来回跳最后报 “重定向次数过多”。这类问题的排查方式是看 DevTools 的 Network 面板如果看到一串连续的 302 且 URL 在循环基本就能定位。根治办法是给重定向链路加一个跳数上限或者在配置层保证跳转关系是单向的。第三个坑是 304 和 CDN 打架。某个模拟项目上线新版本静态资源文件名没变只改了内容。结果一部分用户拿到的还是旧文件。查下来是 CDN 层缓存了旧的 ETag回源时又因为If-None-Match对不上做了某些处理导致链路里 304 的判断出现偏差。最后的解决方案是给静态资源加内容哈希做文件名让“改内容”必然等于“改 URL”从根上绕开协商缓存的复杂性。这个思路在前端构建里已经是标配了。6.2 状态码速查表现象最可能的原因排查动作改了跳转配置浏览器还往老地址跑301 被浏览器缓存换无痕窗口验证引导用户清站点数据接口拿到的 status 一直是 200客户端自动跟随了重定向检查 fetch 的 redirect、axios 的 maxRedirects页面报重定向次数过多跳转链路形成环Network 面板看连续 3xx 的 URL 序列文件内容更新了但用户还是旧版强缓存未过期或 ETag 判断异常看 Cache-Control给资源加内容哈希表单刷新后重复提交未使用 PRG 模式提交后返回 303 跳转到结果页静态资源每次都要回源缓存头配置成 no-store改为 no-cache 走协商缓存或设合理 max-age最后分享一个我自己的习惯凡是涉及跳转的配置变更我都会在提交前问自己两个问题——这个地址以后还会回来吗如果出问题我能在多长时间内让用户恢复正常第一个问题决定用 301 还是 302第二个问题决定我要不要先灰度。这两个问题问完绝大多数选型上的犹豫就消失了。至于 304它其实提醒的是另一件事缓存策略是要设计的不是默认就对的。一个没配 Cache-Control、没配 ETag 的接口性能和体验都会打折反过来配得太激进发版时又要追着用户清缓存。这个平衡点只能在具体项目的访问量和更新频率里慢慢找。