Nginx 与网络排查 列表全部文章
Nginx 与网络排查

上传 5MB 就 413,WebSocket 每 60 秒准时“下班”:两个 Nginx 默认值的经典坑

一次上线连踩两个 Nginx 默认值:client_max_body_size 默认 1m 导致上传 413,Spring Boot multipart 与多层代理要逐层放开;WebSocket 每 60 秒断线,缺 Upgrade 头且 proxy_read_timeout 默认 60s。附 curl 复现命令与心跳方案。

更多

新功能上线第一天,测试同学甩来两个“毫无关联”的 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 配置备份下来,再动手。

二、排查路线图

这两类问题的排查,本质都是“沿着请求路径,逐层问:你有没有一个默认限制?”下图是请求路径上每一层可能的“关卡”:

客户端浏览器/App SLB / CDN / WAF可能有 body 与空闲限制 Nginx 网关1m / 60s 默认值 Spring Bootmultipart 1MB/10MB OSS / 存储单次大小限制 413 / 连接被切先查产品文档 413 页脚是 nginxclient_max_body_size MaxUploadSize…应用日志有异常 规则:每一层都有默认值,最严的那一层决定最终能传多大 / 连接能空闲多久

第二张图对应症状 B:WebSocket 握手的关键头,以及“空闲 60 秒被关”的时间线:

浏览器 Nginx Spring Boot GET /ws Upgrade: websocket 需显式转发 Upgrade + Connection: upgrade 101 Switching Protocols 101,连接升级为双向通道 60s 内两侧无任何数据proxy_read_timeout 到期 Nginx 主动关闭 → onclose 1006

三、动手:用 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 的限制都不再是瓶颈。

五、止血与修复

止血

  1. 413:在 Nginx 的上传 location 里把 client_max_body_size 调到业务需要的值,nginx -t 后 nginx -s reload;同时把 Spring Boot 的 max-file-size / max-request-size 调到不小于它并发布;
  2. WebSocket:给 /ws/ 单独加上 Upgrade/Connection 头,把 proxy_read_timeout 调到 3600s(先止住每分钟断线);
  3. 逐层复测:用第 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。我默默把这条写进了文档,标题叫:“请求路径上的每一层,都是一个等你踩的默认值。”

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人