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?这不是违反规范吗?
历史包袱:
- 早期实现混乱——HTTP/1.0 (RFC 1945) 对 301/302 的 method 行为描述模糊。
- Netscape 把 302 当 GET 跳转用——表单 POST 后服务端返回 302,浏览器自动改成 GET 请求新 URL,体验很好。
- 所有浏览器跟进——Mosaic、IE、Firefox、Chrome 全部沿袭这个行为。
- 想改也改不动——一旦服务端 / 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 HttpResponseRedirect | 302(建议改 303) |
| Express res.redirect | 302(建议显式 303) |
| Spring MVC RedirectView | 302(建议显式 303) |
| Rails redirect_to | 302(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),让用户被迫重新解析
预防方案:
- 拿不准就先用 302 观察 1-2 周,确认稳定再换 301。
- 加 Cache-Control: max-age=86400(一天)限制缓存时长——但 Chrome / Safari 对 301 的 max-age 处理不一致,不能完全依赖。
- 重大跳转上线前做”反向预演”——先在测试环境跑通”撤销 301”的全流程。
- 永远不要用 301 跳转登录态相关 URL——用户清不了缓存就永远登不上。
状态码速查:哪些容易混淆
| 状态码 | 名字 | 含义 | 易混淆点 |
|---|---|---|---|
| 200 | OK | 成功 | RESTful API 偶尔会 200 + 业务错误码 |
| 201 | Created | 创建成功 | POST 创建资源应该返回 201 + Location,不是 200 |
| 204 | No Content | 成功但无 body | DELETE 成功的标准返回;不能带 body |
| 206 | Partial Content | 范围请求成功 | 分片下载、流媒体 seek 用 |
| 301 / 308 | 永久跳转 | 见上 | 308 严格保留 method |
| 302 / 307 | 临时跳转 | 见上 | 307 严格保留 method |
| 303 | See Other | 改成 GET | PRG 模式专用 |
| 304 | Not Modified | 用本地缓存 | 不带 body;和 3xx 跳转完全无关 |
| 400 | Bad Request | 客户端格式错 | 参数校验失败常用 |
| 401 | Unauthorized | 未认证 | ”没登录”——和 403 区别在 401 = 没身份 |
| 403 | Forbidden | 已认证但无权限 | ”登了但没权限”——和 401 不能互换 |
| 404 | Not Found | 资源不存在 | 路由不匹配也是 404 |
| 405 | Method Not Allowed | 方法不对 | URL 存在但不接受这个 method(如对只读端点 POST) |
| 409 | Conflict | 冲突 | 乐观锁失败、唯一约束冲突常用 |
| 410 | Gone | 永久消失 | 比 404 更明确——告诉爬虫”别再来” |
| 422 | Unprocessable Entity | 格式对但语义错 | JSON 解析成功但字段非法(Rails / Laravel 偏好) |
| 429 | Too Many Requests | 限流 | 必须配合 Retry-After header |
| 500 | Internal Server Error | 服务端代码崩了 | 兜底;具体原因写日志 |
| 502 | Bad Gateway | 网关收到错误响应 | 上游服务挂了 |
| 503 | Service Unavailable | 服务暂时不可用 | 维护中、过载;配 Retry-After |
| 504 | Gateway Timeout | 网关超时 | 上游响应太慢;和 502 区别在没收到响应 vs 收到错误响应 |
MIME 类型常见错误
| 文件 | 正确 MIME | 常见错误 | 后果 |
|---|---|---|---|
| .js | application/javascript | text/plain | 浏览器拒绝执行 |
| .mjs | application/javascript | application/octet-stream | Nginx 默认配置缺失 |
| .json | application/json | text/plain | fetch().json() 仍能解析但有些库报错 |
| .svg | image/svg+xml | text/xml | 部分浏览器不渲染 |
| .woff2 | font/woff2 | application/octet-stream | 字体加载慢(CDN 不压缩) |
| .wasm | application/wasm | application/octet-stream | streaming 编译失败 |
| application/pdf | application/octet-stream | 浏览器下载而不是内嵌预览 | |
| .webp | image/webp | image/jpeg | 部分老浏览器拒绝渲染 |
修复入口:
- Nginx:
/etc/nginx/mime.types加新条目,或在location块里default_type+add_header Content-Type ... - Apache:
AddType指令 - Node Express:
express.static({ setHeaders })或全局app.use((req,res,next)=>{res.type(...)}) - OSS / S3 / R2:上传时显式指定
Content-Type,或配置 bucket 的 MIME 推断规则
实战建议清单
✅ 必做:
- POST 跳转用 307/308,不用 301/302
- 表单提交后跳转用 303(PRG 模式)
- 永久迁移上线前先用 302 观察 1-2 周
- 跳转链不超过 2 跳
- 配齐 mime.types,特别是 .mjs / .wasm / .webp
❌ 避免:
- 用 301 跳转可能回滚的 URL
- 在支付回调、Webhook 等关键链路上依赖 HTTP 层重定向
- 把 401 和 403 混用(语义不同,监控统计会乱)
- API 一律返回 200 + body 写错误码(前端处理混乱、CDN 策略失效)
理解 HTTP 状态码不是背”301=永久”这种口诀——是理解每个状态码诞生的历史动因和事实行为。301/308 都是永久,但 308 是为了修 301 的方法改写问题;303 看起来是”临时”,但它的存在本身就是为了让 PRG 模式有一个语义清晰的标准答案。