最近我在线上排查了一个挺有意思的问题,现象是业务方反馈某文件下载接口在高峰期会偶发失败,客户端收到的文件总是不完整,后台监控面板上刷出一片502,nginx 错误日志里反复出现这条:
upstream sent invalid chunked response while reading upstream如果你也和 nginx、上游服务打过不少交道,应该知道这类报错并不是 nginx 本身“坏了”,更常见的是上游 HTTP 响应流出了问题。这篇就用我这次的完整排查过程,把 chunked 响应是怎么被“弄脏”的讲清楚,也把我踩过的坑、验证过的抓包方法、最终如何定位并修复,一并写出来。适合正在处理类似 502、504、流式下载接口异常的后端和运维同学参考。
1. 故障现场:先搞清楚报错长在哪个环节
1.1 线上第一时间的现象
出问题的接口是一个报表下载入口,平时的调用量不算大,但每个请求会流式输出一份比较大的 CSV 文件。那天下午告警群里突然开始刷消息,错误率从千分之几一下子跳到接近 8%,持续了大约十几分钟,然后又自己回落了。这种“时好时坏还带偶发性”的特征,说实话比一直坏更让人头疼。
我第一反应是去看 nginx 的错误日志:
grep "invalid chunked" /var/log/nginx/error.log | tail -n 20日志里对应的时间点和告警时间基本吻合,而且报错集中在同一个 upstream server 上。再配合 access log 看,返回码集中在502,少量499。499说明客户端提前断开了,这还算正常,但502就得认真看。
当时还顺手看了下系统整体负载,CPU、内存、磁盘 IO 都不高。nginx 和后端服务器之间的网络延迟也正常,没有丢包。所以第一轮可以排除掉“机器被打满”“网络基础链路故障”这类粗粒度原因。
1.2 用户侧实际表现:文件半截和乱码
让业务方反馈了一下他们看到的现象,说法很不统一:
- 有的用户直接下载失败,浏览器报“网络错误”;
- 有的用户下载成功,但 CSV 打开只有前面几行,文件大小明显小于预期;
- 还有极个别用户反馈文件能打开,但末尾多出一些奇怪的字符,或者表格结构被截断。
这三种表现其实指向同一个本质:HTTP 响应体没有按照合法格式完整传到客户端。结合 nginx 那句 “invalid chunked response”,基本上可以确定上游返回的是一个“半截的、非法的分块传输编码流”。
1.3 先排除掉几个常见误判
在开始深入之前,我把几个容易背锅的点提前排除掉了:
- 不是 nginx 代理超时导致的。如果
proxy_read_timeout触发,nginx 日志通常是upstream timed out,这里明显不是。 - 不是上游服务完全无响应。如果是连接都建不上,会报
connect() failed;这里是已经建立了连接、也读到了响应,但读到一半数据不合法。 - 不是客户端断连导致的误报。客户端断连通常表现为
499,nginx 不会在 error log 里写 invalid chunked。
这里插一句:遇到问题先看 error.log 的完整上下文,别只盯着报错那一行。nginx 在upstream sent invalid chunked response同一时刻,前面和后面的日志往往能告诉你更多信息,比如是不是同一个 worker 进程在处理、是不是同一个连接反复出现。
2. 拆解 chunked:这条报错到底在说什么
2.1 HTTP/1.1 为什么需要分块传输
要理解upstream sent invalid chunked response while reading upstream,得先明白 chunked 传输编码是怎么工作的。
HTTP/1.1 的响应体有两种主要的长度约定方式:一是Content-Length,服务器在响应头里明确告诉客户端“我这个 body 一共有多少个字节”;二是Transfer-Encoding: chunked,适合响应体长度无法预知的情况。
像文件导出、日志流、SSE、音视频分段这类接口,服务端是在边生成数据边写数据的,根本算不出总长度,于是采用 chunked:把 body 切成若干块,每块先写一个十六进制长度值加\r\n,再写块内容加\r\n,最后用一个长度为0的块作为结束标记。
一个合法 chunked 流的原始字节长这样:
1f\r\n <这一块的实际内容,共31字节>\r\n 3c\r\n <这一块的实际内容,共60字节>\r\n 0\r\n \r\nnginx 作为反向代理,读到上游返回的这个字节流后,会一层层解包,然后把合法的响应再转给客户端。如果上游给它的字节流不符合这个格式,nginx 就会在日志里写upstream sent invalid chunked response while reading upstream,然后把这次请求判定为失败。
2.2 不合法分块响应的几种典型形态
排查这类问题,脑子里要先有一张“非法形态清单”,不然抓了包也不知道在看什么:
- 响应中途连接被断开。上游服务进程崩溃、线程池拒绝、socket 被误关闭,都会导致 chunked 流在“半截”的位置戛然而止。我们这次属于这一类。
- 块大小字段解析失败。比如本该是十六进制数字的地方出现了普通字符,或者是
gzip压缩后的流被二次处理,分块边界全都错乱。 - 响应头同时带了 Content-Length 和 Transfer-Encoding。HTTP 规范里这两个头语义是互斥的,如果上游代码主动设了 Content-Length,又被容器统一加了 Transfer-Encoding: chunked,客户端和代理都会发懵。
- 上游响应头中 Content-Length 与实际 body 长度不一致。这个通常不会直接报 invalid chunked,但会在客户端表现为文件下载损坏,需要和 chunked 错误区分开。
2.3 nginx 在这条链路里做了什么
nginx 在处理上游 chunked 响应时,有几个配置参数会直接影响它怎么读、怎么缓冲。
location /download { proxy_pass http://backend_upstream; proxy_http_version 1.1; proxy_buffering off; }proxy_http_version 1.1:确保 nginx 与上游通信时也使用 HTTP/1.1,这样上游才能使用 chunked。proxy_buffering off:响应不经过 nginx 缓冲,直接转发,适合流式接口,但代价是 nginx 错失了对上游响应流的部分保护机会。proxy_buffers / proxy_buffer_size:控制缓冲区域大小,太小的话,nginx 读大块 header 或 body 时会频繁做临时文件写入。
但要注意,这几项配置一般不会凭空制造出invalid chunked response——它们是 proxy,不是协议转换器,上游来的字节流本身不合法,nginx 才会报错。所以排查重心必须放在上游到底吐了什么字节,而不是反复调 nginx 参数。
3. 完整排查链路:从日志到字节的层层定位
3.1 先复现,同时确认影响范围
我拿到问题的第一步,是找一个能稳定复现的方式。但这类偶发性问题往往跟并发、数据量、网络时机强相关,直接单发 curl 大概率是复现不出来的。
先从 access log 里筛出失败请求的规律:
awk '$9 == 502 {print $4, $7}' /var/log/nginx/access.log | tail -n 100筛完发现两个特征:失败请求集中在某个特定的报表参数区间(数据量比较大的那类),且时间上集中在流量高峰。另外,同一个用户在几分钟内重试,有一定概率会成功。
这说明不是某个固定参数“必现”,而是跟服务端处理该请求时消耗的资源/耗时有关。当时我猜测是长耗时请求在高并发下触发了某个隐藏的分支,比如 socket 空闲被关闭、对象池耗尽、或者容器线程被中断。
3.2 绕开 nginx 直连上游
为了快速区分问题在 nginx 还是上游,第二件事是绕开 nginx,用 curl 直接打后端的端口。
curl -v --http1.1 --raw http://127.0.0.1:8080/download/xxx?type=large.csv -o /tmp/direct.out加--raw是为了让 curl 不要帮你自动解码 chunked,直接把原始字节落盘。如果能拿到完整文件,且是正常 chunked 格式,说明单请求场景下上游是没问题的。
我这边直连打了十几次,大部分都是好的。偶尔在并发压测时,会出现curl: HTTP error 502或者连接被重置。到此基本可以确定:问题在下游异常,nginx 只是那个“发现异常并报 502”的哨兵。
3.3 抓包看最原始的字节流
接下来是这次排查最关键的一步:抓包。只靠应用层日志和 curl 结果,很难看到 chunked 流的真实破坏点。
在 nginx 和后端之间,或者直接在应用服务器本机抓8080端口的包:
tcpdump -i eth0 -s 0 -w /tmp/upstream.cap port 8080抓一阵子,等到复现后,再用 tshark 过滤出 HTTP 层的 chunked 信息:
tshark -r /tmp/upstream.cap -Y "http.transfer_encoding == \"chunked\"" -T fields -e frame.time -e ip.src -e tcp.stream -e http.content_length -e http.chunked如果用 tshark 不方便,还有更朴素的方案:直接在应用侧把响应体接到 nginx 的 TCP 流量上分析。当时我比较赶时间,是用了一个笨但有效的办法——在应用容器里加一层 debug 日志,把每次响应结束时OutputStream.close()前的内容长度记一下,同时抓包对照。
对比了几次成功和失败的 TCP 流之后,规律出来了:失败请求的 TCP 流中,响应头已经被客户端部分接收,随后服务端发送了 RST 包,最后并没有出现合法的 chunked 结束块。也就是说,上游在写出若干 chunk 后,连接被异常重置了。
3.4 看应用日志、线程栈和 GC 日志
到这个阶段,问题范围已经从“nginx 报错”收敛到了“上游 Java 服务在响应过程中主动重置了连接”。接下来要找到是哪个代码路径干的。
我打开了后端应用的访问日志、错误日志和 GC 日志。错误日志里确实有大面积java.io.IOException: Connection reset by peer,但这是连接被重置的结果,不是原因。再把 jstack 打出来的线程栈和失败时间点对齐,发现一个规律:线程池里很多线程此时卡在文件读取和输出流写入之间,阻塞时间特别长,个别请求的处理时间已经超过 5 秒。
进一步看 GC 日志,老年代回收频率明显偏高。不过这次不是 OOM 导致的直接崩溃,更像是 GC 暂停时间太长,导致某些组件误判连接空闲,或者有人在响应写入期间强行做了资源回收。
3.5 逐个验证,缩小嫌疑范围
我又把怀疑对象排了一遍:
- Tomcat 连接器 keepalive 配置?如果连接空闲超时被误杀,应该影响的是下一个请求,而不是当前正在写响应的连接。
- 反向代理超时配置?
proxy_read_timeout默认 60s,我们设置的也不算短,而且报错不是upstream timed out。 - 业务代码中的 finally 块?这个我一开始没太注意,后来才意识到问题很可能就藏在“业务代码之外的框架层”。
最终锁定到一个之前很多人不太会注意的位置:一个自定义 ServletFilter 在响应提交之后,又对 response 做了写入操作。这个过滤器是给所有请求统一打审计日志的,在finally块里会把一些标记信息 append 到 response 中,而它默认在.doFilter()返回之后还会尝试调用response.getWriter()。
4. 根因确认:响应提交后的“追加写入”破坏了 chunked 流
4.1 为什么提交后再写入会导致连接重置
Java Servlet 规范里有一个概念叫“response committed”,也就是响应已经提交。一旦容器把响应头发送出去,或者在流式写出的过程中已经 flush 了一部分数据,就进入“已提交”状态。
已提交之后,理论上就不能再修改响应头,也不能再用getWriter()或getOutputStream()继续写入了。如果违反这条规则,常见结果是容器抛出异常,或者容器内部把 connection 标记为“不可复用”,随后在返回时关闭连接。
但在这个案例里,真正致命的不是抛异常,而是部分旧容器版本对这个行为没有立即报错,而是等连接池收回时触发清理逻辑,把还没写完的 socket 直接打断了。整个过程从上游看,就是“一个写了几块 chunk 的流,突然被 RST 掐断”。nginx 按照 HTTP 协议读到一半发现流不合法,于是报出invalid chunked response。
4.2 从抓包里反推代码错误的具体形态
我特意去看了抓包里失败流的末尾几组字节。正常来说,最后应该这样结束:
0\r\n \r\n但失败流中,前面几块 chunk 都是正常的,到了中间某一处,服务端发出了RST。更准确地说,服务端发出的最后几个包中还有一段未完成的 chunk,那个 chunk 大小声明了 120 字节,实际只传了 58 字节,连接就断了。
这说明:业务输出流确实是正常的流式响应,问题出在业务输出结束之后、连接还没归还之前,有一个“第三者”又向输出流里塞了数据,或者对输出流做了非法操作。对比代码,那条finally里的“审计追加逻辑”正好完美匹配。它甚至不是每次都会触发,只有某种条件下(比如审计字段为空、或者需要拼接的内容缺失)它会走一个异常分支,而这个分支的写操作导致整个连接被标记为脏连接。
4.3 修复方案:让过滤器不再触碰已提交的响应
定位到问题后,修复反而很直接。改造点有这么几处:
第一,在自定义过滤器中增加提交状态判断,提交过就什么都不做:
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(request, response); } finally { if (response.isCommitted()) { // 响应已提交,禁止再写入任何数据 return; } // 只有未提交时才补齐审计字段 appendAuditInfo(response); } }第二,把“追加审计”逻辑从过滤器里挪走,放到请求进入时提前绑定到响应头,而不是在响应结束时追加到 body。这从设计上就避免了和流式输出冲突。
第三,升级并统一 Servlet 容器版本。虽然这段代码在不同容器里表现不一样,但“已提交后再写 response”本身就是未定义行为,不该依赖某个容器的“宽容”来运行。
改完之后,我又用原来的压测场景跑了几轮,之前能稳定在几百并发下复现的失败,现在跑到了上千并发也没再出现。nginx 日志里的 invalid chunked 也彻底消掉了。
5. 同类问题的常见变体:下次遇到别再一头雾水
5.1 不同根因下的典型特征对照
这次的经验可以抽象成一张对照表,以后遇到invalid chunked response可以先按特征快速归类,而不是一上来就怀疑 nginx 配置或网络。
| 根因方向 | 典型特征 | 排查切入点 |
|---|---|---|
| 业务代码在已提交响应后继续写入 | 偶发、峰值时明显,抓包看到中途 RST | 审查过滤器、拦截器、async 回调 |
| 上游进程崩溃/被 OOM Killer 杀死 | 伴随大量其他接口异常,日志看到进程重启 | 看 dmesg、GC 日志、进程重启记录 |
| 上游应用线程池耗尽 | 请求堆积,响应迟迟不写 | jstack 看线程状态、线程池拒绝策略 |
| 反向代理或中间件缓冲导致超时 | 单次请求耗时超长,nginx 日志另有 timed out | 调整 proxy_read_timeout、缓冲参数 |
| chunked 与 Content-Length 同时存在 | 响应头冲突,客户端表现不一致 | curl -v 看原始响应头 |
| 协议栈或网卡驱动异常 | 复现范围广,连简易接口都会挂 | 抓包看 TCP 层异常、硬件信息 |
5.2 容易被忽略的 nginx 侧配置
虽然这次不是 nginx 配置的锅,但排查时我也仔细比较过几种配置的影响,有些经验值得分享。
如果上游接口确实是边生成边下发、且数据量不稳定,建议保持proxy_buffering off;,并确保proxy_http_version 1.1;。这样 nginx 不会为了等完整 chunked 流而消耗过多内存,客户端也能尽早拿到数据。
但要注意,proxy_buffering off也有代价:nginx 几乎完全是“原样透传”上游字节流,一旦上游字节流不合法,nginx 没有机会用缓冲去“救”回来,只能失败。如果你希望 nginx 至少在某种程度上容忍上游的畸形流,可以考虑打开 buffering,并适当调大proxy_buffer_size和proxy_buffers。但这属于“治标”,根治还是要修上游。
另外,proxy_next_upstream也可以配置让 nginx 在碰到invalid_header时自动切换到下一个上游节点:
proxy_next_upstream error timeout invalid_header http_500 http_502;这个配置对多副本部署来说,能在不改变调用方感知的情况下自动容错,但前提是下游接口具备幂等性,否则重试可能会造成重复数据。下载类接口一般幂等性弱,我是建议谨慎开启的。
5.3 监控与问题定位的基础设施建设
这次排查让我比较满意的地方,是自己提前养成了“原始字节优先”的习惯。如果一开始没有抓包,而是在proxy_buffers和proxy_read_timeout之间来回调,大概率会耽误更久。
建议所有做流式接口、下载接口的服务,至少可以提前准备这几样东西:
- 对 nginx error.log 做关键词监控,
invalid chunked、upstream timed out、Connection reset分开告警; - 在上游服务侧记录每次响应的状态、总写入字节数、是否异常关闭连接,便于事后关联;
- 对常见接口建立“校验下载文件大小和 Content-Length 一致”的巡检任务,这类问题往往比 502 更隐蔽。
6. 这次排障留给我的几个操作心得
如果只让我说三条最有价值的经验,我会选这三条。
第一条,先抓包,再调配置。很多人看到 nginx 报upstream sent invalid chunked response,第一反应是去动proxy_buffering、proxy_buffer_size,但实际上绝大多数这类错误都来自上游。抓包能让你一眼看出到底是“流中断”还是“格式错乱”,比靠猜高效得多。
第二条,警惕所有“响应结束之后还想做点什么”的代码。过滤器、拦截器、切面里的 finally 块,都能成为破坏 chunked 流的隐形凶手。凡是可能操作HttpServletResponse的地方,都要在操作前判断response.isCommitted(),并且尽量把这类逻辑前移到请求处理开始之前。
第三条,把复杂问题拆解成“谁先破坏了链路”。代理、网关、容器、应用、网络每一层都有各自的协议视角,报错日志只代表“最后一层发现了问题”,不代表“问题就发生在这一层”。带着分层视角去看问题,排障效率会高很多。
后来我们又排查过几次类似问题,比如某个后端服务启用压缩过滤器后,在 chunked 流尾部错误修改了内容;再比如某 Python 服务在流式响应时没有正确处理客户端断开,导致 socket 状态异常。每一次几乎都是靠抓包和对照“合法 chunked 应长什么样”来定位的。如果你下次再遇到 nginx 刷出invalid chunked response,希望这篇能帮你少走弯路,直接按这条链路一步步查下去。