有次做一个导出接口,前端进度条走到 99% 就不动了,等了半分钟才弹完成。后端日志显示数据十秒就写完了。查下来既不是带宽也不是前端渲染,是中间那层网关把流式响应攒起来一次性发给了浏览器。顺着这条线往回看,才把 Content-Length 和 Transfer-Encoding: chunked 这两个平时不太在意的头重新捋了一遍。
为什么非得知道长度
TCP 只负责把一串字节按顺序送过去,它不理解"这是一次响应"。客户端读完响应头之后,得有个依据判断响应体到哪儿结束——尤其连接还要复用给下一个请求,读少了会留下脏数据,读多了会一直等。
HTTP/1.1 给了两种约定方式。一种是明确告诉对方有多少字节,另一种是边发边说、最后给个结束标记。前者是 Content-Length,后者是分块传输。
第三种老办法是"关掉连接就算结束",HTTP/1.0 这么干,代价是每次请求都要重新建连接,现在基本只在没有响应体的场景里见得到。
Content-Length:先把账算清
Content-Length: 1234 的意思很直白:响应体正好 1234 字节,读完这些就完事。客户端拿到这个数字,能提前分配缓冲、算进度、决定要不要断点续传,连接也能干净地还给连接池。
它要求服务端在发第一个字节之前就知道总长度。静态文件、已经序列化完的 JSON、内存里拼好的字符串,都能轻松满足。一旦数据要边生成边发,这个数字就不好给了——除非先把全部内容生成出来缓存一遍,但那既占内存又让首字节时间变长,等于把流式输出的意义抵消掉。
这个数字写错,症状比想象中怪。 写小了,客户端读够长度就收工,剩下的字节留在连接里,下一个请求会读到半截上一个响应的尾巴,报出来的错往往是"解析响应失败",很难联想到长度。写大了,客户端老老实实等剩下的字节,一直等到超时。两种情况都不会提示"长度不匹配",所以排查时容易跑偏。
还有个常见错误是压缩之后忘了重算。Content-Length 说的是网络上实际传输的字节数,不是压缩前的原始长度。先 gzip 再填长度,或者让框架自己处理,别手工写死。同理,如果代码里手动设了这个头,又在中间件里开了压缩,两个值对不上就是必然的。我的习惯是应用代码里完全不碰这个头,交给框架或网关统一算。
chunked:不知道多长也能开始发
拿不到总长度,就换一种约定:Transfer-Encoding: chunked。响应体被切成若干块,每一块自带长度,最后用一个长度为 0 的块收尾。原始报文大概长这样:
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
4
Wiki
5
pedia
0
每块的开头是十六进制的块长度,接着是 CRLF,再是对应字节数的内容,再一个 CRLF。0 那一块表示结束,后面还可以跟可选的尾部头字段。客户端不需要预先知道总大小,收一块解一块,最后看到空块就收工。
这样服务端可以在数据刚生成一小段时就发出去,浏览器能边收边渲染,进度条是真实的,内存也不用囤整份结果。大文件导出、日志实时推送、大模型那种逐字返回的接口,都靠它。
要注意 Content-Length 和 Transfer-Encoding: chunked 是互斥的。两个同时出现时,规范规定以 Transfer-Encoding 为准并且必须忽略 Content-Length;有些中间设备处理得没那么规矩,结果就五花八门。别给自己找这个麻烦。
另外 HTTP/2 里没有 Transfer-Encoding: chunked 这个东西。它把消息拆成带长度字段的 DATA 帧,长度信息本来就在每一帧的头里,分块这套约定用不上了。网关在做协议转换时会替你把这一层抹平,应用代码感知不到,但看抓包结果时别奇怪为什么找不到那个头。
写流式接口时真正会踩的坑
第一处是缓冲。分块传输是协议层的能力,不代表你的数据真的实时到了对端。中间层、负载均衡、CDN、甚至 Web 框架自己的输出缓冲,都可能把小块攒够一定体积再往下发。我开头那个进度条卡 99% 就是这么来的:应用确实每写一行就 flush,但网关默认开了响应缓冲,攒到接近完成时一次性吐出。
排查办法很土但有效:在服务端日志里打每一块的发出时间,再在客户端打接收时间,两边一对比就知道堵在哪一段。真要实时,除了代码里 flush,还得把网关的缓冲关掉,必要时加一个 X-Accel-Buffering: no 之类的头告诉中间层别攒。
第二处是块边界不等于业务边界。协议按字节切块,切在哪儿由实现决定,可能把你的一条 JSON 从中间劈开。客户端别假设"收一次就是一条完整消息",要么自己按分隔符或长度前缀组包,要么干脆用 SSE 这类已经约定好消息格式的方案。用 iter_content 读的时候按固定大小迭代,拿到的每一段也不是业务记录。
第三处是错误没法反悔。响应头一发出去,状态码就定了。如果流式输出到一半后端抛了异常,你没法再改成 500,只能把连接断掉,或者往流里塞一条约定好的错误事件让客户端自己识别。设计这类接口时得提前把"中途失败怎么表达"说清楚,否则前端只会看到一个莫名其妙的截断。
第四处是超时。流式响应往往持续时间长,网关的空闲超时、客户端的读超时都得单独调。两块数据之间隔太久,连接会被当成死的掐掉。心跳行或者注释行(SSE 里以冒号开头的那种)就是用来填空档的。
什么时候该用哪个
其实不用太纠结,多数情况下框架已经替你选好了。返回内存里的对象,框架算出长度填 Content-Length;返回生成器或流,自动切到分块。你要做的不是去指定头,而是别破坏这个判断:不要在流式接口里手动设长度,不要把生成器先 list() 成完整列表,别让压缩中间件在不知情的情况下改掉字节数。
需要断点续传、需要显示准确进度百分比、需要客户端提前知道文件大小,那就想办法把长度算出来,走 Content-Length。反过来,数据边产生边发、总大小不确定、或者希望首字节尽快到,就走分块,并且把上面那几处缓冲和超时的配置一起检查一遍。
我后来把那个导出接口的网关缓冲关掉,进度条立刻正常了。代码一行没改,改的是配置。这类问题最磨人的地方就在这儿:协议层面一切都对,数据也确实发出去了,只是被谁攒了一把。
本文为原创技术分享。文中的架构思路与排查方法仅供学习参考,请遵守相关平台的服务条款与法律法规。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/xiaobaishu_520/article/details/170177055



