news 2026/4/18 8:21:33

面试官:post 为什么会发送两次请求?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
面试官:post 为什么会发送两次请求?

面试官:post 为什么会发送两次请求?

——这个问题真的是面试高频,又容易翻车。

你想象一下哈。

你在面试,写了个很标准的 Spring Boot Controller:

@RestController @RequestMapping("/user") public class UserController { @PostMapping("/save") public String save(@RequestBody UserDTO user) { System.out.println("save user = " + user); return "ok"; } }

本地调试的时候,你打开浏览器的 Network 面板,或者看后端日志,发现一个很诡异的事:

你明明只点了一次“保存”, 结果:

  • 浏览器 Network 里出现了两条记录

  • 后端日志里也像是执行了两次

这时候面试官一句话丢过来:

“你说说,POST 为啥会发两次?”

如果只回答一句“网络不好重试了”,基本直接凉。这个问题背后,其实大概就那几种情况,只要搞清楚了,回答起来就很顺。

场景一:其实只算一次 —— CORS 预检把你吓到了

最常见的误会:OPTIONS + POST 被你当成“POST 调了两次”。

浏览器在跨域、且请求“比较复杂”的时候,会先发一个OPTIONS请求问问后端:“哥们,这个真正的 POST 我能不能发?”

流程是这样的:

  1. 浏览器先发:OPTIONS /user/save

  2. 后端返回一堆Access-Control-Allow-*

  3. 浏览器确认“没问题”,再发真正的:POST /user/save

所以 Network 面板里会看到两条记录,但真正的业务 Controller 只会命中一次(OPTIONS 通常不会走你的业务逻辑)。

在 Spring Boot 里,如果你打开了全局 CORS,大概长这样:

@Configuration publicclass CorsConfig { @Bean public WebMvcConfigurer corsConfigurer() { returnnew WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true); } }; } }

想确认是不是这个原因,很简单:

  • 看 Network 里的 Method,是不是一条 OPTIONS,一条 POST

  • 后端给 Controller 打个日志,只会进一次就是 CORS 预检

面试的时候可以怎么说?

很多同学看到浏览器有两条记录,就以为 POST 调了两次,其实一条是 CORS 预检的 OPTIONS,一条才是真正的 POST,这种场景业务只会执行一次。

这一句说清楚,面试官一般会点点头。

场景二:真的发了两次 —— 重定向搞的鬼

第二种非常常见的原因,是重定向(redirect)

举个很典型的坑:

你有个接口/order/create,没登录时会被网关或者 Spring Security 拦截,返回 302 跳到/login

流程变成:

  1. 浏览器发:POST /order/create

  2. 服务端返回:302 Location: /login

  3. 浏览器自动再发一个:GET /login

这时候你在 Network 里看到两条记录:

  • 一条POST /order/create 302

  • 一条GET /login 200

严格说只有一个 POST,但如果是“POST -> POST”的重定向,就真有可能业务被打到两次。

比如你在 Nginx 里写了比较奇怪的 rewrite,把/order重写成了/order/,或者从 HTTP 重定向到 HTTPS,配置不当的时候,会出现:

  1. POST http://xxx/api/order/create

  2. 301 跳转到https://xxx/api/order/create

  3. 浏览器再次发请求

有些浏览器/代理在 301/302 时会把 POST 变成 GET,有些在 307/308 会保留原来的方法,这就很有讲头了。

你可以顺手提一句:

如果服务端返回的是 307 / 308,浏览器会保留原来的方法和 body,这时候就可能出现两次 POST 请求,所以线上做跳转时要小心这些状态码的使用。

场景三:客户端 / 网关的“好心重试”

第三种,就是各种“自动重试机制”。

常见几类:

  1. 网关 / 负载均衡

    • Nginx:proxy_next_upstream配置了超时/错误重试

    • 某些 API 网关默认帮你重试一次

  2. HTTP 客户端自己重试

    • OkHttp 默认就有retryOnConnectionFailure(true)

    • Feign 可以配置Retryer

    • 自己封装的 RestTemplate 可能也加了重试逻辑

一个简单的 Feign 重试配置示例:

@Configuration public class FeignRetryConfig { @Bean public Retryer feignRetryer() { // period=100ms, maxPeriod=1s, maxAttempts=3 return new Retryer.Default(100, TimeUnit.SECONDS.toMillis(1), 3); } }

如果下游服务第一次稍微慢一点,或者偶发抖动,Feign 觉得“有点不太对,我再试一次”, 于是你后端就看到了两次 POST。

这类情况的特点:

  • 两次请求的 body 完全一样

  • 时间间隔非常短(几十毫秒到几百毫秒)

  • 第一次可能是超时 / 5xx,第二次成功

排查的时候,除了应用日志,一定要看Nginx / 网关日志,很多“鬼畜请求”都藏在那里面。

场景四:前端真点了两次(或帮你点了两次)

这个就比较接地气了。

最常见的几个:

  1. 用户手速太快,按钮双击

  2. 回车提交 + 点击提交

  3. 页面做了自动重试,或者某种“点击即重发”的逻辑

  4. 前端没做节流/防抖,输入框变更就发 POST

最朴素的示例(反面教材):

<button id="submitBtn">提交</button> <script> document.getElementById('submitBtn').onclick = function () { fetch('/user/save', { method: 'POST', body: JSON.stringify({ name: 'Tom' }), headers: { 'Content-Type': 'application/json' } }); }; </script>

啥防护也没有,用户双击一下,直接两次 POST,后端根本没法分辨是不是同一次操作。

稍微规范一点的写法会在第一次点击后把按钮禁用:

const btn = document.getElementById('submitBtn'); btn.onclick = function () { if (btn.disabled) { return; } btn.disabled = true; fetch('/user/save', { method: 'POST', body: JSON.stringify({ name: 'Tom' }), headers: { 'Content-Type': 'application/json' } }).finally(() => { btn.disabled = false; // 看业务决定要不要恢复 }); };

面试的时候,可以顺带提一句“前端也要配合做防重复提交”,给人感觉你是站在全链路视角看问题的。

核心补救:POST 要配上“幂等性保险”

上面这些情况,有的是“看起来发了两次,其实没问题”(比如 CORS), 有的是真发送了两次。

那真正线上要紧的是:就算发了两次,也不能让业务乱套。

这就绕不过一个词:幂等性—— 同一个请求重复执行多次,结果应该是一样的。

POST 默认不是幂等的,所以要自己加“保险”。

比较常见的几种做法:

1. 用业务唯一键做幂等

比如支付、下单这种,通常都有一个业务唯一号,比如orderNo

服务端可以用 Redis / 数据库做一次“抢占”,谁抢到了谁执行:

@Service publicclass PayService { privatestaticfinal String IDEMPOTENT_KEY_PREFIX = "pay:order:"; @Resource private StringRedisTemplate stringRedisTemplate; public void pay(String orderNo) { String key = IDEMPOTENT_KEY_PREFIX + orderNo; // setIfAbsent = true 说明是第一次处理 Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofMinutes(10)); if (Boolean.FALSE.equals(success)) { // 说明已经处理过这笔支付,直接返回,不再重复扣款 System.out.println("orderNo = " + orderNo + " 已处理过,拒绝重复支付"); return; } // 真正的扣款逻辑 doPay(orderNo); } private void doPay(String orderNo) { // 调用三方支付、更新订单状态、落库等等…… System.out.println("执行真正的支付逻辑, orderNo = " + orderNo); } }

哪怕网关、客户端帮你把同一笔支付请求重试了好几次,只要orderNo相同,这个方法也只会真正执行一次。

2. 用一次性“幂等 token”

再举个更“通用”的 POST 场景,可能没天然的业务唯一键,可以走幂等 token方案:

  • 前端在提交前先向后端要一个token

  • 真正提交时把token带上

  • 后端把token当 key,setIfAbsent一次

  • 重复提交时setIfAbsent失败,直接返回“重复请求”

伪代码示意:

@RestController @RequestMapping("/idempotent") publicclass IdempotentController { @Resource private StringRedisTemplate stringRedisTemplate; @GetMapping("/token") public String generateToken() { String token = UUID.randomUUID().toString(); stringRedisTemplate.opsForValue() .set("idem:token:" + token, "1", Duration.ofMinutes(5)); return token; } @PostMapping("/submit") public String submit(@RequestHeader("Idempotent-Token") String token, @RequestBody SubmitDTO dto) { String key = "idem:token:" + token; Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, "used", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(success)) { return"重复请求"; } // 真正的业务逻辑 handleBusiness(dto); return"ok"; } private void handleBusiness(SubmitDTO dto) { System.out.println("处理业务: " + dto); } }

这样哪怕浏览器帮你“多按了几次”,同一个 token 也只能成功一次。

真遇到线上“POST 两次”,怎么排查?

如果是实战,而不是纸上谈兵,我一般会这么干(你可以照着改成自己的话术):

  1. 先看浏览器 Network

    • Method 是 OPTIONS + POST,还是两条 POST?

    • 有没有 301/302/307/308 这种跳转?

    • 两次请求的 URL、参数、body 是否完全一样?

  2. 看网关 / Nginx 日志

    • 一条请求是否被转发到了多个上游?

    • 有没有重试 / upstream timed out 之类的记录?

  3. 看应用层日志

    比如用 Spring 的过滤器简单加个 requestId:

    @Component publicclass LogFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String requestId = UUID.randomUUID().toString().replace("-", ""); MDC.put("requestId", requestId); try { HttpServletRequest req = (HttpServletRequest) request; System.out.println("requestId=" + requestId + ", " + req.getMethod() + " " + req.getRequestURI()); chain.doFilter(request, response); } finally { MDC.remove("requestId"); } } }
    • 给每个请求加一个requestId或 traceId,打印在入口和核心业务逻辑里

    • 确认业务代码是不是确实走了两遍

  4. 最后看配置

    • Feign / OkHttp / RestTemplate 有没有配置重试

    • 网关有没有“自动重试一次”的选项

    • 前端有没有防重复提交

排查完,你基本可以非常有底气地告诉别人:这两次 POST 到底从哪儿来的。

https://mp.weixin.qq.com/s/9vVePfiEDeE_Ef3Qgx3Cvw

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/17 20:57:43

YOLO26训练多久收敛?200轮迭代效果观察与停止策略

YOLO26训练多久收敛&#xff1f;200轮迭代效果观察与停止策略 在目标检测领域&#xff0c;YOLO系列模型一直以高效、准确著称。随着YOLO26的发布&#xff0c;其更强的架构设计和更高的精度表现吸引了大量开发者关注。但一个实际工程中常被问到的问题是&#xff1a;训练多少轮才…

作者头像 李华
网站建设 2026/4/18 3:37:31

UniHacker终极指南:零成本学习Unity的完整教程

UniHacker终极指南&#xff1a;零成本学习Unity的完整教程 【免费下载链接】UniHacker 为Windows、MacOS、Linux和Docker修补所有版本的Unity3D和UnityHub 项目地址: https://gitcode.com/GitHub_Trending/un/UniHacker UniHacker作为一款革命性的开源学习工具&#xff…

作者头像 李华
网站建设 2026/4/18 3:33:38

Zotero Android移动端文献管理工具配置指南

Zotero Android移动端文献管理工具配置指南 【免费下载链接】zotero-android Zotero for Android 项目地址: https://gitcode.com/gh_mirrors/zo/zotero-android 项目概述 Zotero for Android是一款功能强大的移动端文献管理工具&#xff0c;专为学术研究人员、学生和知…

作者头像 李华
网站建设 2026/3/27 15:34:42

斯坦福四足机器人Pupper V3终极指南:从零构建智能机器人系统

斯坦福四足机器人Pupper V3终极指南&#xff1a;从零构建智能机器人系统 【免费下载链接】StanfordQuadruped 项目地址: https://gitcode.com/gh_mirrors/st/StanfordQuadruped 想要打造一台功能强大的开源机器人平台&#xff0c;却苦于高昂的成本和技术门槛&#xff1…

作者头像 李华