# HTTP报文是什么样子的
30 秒速记
- 结构:起始行 + 头部 + 空行 + 正文(可选),每行以
CRLF(\r\n)结尾 - 请求起始行 = 方法 + 目标 + 版本,响应起始行 = 版本 + 状态码 + 原因短语
- 头部是
key: value,字段名不分大小写;空行是头部和正文的分界线,没有正文也不能省 HTTP/1.1请求必须带Host,缺了服务器回400- 头部太大服务器会拒:
Nginx默认单行8KB,超了回400或431;HTTP/2起报文变成二进制帧,不再是这种文本格式
HTTP/1.1 报文就是一段有固定格式的文本:第一行说做什么,接着若干行头部,一个空行,然后是正文。 请求的第一行是 GET /users HTTP/1.1 这种方法、目标、版本,响应的第一行是 HTTP/1.1 200 OK。头部一行一个 key: value,最后用一个空行告诉对方「头部结束了」,这个空行就算没有正文也必须有。自己用 socket 拼报文时最容易漏的就是这个空行和 Host,漏了服务器直接回 400。到了 HTTP/2,同样的信息被编成二进制帧,语义不变但格式完全不同。
面试官追问
追问 1用 Node 的 net 模块手拼一个最小的 HTTP 请求,怎么写?
require('node:net').connect(80, 'example.com', function () { this.write('GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n') }).on('data', d => console.log(d.toString().split('\r\n')[0])),第一行会打出 HTTP/1.1 200 OK。结尾那个 \r\n\r\n 就是最后一个头部的换行加上空行。
追问 2最后一个头后面忘了空行,直接接 JSON,服务器会怎样?
服务器会把 JSON 当成下一行头部来解析,{"a":1} 不是合法的 key: value,一般直接回 400。有的实现会一直等空行到超时,所以表现可能是请求挂起。
追问 3SDK 生成了 trace_id: abc,测试能收到,线上 Nginx 后面却拿不到,为什么?
Nginx 默认 underscores_in_headers off,带下划线的头会被悄悄丢掉,测试环境如果没过 Nginx 就看不出来。改成 trace-id 最省事,别指望每一层代理都去开这个配置。
追问 4页面请求被服务器回了 431,是什么情况?
431 Request Header Fields Too Large,请求头超限了,最常见的是 Cookie 越攒越大。先在 Network 面板看请求头多大,清理不必要的 Cookie 或者缩小作用域;Node 默认头部上限 16KB,Nginx 由 large_client_header_buffers 控制。
# 报文结构
30 秒速记
- 请求和响应结构一样:起始行 + 头部(合称「请求头 / 响应头」)+ 空行 +
body - 头部是
ASCII文本,肉眼能读;body可以是任意二进制 - 空行 = 连续两个
CRLF(字节0D 0A 0D 0A),解析器靠它从读头切到读正文 - 正文读多长看
Content-Length或Transfer-Encoding: chunked,长度写错会在长连接上串包 - 协议没规定头部上限,但服务器都有:
Nginx默认单行8KB,超了拒绝
HTTP 报文就像「大头儿子」:头部是大头,空行是脖子,正文是身体,没身体可以,没脖子不行。 解析器先一行行读头部,碰到连续两个 \r\n 就知道头部结束了,接下来读多少字节正文,看 Content-Length,或者 chunked 分块的结束标记。这里最容易出事的是长度:长连接上一条请求接一条请求,第一条的 Content-Length 写小了,多出来的字节就会被当成下一条请求的开头,引发串包。浏览器 GET 一般只有头没有正文,是正常的。
- 你也许对 TCP/UDP 的报文格式有所了解,拿 TCP 报文来举例,它在实际要传输的数据之前附加了一个 20 字节的头部数据,存储 TCP 协议必须的额外信息,例如发送方的端口号、接收方的端口号、包序号、标志位等等
- 有了这个附加的 TCP 头,数据包才能够正确传输,到了目的地后把头部去掉,就可以拿到真正的数据。

HTTP 协议也是与 TCP/UDP 类似,同样也需要在实际传输的数据前附加一些头数据,不过与 TCP/UDP 不同的是,它是一个“纯文本”的协议,所以头数据都是 ASCII 码的文本,可以很容易地用肉眼阅读,不用借助程序解析也能够看懂
HTTP 协议的请求报文和响应报文的结构基本相同,由三大部分组成
- 起始行(start line):描述请求或响应的基本信息;
- 头部字段集合(header):使用
key-value形式更详细地说明报文; - 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据
这其中前两部分起始行和头部字段经常又合称为“请求头”或“响应头”,消息正文又称为“实体”,但与“header”对应,很多时候就直接称为“body”。
HTTP 协议规定报文必须有 header,但可以没有 body,而且在 header 之后必须要有一个“空行”,也就是“CRLF”,十六进制的“0D0A”。
所以,一个完整的 HTTP 报文就像是下图的这个样子,注意在 header 和 body 之间有一个“空行”

说到这里,我不由得想起了一部老动画片《大头儿子和小头爸爸》,你看,HTTP 的报文结构像不像里面的“大头儿子”?
报文里的 header 就是“大头儿子”的“大头”,空行就是他的“脖子”,而后面的 body 部分就是他的身体了。
看一下我们之前用 Wireshark 抓的包吧。

在这个浏览器发出的请求报文里,第一行“GET / HTTP/1.1”就是请求行,而后面的“Host”“Connection”等等都属于 header,报文的最后是一个空白行结束,没有 body。
在很多时候,特别是浏览器发送 GET 请求的时候都是这样,HTTP 报文经常是只有 header 而没 body,相当于只发了一个超级“大头”过来,你可以想象的出来:每时每刻网络上都会有数不清的“大头儿子”在跑来跑去。
不过这个“大头”也不能太大,虽然 HTTP 协议对 header 的大小没有做限制,但各个 Web 服务器都不允许过大的请求头,因为头部太大可能会占用大量的服务器资源,影响运行效率。
原理拆解: 对 HTTP/1.1 而言,接收端先读取起始行,再逐行解析头部;每一行都以 CRLF 结束。当连续出现两个 CRLF,即字节序列 \r\n\r\n 时,表示头部结束。随后是否读取正文、读取多少字节,要结合请求方法、响应状态以及 Content-Length、Transfer-Encoding 等信息判断。因此,空行不只是排版习惯,而是解析器从元数据切换到消息内容的协议边界。
最小验证: 输入是一段带有 Content-Length: 5 的完整请求报文,要验证解析器能够分离起始行、头部和正文,并按声明长度取得 hello。
POST /messages HTTP/1.1
Host: example.com
Content-Type: text/plain; charset=utf-8
Content-Length: 5
hello
第一行说明操作类型和目标,随后三行提供协议元数据;空行之后的 hello 才是正文,其按 UTF-8 编码恰好占 5 字节。若删除空行,接收端可能把 hello 当成格式错误的头部;若把长度写成 4,本条消息只会消费四个正文八位组,剩余字节还可能干扰同一连接上的后续报文解析。
边界与排查: “头部可读”只适用于 HTTP/1.x 的报文表达,正文仍可能是图片、压缩数据等任意字节;HTTP/2 则使用二进制帧传输,不能直接套用逐行观察方式。工程上可在浏览器开发者工具或抓包工具中核对请求头、响应头与载荷,也可检查原始字节中是否存在 0D 0A 0D 0A。遇到 400、431 或连接复用后报文错位,应重点检查头部格式、服务器大小上限和正文长度声明是否一致。
面试官追问
追问 1抓包看到浏览器 GET 只有头部和一个空行,后端说请求被截断了,对吗?
不对,GET 本来就没有正文,空行之后就结束了。只要头部完整、以 \r\n\r\n 收尾、也没有 Content-Length,这就是一个完整请求。
追问 2长连接上第一条请求 Content-Length: 4,实际正文 5 字节,会发生什么?
服务器只读 4 个字节当第一条的正文,剩下那 1 个字节留在连接里,被当成第二条请求的开头,第二条请求的起始行就变成了乱码,直接 400。这类长度不一致在代理和源站解析不一致时还会被利用成请求走私攻击。
追问 3不知道正文多长(比如流式输出),怎么发?
用 Transfer-Encoding: chunked,每块前面写十六进制长度,最后发一个长度为 0 的块表示结束。Node 里不设 Content-Length 直接多次 res.write(),框架就自动用 chunked;AI 对话的流式响应常见这种写法。
追问 4想写个脚本按空行切分线上 h2 流量的报文,可行吗?
不行。HTTP/2
