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 的答案是分两步:

  1. 头部的边界靠空行(准确说是连续两个 \r\n)——一路读到空行,头部就完了。
  2. 主体的边界靠头部里的 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/2HTTP/3
传输层TCPQUIC(over UDP)
多路复用HTTP 层实现QUIC 层实现(所以 HTTP/3 自己不用做)
加密外挂 TLSQUIC 内置
头部压缩HPACKQPACK
服务端推送有(已废弃)直接没有
队头阻塞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.1HTTP/2HTTP/3
年份199720152022
传输层TCPTCPQUIC / UDP
格式文本二进制帧二进制帧
并发方式多条连接(每域名 6 条)一条连接多路复用一条连接多路复用
应用层队头阻塞有无无
TCP 层队头阻塞有(但被 6 条连接分散)有,且被放大无
头部压缩无HPACKQPACK
加密可选事实上必须(浏览器)强制,且内置
建连开销1 RTT(+TLS)1 RTT(+TLS)1 RTT / 0-RTT
连接迁移不支持不支持支持
拥塞控制位置内核内核用户态(可快速迭代)

演进的主线只有一句话:每一代都在消灭上一代的队头阻塞,而每一次消灭都需要下沉一层。

  • HTTP/1.1 的队头阻塞在应用层 → HTTP/2 在应用层加了流 ID 解决它
  • 但真正的瓶颈在传输层 → HTTP/3 只好把整个传输层换掉