POST 会发送两次请求吗?

结论:POST 本身不会「自动」发两次——你看到的「两次」,其实是三种性质完全不同的东西

CORS 预检 OPTIONS Expect: 100-continue 307 / 308 重定向 网关重试 幂等设计

①结论先行:先分清你说的是哪种「两次」

绝大多数人问「POST 是不是发两次」,看到的其实是 CORS 预检(OPTIONS)+ 真正的 POST。那是两次请求,但方法不同——POST 只发了一次。除此之外,「Expect: 100-continue」和「TCP 分包」会让一次请求在网络上分两趟走,但服务端只收到一次。真正意义上POST 被发了两次的,只有重定向保留方法、网关重试、客户端重试、用户重复提交这几种。
三种「两次」的本质区别 A. 两次请求,方法不同 OPTIONS(预检)+ POST ① OPTIONS 问:允许吗? ② 允许 → POST 发数据 POST 本身只有 1 次 最常见(约占九成) B. 一次请求,两趟发包 Expect: 100-continue ① 先只发请求头 ② 收到 100 再发 body 服务端仍是 1 次请求 大 body 场景(curl 常见) C. POST 真的发了两次 重定向 / 网关 / 客户端重试 ① POST → 服务端已执行 ② 又发一次 POST 危险:重复下单/扣款 必须从幂等层面解决
图 1 · 先定位属于 A / B / C 哪一类,再谈解决方案
一句话回答「POST 会发送两次请求吗」:
正常情况不会。你看到的「两次」90% 是 CORS 预检 OPTIONS(POST 仍只发一次,且可用 Access-Control-Max-Age 缓存掉);少数是 Expect: 100-continue 的分趟发包(一次请求);只有在 307/308 重定向、网关对未发出请求的重试、客户端重试库、用户重复提交这几种情况下,POST 才真的被发了两次。

②为什么会有这些「两次」:根因各不相同

情形为什么会产生POST 真实发送次数危险程度
CORS 预检跨域 + 非简单请求,浏览器必须先问服务器「允许这种请求吗」1 次(前面多了 1 次 OPTIONS)安全
Expect: 100-continue客户端想先确认服务器收不收,免得白传大 body1 次(头与体分两趟发)安全
307 / 308 重定向规范要求重定向时保持原方法与请求体2 次(两次都是 POST)危险
网关 / 代理重试连接失败或超时,网关换一个后端节点重发1~2 次(取决于是否真的发出去了)危险
客户端重试axios-retry / fetch 重试封装 / 超时补偿2 次或更多危险
用户重复提交双击按钮、刷新确认页、回退再提交2 次或更多危险
TCP 重传 / 分包网络层丢包重传或 body 拆成多个 TCP 段1 次安全

③CORS 预检:最常见的那个「两次」

浏览器对跨域请求有一套安全机制。如果请求属于「非简单请求」,浏览器会先自动发一个 OPTIONS 请求问服务器:「我准备用 POST + JSON + 自定义头去访问你,你允许吗?」只有服务器明确许可,真正的 POST 才会发出。

什么叫「简单请求」(不触发预检)

必须同时满足以下三条,缺一条就触发预检:

简单请求判定:三条全中才免预检 ① 方法 GET / HEAD / POST PUT、DELETE 一律触发 ② Content-Type x-www-form-urlencoded multipart/form-data / text/plain ③ 请求头 只能用 CORS 安全头 不能带 Authorization、X-Token 现实中最常见的踩雷点 POST + Content-Type: application/json → application/json 不在白名单里 → 必然触发 OPTIONS 预检
图 2 · 简单请求三条件:JSON 是最常见的「预检触发器」

完整时序

CORS 预检时序:先 OPTIONS 后 POST 浏览器 网络 服务器 OPTIONS /api/order Origin: a.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type, x-token 204 No Content Access-Control-Allow-Origin: a.com Access-Control-Allow-Methods: POST Access-Control-Max-Age: 600 POST /api/order (真正的业务请求,只有这一次) Content-Type: application/json {"skuId":123,"num":1} 200 OK Access-Control-Allow-Origin: a.com {"orderId":"..."} 若预检被拒(缺少 Allow 头或 Origin 不在白名单)→ 浏览器直接拦下,POST 根本不会发出
图 3 · OPTIONS 是「敲门」,POST 是「进门」——两次请求,但业务只执行一次
如何消除这个 OPTIONS?三种办法,按推荐度排序:
① 缓存预检结果:服务端返回 Access-Control-Max-Age: 600,浏览器在有效期内不再重复预检(各浏览器有上限,Chrome 上限 10 分钟)。
② 改成同源:用 Nginx / 开发服务器代理把前后端放同一个域,跨域不存在,预检自然消失。
③ 改用简单请求格式:application/x-www-form-urlencoded 而非 application/json(代价是数据结构表达能力下降)。
重要:同源请求永远不会触发预检。所以「我在 DevTools 里看到 OPTIONS」只可能发生在跨域场景(或用 curl 手动模拟时)。如果你看到的是同源请求还带 OPTIONS,那多半是框架的探针请求或你自己代码里发的。

④交互:你的请求会触发预检吗

调整下面四个条件,实时判断是否会多出一次 OPTIONS 请求:

请求方法 POST GET PUT DELETE
Content-Type x-www-form-urlencoded application/json text/plain 不设置
自定义请求头 无 有(如 X-Token)
是否跨域 同源 跨域

⑤Expect: 100-continue:一次请求,分两趟走

这是「POST 发两次」说法的第二大来源,也是被误传最广的一个。客户端在请求头里带上 Expect: 100-continue,意思是:「我先只发请求头,你确认能收,我再发 body」——目的是避免白传几百 MB 的文件。

Expect: 100-continue 的握手过程(注意:仍是「一次」HTTP 请求) 客户端 服务器 ① POST /upload Expect: 100-continue Content-Length: 200MB (只有头) ② HTTP/1.1 100 Continue (服务器说:可以,发吧) ③ 真正的请求体(200MB 数据) ④ 200 OK(最终响应) 服务端业务逻辑只执行一次;若服务器拒绝会回 417 Expectation Failed,客户端省下 200MB 流量
图 4 · 头与体分两趟,但服务端只处理一次

谁会发这个头

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临时重定向实践中改为 GET1 次 POST + 1 次 GET
303See Other明确要求改为 GET(这是它存在的意义)1 次 POST + 1 次 GET
307临时重定向(保方法)保持 POST,body 原样重发2 次 POST
308永久重定向(保方法)保持 POST,body 原样重发2 次 POST
302 vs 307:一个降级成 GET,一个原样重发 301 / 302 / 303 → 方法降级 客户端 服务器 POST /old(带 body) 302 Location: /new GET /new(body 丢了) 307 / 308 → 方法与 body 保留 客户端 服务器 POST /old(带 body) 307 Location: /new POST /new(body 原样重发)
图 5 · 307/308 会让服务端收到两次完整的 POST
实际危害场景:下单接口部署在 /api/order,后来因网关或 Nginx 做了 http→https 或路径调整,返回 307。客户端会自动把整个下单请求体原样重发一次到新地址。如果后端没做幂等,就会生成两张订单。

正确做法:非幂等接口(下单、支付、发短信)不要用 307/308;要用重定向就明确用 303(强制转 GET),并配合后文的 PRG 模式。

⑦网关重试:Nginx 到底会不会重发 POST

这是后端最常背锅的一类。结论先说:Nginx 从 1.9.13 起,默认不会对「已经发出去的」POST 请求重试。

Nginx 对 POST 的重试判定:关键看「请求体是否已经发出」 情况一:请求还没发出去就失败 连接后端失败 / 建连超时 → Nginx 换下一个节点 重试 此时上一个节点多半没执行过 相对安全,但仍有瞬间半开的可能 情况二:请求体已发出后出错 返回 500 / 读响应超时 → 默认不重试(因为可能已执行) 这就是 1.9.13 引入的保护 除非显式配置 non_idempotent
图 6 · Nginx 的判断依据:请求体是否已经发给后端
# 默认值:只在 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;
运维排障提示:如果发现「用户点了一次,后端收到两次 POST」,先别急着怪前端。按顺序排查:
① 后端日志里两条的 upstream 地址是否不同(不同 = Nginx 换节点重试了);
② 两次请求的 时间间隔(几十毫秒级 = 网关重试;几百毫秒以上 = 客户端重试或用户双击);
③ 两条日志的 用户 ID / 请求 ID 是否相同(相同 = 同一次操作的重放)。
其它网关同理:Envoy、Kong、Spring Cloud Gateway、云厂商 SLB 也都有类似的重试配置,原则一致 —— 非幂等方法默认不重试,需要时显式打开,且必须配后端幂等。

⑧客户端与用户侧:代码与手指造成的两次

① 重试库自动重试

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,
});

⑨看起来像两次,其实不是

TCP 重传
网络丢包后 TCP 会自动重传报文段。在 Wireshark 里能看到两个一模一样的包,但那是传输层的事,应用层的 HTTP 请求只有一次,服务端也只处理一次。
大 body 分包
一个 10MB 的 POST 会被拆成几千个 TCP 段发送。抓包看到「很多个包」,但拼起来仍是一次请求。
DevTools 里同一条请求显示两次
常见原因是预检 + 正式请求被折叠在同一行,或页面里有两个地方都发了(组件重复渲染、事件绑了两次)。点开看 Request Method 列即可区分。
HTTP/2 的连接预热 / RST_STREAM
HTTP/2 下浏览器可能先开连接再取消,表现为一个被标记 (canceled) 的请求 + 一个成功的请求。这也是一次业务请求。

⑩排查清单:怎么判断我属于哪一种

  1. 看 DevTools 的 Method 列:一条是 OPTIONS 一条是 POST → CORS 预检(正常,POST 只发一次)。
  2. 两条都是 POST 且 URL 不同 → 307/308 重定向。看第一条的响应状态码是不是 307/308。
  3. 两条都是 POST 且 URL 相同 → 看后端日志的 upstream IP:不同 = 网关重试;相同 = 客户端重试或用户重复提交。
  4. 看时间间隔:<100ms 倾向于网关/客户端自动重试;>300ms 倾向于用户双击或代码里发了两次。
  5. 只在开发环境出现 → 高度怀疑 React StrictMode 或热更新重复执行。
  6. 只有 curl / 后端调用出现,浏览器没有 → 查 Expect: 100-continue 与 curl 的自动重试。
  7. 生产偶发、压测时变多 → 查网关的 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:重复请求返回第一次的结果 客户端 网关 服务端 POST /order Idempotency-Key: k-8f3a (第一次) 创建订单成功 {"orderId":"A001"} (并把 k-8f3a → A001 存进 Redis) POST /order Idempotency-Key: k-8f3a (重复到达,可能是重试) 查到 k-8f3a 已存在 → 不新建,直接返回 {"orderId":"A001"} 结果:无论来几次,都只有一张订单
图 7 · 幂等键让「重复请求」退化成「重复查询」
方案做法适用
Idempotency-Key客户端生成唯一键(UUID)放在请求头,服务端用 Redis 记录「键 → 结果」,重复时直接返回首次结果API 首选,支付接口标准做法(Stripe 即用此方案)
数据库唯一索引用业务唯一键(订单号、流水号)建唯一索引,重复插入直接报错最可靠的兜底,几乎必配
Token 机制进页面先领一个 token,提交时带上,服务端校验并删除(一次性)表单提交,配合前端隐藏域
PRG 模式Post → Redirect → Get:处理完 POST 后返回 302/303 跳转到结果页传统表单,根治「刷新重复提交」
状态机校验订单状态流转加前置判断(已支付则拒绝再次支付)有明确生命周期的业务
按钮禁用提交后 disabled + loading只防用户,不能防网关重试,必须配上面的方案
一句话原则:前端防重复提交只是体验优化,真正的数据正确性必须靠服务端幂等。因为网络超时、网关重试、客户端重试这些「重复」你永远无法在前端完全杜绝 —— 客户端根本不知道「第一次到底成没成功」。

⑫误区与面试速记

误区一:「POST 一定发两次,GET 只发一次」
这是流传最广的错误说法,源自对「POST 先发头再发体」的误读。真相:是否分两趟取决于 Expect: 100-continue,而浏览器默认不发这个头。GET 同样是建立连接后发一个报文,并不存在「GET 一次、POST 两次」的协议规定。
误区二:「看到 OPTIONS 就说明 POST 发了两次」
不对。OPTIONS 是预检,是浏览器的安全询问,POST 仍只发一次。而且 OPTIONS 可以被 Access-Control-Max-Age 缓存,之后的 POST 连 OPTIONS 都不发。
误区三:「Nginx 一定会重试 POST」
相反 —— Nginx 默认不重试已发出的 POST(1.9.13 起),正是为了防止重复下单。只有显式加 non_idempotent 才会重试。反倒是「请求还没发出去就连接失败」时会换节点,这才是常见的事故来源。
误区四:「302 会让 POST 原样重发」
不会。实践中浏览器对 301/302 都会把 POST 降级为 GET(body 会丢失),303 更是明确要求转 GET。只有 307/308 才保持方法和 body——这才是 POST 真发两次的场景。
误区五:「前端禁用按钮就够了」
不够。它能防用户手抖,但防不住网关重试、网络超时后的客户端重试、307 重定向。这些场景下前端甚至不知道请求被发了两次。

一句话速记

POST 不会自动发两次。看到的「两次」九成是 CORS 预检 OPTIONS(POST 仍一次,可缓存消除);
偶尔是 Expect: 100-continue 分两趟发包(一次请求,浏览器默认不发这个头);
真正发两次的只有四种:307/308 重定向保留方法、网关重试(Nginx 默认不重试已发出的 POST,除非开 non_idempotent)、客户端重试库、用户重复提交;
301/302/303 会把 POST 降级成 GET(body 会丢);
根治办法永远是服务端幂等:Idempotency-Key + 唯一索引 + 状态机,前端禁用按钮只是体验优化。