新功能上线第一天,测试同学甩来两个“毫无关联”的 bug。第一个:“头像上传,2MB 的图片传不上去,页面提示 413。”第二个:“在线客服的聊天窗口,每隔一分钟左右就掉线一次,重连之后消息还丢。”我看了一眼,心里同时出现了两个念头——这两个是同一个作者写的,这个作者叫 Nginx 默认值。
这两个坑几乎是每个做过文件上传或长连接的后端都踩过的:一个是请求体大小限制,一个是长连接空闲超时。它们的共同点是:开发环境直连后端一切正常,一放到 Nginx 后面就出问题。这篇备忘录讲清原理,并附上能直接复制的复现命令。
一、现象:两个症状,一个套路
症状 A:上传大一点的文件就 413
浏览器控制台里看到:
POST https://demo.example.com/api/upload 413 (Request Entity Too Large)响应体是 Nginx 自己生成的一段 HTML:
<html>
<head><title>413 Request Entity Too Large</title></head>
<body>
<center><h1>413 Request Entity Too Large</h1></center>
<hr><center>nginx</center>
</body>
</html>看到页脚的 nginx,就知道这个 413 是网关返回的,请求根本没到你的 Spring Boot。所以在应用日志里找不到任何线索,这也是最让人抓狂的地方。
症状 B:WebSocket 每 60 秒断一次
前端的 onclose 回调稳定地每 60 秒左右被触发一次,错误码 1006(异常关闭)。后端日志里看不到异常,Nginx 的 error.log 里可能也没有什么,只有 access.log 里一条 101 记录,耗时恰好 60 秒:
$ grep '/ws/chat' /var/log/nginx/access.log | tail -n 3
10.0.0.21 [15/Sep/2026:10:02:14 +0800] "GET /ws/chat HTTP/1.1" 101 812 rt=60.003 ...
10.0.0.21 [15/Sep/2026:10:03:15 +0800] "GET /ws/chat HTTP/1.1" 101 640 rt=60.001 ...
10.0.0.21 [15/Sep/2026:10:04:16 +0800] "GET /ws/chat HTTP/1.1" 101 655 rt=60.002 ...请求耗时 60.00x 秒,这就是“整数秒 = 超时配置在起作用”的指纹。先保留现场:把这几条日志和 Nginx 配置备份下来,再动手。
二、排查路线图
这两类问题的排查,本质都是“沿着请求路径,逐层问:你有没有一个默认限制?”下图是请求路径上每一层可能的“关卡”:
第二张图对应症状 B:WebSocket 握手的关键头,以及“空闲 60 秒被关”的时间线:
三、动手:用 curl 复现,再逐层排查
第 1 步:用 curl 复现 413,别再靠“前端点一下”
造一个 5MB 的文件,直接 POST。这样可以脱离前端,精确控制变量,也方便对比“绕过 Nginx 直连后端”的结果:
$ dd if=/dev/zero of=/tmp/5m.bin bs=1M count=5
5+0 records in
5+0 records out
5242880 bytes (5.2 MB, 5.0 MiB) copied, 0.004 s, 1.3 GB/s
# 经过 Nginx
$ curl -s -o /dev/null -w "%{http_code}\n" -F "file=@/tmp/5m.bin" https://demo.example.com/api/upload
413
# 绕过 Nginx,直连后端(内网)
$ curl -s -o /dev/null -w "%{http_code}\n" -F "file=@/tmp/5m.bin" http://10.0.1.11:8080/api/upload
500经过 Nginx 是 413,直连后端却是 500——说明Nginx 和 Spring Boot 各有一个限制,而且 Nginx 是第一道。这是这类问题最重要的判断方法:每一层单独测,看是哪一层先拒绝。
第 2 步:看 Nginx 的 error.log
$ tail -n 3 /var/log/nginx/error.log
2026/09/15 09:21:44 [error] 1203#1203: *51022 client intended to send too large body: 5242880 bytes, client: 10.0.0.21, server: demo.example.com, request: "POST /api/upload HTTP/1.1", host: "demo.example.com"client intended to send too large body: 5242880 bytes 是 413 的标准日志。Nginx 是看 Content-Length 请求头直接判定的,所以它在读取请求体之前就拒绝了,很快、不会占后端资源。根据官方文档,client_max_body_size 默认是 1m,超过就返回 413,设为 0 表示不检查。而且官方还特别说明:浏览器无法正确显示这个错误——所以有些浏览器上你看到的不是漂亮的 413 页面,而是“连接被重置”之类的错误,给排查添了迷雾。
第 3 步:调大 Nginx,再测,再看 Spring Boot
server {
client_max_body_size 50m; # 也可以只写在 upload 的 location 里,别全站放开
location /api/upload {
proxy_pass http://demo_backend;
}
}$ nginx -t && nginx -s reload
$ curl -s -w "\n%{http_code}\n" -F "file=@/tmp/5m.bin" https://demo.example.com/api/upload
{"timestamp":"2026-09-15T01:30:02.114+00:00","status":500,"error":"Internal Server Error","path":"/api/upload"}
500现在 Nginx 放行了,轮到应用层。看 Spring Boot 的日志:
org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size exceeded;
nested exception is java.lang.IllegalStateException:
org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException:
The field file exceeds its maximum permitted size of 1048576 bytes.1048576 bytes 就是 1MB——Spring Boot 的 spring.servlet.multipart.max-file-size 默认值是 1MB(单个文件),而 max-request-size(整个 multipart 请求)默认是 10MB。两者都要根据需要修改:
spring:
servlet:
multipart:
max-file-size: 50MB # 单文件上限
max-request-size: 100MB # 一次请求总大小上限(多文件时要大于单文件)如果是同时上传多个文件,总大小超过 max-request-size 时的异常是 SizeLimitExceededException: the request was rejected because its size (…) exceeds the configured maximum (…),要看清是“单文件超”还是“总量超”,别改错。
第 4 步:WebSocket,先用 curl 看握手
用 curl 手动发一个升级请求,最快判断 Nginx 有没有把升级头转给后端:
$ curl -i -N --max-time 5 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
https://demo.example.com/ws/chat
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: qGEgH3En71di5rrssAZTmtRTyFk=
...
curl: (28) Operation timed out after 5001 milliseconds返回 101 Switching Protocols 说明握手成功,最后超时是因为我们不会说 WebSocket 协议、curl 只是干等——这是正常的。如果这里得到的是 400 Bad Request 或 200 一个普通页面,说明 Nginx 没有正确转发 Upgrade / Connection 头。
进一步观察“多久断”:让 curl 连着不动,用 time 计时。也可以用 wscat(Node 工具,npm i -g wscat):
$ time wscat -c wss://demo.example.com/ws/chat
Connected (press CTRL+C to quit)
Disconnected (code: 1006, reason: "")
real 1m0.108s不发任何消息,精确 60 秒后断开——和 proxy_read_timeout 默认 60s 完全吻合。
四、根因
1. 413:多层、多个默认值,最严的那层说了算
一次文件上传从客户端到存储,要经过:SLB/CDN/WAF(有的产品有 body 限制)→ Nginx(client_max_body_size 默认 1m)→ 可能还有第二层网关(Spring Cloud Gateway 是 WebFlux,缓冲限制走 spring.codec.max-in-memory-size,默认 256KB,超了会抛 DataBufferLimitException)→ Spring Boot(multipart 1MB / 10MB)→ 对象存储(单次 PutObject 有大小上限,超大文件要用分片上传)。木桶原理:最短的那块板决定你最终能传多大。所以当你修好 Nginx 之后,如果还是失败,别怀疑人生,往下一层看。
另外,Tomcat 有个 server.tomcat.max-swallow-size(默认 2MB):应用拒绝一个过大的请求后,Tomcat 会吞掉这么多剩余请求体,超过就直接断开连接。客户端如果还在发数据,可能看到的是 “Connection reset”,而不是漂亮的 JSON 错误——所以“上传大文件报连接重置”并不一定是网络问题。
2. WebSocket:Upgrade 是逐跳头 + 60 秒空闲超时
Nginx 官方文档讲得很清楚:Upgrade 和 Connection 是逐跳(hop-by-hop)头,不会自动从客户端传给后端,必须用 proxy_set_header 显式转发。同时,Nginx 默认在 60 秒内连接上没有任何数据传输就会关闭它,解决办法有两个:增大 proxy_read_timeout,或者让后端周期性发送 WebSocket ping 帧。
# http 块:根据是否有 Upgrade 头,决定 Connection 是 upgrade 还是 close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://demo_backend;
proxy_http_version 1.1; # WebSocket 需要 HTTP/1.1
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s; # 需大于心跳间隔的数倍
proxy_send_timeout 3600s;
}
}这里的坑:如果只写 proxy_set_header Connection "upgrade"(最简写法,也是官方文档的基础示例),普通 HTTP 请求也会带上 upgrade,一般无害;用 map 写法更规范。而如果你另外在这个 location 上继承了 upstream keepalive 那套 proxy_set_header Connection "",就会把 upgrade 覆盖掉,握手失败——同一个 location 里只能有一种 Connection 的设置。
3. 把超时调大不是解药,心跳才是
光把 proxy_read_timeout 调成 1 小时,会带来另一个问题:僵尸连接。手机断网、切基站,TCP 层可能几分钟都感知不到,连接一直挂着占用后端资源。正确做法是应用层心跳:客户端每 25~30 秒发一个 ping(或服务端发 ping 帧),让连接始终有数据,同时让双方能及时发现对方已经不在。前端伪代码:
// 前端:每 25s 发一次心跳;小于 Nginx 的 proxy_read_timeout,且能容忍丢一次
let timer;
ws.onopen = () => { timer = setInterval(() => ws.send('{"type":"ping"}'), 25000); };
ws.onclose = () => { clearInterval(timer); /* 指数退避重连,并按最后一条消息 id 补拉丢失的消息 */ };后端 Spring 中如果用 @ServerEndpoint 或 WebSocketHandler,也可以周期性地对 Session 发送 Ping 帧;无论哪一端发,只要 60 秒内(或你配置的超时内)有数据经过 Nginx 就算“不空闲”。
4. 大文件上传 504:缓冲与超时
还有第三个“兄弟”:大文件上传到后端,最后返回 504。原因通常有两个。其一,proxy_request_buffering 默认是 on:Nginx 会先把整个请求体读完(大文件会落到临时文件),再转发给后端;期间读取客户端请求体有 client_body_timeout(默认 60s,也是“两次读之间”的间隔)。其二,转发给后端后,后端处理(比如把文件再上传到 OSS)超过 proxy_read_timeout(60s)就 504,而后端其实还在继续处理,用户以为失败重传,结果产生重复文件。
location /api/upload {
client_max_body_size 200m;
client_body_timeout 120s;
proxy_request_buffering off; # 边收边转,不落盘;代价:不能重试到下一个上游
proxy_read_timeout 300s; # 只给上传接口放宽,别全局放宽
proxy_pass http://demo_backend;
}官方文档的说明是:关闭 proxy_request_buffering 后,请求体收到就立刻发给后端,并且一旦开始发送,就不能再把请求传给下一台上游。所以它适合真正的大文件,但对普通接口不建议。更根本的办法是:大文件让前端直传对象存储(OSS/S3 的预签名 URL,或分片上传),不要经过应用服务器,这样 Nginx 和 Spring Boot 的限制都不再是瓶颈。
五、止血与修复
止血
- 413:在 Nginx 的上传 location 里把
client_max_body_size调到业务需要的值,nginx -t后nginx -s reload;同时把 Spring Boot 的max-file-size/max-request-size调到不小于它并发布; - WebSocket:给
/ws/单独加上 Upgrade/Connection 头,把proxy_read_timeout调到 3600s(先止住每分钟断线); - 逐层复测:用第 1 步的 curl 命令从客户端一路测到后端,确认每一层都通过,并测“刚好在限制边界”的文件(比如 49MB 与 51MB)。
根治
- 把上传限制写成一份文档:每一层(SLB、Nginx、网关、Spring、OSS)的上限分别是多少,谁改了要同步;
- 前端在上传前先校验大小,给出友好提示,而不是让用户看到 Nginx 的 413 页面;同时在网关层统一把 413 转成 JSON 响应(
error_page 413 = @json413;); - 大文件直传对象存储 + 分片/断点续传;
- WebSocket 实现心跳 + 断线重连 + 消息补偿(以消息 id 或时间戳拉取丢失的消息),不要依赖连接“永不断”;
- Nginx 配置模板化:所有反向代理 location 用同一个
include片段,避免“这个 location 有 Upgrade、那个没有”。
六、复盘
| 做法 | 为什么 |
|---|---|
413 页脚是 nginx 就先查 Nginx,别翻应用日志 | Nginx 看 Content-Length 就拒绝了,请求根本没到后端 |
| 用 curl 逐层测(经 Nginx / 直连后端) | 一次就能定位是哪一层先拒绝,比反复让前端点上传高效 |
Nginx 的 client_max_body_size 与 Spring 的 max-file-size、max-request-size 三者一起改 | 默认值分别是 1m / 1MB / 10MB,最小的一个决定结果 |
| 多层代理每层都过一遍 | SLB、WAF、网关、二级 Nginx 都可能有自己的限制 |
WebSocket 必须 Upgrade + Connection + proxy_http_version 1.1 | 逐跳头不会自动透传,缺了握手就失败 |
| 超时调大 + 应用层心跳,两者配合 | 只调大会留下僵尸连接,只心跳不调大可能被默认 60s 误杀 |
| 超大文件直传对象存储 | 绕开所有中间层的 body 和超时限制,也减轻应用服务器压力 |
| 日志里看到耗时恰好 60.00x 秒 | 整数秒是超时配置的指纹,直接对号入座 |
一句话备忘:413 看
client_max_body_size(默认 1m)+ Spring 的max-file-size/max-request-size+ 每一层代理;WebSocket 每 60 秒断,补Upgrade/Connection头、调大proxy_read_timeout、加心跳;排查用 curl 一层一层单独测。
后来那个“每分钟掉线”的客服窗口稳定运行了三个月,直到有一天用户反馈“上传视频又失败了”——我打开一看,是运维新加了一层 WAF,body 限制 10MB。我默默把这条写进了文档,标题叫:“请求路径上的每一层,都是一个等你踩的默认值。”
参考资料
- Nginx 官方文档:client_max_body_size——默认 1m,超过返回 413,设为 0 不检查,并说明浏览器无法正确显示该错误;同页还有 client_body_buffer_size。
- Nginx 官方文档:WebSocket proxying——Upgrade/Connection 是逐跳头需显式转发,默认 60 秒无数据关闭连接,可用 proxy_read_timeout 或 ping 帧解决。
- Nginx 官方文档:ngx_http_proxy_module——proxy_request_buffering、proxy_read_timeout 等指令的默认值与语义。
- Spring Boot 官方文档:Common Application Properties——spring.servlet.multipart.max-file-size(默认 1MB)与 max-request-size(默认 10MB)等属性。
一起聊聊