管理后台有个"立即生成"按钮,点下去转圈,几十秒后浏览器跳出一个 504。你以为是失败了,于是又点了一次。结果是——任务跑了两次,数据里多了一份。
现象
管理后台的"立即生成"按钮点下去后,浏览器等待大概一分钟,最终收到:
504 Gateway Timeout
页面显示请求失败,用户自然认为操作没成功。但实际上,后端任务正常完成了,数据也照样入库了。
这种"假失败"比真失败更麻烦:用户基于错误信息做出错误决策——重试、重复提交、或者以为自己搞坏了什么而不敢再碰。
想确认"假失败"也很简单:去数据库里查一下这批数据在不在,或者看后端日志里任务有没有打出"完成"的记录。多数情况下你会看到——数据在、日志也在,唯独浏览器告诉你失败了。
根因
这是同步长任务撞上反向代理超时的典型案例。三个事实叠在一起:
第一,这个操作是同步请求。 整批处理要 50~60 秒,前端一直挂着等后端返回。
第二,反向代理有一个默认的读取超时,通常是 60 秒。 意思是:代理把请求转给后端,然后等后端的响应;如果 60 秒还没等到完整响应,代理就判定上游卡住了,自己造一个 504 返回给浏览器。
第三,也是最关键的一点——代理返回 504 不等于后端停止执行。 后端进程还在那里跑,任务继续完成,数据继续入库。代理只是"没耐心等了",它并不知道、也不关心后端是否真的挂了。
顺带说明一个常见误解:有人会以为"浏览器能收到 504,是不是顺带把后端也中断了?"并不会。HTTP 层面没有"因为客户端或代理不等了,就通知服务端停止处理"的机制。请求已经交到后端手里,它就会按自己的节奏跑完。
所以出现了这幅诡异画面:浏览器显示失败,数据库里数据却是全的。
解决
方案一(快速修复):给这个路径单独调大代理超时。
location /api/admin/generate {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_pass http://backend;
}
读取超时(等响应)和发送超时(发请求体)都设到 300 秒。注意只给这一个路径调大,不要全局调——否则一个卡住的请求会长时间占用连接资源,影响其他请求。
方案二(推荐做法):把这类长任务改成异步。
流程改成"先返回任务号,再轮询进度":
POST /api/tasks → 202 { "taskId": "abc123" }
GET /api/tasks/abc123 → { "status": "running", "progress": 0.4 }
GET /api/tasks/abc123 → { "status": "done", "result": {...} }
这样请求本身是秒回的,不触发任何超时;任务跑多久都不影响用户看到的界面。而且天然支持"刷新页面后继续看进度"——因为任务状态存在服务端,不在浏览器的连接里。注意状态码用 202 Accepted,语义上就表示"已受理、还在处理"。
前端只需轮询:
const { taskId } = await startTask();
const timer = setInterval(async () => {
const r = await fetch(`/api/tasks/${taskId}`);
const s = await r.json();
if (s.status === 'done') { clearInterval(timer); render(s.result); }
}, 2000);
延伸与预防
三条可落地的经验:
一是给所有"可能超过 30 秒"的操作用异步模式,这是根本解法。判断标准可以设成"如果我作为用户在页面上等它,会不会开始怀疑是不是卡住了"。
二是记住一条排查原则:看到 504,先去核对数据,不要直接判定失败。 因为 504 只说明"代理等不及了"。可以去数据库查、可以看后端日志、可以看任务表,确认操作到底有没有生效。
三是给长任务加幂等保护(比如基于任务参数生成一个去重键),即使前端重试也不会产生重复数据。这条是兜底——因为不管怎么做,用户总会重试。
还有一点值得注意:前端也要对"假失败"有心理准备。当请求超时或收到 5xx 时,不要立刻认定"没成功"就自动重试——重试之前先查一次状态(比如按任务号查询),确认到底做没做。把"重试"和"幂等"绑在一起设计,才是稳妥的做法。