报文永远是四段:首行、首部字段、空行、正文。 一个 GET 请求可以一个字节正文都没有,全是首部。
前五章一直在用首部,但没系统看过它。这一章把它一层层剥开——先看它在报文里的位置。
一、四段的固定顺序
第一段是首行。请求的首行是「方法 目标 版本」,响应的首行是「版本 状态码 原因短语」。 就这一行的差别,决定了整段报文是请求还是响应。 第二段是若干行首部字段,每行一个「名字: 值」。 第三段是一个空行——严格说就是一对 CRLF。它的唯一作用是把首部和正文切开。 少了它,整段报文就没法解析。 第四段是正文,可以为零字节。 所以解析器的写法永远是:读到空行为止算首部,剩下的全是正文。 这个「读到空行」的条件,也是所有报文解析漏洞的所在——攻击者发一个永远没有空行的报文, 就能把内存吃光。
二、实测的首部占比
Demo 里那个请求带了六个首部:Host、User-Agent、Accept、Accept-Language、Accept-Encoding、Referer。 总共 225 字节,正文 0 字节,首部占 100%。 响应那边正文只有 47 字节,首部 227 字节,占 82.8%。 这个比例不是特例。一个典型的 API 请求,几十字节的正文配上几百字节的首部是常态。 所以 HTTP/2 干的第一件大事就是压首部:HPACK 用静态表加哈夫曼编码, 能把这类首部压缩到原来的几分之一。 反过来说,传大文件时首部占比可以忽略不计。 所以「首部开销」这个问题只在小请求上成立——而小请求恰恰是数量最多的那批。
三、首部为什么不能省
有人会想:既然首部这么占地方,为什么不砍掉一些? 因为首部承担的是上下文。 同一个 URL,带上不同的 Cookie 是不同的人,带上不同的 Accept 要不同的格式, 带上不同的 If-None-Match 决定要不要传正文。砍掉首部,服务器就只剩一个光秃秃的路径,什么都不知道。 这也是为什么「用 URL 传状态」是个坏主意:你会在 URL 里塞进身份、格式、版本、语言, 最后得到一个又长又不可缓存、还会泄进日志和 Referer 的地址。
四、动手 Demo:把请求和响应拆到每一行
发一个带六个首部的 GET,再把请求和响应各自按「首行/首部字段/空行/正文」逐段打印, 顺便算出首部占了多少字节。
def dump(raw, tag):
lines = X.head_lines(raw)
total = len(raw)
hv = total - len(X.body_of(raw)) # 首部+首行的字节数
print(u'【%s】' % tag)
print(u' 首行', lines[0])
for ln in lines[1:]:
print(u' 首部字段', ln)
print(u' 正文 %d 字节' % len(X.body_of(raw)))
print(u' 合计 %d 字节,其中首部 %d 字节(%.1f%%)'
% (total, hv, 100.0 * hv / total))
实跑输出:
--------------------------------------------------------------------
把一段请求和一段响应拆到每一行
--------------------------------------------------------------------
【请求报文】
首行 GET /index.html HTTP/1.1
首部字段 Host: www.example.com
首部字段 User-Agent: Mozilla/5.0 demo
首部字段 Accept: text/html,application/xhtml+xml
首部字段 Accept-Language: zh-CN,zh;q=0.9
首部字段 Accept-Encoding: gzip, deflate
首部字段 Referer: http://www.example.com/list
空行 一个 CRLF,作用是把首部与正文切开
正文 0 字节
合计 225 字节,其中首部 225 字节(100.0%)
首部行数 7 行(含首行)
【响应报文】
首行 HTTP/1.1 200 OK
首部字段 Date: Sun, 25 Oct 2026 06:00:00 GMT
首部字段 Server: demo/1.0
首部字段 Content-Type: text/html; charset=utf-8
首部字段 Last-Modified: Sat, 24 Oct 2026 12:00:00 GMT
首部字段 Cache-Control: max-age=600
首部字段 Content-Length: 47
首部字段 Connection: close
空行 一个 CRLF,作用是把首部与正文切开
正文 47 字节
合计 274 字节,其中首部 227 字节(82.8%)
首部行数 8 行(含首行)
请求首部占比 100.0%
响应首部占比 82.8%
响应正文 47 字节
报文永远是四段:首行、首部字段、空行、正文。
请求的首行是「方法 目标 版本」,响应的首行是「版本 状态码 原因短语」。
注意那个 GET 请求:正文 0 字节,225 字节全是首部。
GET 本来就不带正文,所以它的首部占比是百分之百;
响应里正文只有 47 字节,首部还是占了八成。
短报文里首部才是大头——这也解释了为什么压首部(HTTP/2)能省那么多。
请求:7 行(1 行首行加 6 行首部),合计 225 字节,正文 0 字节,首部占 100%。
响应:8 行,合计 274 字节,正文 47 字节,首部 227 字节占 82.8%。
响应里多出三个请求没有的首部:Content-Type、Content-Length、
Connection——它们是描述正文和连接的。
那个 Content-Length: 47 和实际正文 47 字节对得上。
这不是巧合,是解析器唯一的依据:不知道正文多长,就没法判断报文在哪结束。
30 秒实验
- 把请求改成 POST 并带 20 字节正文,重跑,看首部占比掉到多少。
- 把
User-Agent改成一个 200 字符的长串,看首部占比变成多少。 - 想一想:如果一段报文永远不出现空行,服务端应该怎么防御?
常见误区
- 「GET 请求也有正文」——规范允许但不建议,实际上绝大多数 GET 没有正文。
- 「空行可有可无」——它是一段报文的必要分隔,没有它就没法解析。
- 「首行也是首部」——两者格式不同,首行没有冒号。
自测三问(点开对答案)
1. 请求和响应的首行区别在哪? —— 请求是「方法 目标 版本」,响应是「版本 状态码 原因短语」。 2. 首部和正文靠什么分开? —— 一个空行,也就是一对 CRLF。 3. 为什么短报文里首部占比那么高? —— 因为首部是固定的上下文开销,正文越小,它占比越大。位置说清了。接下来看首部到底在传什么信息,为什么它不能省。
- ☐ (本节完,下一节 6.2.1 HTTP首部字段传递重要信息)