①结论先行:先分清你说的是哪种「两次」
正常情况不会。你看到的「两次」90% 是 CORS 预检 OPTIONS(POST 仍只发一次,且可用
Access-Control-Max-Age 缓存掉);少数是 Expect: 100-continue 的分趟发包(一次请求);只有在 307/308 重定向、网关对未发出请求的重试、客户端重试库、用户重复提交这几种情况下,POST 才真的被发了两次。②为什么会有这些「两次」:根因各不相同
| 情形 | 为什么会产生 | POST 真实发送次数 | 危险程度 |
|---|---|---|---|
| CORS 预检 | 跨域 + 非简单请求,浏览器必须先问服务器「允许这种请求吗」 | 1 次(前面多了 1 次 OPTIONS) | 安全 |
| Expect: 100-continue | 客户端想先确认服务器收不收,免得白传大 body | 1 次(头与体分两趟发) | 安全 |
| 307 / 308 重定向 | 规范要求重定向时保持原方法与请求体 | 2 次(两次都是 POST) | 危险 |
| 网关 / 代理重试 | 连接失败或超时,网关换一个后端节点重发 | 1~2 次(取决于是否真的发出去了) | 危险 |
| 客户端重试 | axios-retry / fetch 重试封装 / 超时补偿 | 2 次或更多 | 危险 |
| 用户重复提交 | 双击按钮、刷新确认页、回退再提交 | 2 次或更多 | 危险 |
| TCP 重传 / 分包 | 网络层丢包重传或 body 拆成多个 TCP 段 | 1 次 | 安全 |
③CORS 预检:最常见的那个「两次」
浏览器对跨域请求有一套安全机制。如果请求属于「非简单请求」,浏览器会先自动发一个 OPTIONS 请求问服务器:「我准备用 POST + JSON + 自定义头去访问你,你允许吗?」只有服务器明确许可,真正的 POST 才会发出。
什么叫「简单请求」(不触发预检)
必须同时满足以下三条,缺一条就触发预检:
完整时序
① 缓存预检结果:服务端返回
Access-Control-Max-Age: 600,浏览器在有效期内不再重复预检(各浏览器有上限,Chrome 上限 10 分钟)。② 改成同源:用 Nginx / 开发服务器代理把前后端放同一个域,跨域不存在,预检自然消失。
③ 改用简单请求格式:
application/x-www-form-urlencoded 而非 application/json(代价是数据结构表达能力下降)。④交互:你的请求会触发预检吗
调整下面四个条件,实时判断是否会多出一次 OPTIONS 请求:
⑤Expect: 100-continue:一次请求,分两趟走
这是「POST 发两次」说法的第二大来源,也是被误传最广的一个。客户端在请求头里带上 Expect: 100-continue,意思是:「我先只发请求头,你确认能收,我再发 body」——目的是避免白传几百 MB 的文件。
谁会发这个头
curl:当 body 大于约 1KB 时会自动加上;可用 -H "Expect:" 禁用。浏览器(Chrome / Firefox 的 fetch 与 XHR):默认不发这个头。
为什么要这么做
避免「传了 200MB 才被服务器以 401 拒绝」。先握手确认,能省下大量无效流量。
常见坑
某些老旧服务器/网关不认识这个头,不会回 100,客户端干等到超时(通常 1 秒)后才发 body,表现为每个大请求都慢 1 秒。
⑥重定向:唯一让 POST 真的发两次的「正规」机制
这是最需要警惕的一类。HTTP 不同状态码对「重定向时是否保留方法与请求体」的规定完全不同:
| 状态码 | 含义 | POST 会变成什么 | POST 发送次数 |
|---|---|---|---|
| 301 | 永久重定向 | 实践中改为 GET(浏览器普遍这么实现) | 1 次 POST + 1 次 GET |
| 302 | 临时重定向 | 实践中改为 GET | 1 次 POST + 1 次 GET |
| 303 | See Other | 明确要求改为 GET(这是它存在的意义) | 1 次 POST + 1 次 GET |
| 307 | 临时重定向(保方法) | 保持 POST,body 原样重发 | 2 次 POST |
| 308 | 永久重定向(保方法) | 保持 POST,body 原样重发 | 2 次 POST |
/api/order,后来因网关或 Nginx 做了 http→https 或路径调整,返回 307。客户端会自动把整个下单请求体原样重发一次到新地址。如果后端没做幂等,就会生成两张订单。正确做法:非幂等接口(下单、支付、发短信)不要用 307/308;要用重定向就明确用 303(强制转 GET),并配合后文的 PRG 模式。
⑦网关重试:Nginx 到底会不会重发 POST
这是后端最常背锅的一类。结论先说:Nginx 从 1.9.13 起,默认不会对「已经发出去的」POST 请求重试。
# 默认值:只在 error / timeout 时换节点
proxy_next_upstream error timeout;
# 想让 500 / 502 / 503 也换节点(注意:对已发出的 POST 仍然不重试)
proxy_next_upstream error timeout http_500 http_502 http_503;
# ⚠️ 危险:加上 non_idempotent 才会对 POST / LOCK / PATCH 重试
# 一旦开启,超时或 500 时 POST 会真的发第二次 → 可能重复下单
proxy_next_upstream error timeout http_500 non_idempotent;
# 同时建议限制重试次数与总时长
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
① 后端日志里两条的 upstream 地址是否不同(不同 = Nginx 换节点重试了);
② 两次请求的 时间间隔(几十毫秒级 = 网关重试;几百毫秒以上 = 客户端重试或用户双击);
③ 两条日志的 用户 ID / 请求 ID 是否相同(相同 = 同一次操作的重放)。
⑧客户端与用户侧:代码与手指造成的两次
① 重试库自动重试
axios-retry、自研的 fetch 封装、超时补偿逻辑,只要没排除 POST,就会在超时后自动再发一次。检查配置里的 methods 白名单与 retryCondition。
② React 18 StrictMode
开发环境下 <StrictMode> 会故意双调用 effect 来暴露副作用问题。表现为开发时 POST 两次、生产正常。这是设计行为,不是 bug,但说明你的 effect 不幂等。
③ 用户双击 / 重复点击
按钮没做 loading 禁用,用户点两下就是两次请求。最朴素的解决:提交后立即 disabled + 显示 loading。
④ 刷新 / 回退
表单 POST 提交后停在结果页,用户按 F5 会弹出「确认重新提交表单」——点了就再来一次。解法是下面的 PRG 模式。
// 反例:StrictMode 下会跑两次,POST 也就发了两次
useEffect(() => {
fetch('/api/track', { method: 'POST', body: ... }); // 副作用写在 effect 里
}, []);
// 正解一:加清理函数(AbortController 取消请求)
useEffect(() => {
const ac = new AbortController();
fetch('/api/track', { method: 'POST', signal: ac.signal });
return () => ac.abort();
}, []);
// 正解二:axios-retry 明确只对幂等方法重试
axiosRetry(axios, {
retries: 3,
retryCondition: (err) => err.code === 'ECONNABORTED',
// 关键:只重试 GET,绝不重试 POST
shouldResetTimeout: true,
});
⑨看起来像两次,其实不是
(canceled) 的请求 + 一个成功的请求。这也是一次业务请求。⑩排查清单:怎么判断我属于哪一种
- 看 DevTools 的 Method 列:一条是
OPTIONS一条是POST→ CORS 预检(正常,POST 只发一次)。 - 两条都是 POST 且 URL 不同 → 307/308 重定向。看第一条的响应状态码是不是 307/308。
- 两条都是 POST 且 URL 相同 → 看后端日志的 upstream IP:不同 = 网关重试;相同 = 客户端重试或用户重复提交。
- 看时间间隔:<100ms 倾向于网关/客户端自动重试;>300ms 倾向于用户双击或代码里发了两次。
- 只在开发环境出现 → 高度怀疑 React StrictMode 或热更新重复执行。
- 只有 curl / 后端调用出现,浏览器没有 → 查
Expect: 100-continue与 curl 的自动重试。 - 生产偶发、压测时变多 → 查网关的
proxy_next_upstream是否包含non_idempotent,以及超时配置是否过短。
# 用 curl 观察到底发了几次、发的什么
curl -v -X POST https://api.example.com/order \
-H 'Content-Type: application/json' \
-d '{"skuId":123,"num":1}'
# 输出里重点看:
# > Expect: 100-continue ← 是否分两趟发
# < HTTP/1.1 100 Continue ← 服务器的确认
# < HTTP/1.1 307 ← 是否发生保方法重定向
# < location: https://... ← 重定向目标
⑪根本解法:让 POST 幂等
无论上面哪种原因导致重复,最可靠的防线永远是服务端的幂等设计——因为网络不可靠,重复请求无法 100% 避免,只能让它「重复了也没关系」。
| 方案 | 做法 | 适用 |
|---|---|---|
| Idempotency-Key | 客户端生成唯一键(UUID)放在请求头,服务端用 Redis 记录「键 → 结果」,重复时直接返回首次结果 | API 首选,支付接口标准做法(Stripe 即用此方案) |
| 数据库唯一索引 | 用业务唯一键(订单号、流水号)建唯一索引,重复插入直接报错 | 最可靠的兜底,几乎必配 |
| Token 机制 | 进页面先领一个 token,提交时带上,服务端校验并删除(一次性) | 表单提交,配合前端隐藏域 |
| PRG 模式 | Post → Redirect → Get:处理完 POST 后返回 302/303 跳转到结果页 | 传统表单,根治「刷新重复提交」 |
| 状态机校验 | 订单状态流转加前置判断(已支付则拒绝再次支付) | 有明确生命周期的业务 |
| 按钮禁用 | 提交后 disabled + loading | 只防用户,不能防网关重试,必须配上面的方案 |
⑫误区与面试速记
Expect: 100-continue,而浏览器默认不发这个头。GET 同样是建立连接后发一个报文,并不存在「GET 一次、POST 两次」的协议规定。Access-Control-Max-Age 缓存,之后的 POST 连 OPTIONS 都不发。non_idempotent 才会重试。反倒是「请求还没发出去就连接失败」时会换节点,这才是常见的事故来源。一句话速记
偶尔是 Expect: 100-continue 分两趟发包(一次请求,浏览器默认不发这个头);
真正发两次的只有四种:307/308 重定向保留方法、网关重试(Nginx 默认不重试已发出的 POST,除非开
non_idempotent)、客户端重试库、用户重复提交;301/302/303 会把 POST 降级成 GET(body 会丢);
根治办法永远是服务端幂等:Idempotency-Key + 唯一索引 + 状态机,前端禁用按钮只是体验优化。