上一篇把 URL、报文三段式和 method/status 的骨架立起来了。但只靠那条骨架,HTTP 还跑不起来——因为一条报文光有 method 和 path,服务器还是不知道:你发的 body 是 JSON 还是表单?多长?要不要压缩?这次请求从哪个页面跳过来?浏览器能接受哪种语言?
这些信息全部塞在 header 里。很多人把 header 当成「请求参数的另一种写法」,写代码时随手加几个自定义字段就完事。这个认知会埋下两类坑:一是用错了标准 header(比如拿 Referer 拼 URL、拿 Content-Type 当备注),二是不知道 header 在传输过程中会被中间件改写——你以为端到端的内容,其实在第一个代理那里就被剥掉了。
这一篇把 header 当成一张请求的元信息契约来看,而不是参数表。
header 的格式:为什么是 字段名: 值 这种朴素结构
一个 header 行就是 字段名: 值,用 \r\n 结尾。比如:
Host: rubyfun.cn
Content-Type: application/json
Accept-Language: zh-CN,zh;q=0.9
这套格式朴素到让人怀疑它的设计是不是太简陋了。但它和报文三段式是同一个历史约束的产物:解析器要能逐行扫描。一个 TCP 字节流过来,读到冒号前是字段名,冒号后是值,遇到空行结束 header 区。不需要引号、不需要嵌套、不需要转义——这是 1991 年那个年代能负担得起的复杂度。
这种朴素也带来三个实际后果,写代码时经常踩:
- 字段名不区分大小写:
content-type和Content-Type是同一个字段,RFC 9110 明确规定。但约定俗成都用首字母大写的驼峰式。 - 冒号后允许任意空格:
Content-Type:application/json和Content-Type: application/json等价,但前者可读性差,生产代码都加空格。 - 同一个字段名可以出现多次:多个
Set-Cookie、多个Accept-Encoding都合法,规范里允许用逗号合并(Accept-Encoding: gzip, br)。
header 分五类:别把它们当成一锅粥
header 有几十个标准字段,硬背没意义。按职责分成五类,立刻清晰:
上面这张图把常用的 header 按职责归位。几个值得记住的归属:
- 通用类(请求响应都有):
Cache-Control、Connection、Date、Transfer-Encoding。 - 请求类:
Host、User-Agent、Accept、Authorization、Referer、Cookie。 - 响应类:
Server、Set-Cookie、Location、WWW-Authenticate、Retry-After。 - 表示类(描述 body):
Content-Type、Content-Length、Content-Encoding、Content-Language。
最容易混的是「表示类」。Content-Type 不是请求才有的——响应也用它告诉客户端「我返回的是 JSON」。它的统一职责是描述 body 这个东西长什么样,不论谁发的。RFC 9110 把这类 header 从「实体(entity)」改名叫「表示(representation)」,就是因为请求和响应的 body 都是「资源的一种表示」。
Host 头:HTTP/1.1 强制要求的「虚拟主机开关」
Host 头值得单独拎出来讲,因为它解决了一个 HTTP/1.0 时代的大问题。
HTTP/1.0 时代,一个 IP 只能挂一个域名。服务器收到请求,看 TCP 连接的 IP 就知道该返回哪个站点。但域名越来越多、IPv4 地址不够用,于是出现了虚拟主机(virtual hosting):一个 IP 挂几十个域名,服务器怎么区分?
答案就是 Host 头。客户端在请求里带上 Host: rubyfun.cn,服务器靠这个字段路由到正确的站点。HTTP/1.1 把 Host 列为唯一一个强制要求的请求头——所有 HTTP/1.1 请求必须带它,否则返回 400。
GET / HTTP/1.1
Host: rubyfun.cn ← 没有这行,直接 400 Bad Request
这也解释了为什么你用 curl 测试时,明明 IP 对了却返回别的网站——因为 Host 没写对,被路由到了默认站点。CDN、反向代理、云负载均衡全部靠 Host 头做路由,它是整个现代 Web 托管的基础。
Content-Type 和 Accept:一次内容协商的拉锯
header 里最容易让人困惑的是这一对:Content-Type(我发的是什么)和 Accept(我能收什么)。它们配合完成内容协商。
一个完整的协商过程是这样:
- 客户端发请求:
Accept: application/json(我想要 JSON)。 - 客户端如果有 body:
Content-Type: application/json(我发的是 JSON)。 - 服务器看
Accept,决定返回什么格式:Content-Type: application/json(我给你的就是 JSON)。
这里有个反直觉的点:Accept 是客户端的「期望」,但服务器不一定满足。客户端说我要 JSON,服务器完全可以返回 HTML(比如它只支持 HTML),只要状态码正常。客户端得自己检查 Content-Type,别假设服务器一定听话。
Accept 还支持**质量因子(q 值)**表达优先级:Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 表示「最想要中文,其次英文」。这个 q 不是质量评分,是 0 到 1 的权重,服务器据此选最合适的。Accept-Encoding、Accept-Charset 都用同一套 q 值语法。
端到端 vs 逐跳:header 在中间会被改写
这是 header 最容易被忽略、却最容易出 bug 的一条性质:不是所有 header 都会一路传到目标服务器。
RFC 把 header 分成两类:
- 端到端(end-to-end):绝大多数 header,要从客户端一路传到最终服务器,中间代理只能读取不能删除。
- 逐跳(hop-by-hop):只在两个直接相邻的节点之间有效,经过代理时必须被剥掉。
逐跳 header 只有少数几个,最常见的是 Connection、Keep-Alive、Transfer-Encoding、TE、Upgrade,以及 Connection 头里显式列出的自定义字段。
为什么要分这两类?因为有些信息只对「当前这一跳」有意义。比如 Connection: keep-alive 是告诉对面的代理「这条 TCP 连接别关」,但下一个代理需不需要复用是它自己的事,跟客户端无关。所以代理转发请求时,要把 Connection 和它列出的字段全部删掉,再决定自己用什么连接策略。
这个机制解释了一个线上怪象:你在客户端自定义了一个 header 放进 Connection 列表,结果服务器死活收不到——因为它在第一个代理就被当逐跳字段剥掉了。自定义 header 默认是端到端的,除非你显式把它加进 Connection 列表,那就变成逐跳了,只有第一个中间件能看到。
取舍与边界
header 这套设计有几个绕不开的代价:
- ASCII 文本限制了表达力:header 只能传文本,传二进制要 base64 编码(膨胀 33%),传结构化数据要么塞 body 要么用逗号分隔的列表。这正是 HTTP/2 引入二进制帧头部压缩(HPACK)的动因之一。
- 历史 header 命名混乱:
Referer是历史遗留的拼写错误(少个 e),X-前缀曾经是自定义字段的约定,后来 RFC 6648 又说不建议再用X-了。这些历史包袱让 header 看起来像一堆没整理过的字段。 - header 注入是真实的安全风险:如果服务器把用户输入直接拼进响应 header(比如
Location: <用户输入>),而用户输入里有\r\n,就能注入新的 header 行甚至拆出一段假的 body。这就是 header injection 漏洞,防御方式是永远对拼进 header 的内容做\r\n过滤。
收束:header 是契约,不是注释
把 header 当参数表,你会用得很随意;把 header 当契约,你会开始问每一个字段「它表达了什么意图、它的生命周期到哪里、中间件会不会改它」。
这三问(机制是什么 → 生命周期到哪 → 会不会被改写)是后面讲缓存、内容协商、代理时的通用排查路径。下一篇讲 method 的语义边界——为什么 GET 和 POST 的差别不在「有没有 body」,而在「这次操作的语义是什么」。
关于十三Tech
我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。
我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。
如果你想继续跟完这套「图解 HTTP」,欢迎关注公众号 「十三Tech」。后续会按 URL 与报文、连接与传输、缓存与协商、安全与边界、HTTP/2 与 HTTP/3 这条线更新。

