目录

别背 HTTP/2:从 HTTP/1 的问题开始理解它

学习 HTTP/2 的时候,我们经常会看到这么一张清单:

  • Binary Framing(二进制分帧)
  • Stream & Multiplexing(流与多路复用)
  • HPACK(头部压缩)
  • Flow Control(流量控制)
  • Stream Priority(优先级)
  • SETTINGS / GOAWAY / RST_STREAM…

如果直接去背这些名词,比如 Frame 是什么、Stream 状态机怎么转、为什么 TCP 有流控了 HTTP/2 还要再搞一套……很快就会发现,背是背下来了,但过两周全忘光了。

其实换个思路,我们别去背协议规范,而是坐下来聊聊:如果当年由你来重构 HTTP/1.1,面对那一堆让人头疼的问题,你会怎么一步步把它设计出来?


一、HTTP/1.1 已经有 Keep-Alive 了,为什么还是卡?

HTTP/1.1 并不弱,它统治了互联网整整二十年。

在它之前的 HTTP/1.0 时代,最痛苦的是“短连接”:网页里有 20 张图片,浏览器就得跟服务器握手 20 次、挥手 20 次。

HTTP/1.1 引入了持久连接(Keep-Alive),让连接可以保持不断开,一个请求完了接着发下一个:

HTTP/1.1 Keep-Alive 模式:
Client ─── Request A ───→ Server
Client ◀── Response A ─── Server

Client ─── Request B ───→ Server
Client ◀── Response B ─── Server

这确实省去了重复握手的开销,但它依然是 “发一个、等一个” 的串行排队模式。

HTTP/1.1 也曾尝试过自救,提出了 管线化(Pipelining):允许客户端不等 A 的响应回来,就一口气把 Request A、B、C 连着发出去。

HTTP/1.1 Pipelining 设想:
Client ─── Request A ───→ Server
Client ─── Request B ───→ Server
Client ─── Request C ───→ Server

看似实现了并发,但问题出现在服务端的响应环节:

Server ─── Response A ───→ Client
Server ─── Response B ───→ Client (必须等 A 发完才能发 B!)
Server ─── Response C ───→ Client (必须等 B 发完才能发 C!)

为什么响应必须严格按顺序排队回传?

因为 HTTP/1.1 的响应报文里,根本没有字段标明“我是哪个请求的响应”。客户端区分响应归属的唯一依据,就是靠收到的先后顺序。

设想一个场景:

  • 请求 A 是个要跑 5 秒钟的复杂报表 SQL;
  • 请求 B 是个只需 2 毫秒就能从内存吐出的 CSS 文件。

即使服务端在第 2 毫秒就准备好了 Response B,也只能硬憋在缓冲区里,眼睁睁看着慢吞吞的 Response A 传完,才能接着发 B。

这就是经典的 HTTP 应用层队头阻塞(HTTP Head-of-Line Blocking)

在 HTTP/1.1 时代,为了让网页加载更快,大家被逼出了各种招数:

  • 浏览器对同一个域名默认同时开 6 条 TCP 连接;
  • img1.cdn.comimg2.cdn.com 域名分片绕开 6 个连接的限制;
  • 搞雪碧图把小图标拼成一张大图;
  • 把几十个 JS 模块打包成一个几兆的大文件。

但这些方法治标不治本,多条 TCP 连接带来更多握手和内存消耗,大文件打包又破坏了局部缓存。

那自然就会想:能不能只用一条 TCP 连接,同时发很多个请求,谁的数据先准备好谁就先传?


二、数据混着发?那就先装进“带标签的易拉罐”

假设现在客户端想在单条连接里同时发三个请求的数据:ABC

我们希望它们在网络里可以自由交错前行:

[A1] [B1] [C1] [A2] [C2] [B2] ...

但网络连接本质上只是一串连续的字节流。接收端拿到这一长串字节,怎么知道哪一段属于 A,哪一段属于 B?

打个比方:把可乐、雪碧、芬达直接倒进同一根水管,流出来的就是混合液体,根本分不开。但如果先把它们装进易拉罐,罐身贴上标签(编号 1、3、5),不管它们在管子里怎么混着排队,接收端按标签分拣就行了。

在 HTTP/2 里:

  • 实际数据(Headers / Body)就是饮料;
  • 传输的最小单元 Frame(帧) 就是易拉罐;
  • Stream ID(流编号) 就是罐子上的标签;
  • 单条 TCP 连接就是那根水管。

这就是 HTTP/2 的 Binary Framing Layer(二进制分帧层)

┌────────────────────────────────────────┐
│          HTTP 应用层 API 语义           │
│   (GET /api, Headers, Status Code)     │
├────────────────────────────────────────┤
│      Binary Framing Layer (分帧层)      │
│   [ HEADERS 帧 ]     [ DATA 帧 ]       │
├────────────────────────────────────────┤
│           TCP 传输层 (字节流)           │
└────────────────────────────────────────┘

HTTP/1.1 是纯文本协议,得靠空格和换行符(\r\n)来一行行读,不仅解析慢,还容易产生歧义。

HTTP/2 给每个帧规定了 9 字节的固定头部,里面写了几个关键信息:

  1. Length(3 字节):这个包里的 Payload 有多长;
  2. Type(1 字节):装的是什么类型(装头部的 HEADERS 帧,装正文的 DATA 帧,还是控制信号);
  3. Flags(1 字节):开关标记(比如 END_STREAM 标记“这是该流的最后一个数据包”);
  4. Stream ID(4 字节):这个帧属于哪一个流。

有了这 9 个字节,计算机直接按偏移量读取,速度极快,边界也不会产生任何歧义。


三、多路复用(Multiplexing):一个连接,多路狂飙

有了 Frame 和 Stream ID 之后,一条连接里就可以划分出多个 Stream(流):

Connection(物理 TCP 单连接)
  └── Stream(双向虚拟信道,有唯一 Stream ID)
        └── Message(完整的 HTTP 请求或响应)
              └── Frame(最小传输单位:HEADERS 帧、DATA 帧)

假设现在页面要加载 CSS(Stream 1)、JS(Stream 3)和发起一个 API 请求(Stream 5)。

在底层的单条 TCP 连接上,它们的帧可以随意穿插传输:

TCP 实际传输的交错帧序列:
┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ Stream 1│ Stream 3│ Stream 5│ Stream 1│ Stream 5│ Stream 3│
│ HEADERS │ HEADERS │ HEADERS │  DATA   │  DATA   │  DATA   │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

接收端每收到一个帧,看一眼它的 Stream ID:

  • ID 是 1?扔进 Stream 1 的拼图池;
  • ID 是 3?扔进 Stream 3 的拼图池;
  • 收到打着 END_STREAM 标记的帧,就把池子里的数据拼成一个完整的 HTTP Message 丢给上层业务。

慢请求再也不会挡住别人了:Stream 1 的报表就算要查 5 秒钟,中间 Stream 3 和 Stream 5 的帧照样能在连接上全速发完。

这就是多路复用。HTTP 应用层面的队头阻塞在这里就被干掉了。

这里还有一个小细节:在一条连接里,客户端和服务端都可以主动创建流。为了防止两边同时建流时分配出一样的 ID 导致冲突,HTTP/2 规定:

  • 客户端发起的流用奇数(1, 3, 5, 7…);
  • 服务端发起的流用偶数(2, 4, 6, 8…);
  • Stream 0 专门留给全局控制信令用,不传业务数据。

两边各自自增,谁也不用跟谁协商,天然做到了零冲突。


四、多路复用搞定了,新麻烦马上就来:流控危机

现在多路复用搞定了,那接着想:会有新问题吗?

设想一个场景:一条连接里,Stream 1 在下载一个 500MB 的大视频,Stream 3 是一个 10KB 的首屏关键 CSS,Stream 5 是一个 2KB 的点赞 API 请求。

如果接收端(比如一台老手机)消费 Stream 1 的速度非常慢,写入闪存跟不上,服务端又在疯狂发 Stream 1 的视频帧,会发生什么?

手机上的 TCP 内核接收缓冲区很快会被 500MB 的视频塞满。这时候操作系统的 TCP 协议栈会触发自我保护,把 TCP 滑动窗口调成 0,向服务端喊:“别发了,管子全满了!”

这时候有意思的问题就来了:

因为 Stream 1 一个流消费过慢
导致整条 TCP 连接滑动窗口归零
Stream 3 (关键 CSS) 被连坐卡死!
Stream 5 (点赞 API) 跟着一起陪葬!

TCP 不是已经有 Flow Control 了吗?为什么救不了它们?

因为 TCP 的流控只认整条连接,它根本不知道连接里面有什么 Stream 1、Stream 3。TCP 窗口一关,全车人都得熄火。

所以,HTTP/2 必须在自己的应用层,给每个 Stream 搞一套独立的流量控制。


五、双层流量控制:像发信用卡额度一样管流量

HTTP/2 的流控是怎么搞的?其实就像发信用卡额度(Credit)

它维护两个维度的窗口:

  • Connection-level Window(整条连接还剩多少全局额度);
  • Stream-level Window(某个具体 Stream 还剩多少可用额度)。

运作过程很直观:

  1. 连接建立时,默认给每个 Stream 和整条连接分配一个初始窗口(默认 65,535 字节 / 约 64 KB);
  2. 发送方每发一段 DATA 帧,就扣掉相应数量的 Stream 额度和 Connection 额度;
  3. 一旦某个 Stream 额度用完了,发送方就必须暂停发送该 Stream 的数据;
  4. 接收端在应用层把数据读取、消费并释放内存后,主动给发送端发一个 WINDOW_UPDATE:“我又消化了 32KB,给你充值 32KB 额度,继续发吧!”
Client (接收方)                                  Server (发送方)
      │ ◀──────────── DATA (Stream 1, 16 KB) ──────────│ (S1 扣 16K, Conn 扣 16K)
      │ ◀──────────── DATA (Stream 1, 16 KB) ──────────│ (S1 扣 16K, Conn 扣 16K)
      │                                                │
 (应用层消费完毕,腾出内存)                              │
      │ ──── WINDOW_UPDATE (Stream 1, +32 KB) ───────▶ │ (S1 额度恢复!)
      │ ──── WINDOW_UPDATE (Stream 0, +32 KB) ───────▶ │ (全局连接额度恢复!)

回到刚才的场景:Stream 1 消费慢,接收端就不给 Stream 1 发 WINDOW_UPDATE,Stream 1 额度用完就自己停了;而 Stream 3 和 Stream 5 消费很快,不断收到 WINDOW_UPDATE,继续全速跑。多路复用的独立性这才真正保住了。

这里还有一个关键设计:只有业务数据的 DATA 帧会扣流控额度。像取消流的 RST_STREAM、会话协商的 SETTINGS 等控制帧一律不扣额度,免得数据塞车的时候连控制信令都发不出去。


六、流是双向的,什么时候才算“完事”?

普通的 HTTP 请求是一个典型的“一问一答”模型:

Client ─── Request ───→ Server
Client ◀── Response ─── Server

注意:客户端把请求发完了,并不代表这个 Stream 就可以直接关闭了。

以最常见的 GET 请求为例:

  1. 客户端发完 HEADERS 帧后,后面没有 Request Body 了。它怎么告诉服务端“我这边说完了”?
  2. 它在发出的这个帧上带上一个标志位:END_STREAM
  3. 这时候该 Stream 进入了 半关闭状态(Half-Closed):客户端不能再往这个流发数据了,但服务端可以继续往回发响应的 HEADERSDATA 帧;
  4. 直到服务端发出的最后一个响应数据帧上也带上了 END_STREAM,双方都说完了,这个 Stream 才正式宣告使命完成,进入 closed 状态销毁。
Stream 状态演变:
[ idle (空闲) ] ──发/收头──▶ [ open (打开) ] ──本端发完 END_STREAM──▶ [ half-closed (半关闭) ] ──对端发完 END_STREAM──▶ [ closed (彻底关闭) ]

七、中途反悔了怎么办?(RST_STREAM)

设想一个场景:用户点进一个视频,看了 2 秒觉得没意思,直接把页面关了;或者单页应用快速切换路由,上个页面发出的图片请求全变成了没用的垃圾请求。

在 HTTP/1.1 时代,浏览器想要中止正在传的大文件,唯一的办法就是调用 close() 把整个 TCP 连接掐断。但这会导致同连接上排队的其他请求全部遭殃。

在 HTTP/2 里既然每个请求都是独立的 Stream,解决起来就干净多了:客户端直接发一个 RST_STREAM给服务端,带上错误码(比如 CANCEL)。

RST_STREAM:
Stream ID = 1, Error Code = CANCEL

服务端收到后立刻掐掉 Stream 1,不再发后续数据;而底层的 TCP 连接完好无损,Stream 3、Stream 5 继续全速运行。


八、每次请求头都那么大,怎么瘦身?(HPACK)

解决完并发和流控,再来看 HTTP/1.1 的另一个大毛病:请求头太大了

很多请求的 Body 只有几十字节,但 Header 动辄 1~2KB(塞满了各种 CookieUser-AgentAccept 等)。一个页面几十个请求发出去,好几百 KB 的 Header 全在机械重复地发送同一批字符串。

那能不能用 gzip 压一下 Header?当年 SPDY 确实试过,结果踩了安全大坑——CRIME 漏洞。攻击者可以通过在请求中注入特定明文,观察压缩后密文长度的微小变化,逐字节猜出用户的私密 Cookie

为了既要压缩、又要安全,HTTP/2 搞了一套专门的 HPACK 压缩算法。它的逻辑很朴素:既然双方都知道内容,就别反复发字符串了,发索引编号。

┌─────────────────────────────────────────────────────────────┐
│                       HPACK 三大件                          │
├──────────────────────────────┬──────────────────────────────┤
│ 1. 静态字典 (Static Table)   │ 2. 动态字典 (Dynamic Table)  │
│    协议写死的 61 个常见键值  │    连接中第一次见到的 Header │
│    比如: Index 2 代表 GET    │    双方在内存中动态编号记录  │
├──────────────────────────────┴──────────────────────────────┤
│ 3. 静态哈夫曼编码 (Huffman Coding)                          │
│    对于没命中的新字符串,用高频字符编码表再压缩 30% 左右     │
└─────────────────────────────────────────────────────────────┘
第 1 个请求发送:
User-Agent: Mozilla/5.0... (双方首次见到,记录在动态表 Index 62)

第 2 个请求发送:
直接发送数字 62 即可!(体积从几百字节瞬间缩水为 1 字节)

顺带地,HTTP/2 引入了伪头部(统一以 : 开头,如 :method:path:status),把以前 HTTP/1.1 的请求行和状态码也规范成了键值对,一同享受 HPACK 的高效压缩。


九、大家能力不一样,怎么提前打招呼?(SETTINGS 帧)

一条 HTTP/2 连接建立起来之后,双方维护着不少状态:HPACK 动态表、流控窗口、并发流限制……

但两端的机器能力可能差距很大。比如一台高性能服务器想支持 1000 个并发流,而一台低功耗设备可能最多只能撑 50 个;或者手机端想把动态表限制在 4KB 内存以内。

怎么告诉对方自己的底线?

连接刚建好时,双方会互发 SETTINGS,亮出自己的参数:

Client                                           Server
  │ ─── SETTINGS (MAX_CONCURRENT_STREAMS = 100) ──▶ │ (我最多同时处理 100 个流)
  │ ◀── SETTINGS (INITIAL_WINDOW_SIZE = 1 MB) ──── │ (新流默认给 1MB 额度)
  │                                                 │
  │ ─── SETTINGS (Flags = ACK) ───────────────────▶ │ (收到,按你的规矩办)
  │ ◀── SETTINGS (Flags = ACK) ─────────────────── │ (收到,按你的规矩办)

收到对方的 SETTINGS 后,回送一个带 ACK 标记的空 SETTINGS 帧确认一下,之后大家就按这套规矩办事。


十、服务器想重启下线,怎么不误伤正在跑的请求?(GOAWAY)

在线上日常运维中,服务发布重启或者缩容下线是家常便饭。

在 HTTP/1.1 时代,服务器想重启,在响应头里加个 Connection: close,处理完当前这一个请求把 TCP 关掉就行了。

但在 HTTP/2 下,一条连接里可能同时跑着好几个流:

当前连接状态:
├── Stream 1 (已完成)
├── Stream 3 (正在写数据库,已执行 80%)
└── Stream 5 (刚收到请求,还没开始处理)

如果直接关 TCP,Stream 3 和 Stream 5 的用户会直接收到连接断开报错。

但如果服务器傻等着所有流结束,网络对面的客户端可能还在以毫秒级的速度不断发起新的 Stream 7Stream 9……那服务器永远别想停机了。

所以服务器需要一种方式告诉客户端:“我要准备关机了,别再往这里发新请求了;但已经在处理的旧请求,我会负责到底。”

这就是 GOAWAY 的作用。

这里有一个细节:因为网络有延迟,服务器发 GOAWAY 的瞬间,客户端可能刚好发出了 Stream 7。客户端怎么知道服务端到底有没有接单?

GOAWAY 帧里带了一个核心字段:Last-Stream-ID

服务器发送:
GOAWAY (Last-Stream-ID = 3)
                     Last-Stream-ID = 3
            Stream 1     Stream 3    │    Stream 5     Stream 7
      ───── 已经受理,保证处理完毕 ────┼─── 还没看,请去新连接重试 ───
                                水位线 (Watermark)

这就是一道清晰的水位线(Watermark):

  • ID $\le$ 3 的流:服务器确认接单并会处理完,客户端安心等待响应即可;
  • ID $>$ 3 的流:服务器明确声明“我没处理过”,客户端可以放心地在新建的另一条连接上做无损透明重试,即使是非幂等的 POST 请求也不会导致重复扣款。

回过头看前面讲的 Stream ID 为什么要单调递增,在这里就显出威力了。


十一、都能发的时候,到底先给谁让路?(优先级 Stream Priority)

多路复用之后,服务端又面临一个新问题。

假设页面同时发起了 4 个请求:

  • Stream 1 是关键 CSS(没它页面白屏);
  • Stream 3 是关键 JS;
  • Stream 5 是底部的装饰小图片;
  • Stream 7 是侧边栏的大视频。

此时 4 个 Stream 的流控窗口都大于 0,都可以发数据。

如果服务端轮流发:S1 发一个包、S3 发一个包、S5 发一个包、S7 发一个包……结果就是 CSS 的带宽被大视频严重分流,首屏白屏时间被拖长。

所以需要 Priority 告诉服务端谁更重要。

不过在优先级机制上,HTTP/2 最初走了一段弯路:RFC 7540 设计了复杂的流依赖树和权重计算模型(Stream A 依赖 Stream B,权重 16 等等)。实际用起来发现各家浏览器实现完全不同,中间代理也搞不懂,导致了过度设计。

后来演进的方案(RFC 9218)大幅简化为直接在 Header 里带上 Urgency(紧急度 0~7)Incremental(是否可增量交付),回归实用主义。


十二、想主动给浏览器塞资源?(Server Push 的翻车教训)

既然服务端可以主动开流(偶数 Stream ID),HTTP/2 最初还设想过一个功能:Server Push(服务端推送)

想法很直观:你请求 index.html,服务端明明知道你接下来一定会需要 style.css,何必等浏览器拿到 HTML 解析后再来发请求?服务端直接在偶数流(如 Stream 2)上连带把 CSS 一起推过去得了。

Server Push 设想:
Client ─── GET /index.html ───▶ Server
Client ◀── PUSH_PROMISE (style.css, S2) ─── Server (提前打招呼)
Client ◀── 200 OK (HTML, S1) ─── Server
Client ◀── DATA (style.css, S2) ── Server (资源直接塞进浏览器缓存)

然而在实际生产环境里,Server Push 几乎全面翻车:

  1. 缓存盲区:服务端根本不知道浏览器本地是不是早就强缓存了 style.css。本地明明有缓存,服务端还硬推,纯属浪费带宽;
  2. 带宽竞争:推送过来的资源经常把关键 HTML 的传输通道挤占了;
  3. 难以预测:现代前端和 CDN 架构复杂,源站很难精准把握依赖关系。

最终,主流浏览器(如 Chrome 106+)基本全票移除了对 HTTP/2 Server Push 的支持。


十三、更优解:103 Early Hints(我给你线索,你自己定)

Server Push 的教训说明了一件事:服务端虽然知道资源依赖,但只有客户端才掌握最完整的缓存和渲染上下文。服务端不能替客户端强做主。

于是,后来有了更优雅的 103 Early Hints

它的工作方式是:

  1. 客户端请求 GET /index.html
  2. 服务端在查数据库、渲染模板的耗时阶段(Think Time),先提前给客户端回一个 103 Early Hints 状态码和响应头:Link: </style.css>; rel=preload
  3. 客户端收到后自己查本地缓存:缺这个文件,就自己开一个新的 Stream 去拉;不缺,就直接忽略。
传统后端思考期间 (Think Time):
传统模式:    [  Server 查数据库渲染 (白等)  ] [ 传输 HTML ] [ 发现并请求 CSS ]
Early Hints: [ 103 提示 ] ──────────────────▶ [ 提前并行下载 CSS ]

在 HTTP/2 里,103 依然走普通的 HEADERS 帧,没有任何额外复杂度。服务端负责给提示,客户端自主做决策,配合得刚刚好。


十四、全景复盘:HTTP/2 的逻辑拼图

到这里,我们把整条推导链串起来看一遍:

               【HTTP/1.1 核心痛点】
             单连接串行排队、队头阻塞
                        ▼ 解决手段
              【二进制分帧 + 流 (Stream)】
                        ▼ 达成目标
                 【多路复用 Multiplexing】
          ┌─────────────┴─────────────┐
          ▼ 产生新问题                ▼ 产生新问题
     慢流拖垮整条连接            多流并发抢占带宽
          │                           │
          ▼ 解决手段                  ▼ 解决手段
   【双层流量控制 Flow Control】  【优先级调度 Priority】
   (WINDOW_UPDATE / 额度制)      (RFC 9218 极简化)
          │                           │
          └─────────────┬─────────────┘
        ┌───────────────┼───────────────┐
        ▼ 解决头冗余    ▼ 解决双端差异  ▼ 解决优雅停机
      【HPACK 压缩】   【SETTINGS 协商】 【GOAWAY 水位线】
   (静态字典+动态字典)  (参数宣告与 ACK)  (Last-Stream-ID 重试)

你看,每一个机制的出现,都不是为了凭空发明新名词,而是在解决前面某一个具体的设计痛点。


十五、留下的遗憾:为什么未来还需要 HTTP/3?

HTTP/2 把应用层能改的都改得差不多了,但它依然有一个绕不开的底层硬伤:它依然跑在单条 TCP 连接上

TCP 的规矩是:对上层承诺严格按顺序交付。

HTTP/2 逻辑层:
Stream 1 (视频) ──┐
Stream 3 (CSS)  ──┼─── 逻辑上彼此独立,互不相干!
Stream 5 (API)  ──┘
        ▼ 压进单条 TCP 连接传输
 [ Packet 1 ] [ Packet 2 ] [ Packet 3 (丢包!) ] [ Packet 4 ] [ Packet 5 ]
   (属于 S1)     (属于 S3)       (属于 S1)        (属于 S3)     (属于 S5)

如果网络稍微抖了一下,装有 Stream 1 数据的 3 号包丢了:

  • 哪怕属于 Stream 3(CSS)的 4 号包和属于 Stream 5(API)的 5 号包早已完好无损地到达了网卡;
  • 但因为 TCP 必须坚守“按序递交”的铁律,TCP 协议栈只能把 4 号和 5 号包死死扣在内核缓冲区,硬等 3 号包超时重传补齐。

这就是 TCP 层面的队头阻塞。应用层在搞多路复用,传输层却在排单队。一旦丢包,所有流全被连坐。

要彻底解决这个问题,流(Stream)的概念就必须真正做到底层传输层里去。而这,就是后来抛弃 TCP、基于 UDP 自研传输机制的 QUIC 协议与 HTTP/3 的故事了。