Nginx 多层代理下大文件上传失败排查(MySQL max_allowed_packet 限制)
记录时间:2026-07-22
环境:联通云 WAF + 外层 Nginx(联通云-172.16.211.53-nginx) + K8s Ingress Nginx + cheetah-server(Spring Boot) + MySQL 8.0
一、问题现象
客服反馈通过 https://xxx/bg/api/trust/uploadext 上传约 40MB 文件时失败,前端提示”上传失败”。小文件(几十 KB)上传正常。
事件背景与排查动机:cheetah-server 开发方(外包)初步判断为运维侧 Nginx 代理层拦截所致,将问题归到运维这边。本次排查的核心目标之一,就是逐层验证 WAF → Nginx → Ingress 各代理链路是否真的拦截了请求,以厘清责任归属。最终结论:代理链路各层均未拦截,根因在业务侧的存储架构与数据库配置。
请求链路如下:
浏览器 → 联通云 waf → 外层 Nginx(联通云) → K8s Ingress → cheetah-server → MySQL
二、排查过程
2.1 检查外层 Nginx 配置
首先查看 /bg/ 路径的代理配置,确认 client_max_body_size 和超时参数:
location /bg/ {
proxy_connect_timeout 15s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
client_max_body_size 100m;
proxy_pass http://bg/;
}
结论:
client_max_body_size 100m,40MB 文件在限制内,外层 Nginx 不是瓶颈。
2.2 检查 K8s Ingress 配置
查看 Ingress 的 annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 50m
结论:50MB > 40MB,Ingress 层也不是瓶颈。
2.3 逐层检查日志(WAF / 外层 Nginx / Ingress)
按链路顺序从前到后检查各层日志,三层 HTTP 状态码均为 200。
联通云 WAF 访问日志:
"POST /bg/api/trust/uploadext HTTP/1.1" 200 ...
结论:WAF 日志返回 200,说明请求体未被 WAF 拦截。WAF 控制台请求体大小限制配置为 100m,40MB 文件在限制内。
外层 Nginx 访问日志:
"POST /bg/api/trust/uploadext HTTP/1.1" 200 66 ... upstream_response_time 0.017 request_time 0.018
注意:Nginx 返回 HTTP 200,响应时间仅 0.017s,看起来请求”成功”了。但这里有一个关键认知——Nginx 日志中的 200 只代表后端返回了 HTTP 200 状态码,不代表业务逻辑成功。后端完全可以在 HTTP 200 的响应体里返回业务错误(如
{"code":500,"msg":"上传失败"}),Nginx 无法感知。
Ingress 日志:
"POST /api/trust/uploadext HTTP/1.1" 200 72 ... 0.014
Ingress 同样返回 200,响应时间正常。
WAF、外层 Nginx、Ingress 三层代理链路均返回 200,说明请求体在传输过程中未被任何代理层拦截,问题必然在后端服务或数据库。查看 cheetah-server 应用日志,发现关键错误:
ERROR com.mysql.cj.jdbc.exceptions.PacketTooBigException: Packet for query is too large (84,745,533 > 67,108,864)
根因定位:MySQL JDBC 驱动抛出了
PacketTooBigException,单条 SQL 语句约 84MB,超过了 MySQLmax_allowed_packet的 64MB 默认限制。
三、根因分析
3.1 各层限制对比
| 层级 | 配置项 | 当前值 | 40MB 文件是否通过 | 是否瓶颈 |
|---|---|---|---|---|
| 联通云 WAF | 请求体大小限制 | 100m | ✅ | ❌ |
| 外层 Nginx | client_max_body_size |
100m | ✅ | ❌ |
| K8s Ingress | proxy-body-size |
50m | ✅ | ❌ |
| 后端服务 | 无限制 | — | ✅ | ❌ |
| MySQL | max_allowed_packet |
64m(默认) | ❌ | ✅ |
3.2 为什么 40MB 文件会变成 84MB 的 SQL
后端 cheetah-server 将上传文件的内容直接存入 MySQL resource 表的 content 字段(类型推测为 LONGBLOB 或 LONGTEXT)。在这个过程中数据发生了膨胀:
- 文件上传后可能经过 Base64 编码(膨胀约 33%),或应用层对二进制数据做了其他序列化处理
- SQL 语句本身包含 INSERT 语法、字段名、引号、转义字符等开销
40MB 原始文件 → 经应用层处理后约 53MB(以 Base64 为例) → 加上 SQL 语句开销后约 80-85MB → 超过 max_allowed_packet 默认 64MB 限制。
3.3 为什么 Nginx 返回 200 但上传失败
后端的异常处理逻辑捕获了 PacketTooBigException,将其包装为 HTTP 200 + JSON 业务错误码返回。从 Nginx 层面看请求”正常完成”,但业务层实际已失败。这是多层架构中常见的监控盲区——代理层健康不等于业务健康。
3.4 架构层面的根本原因:MySQL 被当作文件存储使用
表面根因是 max_allowed_packet 太小,但更深层的问题是存储架构设计本身存在缺陷。这套智能报关系统(cheetah-server)由外包团队开发,将 MySQL 同时当作 MongoDB(存大文档字段)和 MinIO(存文件二进制内容)使用,把上传文件的原始内容直接塞进 resource.content 字段。这种用法带来几个代价:
- 数据库承担了不该承担的职责:MySQL 是关系型数据库,擅长结构化数据的事务与查询,而非大对象存储。把几十 MB 的二进制内容写入 BLOB 字段,本质是在用关系库模拟对象存储。
- 放大效应:每上调一次
max_allowed_packet,单条 SQL 的内存占用、InnoDB buffer pool 占用、redo log 体积都会同步放大,治标不治本——今天 40MB 卡在 64MB,调到 100MB 后,下一次 120MB 的文件又会卡住。 - 运维侧无法根治:作为代理层 / Nginx 运维方,只能通过调 MySQL 参数止血,真正的根治需要推动业务方改造存储架构。
本次调整
max_allowed_packet只是把阈值从 64MB 抬到 100MB 的临时止血。只要文件内容仍直存 MySQL,未来必然反复出现同类问题。
四、解决方案
方案一:调整 MySQL max_allowed_packet(临时止血,已执行)
| 参数 | 值 | 说明 |
|---|---|---|
max_allowed_packet |
100M | 单个数据包最大大小,需大于实际 SQL 语句体积 |
-- 临时生效(重启失效)
SET GLOBAL max_allowed_packet = 104857600; -- 100MB
-- 永久生效:修改 my.cnf 后重启 MySQL
[mysqld]
max_allowed_packet = 100M
注意:
SET GLOBAL只对新连接生效,已有的数据库连接池连接需要重连才能继承新值。如果使用 HikariCP/Druid 等连接池,建议配合连接池重启或滚动更新应用。
方案二:应用层优化(长期方案,已交回业务方整改)
处理结论:本次排查已逐层证明 WAF / 外层 Nginx / K8s Ingress 代理链路均未拦截请求,根因在业务侧的存储架构(文件直存 MySQL)与数据库
max_allowed_packet配置。运维侧完成临时止血(上调max_allowed_packet)并将根因分析交回 cheetah-server 外包团队,由其按应用层方案自行整改,运维侧不再介入代码层修改。
当前将文件内容直接存入 MySQL content 字段存在以下问题:
- 单条 SQL 体积随文件大小线性增长,总有超过
max_allowed_packet的一天 - 大事务会占用 InnoDB buffer pool,影响其他查询性能
- 数据库备份体积快速膨胀
建议改为对象存储方案:
- 文件上传后存入 MinIO / S3 / OSS,MySQL 只存文件元信息(URL、大小、MD5、上传时间等)
- 如需保留当前 API 契约,可在 cheetah-server 中加一层适配——上传接口先将文件写入对象存储,再将 URL 写入
resource表
五、验证
5.1 确认 MySQL 配置生效
SHOW VARIABLES LIKE 'max_allowed_packet';
-- 预期输出:104857600(100MB)
5.2 确认连接池已刷新
如果使用了连接池,检查当前连接的 session 变量是否已更新:
-- 查看当前会话的实际值
SELECT @@session.max_allowed_packet;
5.3 业务验证
重新上传 40MB 文件,逐层确认:
# 1. 外层 Nginx 日志无异常
tail -f /AppLogs/ngx_acc_logs/access.log.json | grep uploadext
# 2. 后端日志无 PacketTooBigException
tail -f /AppLogs/cheetah-server/app.log | grep -i packet
# 3. 前端上传成功,返回业务成功标识
六、注意事项
Nginx 返回 200 不代表业务成功:后端可以返回 HTTP 200 + 业务错误 JSON(如
{"code":500}),Nginx 只关心 HTTP 状态码,无法感知业务层错误。排查这类问题时不能只看代理层日志,必须追踪到后端应用日志。多层代理场景需逐层排查请求体限制:联通云 WAF(请求体大小限制)→ 外层 Nginx(
client_max_body_size)→ K8s Ingress(proxy-body-size)→ 后端框架(如 Spring Boot 的spring.servlet.multipart.max-file-size)→ 数据库(max_allowed_packet),每层都可能有独立的体积限制,缺一不可。排查方向选择:当代理层日志显示正常时,优先向下游追溯——先看后端应用日志,再看数据库日志,而不是反复在代理层找问题。
- 大文件存储不建议直接放 MySQL,应优先考虑对象存储(MinIO、S3、OSS 等),MySQL 只存元数据
SET GLOBAL max_allowed_packet对已有连接不生效,连接池场景需要滚动重启应用- MySQL 的
max_allowed_packet默认 64MB(MySQL 8.0),最大可设置 1GB,但过大会增加内存占用风险