一次"提交表单后跳转"的改造,把 302 换成了 301——理由是"永久跳转更规范"。上线后用户"提交后刷新"会重复提交(因为没有跳转),而浏览器缓存了 301,把"提交地址"永久指向了"结果页"。
3xx 家族有四个常用状态码,而它们的差别集中在两件事:是否允许"改变方法",这两件事的"组合"决定了风险:"永久 + 会改方法"(301)是最危险的组合——它既"记住了",又"改了语义"。
以及是否可缓存。
1. 四个状态码的关键差别
| 状态码 | 方法是否可变 | 可缓存 |
|---|---|---|
| 301 | 部分客户端变 GET | 是(永久) |
| 302 | 会变 GET | 否(历史问题) |
| 303 | 总是变 GET | 否 |
| 307 | 不变(保持方法与 body) | 否 |
| 308 | 不变 | 是(永久) |
第二项是"经典坑":302 在历史实现里"大多数客户端会把 POST 变 GET",而标准文本并不强制——于是"实际行为"与"标准"不同。
第四项是"要保住 POST 时"的唯一选择:307 保证"方法不变、body 重发"。第四项的"前提"是"目标地址能接受同一个 body":**如果"跳转是为了"去另一个服务处理",那 307 会把原始 body 发过去——而那个服务可能不认识它。
2. 三种"该用哪个"的场景
| 场景 | 选择 |
|---|---|
| 提交表单后展示结果页 | 303(明确的"看那个") |
| 域名/路径永久迁移 | 301 或 308 |
| 临时维护跳转 | 302 或 307 |
| 需要保留 POST body | 307 |
第一项的"额外好处"是"不缓存":**于是"提交 → 看结果"这条路径,不会因为"某个 3xx 被缓存"而错乱——这正是"提交后跳转"最常见的需求。
第一项的"303"是"语义最清楚"的:它的定义就是"去另一个地址 GET 那个结果"——于是"提交后跳转"用 303 最不容易出错。
3. "301 被永久缓存"的三种后果
| 后果 | 说明 |
|---|---|
| 改回来很难 | 浏览器缓存了"永久" |
| 提交地址被改写 | 表单指向了结果页 |
| 排查困惑 | "我明明改了配置" |
第二项就是开头的故障:**浏览器把"POST 到 /submit"永久记成"去 /result"**第二项的"传导"很彻底:连"表单页本身"都可能被绕过——用户点"提交"时浏览器直接去 /result,于是"没有提交",只有"看结果"。
——于是"用户怎么提交都没反应"。
// 三种跳转的实际行为(同一表单 POST)
301 → POST /submit:多数浏览器"变 GET 到 /result",且"永久记住"
302 → 同左(但理论上不保证,历史实现如此)
303 → "变 GET 到 /result"(语义正确,不永久)
307 → "保持 POST /result"(要保证 body 重发)
// 判据:这次跳转"是不是永久的",以及"方法要不要保留"
"提交后的跳转"推荐 303,而它和"前端路由跳转"是两件事:
POST /orders HTTP/1.1
Content-Type: application/json
{"sku":"A-12","qty":1}
→ HTTP/1.1 303 See Other
Location: /orders/O-1001 # 浏览器去 GET 这个地址
// 关键:body 已经处理完,跳转只是"去看结果",方法必须是 GET
4. "临时"与"永久"的分界
| 情形 | 该用 |
|---|---|
| 域名换新(旧域名永久保留) | 301/308 |
| 页面临时维护 | 302/307(加 Cache-Control: no-store) |
| A/B 分流跳转 | 302(且不缓存) |
| 提交后跳结果 | 303 |
第二项的"实践"是"默认给所有"临时跳转"加 no-store":**成本一个 header,收益是"改配置立刻生效"——而"没加"时要等 CDN 缓存过期。
第二项的"注意"是:即使 302 本身不该被缓存,某些代理/CDN 仍会缓存——所以要显式加 Cache-Control: no-store。
"跳转链"本身可以设上限,避免"无限循环":
// 两种"链"的处理
浏览器:多数限制 20 次跳转(超过即报 ERR_TOO_MANY_REDIRECTS)
客户端(API):应设"最多 3 次"并禁用"跨源跳转"
// 判据:每次跳转都"增加一次 RTT",超过 2 次就该合并成直接跳
5. "跳转链"的三个坑
| 坑 | 说明 |
|---|---|
| 多次跳转(A→B→C) | 延迟叠加、易断 |
| 跳转丢失 header | 跨域时 Authorization 会丢 |
| 跳转丢失 body | 301/302 变 GET 后 body 没了 |
第二项在"API 跳转"里最痛:客户端跟着 3xx 走,但"认证头"不带过去第二项的"判别"是"看目标地址是否同源":同源时浏览器会带 cookie,跨源时不会——于是"跳转后 401"多半是"跨源"。
——于是"跳到新地址后 401"。
第三项的"表现"很具体:服务端日志里"跳转后的请求"没有 body,于是"参数全空"——而它看起来像"客户端没传参"。
第三项与"方法变化"是同一件事:变 GET 之后 body 被丢弃,于是"跳转后的请求看起来是空的"。
"301 被缓存"可以用"换路径"来解,而"换路径"要配合"旧的 301 改回 302":
// 修法:新提交路径 + 旧路径改回"临时"(不再被永久记住)
location = /submit {
return 302 /submit-v2; # 不再是 301
}
location = /submit-v2 {
proxy_pass http://backend; # 新路径承接提交
}
// 说明:已经缓存了 301 的浏览器仍会被带到 /result,但它们"至少不会再提交"
6. 排错:五种"跳转相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 提交后没反应 | 301 把 POST 变 GET 并缓存 | 改 303/307 + 清缓存 |
| 跳转后 401 | 认证头未跟随 | 同域跳转或用 307 |
| 改了配置不生效 | 永久缓存 | 用新路径/清 CDN |
| 跳转链太长 | 多层重写 | 收敛到一层 |
| 刷新重复提交 | 无"提交后跳转" | 用 303 跳结果页 |
第一项是"最难修"的:浏览器缓存了 301,只有"清缓存"或"换 URL"能救第一项的"修法"很现实:换一个新的提交路径(/submit-v2),于是"旧缓存"不再被命中——而"清缓存"这种"让用户操作"的方案通常不可行。
——而"用户不会清缓存"。
最后一个"测试"建议:用 curl -I -L 看"跳转链"与"最终方法",比"在浏览器里点"更容易发现方法变化。
// 三步验证跳转语义(command-line 更清楚)
curl -sI http://host/submit | head -3 # 状态码
curl -sI -L -X POST http://host/submit | head -5 # 跟随后的状态
curl -s -L -X POST -d '{"a":1}' http://host/submit -v | grep -E '^> (POST|GET)'
// 关键:最后一行能看到"跟随之后用的是 POST 还是 GET"
7. 边界:什么情况不必纠结状态码
| 场景 | 为什么 | 做法 |
|---|---|---|
| 内部 API(客户端自己实现跳转) | 不依赖 3xx | 用 200 + 响应体 |
| SPA 前端路由 | 跳转在前端 | 只配 index.html 回退 |
| 单页无跨域 | 302 够用 | 302 |
| 静态资源迁移(已有 CDN 规则) | CDN 处理 | 跟随规则 |
最后一条与"可观测"有关的边界:跳转应当在"访问日志"里留痕(源路径、目标、状态码)。否则"用户说没反应"时,你只能看到"没有请求到达源路径"——而"为什么没到达"无从判断。
判断口径一句话:"这次跳转会不会被"浏览器或代理"记住,以及"要不要保住方法与 body"。会被记住(永久迁移),用 301/308 且"想清楚后果";要保方法(提交类),用 307;只是"看另一个页面",用 303。
8. 自测
- 五个状态码的关键差别是哪两件事?为什么 302 是"经典坑"?
- "提交后跳转"为什么该用 303?它的语义是什么?
- 301 被永久缓存后,三种后果分别是什么?为什么"用户不会清缓存"让问题难修?
- "临时维护"为什么除了 302 还要加
Cache-Control: no-store? - "跳转丢失 header/body"的两个机制分别是什么?
- 判断"该用哪个 3xx"的两个问题是什么?