面试官: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 我能不能发?”
流程是这样的:
浏览器先发:
OPTIONS /user/save后端返回一堆
Access-Control-Allow-*头浏览器确认“没问题”,再发真正的:
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。
流程变成:
浏览器发:
POST /order/create服务端返回:
302 Location: /login浏览器自动再发一个:
GET /login
这时候你在 Network 里看到两条记录:
一条
POST /order/create 302一条
GET /login 200
严格说只有一个 POST,但如果是“POST -> POST”的重定向,就真有可能业务被打到两次。
比如你在 Nginx 里写了比较奇怪的 rewrite,把/order重写成了/order/,或者从 HTTP 重定向到 HTTPS,配置不当的时候,会出现:
POST http://xxx/api/order/create301 跳转到
https://xxx/api/order/create浏览器再次发请求
有些浏览器/代理在 301/302 时会把 POST 变成 GET,有些在 307/308 会保留原来的方法,这就很有讲头了。
你可以顺手提一句:
如果服务端返回的是 307 / 308,浏览器会保留原来的方法和 body,这时候就可能出现两次 POST 请求,所以线上做跳转时要小心这些状态码的使用。
场景三:客户端 / 网关的“好心重试”
第三种,就是各种“自动重试机制”。
常见几类:
网关 / 负载均衡
Nginx:
proxy_next_upstream配置了超时/错误重试某些 API 网关默认帮你重试一次
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 / 网关日志,很多“鬼畜请求”都藏在那里面。
场景四:前端真点了两次(或帮你点了两次)
这个就比较接地气了。
最常见的几个:
用户手速太快,按钮双击
回车提交 + 点击提交
页面做了自动重试,或者某种“点击即重发”的逻辑
前端没做节流/防抖,输入框变更就发 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 两次”,怎么排查?
如果是实战,而不是纸上谈兵,我一般会这么干(你可以照着改成自己的话术):
先看浏览器 Network
Method 是 OPTIONS + POST,还是两条 POST?
有没有 301/302/307/308 这种跳转?
两次请求的 URL、参数、body 是否完全一样?
看网关 / Nginx 日志
一条请求是否被转发到了多个上游?
有没有重试 / upstream timed out 之类的记录?
看应用层日志
比如用 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,打印在入口和核心业务逻辑里确认业务代码是不是确实走了两遍
最后看配置
Feign / OkHttp / RestTemplate 有没有配置重试
网关有没有“自动重试一次”的选项
前端有没有防重复提交
排查完,你基本可以非常有底气地告诉别人:这两次 POST 到底从哪儿来的。
https://mp.weixin.qq.com/s/9vVePfiEDeE_Ef3Qgx3Cvw