Summary
前面几篇我们一直在讲"怎么把一堆字节可靠地送到对面"。TCP 把这件事做完了,但它送过去的只是一串没有含义的字节流,至于这些字节怎么表示,TCP 完全不关心。
应用层协议要补的就是这个:给字节流赋予含义。HTTP 定义了一套双方都认的规矩——请求长什么样、响应长什么样、哪些字段表示什么意思。
HTTP 的模型简单到近乎粗暴:客户端问一句,服务端答一句,完事。没有服务端主动推送,没有复杂的会话协商。就是这么一个简单模型,撑起了整个 Web。
HTTP1.1
HTTP1.1是目前互联网上运行的最广泛的一个版本。
HTTP 的报文:纯文本的一问一答
HTTP/1.1 的报文是纯文本的(这也是它和 HTTP/2 最直观的区别——后者是二进制)。用 telnet 到 80 端口手敲都能发一个请求出去,这在协议里很少见。
请求报文
请求报文由请求行(方法 + 路径 + 版本)、头部(header)、空行(是分割header和body的关键)、请求主体(body),例如:
GET /api/quote?pair=ETH-USDC HTTP/1.1
Host: api.example.com
User-Agent: Go-http-client/1.1
Accept: application/json
Connection: keep-alive
{"slippage": 0.005}
响应报文
响应报文由状态行(版本 + 状态码 + 原因短语)、头部、空行、主体组成,例如:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 27
Date: Sat, 16 Aug 2026 10:00:00 GMT
{"price":"3200.5","fee":"3"}
那个空行看着不起眼,但它是解析的命门。TCP 是字节流,不保留消息边界,接收方必须自己知道"这条消息到哪结束"。HTTP 的答案是分两步:
- 头部的边界靠空行(准确说是连续两个
\r\n)——一路读到空行,头部就完了。 - 主体的边界靠头部里的
Content-Length——头部里写明"接下来有 27 个字节",照着读满就行。
长度事先不知道怎么办
Content-Length 有个前提:你得事先知道主体有多长。但很多时候不知道——比如响应是实时生成的(流式输出、大文件边读边发、AI 逐字返回)。
HTTP/1.1 的解法是分块传输编码(chunked transfer encoding):不给总长度,改成一块一块发,每块前面写自己这块的长度,最后用一个长度为 0 的块表示结束。
HTTP/1.1 200 OK
Transfer-Encoding: chunked ← 声明用分块,此时不能有 Content-Length
1a\r\n ← 这块 0x1a = 26 字节
{"price":"3200.5","fee":3}\r\n
10\r\n ← 这块 0x10 = 16 字节
{"status":"ok"}\r\n
0\r\n ← 长度 0,传输结束
\r\n
这就像寄一批货,你不知道总共几箱,那就每箱贴个标签写明重量,最后寄一张空标签表示"没了"。
它是流式响应的基础:SSE(Server-Sent Events)、大文件下载、日志实时推送,底下都是 chunked。Go 里你只要不设 Content-Length 就自动启用:
func handler(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
for i := 0; i < 5; i++ {
fmt.Fprintf(w, "chunk %d\n", i)
flusher.Flush() // 关键:立刻把这一块发出去,不等缓冲区满
time.Sleep(time.Second)
}
}
Flush() 那行是重点——不调它,数据会攒在缓冲区里,客户端半天收不到,流式就变成了一次性。
方法:幂等性才是重点
HTTP 定义了一组方法(method),表示"你想对这个资源做什么"。日常用的就几个:
| 方法 | 语义 | 安全? | 幂等? | 可缓存? |
|---|---|---|---|---|
| GET | 获取资源 | ✅ | ✅ | ✅ |
| HEAD | 只要头部,不要主体 | ✅ | ✅ | ✅ |
| POST | 提交数据 / 创建 | ❌ | ❌ | 一般否 |
| PUT | 整体替换资源 | ❌ | ✅ | ❌ |
| PATCH | 局部更新 | ❌ | ❌ | ❌ |
| DELETE | 删除资源 | ❌ | ✅ | ❌ |
| OPTIONS | 询问支持什么 | ✅ | ✅ | ❌ |
表里有两个属性值得展开,它们比方法本身重要得多:
安全(safe)= 不会改变服务端状态。 GET 就该只是"读一下",不该有副作用。违反这条会出真事:早年有网站把删除操作做成 GET /delete?id=1,结果搜索引擎爬虫一路爬过去,把数据全删了——爬虫以为自己只是在"读页面"。
幂等(idempotent)= 执行一次和执行多次,结果一样。 。
幂等性直接决定了"这个请求能不能安全重试",这是工程上最实用的一条:
- GET / PUT / DELETE 幂等 → 超时了可以放心重发。
PUT /user/1 {name:"张三"}执行十次,结果都是"用户 1 叫张三";DELETE /user/1删十次,结果都是"用户 1 不存在了"。 - POST 不幂等 → 不能盲目重试。
POST /orders发十次,可能真创建十个订单。
网络超时只告诉你"我不知道",不告诉你"失败了"。所以能不能重试,完全取决于操作是否幂等。这就是为什么各种 HTTP 客户端库的自动重试默认只对 GET 等幂等方法开启——Go 的 http.Transport 也是这个策略。
实践提醒:如果你的 POST 接口必须支持重试(跨链场景很常见),就得自己加幂等键——客户端生成一个唯一 ID 放在头里(如
Idempotency-Key),服务端拿它去重。Stripe 的支付 API 就是这么做的,这也是金融类接口的标准做法。
状态码
状态码三位数,第一位决定了类别,这个分类法本身就是设计:
1xx 信息 —— 收到了,继续 (少见:100 Continue、101 Switching Protocols)
2xx 成功 —— 你要的事办成了
3xx 重定向 —— 东西不在这,去别处找
4xx 客户端错 —— 你的问题(请求有毛病)
5xx 服务端错 —— 我的问题(我这儿炸了)
4xx 和 5xx 的分界线是"这是谁的锅",这条在排查线上问题时价值极大:看到 4xx 涨了,先查调用方发了什么;看到 5xx 涨了,先查自己的服务。监控告警也该分开设——把两者混在一个"错误率"指标里,会掩盖真正的问题。
几个必须认识的:
| 码 | 含义 | 什么时候会遇到 |
|---|---|---|
| 200 OK | 成功 | — |
| 201 Created | 创建成功 | POST 建资源后的规范返回 |
| 204 No Content | 成功但无主体 | DELETE 成功、只需状态不需数据 |
| 301 / 308 | 永久重定向 | 换域名。浏览器会缓存,而且很难清掉——用之前想清楚 |
| 302 / 307 | 临时重定向 | 登录跳转等临时场景 |
| 304 Not Modified | 没变,用你的缓存 | 协商缓存命中(见第 6 节) |
| 400 Bad Request | 请求格式错 | 参数校验没过 |
| 401 Unauthorized | 未认证 | 没登录 / token 无效 |
| 403 Forbidden | 已认证但没权限 | 登录了但不该访问 |
| 404 Not Found | 资源不存在 | — |
| 429 Too Many Requests | 被限流了 | 调第三方 API 时的常客,配 Retry-After 头 |
| 500 Internal Server Error | 服务端异常 | 兜底的 panic |
| 502 Bad Gateway | 网关拿到了无效响应 | 上游服务挂了/连不上 |
| 503 Service Unavailable | 服务不可用 | 过载、维护中 |
| 504 Gateway Timeout | 网关等上游超时 | 上游太慢 |
401 vs 403 是最常被搞混的一对:401 是"我不知道你是谁"(去登录),403 是"我知道你是谁,但你不配"(别白费劲了)。返回错了会让客户端做出错误的重试行为——收到 401 会去刷新 token 重试,收到 403 就该直接放弃。
502/503/504 这三兄弟在你的方向上会天天见:调 RPC 节点、调 DEX 接口时,502 通常是节点挂了(该换一个节点),504 是节点太慢(该调超时或换节点),503 是对方在限流或维护。分清它们才能写出正确的故障转移逻辑——502/504 值得换节点重试,而 400 这种客户端错误重试一万次也没用。
无状态:HTTP 最大的设计取舍
HTTP 是无状态(stateless)的:服务端处理完一个请求就把它忘得干干净净,下一个请求来的时候完全不记得你是谁。
打个比方:HTTP 服务端像一个每次见面都失忆的客服。你上一句刚说完"我要查订单",他转头就忘了,你下一句必须重新说"我是张三,我要查订单 123"。
这听起来很蠢,但它是刻意的设计,而且是 Web 能扩展到今天这个规模的关键原因:
因为服务端不记得任何事,所以任何一台服务器都能处理任何一个请求。 你可以随便加机器、随便挂负载均衡、随便重启某台机器——用户完全无感。如果服务端要记住每个用户的状态,那这个用户的后续请求就必须回到同一台机器上(所谓"会话粘滞"),扩容、容灾立刻变成噩梦。
这是一个非常典型的取舍:牺牲单次请求的效率(每次都要重复带上下文),换取整个系统的水平扩展能力。 你在做分布式系统设计时会反复遇到同一个权衡。
但业务确实需要状态:三种补救
问题是,业务不可能真的无状态——总得知道"谁登录了"。HTTP 的解法是:状态不由协议管,由应用自己想办法带上。
① Cookie + Session(服务端存状态)
服务端把用户状态存起来(内存/Redis),给客户端一个 session ID 当凭证;客户端每次请求把这个 ID 带上。
首次登录:
服务端 → Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=Lax
后续请求:
客户端 → Cookie: sessionid=abc123
服务端拿 abc123 去 Redis 查:"哦,这是张三"
Cookie 就是存包处的号码牌:牌子本身没有任何信息,真正的东西在柜台后面存着,你拿牌子去换。
三个安全属性必须会写:
HttpOnly:JavaScript 读不到这个 Cookie。防 XSS 偷 token。Secure:只在 HTTPS 下发送。防明文泄露。SameSite:限制跨站请求携带。防 CSRF。
② Token / JWT(客户端存状态)
服务端不存了,把用户信息签名后交给客户端自己保管。服务端收到时验签名,签名对就信。
这是"用计算换存储":服务端省掉了会话存储(彻底无状态,扩容随便加机器),代价是每次要验签,而且token 一旦发出去就很难提前作废(要撤销就得再引入一个黑名单,又变回有状态了)。
③ 直接放在 URL / 请求头里
API Key、Authorization: Bearer xxx。你调 RPC 节点、调 DEX API 用的基本都是这种——服务端之间的调用没有浏览器,也就没有 Cookie 那套机制。
连接管理:从"每次重连"到"管道化的失败"
这一节是1.1的核心,它解释了 HTTP/1.1 的性能天花板在哪。
HTTP/1.0:一个请求一条连接
早期 HTTP 是这样的:发一个请求 → 建一条 TCP 连接 → 拿到响应 → 关闭连接。下一个请求?再建一条。
代价有多大,算一笔账就知道:
每个请求的固定开销:
TCP 三次握手 1 RTT
(HTTPS 还要 TLS 握手 1~2 RTT)
发请求+收响应 1 RTT
四次挥手 (还要留下 TIME_WAIT)
一个网页有 100 个资源(图片、CSS、JS),就要建 100 条连接、握手 100 次。在 RTT 100ms 的网络上,光握手就浪费 10 秒。
而且每条新连接的拥塞窗口都从头慢启动。短连接意味着你永远在"起步阶段"就结束了,永远吃不到全速。
keep-alive:HTTP/1.1 的第一个大改进
HTTP/1.1 把持久连接(persistent connection / keep-alive) 变成了默认行为:一条 TCP 连接处理完一个请求后不关闭,接着跑下一个请求。
HTTP/1.0(短连接):
建连 → 请求1 → 响应1 → 断开
建连 → 请求2 → 响应2 → 断开 每次都要握手
HTTP/1.1(keep-alive):
建连 → 请求1 → 响应1 → 请求2 → 响应2 → 请求3 → … → 断开
└──────── 一条连接全包了 ────────┘
这就是"打电话时不挂断,接着聊下一件事"。 收益是双重的:省掉了反复握手,而且拥塞窗口一直是"热"的。
想关掉就显式声明 Connection: close。
管道化(Pipelining):一个失败的好主意
keep-alive 解决了"反复建连",但还剩一个问题:同一条连接上,必须等上一个响应完全收到,才能发下一个请求。
串行的 keep-alive:
请求1 ────▶
◀──── 响应1
请求2 ────▶ ← 必须等响应1 回来才能发
◀──── 响应2
每个请求还是要花一个完整的 RTT,N 个请求就是 N 个 RTT
HTTP/1.1 提出了 管道化(pipelining):不等响应,把多个请求一口气全发出去,服务端按顺序返回响应。
管道化:
请求1 请求2 请求3 ────▶ 一口气发完
◀──── 响应1 响应2 响应3 按序返回
理论上 N 个请求只要 1 个 RTT
理论上很美好,实践中彻底失败了——所有主流浏览器最终都默认关闭甚至移除了它。
队头阻塞:管道化失败的根本原因
管道化有一条死规定:响应必须严格按请求的顺序返回。
为什么必须按序?因为 HTTP/1.1 的响应里没有任何标识说明"我是回答哪个请求的"——报文格式里就没这个字段。客户端只能靠顺序来对号入座:第一个响应对应第一个请求,第二个对应第二个。
于是灾难来了:
客户端一口气发出: 请求1(一个巨大的报表,要算 5 秒)
请求2(一个小图标,1ms 就好了)
请求3(一段 CSS,1ms 就好了)
服务端: 响应1 还在算… ← 卡住 5 秒
响应2 早就好了,但【不能先发】,必须等响应1
响应3 同上
结果:后面两个明明 1ms 就能返回,却被迫等了 5 秒
这就是队头阻塞(Head-of-Line Blocking):队首那个慢的,把后面所有快的全堵住了。
生活里的对应场景太熟悉了:超市结账排队,前面那位在翻包找零钱、或者要退换货,你手里就一瓶水,但你只能干等。 你的东西再简单,也快不过队首那个人。
还有一个致命问题:如果响应 1 在传输中出了任何问题,整条流水线的对应关系就全乱了——客户端没法知道自己手里这个响应到底是回答谁的。中间还有大量代理服务器实现不正确,进一步放大了风险。正确性风险 + 收益不确定,这就是管道化被放弃的原因。
两层队头阻塞:一定要分清
这里必须点破一个极易混淆的点。“队头阻塞"这个词在 HTTP 语境下指两件不同层次的事:
① HTTP 层的队头阻塞
成因:HTTP/1.1 的响应必须按序返回,慢的请求堵住快的
解法:HTTP/2 的多路复用(给每个请求加个流 ID,响应就能乱序返回了)
② TCP 层的队头阻塞
成因:TCP 必须按序交付,丢一个包,后面已到达的包也得在内核里等
解法:HTTP/3 换用 UDP(QUIC 自己管理多个独立的流)
理解这两层,整条 HTTP 演进史就串起来了:
- HTTP/2 用流 ID + 二进制分帧,解决了 ①。但它跑在一条 TCP 连接上,② 反而更严重了——以前 6 条连接,一条卡了还有 5 条能跑;现在全挤在一条连接上,一丢包所有流一起卡。这正是之前讲UDP时提到的"HTTP/2 在弱网下可能比 HTTP/1.1 还慢"的原因。
- HTTP/3 换到 UDP 上,才把 ② 也解决了。
这条脉络是网络面试里区分"背过"和"真懂"的分水岭。
HTTP/1.1 时代的三个 workaround
既然管道化不能用,那个年代只能靠"堆连接"绕:
① 多开并发连接。 浏览器对同一个域名最多开 6 条 TCP 连接(各浏览器略有差异)。为什么限制?为了不让一个网站把用户带宽和服务器连接数吃光——这是浏览器替整个网络做的自律。
② 域名分片(domain sharding)。 既然每域名限 6 条,那就多搞几个域名:img1.example.com、img2.example.com……6 条变 18 条。典型的"上有政策下有对策”。代价是每个新域名都要重新做 DNS 解析、TCP 握手、TLS 握手。
③ 资源合并。 既然每个请求都贵,那就减少请求数:CSS/JS 打包成一个大文件、小图标拼成一张 CSS Sprite 雪碧图、小图片用 base64 内联进 HTML。
这三招在 HTTP/2 之后全都变成了反模式——HTTP/2 一条连接就能跑几百个并发流,分片反而增加了握手成本;文件合并会破坏缓存粒度(改一个小图标,整张雪碧图的缓存全失效)。如果你在维护老项目,看到这些优化手段,要知道它们是为 HTTP/1.1 而生的历史包袱。
缓存:HTTP 性能优化的第一杠杆
最快的请求是根本不发出去的请求。 HTTP 的缓存机制分两级,理解这两级的区别很实用。
强缓存:压根不问服务器
服务端在响应里说明"这东西你可以自己存着用多久":
Cache-Control: max-age=3600 ← 一小时内直接用本地的,别来问我
Cache-Control: no-cache ← 可以存,但每次用之前必须来问一下
Cache-Control: no-store ← 一个字节都别存(敏感数据用这个)
Cache-Control: public / private ← 能否被 CDN 等中间节点缓存
在有效期内,客户端根本不发请求,直接用本地副本。浏览器开发者工具里那个 from disk cache / from memory cache 就是它。
注意 no-cache 这个名字起得极其失败——它不是"不缓存",而是"缓存了但每次都要验证"。真正的"不缓存"是 no-store。这个坑坑了无数人。
老的
Expires头指定一个绝对时间点,问题是它依赖客户端的本地时钟,用户手动改了时间就失效。Cache-Control: max-age用相对时长,不受时钟影响,优先级也更高。新代码只用Cache-Control。
协商缓存:问一句"还是原来那个吗"
强缓存过期后,不必直接重新下载——先问一句:
① 基于内容指纹(ETag,推荐)
服务端首次返回: ETag: "a1b2c3" ← 内容的指纹
客户端下次询问: If-None-Match: "a1b2c3"
服务端比对: 没变 → 304 Not Modified(无主体,极小)
变了 → 200 + 新内容 + 新 ETag
② 基于修改时间(Last-Modified,较弱)
服务端首次返回: Last-Modified: Sat, 16 Aug 2026 10:00:00 GMT
客户端下次询问: If-Modified-Since: Sat, 16 Aug 2026 10:00:00 GMT
304 响应没有主体,通常只有几百字节——省掉的是整个文件的传输量。
为什么 ETag 比 Last-Modified 好? 三个原因:
- 精度:
Last-Modified只精确到秒,一秒内的多次修改分辨不出来。 - 准确性:文件被重新生成但内容没变(比如重新构建部署),修改时间变了,
Last-Modified会误判成"变了"从而白传一次;ETag 基于内容,不会误判。 - 适用性:动态生成的内容压根没有"修改时间"这回事,但可以算哈希。
两级缓存的关系用买菜类比就很清楚:强缓存是"冰箱里有,直接用,不去超市";协商缓存是"去超市问一句’还是原来那批货吗’,是的话空手回来(304),不是才拎新的"。
HTTP2.0
HTTP1.1时代在同一条连接上,请求必须排队,响应必须按序返回。 队首那个慢的,把后面所有快的全堵住。管道化想解决它,但因为"响应没有任何标识说明自己在回答哪个请求",只能靠顺序对号入座,最终失败了。
那个年代只能靠"堆连接"绕:每个域名开 6 条 TCP、域名分片、资源合并。这些手段治标不治本,而且每一条新连接都要付出握手和慢启动的代价。
HTTP/2(2015 年,基于 Google 的 SPDY)从根上解决了它,思路一句话就能说清:
给每个请求发一个编号,响应带上编号回来。
有了编号,响应就不必再按顺序返回了——谁先好谁先回,客户端看编号对号入座。队首那个算 5 秒的报表,再也堵不住后面 1ms 就能返回的小图标。
听起来简单到不可思议。但为了塞进这个编号,HTTP 不得不做一个根本性的改变:从纯文本协议变成二进制协议。
二进制分帧层:一切改变的基础
HTTP/2 最核心的改变,是在应用层和传输层之间插入了一个二进制分帧层(binary framing layer)。
HTTP/1.1 HTTP/2
┌──────────────┐ ┌──────────────┐
│ HTTP 语义 │ │ HTTP 语义 │ ← 方法/状态码/头部,语义完全没变!
│ (纯文本报文) │ ├──────────────┤
│ │ │ 二进制分帧层 │ ← 新增的这一层
├──────────────┤ ├──────────────┤
│ TCP │ │ TCP │
└──────────────┘ └──────────────┘
这里必须先明确一件事:HTTP/2 没有改变 HTTP 的语义。 GET 还是 GET,200 还是 200,Content-Type 还是 Content-Type。它改的只是"这些东西怎么在网络上编码和传输"。所以你的业务代码基本不用动——这也是 HTTP/2 能被迅速推广的关键原因。
为什么必须改成二进制
HTTP/1.1 是纯文本的,你可以 telnet 上去手敲。这看着很友好,但代价不小:
- 解析慢且易错:要处理各种空格、大小写、换行符变体、可选的空白。文本解析在高并发下是实打实的 CPU 开销。
- 边界模糊:靠空行和
Content-Length划分,规则琐碎,历史上因此出过不少安全漏洞(比如请求走私 HTTP Request Smuggling——利用前后端代理对Content-Length和Transfer-Encoding的解析差异偷渡请求)。 - 没地方放元数据:想加个"流编号"?文本格式里没有合适的位置,加了还要考虑和老实现的兼容。
二进制帧解决了全部三点:固定 9 字节的帧头,字段位置和长度都是定死的,解析就是按偏移量读几个整数,又快又不可能有歧义,而且想加什么字段就有地方加。
HTTP/2 帧结构(帧头固定 9 字节):
┌────────────────────────────────────┐
│ Length (24 bit) │ ← 载荷长度,直接告诉你读多少
├────────────┬───────────────────────┤
│ Type (8) │ Flags (8) │ ← 帧类型 + 标志位
├────────────┴───────────────────────┤
│R│ Stream Identifier (31) │ ← 流 ID!整个 HTTP/2 的关键
├────────────────────────────────────┤
│ Frame Payload │
└────────────────────────────────────┘
代价当然也有:你再也不能 telnet 上去手敲了,抓包也看不懂原始字节,必须靠工具(Wireshark 会自动解析,curl --http2 -v 能看到解码后的内容)。可读性换性能和扩展性,这笔交易 HTTP/2 认了。
三个层次:流、消息、帧
理解 HTTP/2 必须分清这三个概念,它们是嵌套关系:
一条 TCP 连接
└── 流(Stream):一次请求-响应的完整交互,有唯一的流 ID
└── 消息(Message):一个完整的请求或响应
└── 帧(Frame):传输的最小单位,带流 ID
- 帧(Frame):最小传输单位。每个帧都在帧头里带着自己属于哪个流。
- 消息(Message):一个完整的请求或响应,由若干帧组成(通常是 1 个 HEADERS 帧 + N 个 DATA 帧)。
- 流(Stream):一次完整的请求-响应交互。一条 TCP 连接上可以并存成百上千个流,它们互相独立。
用收发快递来理解这三层:一条 TCP 连接是一辆货车;每个流是一份订单;每个帧是一个箱子,箱子上贴着订单号。一辆车上可以混装很多份订单的箱子,到了目的地按订单号分拣——这就是多路复用的全部秘密。
多路复用:队头阻塞的解药
有了帧头里的流 ID,不同请求的帧就可以在同一条连接上任意交错发送:
HTTP/1.1(必须排队,响应必须按序):
连接1: [───── 请求1 ─────][──── 响应1(慢) ────][请求2][响应2]
↑ 后面的全被堵住
HTTP/2(帧交错,响应可乱序):
连接1: [H1][H2][H3][D2][D2][D3][D1][D1][D3]…
└流1 └流2 └流3 ← 每个帧带流ID,收到后按ID重组
流2、流3 先返回完,流1 慢慢来,互不影响
这就叫多路复用(multiplexing):一条连接,并行跑多个请求,谁先好谁先回。
带来的连锁好处:
① 应用层队头阻塞消失了。 慢请求不再堵住快请求。
② 连接数从 6 条降到 1 条。 HTTP1.1的三个 workaround 全部作废。
③ 拥塞窗口能真正"热"起来。 以前 6 条连接各自慢启动,现在 1 条连接持续使用,窗口能一路涨上去、维持在高位。这个收益经常被低估,但在长会话里非常可观。
④ TCP 握手和 TLS 握手只做一次。 省下 6 倍的握手开销。
流的状态与 ID 分配
流 ID 有两条规则值得知道:
- 客户端发起的流用奇数(1, 3, 5…),服务端发起的用偶数(2, 4, 6…)。这样双方各管一半,不会撞号,不需要协商。
- 流 ID 只增不减。用完就作废,不复用。31 位用完了(约 21 亿),就得重建连接——长连接服务需要注意这个上限,不过实践中很少触及。
流有自己的生命周期状态机(idle → open → half-closed → closed),和TCP 状态机是同一种思路,只是轻量得多——因为流是纯逻辑概念,创建和销毁不需要任何网络往返。这一点很关键:HTTP/2 里"新开一个流"的成本几乎为零,而 HTTP/1.1 里"新开一个连接"要付出一个 RTT 加慢启动。
流量控制:HTTP/2 自己也有一套
既然一条 TCP 连接上跑几百个流,那就有个新问题:某个流疯狂发数据,会不会把整条连接的带宽吃光,饿死别的流?
所以 HTTP/2 在流级别和连接级别各自实现了一套流量控制,机制和TCP 窗口很像:接收方通过 WINDOW_UPDATE 帧告诉对方"我这个流还能接多少"。
这就出现了一个两层流量控制的局面:
应用 ← HTTP/2 流量控制(每个流一个窗口 + 整条连接一个窗口)
← TCP 流量控制(rwnd,整条连接)
← 网络
为什么 TCP 已经有流量控制了,HTTP/2 还要再做一层?因为TCP 的窗口是整条连接共享的,它无法区分里面的不同流。TCP 只知道"这条连接的数据太多了",不知道"是流 5 在刷屏"。要做到流级别的公平,只能在懂"流"这个概念的那一层做——也就是 HTTP/2 自己。
这里有个实践坑:HTTP/2 的默认初始窗口只有 64KB,在高带宽长距离链路上会成为瓶颈(窗口小于 BDP(带宽时延积) 就跑不满)。传输大文件慢的时候,记得检查这个参数。
HPACK:头部压缩
多路复用解决了并发,但 HTTP/2 还盯上了另一块浪费:头部。
问题:头部的重复量大得惊人
HTTP 是无状态的,所以每个请求都得把完整的上下文重新带一遍:User-Agent、Accept、Cookie、Authorization…… 一个典型的请求头部有 500~800 字节,带上大 Cookie 能到几 KB。
关键在于:同一个页面的几十上百个请求,头部几乎完全相同。你请求 100 张图片,User-Agent 一模一样地传了 100 遍。
而且 HTTP/1.1 的主体可以用 gzip 压缩,头部却不能——头部是明文传输的,一个字节都没省。在移动网络的上行带宽下(上行通常比下行窄得多),这个浪费非常刺眼。
HPACK 的三板斧
HPACK(RFC 7541) 用三个手段解决它:
① 静态表:把最常见的头部编成号
协议预定义了一张 61 项的表,把最常用的头部键值对编号:
索引 头部名 值
2 :method GET
3 :method POST
8 :status 200
16 accept-encoding gzip, deflate
...
于是 :method: GET 这 12 个字符,在网络上只需要传一个字节 2。
② 动态表:双方各存一份"暗号本"
静态表只有 61 项,覆盖不了 Cookie、自定义头这些。所以 HPACK 让通信双方各自维护一张动态表:第一次出现的头部完整传输,同时双方都把它加进各自的表里并编上号;之后再出现,就只传那个号。
这就像两个人约定暗号:第一次说"我们把’今天天气不错’叫做 7 号",之后只要说"7",对方就懂。
第 1 个请求: cookie: session=abc...(完整传输,双方都记为索引 62)
第 2 个请求: 62 ← 只传一个索引!
第 3 个请求: 62
...
对 Cookie 这种又长又每次都带的头部,压缩率经常能到 90% 以上。
③ 赫夫曼编码:剩下的也要压
必须完整传输的部分(比如第一次出现的值),用一张针对 HTTP 头部字符频率优化的静态赫夫曼表编码,再省 20%~30%。
一个必须知道的副作用:有状态
动态表带来一个重要后果:HTTP/2 的连接变成有状态的了。
因为双方的动态表必须严格保持同步——你说"62 号",我得知道 62 号是什么。这意味着:
- 帧的顺序不能乱。HEADERS 帧必须按发送顺序处理,否则两边的表会错位。
- 一条连接的头部压缩上下文不能跨连接复用。换条连接就得从头建表。
- 中间代理必须完整解压再重压,不能简单转发。
这也解释了一个安全事件:HPACK 的前身 SPDY 用 gzip 压缩头部,结果被 CRIME 攻击攻破——攻击者通过观察压缩后的长度变化,能一个字符一个字符地猜出 Cookie 的内容。HPACK 特意设计成不使用通用压缩算法(不做跨字段的字典匹配),就是为了防这类攻击。压缩和安全在这里是有张力的,值得记住。
流优先级与服务端推送:两个"看起来很美"的特性
HTTP/2 还带了两个特性,但它们的实际命运截然不同,很值得对照着看。
流优先级:想法对,实现太复杂
既然一条连接上跑几百个流,那先传哪个就有讲究了。浏览器加载页面时,HTML 和关键 CSS 显然比页脚的图片重要——先传关键资源,页面能更早渲染。
HTTP/2 设计了一套依赖树 + 权重的优先级模型:每个流可以声明"我依赖某个流"、“我的权重是多少”,服务端据此分配带宽。
结果是:太复杂,几乎没人正确实现。 很多服务端干脆忽略优先级信息,浏览器之间的实现也不一致。RFC 9113(2022 年的 HTTP/2 修订版)干脆把这套优先级机制标记为废弃(deprecated),改用更简单的方案(HTTP/3 用了一套全新的、简化的优先级信号)。
这是一个很好的工程教训:一个理论上正确但实现复杂度过高的设计,在现实中会因为"没人正确实现"而失效。 协议设计和 API 设计一样,复杂度本身就是成本。
服务端推送:好心办坏事
服务端推送(Server Push) 允许服务端在客户端还没请求的时候,主动把资源推过去。理论上很妙:客户端请求 index.html,服务端知道它待会儿肯定要 style.css,那就一起推过去,省掉一个往返。
但它失败了,而且失败得很彻底——Chrome 在 2022 年默认禁用了它,HTTP/3 直接没有这个特性。
失败原因很实在:
- 服务端不知道客户端有没有缓存。 推过去的资源客户端可能早就有了,纯属浪费带宽——而且是浪费在最宝贵的首屏时间上。
- 和优先级冲突。 推送占用了带宽,可能挤掉客户端真正急需的资源,反而拖慢首屏。
- 有更简单的替代品。
<link rel="preload">这种"提示"机制,让客户端自己决定要不要提前拉,把决定权留给了掌握缓存信息的一方——这才是正确的做法。后来的103 Early Hints状态码也是这个思路。
教训是:不要替对方做他比你更有信息优势的决定。 服务端不知道客户端缓存里有什么,那就别自作主张推,给个提示让他自己判断。这个原则在做 API 设计、缓存策略、跨链数据同步时都适用。
HTTP/2 没能解决的:TCP 层队头阻塞
HTTP/2 的多路复用只解决了 HTTP 层的那一层。底下那层还在,而且——反而变严重了。
HTTP/1.1:6 条 TCP 连接
连接1 [流A] ← 丢包!只有连接1 上的请求受影响
连接2 [流B] ← 照常跑
连接3 [流C] ← 照常跑
... 丢包的影响被分散到 1/6
HTTP/2:1 条 TCP 连接,所有流都在上面
连接1 [流A][流B][流C][流D]…
↑ 任何一个包丢了,TCP 必须等它重传才能按序交付
→ 【所有流一起卡住】,哪怕它们的数据早就到了
TCP 必须按序把数据交给应用,第 3 个包丢了,第 4、5、6 个包哪怕躺在内核缓冲区里也不能交付。
问题在于:TCP 完全不知道"流"这个概念。在它眼里这就是一条普通的字节流,它没法说"这几个字节是流 B 的,和丢掉的流 A 无关,可以先交上去"。它只能一视同仁地全部卡住。
于是出现了一个反直觉的结论:
在丢包率高的网络(弱网、移动网络)下,HTTP/2 可能比 HTTP/1.1 更慢。
因为 HTTP/1.1 的 6 条连接是天然的容错分散:一条卡了还有 5 条能跑。而 HTTP/2 把所有鸡蛋放进了一个篮子——一丢包,全军覆没。
这不是理论推演,是实测反复验证过的现象。丢包率超过 2% 左右时,HTTP/2 的优势就开始被这个问题吃掉。
这个死结在 TCP 上无解。 因为"按序交付"是 TCP 的核心承诺,你不可能既要 TCP 的可靠有序,又要它对某些字节网开一面。要解决它,只能换掉 TCP——这就是 HTTP/3 要做的事,也是它为什么必须建在 UDP 上。
HTTP3
HTTP/2 把所有请求塞进一条 TCP 连接。任何一个包丢了,TCP 为了保证"按序交付"这个承诺,会把整条连接上所有流的数据全部卡住——哪怕那些数据早就到了、和丢的包毫无关系。
而 TCP 完全不知道"流"这个概念。在它眼里这就是一条没有结构的字节流,它没有任何办法说"这几个字节属于流 B,和丢掉的流 A 无关,可以先交上去"。
问题的根子不在 HTTP,在 TCP 的设计承诺里。你不可能既要 TCP 的"可靠有序",又要它对某些字节网开一面——“有序"是它对上层的核心契约,一旦破例,整个 TCP 就不成立了。
所以要解决它,只有一条路:别用 TCP 了。
这就是 QUIC 的由来。而 HTTP/3 就是"跑在 QUIC 上的 HTTP,也是它的第一个用户”——先把这个关系理清楚,后面就不会乱:
HTTP/2 HTTP/3
┌──────────────┐ ┌──────────────┐
│ HTTP 语义 │ │ HTTP 语义 │ ← 语义还是没变!GET 还是 GET
├──────────────┤ ├──────────────┤
│ 二进制分帧 │ │ QUIC │ ← 流、可靠传输、拥塞控制、加密
├──────────────┤ │ (含 TLS1.3) │ 全在这一层
│ TLS │ ├──────────────┤
├──────────────┤ │ UDP │ ← 换掉了 TCP!
│ TCP │ ├──────────────┤
└──────────────┘ └──────────────┘
为什么建在 UDP 上,而不是造一个新的传输层协议
这是学 QUIC 时第一个该问的问题,而答案很现实,甚至有点无奈。
按理说,既然 TCP 不合适,那就设计一个全新的传输层协议(和 TCP、UDP 平级,直接跑在 IP 上)——这才是"正确"的做法。事实上早就有人这么干过,比如 SCTP(它甚至天生就支持多流,正好能解决队头阻塞)。
但 SCTP 基本没人用。为什么?因为它在互联网上根本传不过去。
原因叫协议僵化(ossification):
互联网上遍布着 NAT、防火墙、运营商的中间设备。这些设备只认识 TCP(协议号 6)和 UDP(协议号 17)——它们是十几年前做的,固件早就不更新了。你发一个协议号 132(SCTP)的包出去,大概率在半路就被某个设备当成"不认识的东西"丢掉了。
实测数据:新传输层协议在公网上的可达率低到无法用于生产。
这就是现实:互联网的传输层已经被"焊死"了,你只能在 TCP 和 UDP 之间选。
TCP 不行(队头阻塞),那就只剩 UDP。而 UDP 恰好是完美的载体:
- 它足够"薄"——UDP 只加了端口和校验和,几乎不干预你,把所有决定权留给上层。
- 它到处都通——所有中间设备都认识它。
- 它在用户态——不需要等操作系统升级内核。
所以 QUIC 的定位是:把 TCP 该有的东西(连接、可靠、有序、拥塞控制)全部在 UDP 之上重新实现一遍,但这次按自己的想法来设计。
用一个类比:这就像高速公路只允许轿车和卡车两种车型通行。你想造一辆全新的交通工具,发现根本上不了路——于是你干脆买一辆卡车,把车厢完全掏空重新装修,只保留外壳。 UDP 就是那个外壳。
QUIC 的核心突破:真正独立的流
QUIC 最重要的设计,也是它存在的全部理由:流的独立性做到了传输层。
对比:同样是"多个流",差别在哪
HTTP/2 over TCP:
┌─────────────────────────────────────┐
│ 一条 TCP 字节流 │ ← TCP 只看到一条流,不知道里面有结构
│ [流A][流B][流C][流A][流B][流C]… │
└─────────────────────────────────────┘
↑ 丢一个包 → TCP 卡住整条 → 所有流全停
HTTP/3 over QUIC:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 流 A │ │ 流 B │ │ 流 C │ ← QUIC 自己管理每个流的
│(独立序号)│ │(独立序号)│ │(独立序号)│ 序号、确认、重传
└──────────┘ └──────────┘ └──────────┘
↑ 流A 丢包 流B、流C 完全不受影响,照常交付
关键差别:QUIC 把"可靠有序"这个保证的粒度,从"整条连接"降到了"每个流"。
具体做法是:每个流有自己独立的序号空间和确认机制。流 A 的第 3 个包丢了,QUIC 只会阻塞流 A 的后续数据,流 B、C 的数据该交付就交付——因为 QUIC 知道它们是独立的,而 TCP 不知道。
这就是那句关键的话:“多路复用必须做在懂’流’这个概念的那一层。” HTTP/2 把多路复用做在了 HTTP 层,底下的 TCP 不懂,所以漏了;QUIC 把它下沉到了传输层,自己既管流又管可靠性,才真正做到。
流内仍然有序,流间完全独立
需要精确一点:QUIC 保证每个流内部是有序可靠的(和 TCP 一样),但不保证流与流之间的顺序。这正是我们想要的——你本来就不关心图片 A 和图片 B 谁先到。
QUIC 也支持单向流和双向流,由发起方和方向决定流 ID 的低两位:
流 ID 的低 2 位编码了两件事:
0x0 客户端发起的双向流
0x1 服务端发起的双向流
0x2 客户端发起的单向流
0x3 服务端发起的单向流
单向流很有用:比如 HTTP/3 用它传输 QPACK 的编码器/解码器指令、传输控制帧——这些是"我告诉你就行,不需要你回应"的场景,用单向流比双向流省一半资源。
连接迁移:一个 TCP 永远做不到的能力
这是 QUIC 的第二个杀手锏,而且是用户能直接感知到的改进。
TCP 的死穴:连接被 IP 地址绑死了
一条 TCP 连接由四元组((源 IP, 源端口, 目标 IP, 目标端口))来唯一标识,这意味着:只要你的 IP 变了,这条连接就必然死掉,没有任何商量余地。
而 IP 变化在移动时代是家常便饭:
- 你在家用 WiFi 看视频,出门走到街上,手机切到 4G → IP 变了 → 所有连接断开 → 视频卡住转圈 → 重新握手 → 重新慢启动。
- 从公司 WiFi 走到电梯里再出来,同理。
- NAT 设备的映射超时了,重新分配了一个端口,同样断连。
这个体验问题的根源,是 TCP 把"连接的身份"和"网络位置"绑在了一起。
QUIC 的解法:给连接一个不依赖位置的身份
QUIC 引入了连接 ID(Connection ID):一个和 IP、端口完全无关的随机标识符。
TCP: 连接 = (源IP, 源端口, 目标IP, 目标端口) ← 地址变 = 换了个人 = 连接死
QUIC: 连接 = Connection ID ← 地址随便变,ID 不变就还是你
这就像身份证和居住地址的关系:你搬家了,地址变了,但身份证号没变,你还是你。 TCP 用"地址"当身份,搬家就等于换了个人;QUIC 用"身份证号"当身份,搬到哪都认。
于是:手机从 WiFi 切到 4G,QUIC 连接不断。 数据包从新 IP 发出来,服务端一看连接 ID 还是那个,直接继续用原来的连接——不用重新握手,不用重新慢启动,视频不卡。
顺带解决了一个安全问题:连接 ID 可以有多个、可以轮换,这样网络中间人也不容易靠 IP 追踪一个用户的连接。而 TCP 的四元组是明晃晃暴露的。
这也是 NAT 超时问题的解药
NAT 会为每条连接维护映射,而这个映射有超时时间(UDP 的映射通常只有几十秒到几分钟)。长连接空闲久了,映射被回收,连接就废了——这是长连接服务要发心跳的原因之一。
QUIC 下即使 NAT 换了映射(源端口变了),连接 ID 还在,连接就还活着。这让它在移动网络下比 TCP 健壮得多。
握手:把三个往返压成一个,甚至零个
QUIC 的第三个大改进在建连速度上。一个HTTPS的请求,首先要建立一个TCP的连接,进行三次握手,握手之后还要记你行TLS握手,至少两个RTT,在 RTT = 100ms 的跨境链路上,光是建连就要 200ms,用户什么都还没看到。
QUIC:把 TLS 焊进传输层
QUIC 做了一件很激进的事:它不是"跑在 TLS 上",而是把 TLS 1.3 直接集成进了自己的握手过程。传输层的参数协商和密钥协商在同一批数据包里完成。
QUIC 首次连接: 1 RTT ← 传输参数 + 密钥一起协商完
QUIC 重连(已有会话票据): 0 RTT ← 第一个包就能带应用数据!
0-RTT 的意思是:客户端发出的第一个数据包里就直接带着 HTTP 请求。 不用等任何往返,服务端收到就能处理。这对短连接、高频请求的场景是革命性的。
另一个附带结果:QUIC 是强制加密的。它没有"明文模式"——连它的大部分包头字段都是加密的。这有两个好处:一是安全性默认拉满,二是防止协议僵化(中间设备看不到内部字段,就没法基于它们做假设,将来协议演进就不会被卡住)。这是从 TCP 的教训里学来的:TCP 的头部太透明,中间设备各种窥探和干预,导致 TCP 后来几乎无法演进。
0-RTT 的代价:重放攻击
0-RTT 不是免费的午餐,它有一个真实的安全弱点必须知道。
0-RTT 数据是用上次连接留下的密钥加密的,而服务端在收到它时还没有和客户端做任何新鲜的交互——它没法确认"这个请求是刚发的,还是攻击者录下来重放的"。
攻击者在网络中间录下一个 0-RTT 包
↓
过一会儿把它原样再发一遍
↓
服务端无法分辨,可能重复执行这个请求
所以规范明确要求:0-RTT 只能用于幂等的请求。
GET 可以走 0-RTT(重放一次也只是多读一遍),POST 绝对不行(重放可能变成重复下单、重复转账)。
实际实现里,浏览器和 HTTP 库会自动把非幂等请求排除在 0-RTT 之外。 但如果你自己写 QUIC 客户端,这是必须自己处理的。
用户态实现:优点和代价
QUIC 跑在 UDP 上,意味着它的全部逻辑都在用户态(应用程序里),而不是像 TCP 那样在内核里。这带来一组鲜明的优缺点。
优点:终于能快速迭代了
TCP 的最大困境是升级极慢。 它在内核里,你想用一个新的拥塞控制算法,得等:操作系统厂商实现 → 发布新版本 → 用户升级系统。这个周期是以年计的,而且服务器上的老内核可能永远不会升级。
QUIC 在用户态,升级它就是升级一个库。想换 BBR?改个配置重新部署就行。想试验新算法?自己实现一个。Google 能在几周内给全球用户推送新的拥塞控制策略并 A/B 测试——这在 TCP 时代不可想象。
这一点的长远意义,可能比队头阻塞更大:它让传输层重新变成了一个可以持续演进的领域。
代价:CPU 开销更高
QUIC 的 CPU 消耗明显高于 TCP,通常是 2 倍左右(早期差距更大):
- TCP 有几十年的优化:内核实现高度优化,还有网卡硬件卸载(TSO/GSO/校验和卸载),很多活是网卡芯片干的。
- QUIC 是纯软件:每个包都要在用户态做加解密、维护状态。
- 系统调用开销:UDP 收发包要频繁陷入内核,不像 TCP 一次系统调用能处理一大批数据。
优化手段在跟进(UDP GSO 批量发包、内核旁路、eBPF 加速),差距在缩小。但在 CPU 敏感的场景下,这个代价是真实存在的,需要压测验证。
一个现实问题:UDP 有时被限速甚至封禁
因为 UDP 常被用于 DDoS 放大攻击,一些企业网络、运营商会限制或降级 UDP 流量。所以 HTTP/3 的部署必须能优雅回退:
客户端行为:
1. 先用 HTTP/2 (TCP) 连上去
2. 服务端在响应头里说:"我也支持 HTTP/3"
Alt-Svc: h3=":443"; ma=86400
3. 客户端后台尝试建立 QUIC 连接
4. 成功 → 后续走 HTTP/3;失败 → 继续用 HTTP/2
这个机制叫 Alt-Svc(Alternative Services)。注意它意味着 HTTP/3 通常不是第一次连接就能用上的——第一次还是走 TCP,之后才升级。(有个叫 HTTPS DNS 记录的新机制能让客户端在 DNS 阶段就知道支持 h3,从而首次连接直接上 QUIC,正在逐步铺开。)
HTTP/3:QUIC 之上的那层薄壳
讲完 QUIC,HTTP/3 本身反而没什么好讲的了——因为大部分工作 QUIC 已经做完了。
HTTP/3 相对 HTTP/2 的变化:
| HTTP/2 | HTTP/3 | |
|---|---|---|
| 传输层 | TCP | QUIC(over UDP) |
| 多路复用 | HTTP 层实现 | QUIC 层实现(所以 HTTP/3 自己不用做) |
| 加密 | 外挂 TLS | QUIC 内置 |
| 头部压缩 | HPACK | QPACK |
| 服务端推送 | 有(已废弃) | 直接没有 |
| 队头阻塞 | TCP 层仍有 | 彻底消除 |
QPACK:为什么不能直接用 HPACK
HTTP2中讲过 HPACK 的动态表:双方各维护一张"暗号本",必须严格同步。
而这个"严格同步"依赖一个前提:头部帧是按顺序处理的。HPACK 的动态表是按顺序更新的,你说"62 号",我得确保我的表里已经有 62 号了。
但 QUIC 的流之间是乱序的! 流 5 的头部可能比流 3 的头部先到。如果直接用 HPACK,接收方可能收到一个引用了"还没建立的表项"的头部——表就错乱了。
QPACK 的解法是把"表的更新"和"表的引用"分开:
- 动态表的更新指令走一条专门的单向流(保证有序)。
- 各个请求的头部引用表项;如果引用了一个还没同步过来的表项,这个头部就先阻塞等一下(叫 HOL blocking on the encoder stream)。
- 编码器可以选择保守一点——只引用确定已同步的表项,用一点压缩率换取零阻塞。
这是个典型的"为了适应乱序而做的重新设计":上层机制依赖了下层的某个保证(有序),下层的保证没了,上层就得重做。这种连锁反应在协议演进里很常见。
8. 三代 HTTP 总览
把 06、07、08 三篇串起来,这张表是网络面试和实际选型的核心:
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 年份 | 1997 | 2015 | 2022 |
| 传输层 | TCP | TCP | QUIC / UDP |
| 格式 | 文本 | 二进制帧 | 二进制帧 |
| 并发方式 | 多条连接(每域名 6 条) | 一条连接多路复用 | 一条连接多路复用 |
| 应用层队头阻塞 | 有 | 无 | 无 |
| TCP 层队头阻塞 | 有(但被 6 条连接分散) | 有,且被放大 | 无 |
| 头部压缩 | 无 | HPACK | QPACK |
| 加密 | 可选 | 事实上必须(浏览器) | 强制,且内置 |
| 建连开销 | 1 RTT(+TLS) | 1 RTT(+TLS) | 1 RTT / 0-RTT |
| 连接迁移 | 不支持 | 不支持 | 支持 |
| 拥塞控制位置 | 内核 | 内核 | 用户态(可快速迭代) |
演进的主线只有一句话:每一代都在消灭上一代的队头阻塞,而每一次消灭都需要下沉一层。
- HTTP/1.1 的队头阻塞在应用层 → HTTP/2 在应用层加了流 ID 解决它
- 但真正的瓶颈在传输层 → HTTP/3 只好把整个传输层换掉