301 vs 302 vs 307 vs 308:HTTP 跳转该用哪个?永久、临时与方法保留的真实差别

· 约 6 分钟 📡 HTTP 状态码 / MIME 速查

301 / 302 / 307 / 308 看着像 HTTP 跳转的”永久/临时 × 严格/宽松”四象限,但实际开发里真正出问题的,几乎都是方法改写缓存撤不掉这两件事——不是看了对照表就能避免的。

四个跳转码的真实差别

状态码永久 / 临时允许改 method浏览器缓存典型场景
301 Moved Permanently永久⚠️ 实际允许强缓存(持久)域名迁移、URL 规范化
302 Found临时⚠️ 实际允许不缓存登录态跳登录页
307 Temporary Redirect临时❌ 严格禁止不缓存API 临时转发、需保留 POST body
308 Permanent Redirect永久❌ 严格禁止强缓存API 端点永久迁移
303 See Other临时✅ 强制改成 GET不缓存表单提交后跳成功页(PRG)

核心分界线不是”永久 vs 临时”,是”客户端会不会把 POST 改成 GET”——这是 90% 跳转 bug 的根源。

为什么 301/302 会”允许”改 method?这不是违反规范吗?

历史包袱:

  1. 早期实现混乱——HTTP/1.0 (RFC 1945) 对 301/302 的 method 行为描述模糊。
  2. Netscape 把 302 当 GET 跳转用——表单 POST 后服务端返回 302,浏览器自动改成 GET 请求新 URL,体验很好。
  3. 所有浏览器跟进——Mosaic、IE、Firefox、Chrome 全部沿袭这个行为。
  4. 想改也改不动——一旦服务端 / CDN / 客户端形成依赖,强行改回严格语义会让海量站点崩溃。

最终 IETF 在 RFC 7231 里承认现实:301/302 在 POST 跳转时,客户端可能改成 GET,且这是合规的(事实上的标准)。要严格保留 method?请用 307/308。

一个真实的踩坑:用 302 重定向支付回调

场景:电商支付成功后,支付网关 POST 通知到商户回调 URL;商户 URL 因维护临时挪到备用域名,用 302 跳转。

结果

  • 支付网关 POST 到 https://shop.com/callback
  • 商户返回 302 Location: https://backup.shop.com/callback
  • 支付网关的 HTTP 客户端(curl 默认行为)把 POST 改成 GET 跟随跳转
  • 备用域名收到 GET 请求,没有 body,没有签名——支付状态丢失
  • 用户付了钱,订单系统没更新,客服爆炸

修复:跳转改成 307,强制保留 POST + body。

更彻底的修复:支付回调这种场景根本不应该用跳转——应该让商户在支付网关后台直接配新 URL,或者让商户的旧 URL 内部转发(服务端 fetch + 转发响应)而不是依赖 HTTP 层重定向。

表单提交跳转的标准模式:PRG (Post-Redirect-Get)

浏览器                      服务器
   |                           |
   |  POST /order  body=...    |
   |-------------------------->|
   |                           | 处理订单、写库
   |                           |
   |  303 See Other            |
   |  Location: /order/success |
   |<--------------------------|
   |                           |
   |  GET /order/success       |
   |-------------------------->|
   |                           |
   |  200 OK + 成功页 HTML     |
   |<--------------------------|
   |                           |

为什么必须用 303 而不是 302

状态码浏览器实际行为用户刷新成功页会怎样
302多数浏览器改成 GET(事实标准)通常只 GET 一次,但老浏览器可能再 POST → 重复下单
303强制 GET(规范明确)只 GET 一次,绝对不会重复 POST
307严格保留 POST浏览器再 POST → 必然重复下单

Web 框架默认行为

框架表单成功跳转默认状态码
Django HttpResponseRedirect302(建议改 303)
Express res.redirect302(建议显式 303)
Spring MVC RedirectView302(建议显式 303)
Rails redirect_to302(Rails 4 后默认 303 + see_other)
Next.js redirect()307(严重问题——表单提交场景必须显式改 303)

Next.js 的坑特别值得警惕redirect() 默认 307,server action 提交后 redirect 会保留 POST,刷新成功页会重新触发 server action——直接造成重复提交。Next.js 14 后提供 permanentRedirect()RedirectType 参数,建议显式 redirect(url, RedirectType.replace) 或返回 303。

301 缓存撤不掉的真实代价

301 被浏览器写入持久缓存(HTTP cache + DNS cache 类似但更激进),影响周期可能跨年。

踩坑案例:某 SaaS 把 app.example.com 永久迁到 app.example.io,配 301。半年后业务调整要回迁——发现:

  • 新用户访问 app.example.com 会被旧浏览器缓存的 301 直接跳到 .io
  • 服务端把 301 改成 200 → 没用,浏览器根本不会再请求原 URL
  • 撤销缓存的唯一办法:换一个新 URL(如 app2.example.com),让用户被迫重新解析

预防方案

  1. 拿不准就先用 302 观察 1-2 周,确认稳定再换 301。
  2. 加 Cache-Control: max-age=86400(一天)限制缓存时长——但 Chrome / Safari 对 301 的 max-age 处理不一致,不能完全依赖。
  3. 重大跳转上线前做”反向预演”——先在测试环境跑通”撤销 301”的全流程。
  4. 永远不要用 301 跳转登录态相关 URL——用户清不了缓存就永远登不上。

状态码速查:哪些容易混淆

状态码名字含义易混淆点
200OK成功RESTful API 偶尔会 200 + 业务错误码
201Created创建成功POST 创建资源应该返回 201 + Location,不是 200
204No Content成功但无 bodyDELETE 成功的标准返回;不能带 body
206Partial Content范围请求成功分片下载、流媒体 seek 用
301 / 308永久跳转见上308 严格保留 method
302 / 307临时跳转见上307 严格保留 method
303See Other改成 GETPRG 模式专用
304Not Modified用本地缓存不带 body;和 3xx 跳转完全无关
400Bad Request客户端格式错参数校验失败常用
401Unauthorized未认证”没登录”——和 403 区别在 401 = 没身份
403Forbidden已认证但无权限”登了但没权限”——和 401 不能互换
404Not Found资源不存在路由不匹配也是 404
405Method Not Allowed方法不对URL 存在但不接受这个 method(如对只读端点 POST)
409Conflict冲突乐观锁失败、唯一约束冲突常用
410Gone永久消失比 404 更明确——告诉爬虫”别再来”
422Unprocessable Entity格式对但语义错JSON 解析成功但字段非法(Rails / Laravel 偏好)
429Too Many Requests限流必须配合 Retry-After header
500Internal Server Error服务端代码崩了兜底;具体原因写日志
502Bad Gateway网关收到错误响应上游服务挂了
503Service Unavailable服务暂时不可用维护中、过载;配 Retry-After
504Gateway Timeout网关超时上游响应太慢;和 502 区别在没收到响应 vs 收到错误响应

MIME 类型常见错误

文件正确 MIME常见错误后果
.jsapplication/javascripttext/plain浏览器拒绝执行
.mjsapplication/javascriptapplication/octet-streamNginx 默认配置缺失
.jsonapplication/jsontext/plainfetch().json() 仍能解析但有些库报错
.svgimage/svg+xmltext/xml部分浏览器不渲染
.woff2font/woff2application/octet-stream字体加载慢(CDN 不压缩)
.wasmapplication/wasmapplication/octet-streamstreaming 编译失败
.pdfapplication/pdfapplication/octet-stream浏览器下载而不是内嵌预览
.webpimage/webpimage/jpeg部分老浏览器拒绝渲染

修复入口

  • Nginx/etc/nginx/mime.types 加新条目,或在 location 块里 default_type + add_header Content-Type ...
  • ApacheAddType 指令
  • Node Expressexpress.static({ setHeaders }) 或全局 app.use((req,res,next)=>{res.type(...)})
  • OSS / S3 / R2:上传时显式指定 Content-Type,或配置 bucket 的 MIME 推断规则

实战建议清单

必做

  1. POST 跳转用 307/308,不用 301/302
  2. 表单提交后跳转用 303(PRG 模式)
  3. 永久迁移上线前先用 302 观察 1-2 周
  4. 跳转链不超过 2 跳
  5. 配齐 mime.types,特别是 .mjs / .wasm / .webp

避免

  1. 用 301 跳转可能回滚的 URL
  2. 在支付回调、Webhook 等关键链路上依赖 HTTP 层重定向
  3. 把 401 和 403 混用(语义不同,监控统计会乱)
  4. API 一律返回 200 + body 写错误码(前端处理混乱、CDN 策略失效)

理解 HTTP 状态码不是背”301=永久”这种口诀——是理解每个状态码诞生的历史动因和事实行为。301/308 都是永久,但 308 是为了修 301 的方法改写问题;303 看起来是”临时”,但它的存在本身就是为了让 PRG 模式有一个语义清晰的标准答案。

❓ 常见问题

301 和 302 到底差在哪?只是"永久 vs 临时"吗?

不只是。永久/临时只决定浏览器和搜索引擎要不要缓存这次跳转——301 会被缓存(甚至 SEO 权重传递),302 不缓存。但还有个更隐蔽的差别:301/302 在历史实践中允许客户端把 POST 改写成 GET 重新发请求(违反规范但所有主流浏览器都这么干),而 307/308 严格保留原 method 和 body。实务结论:纯 GET 跳转两者影响小,POST/PUT 跳转必须用 307/308 否则会丢请求体。

那 307 和 308 又是什么时候出现的?为什么要新加?

307 是 HTTP/1.1 (RFC 2616, 1999) 引入的,目的就是修复 302 的方法重写问题——明确规定"必须用相同 method 重发"。308 是 RFC 7538 (2015) 后出的,是 301 的"严格版"。为什么要新加:因为 301/302 的"方法可改写"已经被各大浏览器/CDN/客户端写死了,没法回头改它们的语义;只能新出 307/308 占据"严格保留 method"的语义位。选择口诀:(1) 永久 + 不在意 method → 301(SEO 友好、传统兼容);(2) 永久 + 必须保留 method → 308;(3) 临时 + 不在意 method → 302;(4) 临时 + 必须保留 method → 307。

用户提交表单后跳转到成功页,应该返回什么?

应该用 303 See Other,不是 302。这是 PRG(Post-Redirect-Get)模式的标准用法:POST 提交 → 服务端返回 303 + Location → 浏览器以 GET 请求成功页。为什么不用 302:理论上 302 不允许改 method,但多数浏览器实际会改成 GET——这"歪打正着"实现了 PRG,但语义混乱。为什么不用 307:307 严格保留 POST,浏览器会把表单数据再 POST 一次到成功页 → 用户刷新成功页就重复下单303 是为 PRG 量身定做的:明确告诉客户端"换成 GET,body 丢掉",用户刷新成功页只是再 GET 一次,不会重复提交。

301 跳转能传递 SEO 权重,那是不是改 URL 就用 301 就完事了?

不是无脑用 301真正传递权重的前提:(1) 301 必须是长期稳定的——临时性改 URL 用 301 会让搜索引擎更新索引,回退时新旧页都掉权重;(2) 跳转链不要超过 2 跳——301→301→301 会被搜索引擎认为是劫持嫌疑,权重打折;(3) 跳转目标要主题相关——把 /article/123 全部 301 到首页,搜索引擎会判定为"软 404"不传权重。陷阱:很多 SaaS / Shopify 商家做"换域名"时,把整站 301 到新域名首页,结果新域名收录数远低于旧域名——因为目标页主题完全不匹配。正确做法:旧 URL → 一一对应的新 URL,保留 Sitemap,至少维持 6 个月。

301 缓存"永久"到底有多久?能撤销吗?

严格来说没有 TTL,浏览器自己决定。Chrome / Firefox 会把 301 写入持久缓存——只要用户没清缓存或没强制刷新(Ctrl+Shift+R),可能几个月甚至几年都不会再请求原 URL。撤销 301 的痛苦:(1) 服务端把 301 改成 200 → 已经缓存的用户永远访问不到新内容,必须等他们清缓存;(2) 设置 Cache-Control: max-age=0 → 只对未来的 301 有效,过去缓存的不变;(3) 唯一兜底:换一个新 URL 让用户被迫重新解析。实务建议:(1) 不确定是不是永久迁移时,先用 302 观察 1-2 周;(2) 大型产品做 301 前先内部预演——一旦上线,回滚成本极高;(3) 给跳转加 Cache-Control: max-age=86400(一天)能限制缓存时长,但很多浏览器不严格遵守。

API 返回 304 Not Modified 是什么意思?跟 301/302 是一类吗?

完全不是一类。3xx 是"重定向"大类,但 304 是条件请求的产物——客户端发请求时带 If-None-Match: <etag>If-Modified-Since: <date>,服务端发现资源没变就回 304 + 空 body,告诉客户端"用你本地缓存的版本"。用途:节省带宽——304 响应通常只有几百字节 header,避免传输几 KB 到几 MB 的实际内容。:(1) 304 必须不带 body——带了客户端会忽略;(2) 服务端必须保证 ETag/Last-Modified 与 200 响应时一致,否则客户端缓存失效;(3) 移动端弱网下,304 + 本地缓存的体验显著优于每次重发。速查:301/302/307/308 是"换 URL",304 是"沿用本地",是两个独立维度。

MIME 类型写错会怎么样?比如 JS 写成 text/plain

严格模式下浏览器拒绝执行。现代浏览器在 <script> 加载时启用 X-Content-Type-Options: nosniff 后,必须看到 application/javascript 或 text/javascript 才会执行——MIME 错了控制台会报 "Refused to execute script ... because its MIME type (text/plain) is not executable"。常见踩坑:(1) Nginx 默认配置不全,.mjs 文件没在 mime.types 里 → 返回 application/octet-stream → 浏览器拒绝;(2) OSS / S3 上传时不指定 Content-Type → 默认 binary/octet-stream → 浏览器下载而不是渲染;(3) HTML 写成 text/html 但实际是 XHTML → 严格模式渲染失败。修复:服务端配置 mime.types(Nginx)/ MimeTypes(IIS)/ Content-Type 上传头(S3),或在响应里手动 setHeader。

为什么有的 API 返回 200 + body 里写"error: 404",而不是直接返回 HTTP 404?

两派立场REST 派主张严格用 HTTP 状态码——资源不存在就是 404、未授权就是 401、服务端错就是 500,body 是错误详情。RPC / GraphQL 派主张永远 200 + body 里说错——理由:(1) 客户端处理统一(不用 try/catch HTTP 错误);(2) CDN / 中间件不会把"业务错误"误当成"服务故障"切流量;(3) GraphQL 一次请求多操作,部分成功部分失败用 HTTP 状态码表达不了。实务建议:(1) 对外 OpenAPI / Web 资源用 REST 风格——便于 CDN 缓存策略、监控告警按状态码统计;(2) 内部 RPC / 移动端 BFF 可以走 200 + 业务码——团队内一致即可;(3) 最坏情况是混用——同一个 API 有时 200 有时 404,前端处理逻辑两套。

📡 打开 HTTP 状态码 / MIME 速查 HTTP 状态码 + MIME 类型双表查询·中文释义·扩展名↔MIME 反查·一键复制·锚点深链