你有没有遇到过这种问题:两个字符串看起来都是 5 个字符,为什么一个响应的 Content-Length 是 5,另一个却是 11?
const english = 'hello'
const chinese = '你好呀!!'
console.log(english.length) // 5
console.log(chinese.length) // 5
console.log(Buffer.byteLength(english, 'utf8')) // 5
console.log(Buffer.byteLength(chinese, 'utf8')) // 11还有一个更反直觉的现象:如果服务端要边查数据库边返回结果,它连最终会产生多少数据都不知道,为什么还能先把前几行发给浏览器?接收方又凭什么判断“这一条响应已经结束,后面的字节属于下一条响应”?
这两个问题看起来一个属于字符编码,一个属于流式传输,底层却都在追问同一件事:HTTP 传的到底是什么字节,这串字节又在哪里结束。

早期资料常把这一整套概念统称为“HTTP 实体”。这个叫法并非完全无法理解,但很容易把资源、表示、内容和线上消息体揉成一团。现代 HTTP 规范更愿意分别讨论 representation(表示)、content(内容) 和具体版本里的消息成帧。我们也沿用这组更精确的词。
访问 /users/42 时,服务器并没有把“42 号用户”这个资源本身塞进网络。资源是一个抽象目标,它可以是用户、订单、当前天气,也可以是某次计算的结果。服务器真正发出的,是这个资源在某一时刻、以某一种格式呈现出来的表示。
同一个资源可以有多种表示。例如,同一个订单既可以返回 JSON,也可以返回便于人工查看的 HTML;同一张图片可以有 AVIF 和 PNG 两种格式;同一篇文档可以有中文和英文版本。它们指向同一个资源,却不是同一串字节。
一个表示由两部分组成:
Content-Type、Content-Encoding 和 Content-Language。这里还要把几个相近的词分开:
Transfer-Encoding: chunked,消息体还包含块大小、换行和终止块;把分块编码解开以后,才得到 content。
这组区分不是术语洁癖。代理可以去掉 HTTP/1.1 的分块外壳,再用 HTTP/2 的 DATA 帧转发,但只要没有改变内容和表示元数据,应用看到的含义就没有变。反过来,代理如果解压了 Content-Encoding: gzip,却没同步修改表示元数据和长度,含义就被破坏了。
请求内容的含义由方法决定。PUT 请求里的表示通常描述资源希望变成的状态;POST 请求里的内容通常是一份等待处理的数据。响应也要结合请求方法和状态码解释:200 GET 常携带资源的当前表示,206 Partial Content 只携带选中表示的一部分,错误响应的内容则可能描述错误本身。
所以,判断消息边界解决的是“收到多少字节”,判断消息语义解决的是“这些字节表示什么”。先把前者读完整,应用才有资格讨论后者。
拿到一段 7b 22 6e 61 6d 65 ... 的字节,接收方并不能只靠外观断定它是 JSON、普通文本还是某种二进制格式。Content-Type 给出的媒体类型就是这段数据的说明书。
Content-Type: application/json
Content-Type: text/html; charset=utf-8
Content-Type: image/png
Content-Type: multipart/form-data; boundary=----WebFormBoundary7MA4YWxk媒体类型的主体通常写成 主类型/子类型。text/html 表示文本类的 HTML,image/png 表示图片类的 PNG,application/json 表示 JSON 数据。类型后面还可以跟参数,参数的意义由对应媒体类型定义。
在请求里,Content-Type 描述“我现在送给你的内容是什么”。例如客户端提交 JSON 时,服务器据此选择 JSON 解析器;如果接口只接受 JSON,却收到 text/plain,服务器可以返回 415 Unsupported Media Type。
在响应里,它描述“我现在返回给你的内容是什么”。浏览器会根据媒体类型决定解析、渲染或下载。文件扩展名只是 URL 的一部分,不能代替正确的 Content-Type。
这个区别很容易和 Accept 搞混:
Content-Type 描述本次消息里已经存在的内容。Accept 表达发送方希望对方返回哪些媒体类型。charset 指明文本字节应按什么字符编码解码。text/html; charset=utf-8 的意思不是“这是一段 UTF-8”,而是“这是一段 HTML,并且其中的字符按 UTF-8 编码”。顺序上先识别媒体类型,再按它的参数解释数据。
不要再依赖“HTTP 文本默认都是 ISO-8859-1”这类旧印象。字符集默认值由具体媒体类型和使用环境决定。自己定义的文本接口最好明确约定 UTF-8,并确保实际编码、Content-Type 与字节长度一致。JSON 在开放系统中应使用 UTF-8,不要随意声明一个非 UTF-8 的 charset,指望所有接收方都跟着切换。
multipart/form-data 的 boundary 参数也经常被误解。它只是在一份 multipart 内容内部划分各个 part,并不是整条 HTTP 消息的结束标志。外层消息边界仍由 HTTP 版本自己的成帧规则决定。

如果响应没有 Content-Type,接收方可能退回到通用二进制类型,也可能检查内容后猜测类型。浏览器的 MIME sniffing 能修补部分错误配置,却也可能把本应作为普通文本或下载文件处理的用户内容当成 HTML 或脚本。
后端应该根据服务端实际生成或验证过的内容设置媒体类型,而不是照抄上传者提供的文件名和声明。对不应被浏览器猜测为脚本或样式的响应,可以配合 X-Content-Type-Options: nosniff 收紧行为。这样做的代价是配置错误会更快暴露,但这通常比让浏览器“猜对就算了”更容易维护。
现在假设 /report/weekly 同时提供 HTML 和 JSON。客户端发出:
GET /report/weekly HTTP/1.1
Host: example.test
Accept: application/json, text/html;q=0.8
Accept-Encoding: br, gzip;q=0.7
Accept-Language: zh-CN, en;q=0.5没有写 q 的偏好权重默认为 1,q=0 表示不可接受。上面的客户端优先要 JSON、Brotli 压缩和中文,但这些值是偏好,不是对服务器的远程控制。服务器会在自己真正拥有的表示中选择;没有合适结果时,它可以返回 406 Not Acceptable,也可以在协议允许的范围内忽略偏好,返回它认为更合适的内容。
响应把最终选择说清楚:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: zh-CN
Content-Encoding: br
Vary: Accept, Accept-Encoding, Accept-LanguageVary 是这里容易漏掉的一环。缓存如果只按 URL 保存响应,先到的中文 JSON 可能被错误地发给后来请求英文 HTML 的用户。Vary 告诉缓存:这些请求头参与了表示选择,缓存键也要按它们区分。

主动协商减少了一次往返,客户端第一次请求就可能拿到合适结果。代价也很直接:服务器选择逻辑更复杂,请求头暴露更多客户端特征,共享缓存会产生更多变体。对下载格式、界面语言这类用户会明确选择的内容,使用不同 URL 或保留显式切换入口,往往比完全依赖隐式协商更可控。
Content-Length 是十进制的非负整数,单位是 octet,也就是 8 位字节。它不数“字符”,不数 JavaScript 字符串的 length,也不数对象里有几个字段。
如果一份表示作为消息内容发送,Content-Length 描述的是这份表示数据实际包含的字节数。对于 HTTP/1.1,它还能让接收方从消息体中精确读出这么多字节,随后安全地把同一连接上的后续字节当成下一条消息。
可以把常见处理顺序写成:
业务数据
→ 按 Content-Type 序列化并编码字符
→ 按 Content-Encoding 压缩
→ 得到表示数据与 HTTP content
→ HTTP/1.1 如有需要再应用 Transfer-Encoding
→ 得到线上 message body因此,响应带有 Content-Encoding: gzip 时,Content-Length 是 gzip 后表示数据的字节数,不是解压后的文本长度。使用 Transfer-Encoding: chunked 时,发送方不能再用 Content-Length 给同一条 HTTP/1.1 消息定界;块大小、块扩展和 CRLF 也不计入表示数据长度。
下面这段 Node.js 代码同时计算字符数、UTF-8 字节数和 gzip 后的字节数:
import { gzipSync } from 'node:zlib'
const body = JSON.stringify({ message: '你好,HTTP' })
const utf8Body = Buffer.from(body, 'utf8')
const gzipBody = gzipSync(utf8Body)
console.log({
javascriptLength: body.length,
utf8Bytes: utf8Body.byteLength,
gzipBytes: gzipBody.byteLength,
})真实后端通常让框架在序列化和压缩完成后设置长度。若必须手工设置,应先得到最终要作为 content 发送的字节缓冲区,再读取它的字节数。不要用 body.length 猜,也不要在压缩前写死长度。

边界判断不能只搜一个请求头。对 HEAD 的响应永远不包含内容,但服务器可以发送 Content-Length,表示“如果这是对应的 GET,内容会有多少字节”。304 Not Modified 也可以携带原本 200 OK 表示的长度。它们用于描述所选表示,不是在宣布后面真有这么多字节。
相反,1xx、204 和成功建立隧道的 CONNECT 响应有更严格的规则。解析器必须先看请求方法和响应状态,再决定后面的字段是否能参与定界。
长度写小了,接收方会把剩余字节误认为下一条消息;长度写大了,接收方会继续等待不存在的数据,直到超时或连接关闭。一个看似普通的数字,实际上参与了连接上所有后续消息的切分。
HTTP/1.1 可以在一条持久 TCP 连接上依次传多条消息。TCP 只提供连续字节流,不认识“第一个响应”和“第二个响应”。HTTP 解析器必须依照固定优先级确定边界,不能随便挑一个看起来合理的字段。
可以把响应与请求的主要规则压缩成下面这条判断链:
HEAD 响应以及 1xx、204、304 响应在头部结束处就结束,没有消息体。成功的 CONNECT 随后进入隧道,也不按普通 HTTP 消息体解析。Transfer-Encoding 和 Content-Length 是歧义消息。协议规定传输编码覆盖长度,但这类消息应按错误处理;转发前至少必须移除 Content-Length 并正确处理传输编码。Transfer-Encoding 的最终编码是 chunked 时,读到终止块和尾部字段结束处为止。Transfer-Encoding、最终编码却不是 chunked 时,只能读到服务器关闭连接;请求出现这种结构则无法可靠定界,服务器应返回 400 Bad Request 并关闭连接。Transfer-Encoding、Content-Length 却非法或互相冲突时,边界不可恢复,应拒绝消息,而不是猜一个长度。Transfer-Encoding 且 Content-Length 有效时,读取指定数量的字节。
最后一种“连接关闭即结束”保留了兼容性,却分不清服务器正常发完后关闭,还是网络中途断了。它也让连接无法继续复用。因此只要可能,服务端都应使用明确的长度或协议提供的流结束信号。
下面这种消息不能靠“取第一个”“取最后一个”或“取较小值”来修补:
Content-Length: 12
Content-Length: 37不同接收方如果采用不同策略,边界就会分裂。有效实现应拒绝互相冲突的值。完全相同的重复值,例如合并后形成 Content-Length: 42, 42,接收方可以拒绝,也可以规范化为单个 42;不能把这个例外扩大到不同数字。
长度解析还要防整数溢出。协议没有给内容大小设置一个全局上限,应用却必须有自己的请求体上限、读取超时和整数范围检查。否则一个极大的十进制值即使语法上像数字,也可能在转换时绕回小值或耗尽资源。
动态报表、代理转发和逐步生成的页面经常无法预先知道总长度。把全部内容先缓存在内存里可以计算 Content-Length,代价是首字节更晚、内存占用更高。直接靠关闭连接表示结束又失去可靠的完整性判断和连接复用。
HTTP/1.1 的 Transfer-Encoding: chunked 走了第三条路:每一块先声明自己的字节长度,最后用一个长度为 0 的块宣布整条内容结束。总长度未知,局部边界仍然明确。
下面是一个包含两个数据块和一个 trailer 的完整示意。代码块里的 \r\n 表示实际线路上的回车换行,不是六个可见字符:
HTTP/1.1 200 OK\r\n
Content-Type: text/plain; charset=utf-8\r\n
Transfer-Encoding: chunked\r\n
Trailer: X-Stream-Result\r\n
\r\n
5\r\n
Hello\r\n
6\r\n
World\r\n
0\r\n
X-Stream-Result: complete\r\n
\r\n5 和 6 是十六进制块大小,分别表示后面有 5 和 6 个字节。接收方读到 0 块后,不再等待数据块;它继续读取可选的 trailer section,直到空行,整条消息才结束。拼出的 content 是 Hello World,不包含块大小、CRLF、终止块和 trailer。

一次 write() 不保证在线路上对应一个 chunk,一个 chunk 也不保证到应用时只触发一次读取。服务器、运行库和代理都可能合并或拆分块。应用若要发送 JSON Lines、Server-Sent Events 或自定义记录,必须在内容内部定义自己的分隔规则,不能借 HTTP chunk 边界偷懒。
块大小按该块数据的字节数计算,仍然不是字符数。块扩展可以携带每块的附加信息,但中间代理可能解码或重新分块,因此普通应用不应依赖它们实现端到端语义。
有些值只有内容发送完才能算出来,例如完整性校验、流式处理结果或发送统计。trailer 可以把它们放在内容之后。发送方通常会先用 Trailer 头声明可能出现的字段名,让接收方提前准备。
不过 trailer 可能在协议转换或代理处理中被丢弃,客户端表示能接收 trailer,也不等于它理解某个具体字段。路由、认证、消息边界、内容格式这类必须在读取内容前知道的信息不能拖到最后。业务正确性也不能依赖一个“丢了就无法完成”的 trailer。
这两个名字很像,但生命周期完全不同。
Content-Type: application/json
Content-Encoding: gzip
Transfer-Encoding: chunked这组字段表达的处理顺序是:先把业务数据编码成 JSON,再对 JSON 字节应用 gzip,最后为了在 HTTP/1.1 上流式发送,把压缩后的字节做 chunked 成帧。接收方反向处理:先解析并去掉 chunked 外壳,再根据 Content-Encoding 解压,最后按 Content-Type 解析 JSON。
Content-Encoding 是所选表示的属性,常用于端到端压缩。缓存可能分别保存 gzip、br 和未压缩的不同表示,强校验器和字节范围也对应编码后的表示数据。
Transfer-Encoding 是当前 HTTP/1.1 消息的属性,属于逐跳机制。中间代理可以解开这一跳的 chunked,再为下一跳重新分块,或者转成 HTTP/2 的 DATA 帧。代理每改变一次传输编码,都必须同步更新相关字段。
这里还有两个容易混淆的请求头:
Accept-Encoding 协商客户端能接受哪些内容编码,通常对应响应里的 Content-Encoding。TE 协商当前连接能接受哪些传输编码或 trailer。它不是 Transfer-Encoding 的缩写写法,语义和使用位置都不同。把 gzip 放进 Content-Encoding 还是 Transfer-Encoding,字节变换算法可能一样,缓存、代理和最终接收方对它的处理责任却完全不同。实际 Web 服务几乎总是用 Content-Encoding 表示压缩,使用 chunked 解决 HTTP/1.1 的未知长度边界。
到了 HTTP/2,消息不再靠 HTTP/1.1 文本行里的十六进制块大小定界。一个消息由承载头部的 HEADERS 帧、零个或多个 DATA 帧,以及可选的尾部 HEADERS 帧组成。最后一个相关帧上的 END_STREAM 标志结束这条流。
HTTP/3 的形状相似:头部和 trailer 使用 HEADERS 帧,内容使用 DATA 帧。不同之处是它运行在 QUIC 上,一次请求与响应占用对应的 QUIC 流,流关闭表示最终消息结束。
所以 HTTP/2、HTTP/3 仍然可以边生成边发送,也仍然不必预先知道总长度,只是不再使用 Transfer-Encoding: chunked。HTTP/3 明确不定义传输编码,HTTP/2 也禁止 chunked。请求里的 TE 在这两个版本中只有 trailers 这一种允许的值;注意这是 TE,不是 Transfer-Encoding。
帧本身已经带长度,流也有结束信号,因此 HTTP/2、HTTP/3 不靠 Content-Length 寻找消息终点。这个字段仍可出现,用于提前展示进度、保留跨版本语义,以及检查内容是否完整。
一旦发送了 Content-Length,它就必须等于组成内容的 DATA 数据总长度。HTTP/2 的填充字节不属于内容长度;HTTP/3 则直接比较收到的 DATA 帧数据长度之和。不匹配的消息是 malformed message,不能假装“帧能结束,所以数字错了也无所谓”。
trailer 也没有消失。HTTP/2 用结束流的 HEADERS 帧承载尾部字段;HTTP/3 在所有 DATA 之后发送一个尾部 HEADERS 帧,然后关闭流。它们不需要 HTTP/1.1 的 0 块。

想象一条请求依次经过 CDN、反向代理和应用服务器。如果最前面的代理按 Content-Length 判断请求结束,后面的应用却按 Transfer-Encoding: chunked 判断结束,同一串字节就会被切成两种结果。前一层认为仍属于请求体的部分,后一层可能把它当成下一条请求。
这就是请求走私最核心的条件:链路上的两个解析器对消息边界意见不一致。攻击者不需要打破 TCP,只要构造一个有歧义的 HTTP/1.1 消息,就可能把隐藏请求越过前置安全检查,污染其他用户请求的响应,或绕过路由和缓存规则。
Content-Length 与 Transfer-Encoding 同时出现是典型信号,但问题不只这一种:互相冲突的重复长度、非最终位置的 chunked、异常空白、字段换行、超大数值和协议转换时错误保留逐跳字段,都可能让实现走向不同分支。
后端链路可以按下面的原则收紧:
Content-Length 和 Transfer-Encoding 的请求优先拒绝,并关闭对应 HTTP/1.1 连接,不要把“容错”留给下游。Content-Length 必须全部合法且数值相同,否则拒绝。不要让不同组件分别采用第一个值和最后一个值。chunked 必须是最终编码;无法可靠确定长度的请求不能继续转发。允许宽松输入的好处是兼容一些历史客户端,代价是每多一种容错分支,就多一种前后端理解不一致的可能。面对消息边界,统一拒绝通常比“猜发送方想表达什么”更安全。
以后再看到一个响应体,可以按固定顺序检查:
Content-Type 和参数告诉我怎样解释原始数据?Content-Encoding 是否要求先解压或解码表示?Content-Length,它是否和最终表示字节或 DATA 数据总量一致?字符编码决定文本如何变成字节,内容编码决定表示怎样变换,消息成帧决定这一串字节在哪里结束。把这三层分开,你才能解释为什么两个等长字符串会有不同的 Content-Length,也能解释为什么未知总长度的服务端仍然可以马上开始发送。
下一次学习缓存、范围请求或流式接口时,这条链路还会继续出现:缓存保存的是哪个表示,范围截取的是编码前还是编码后的字节,流结束又由谁确认。边界不是底层实现顺手处理的小细节,而是后端正确解释每一条 HTTP 消息的起点。