一座城市的邮局即将改革。旧规则(HTTP/1.1)下,同一条邮路上一次只能送一封信,信件必须排成一条长队逐一前行;如果排最前的收件人迟迟不来取信,后面所有人只能干等。更麻烦的是,客户想同时寄十封信,就得开十条邮路,每条邮路都要单独办一次出库手续。整个城市因此到处堵车。 新物流中心(HTTP/2)换了一套玩法:所有客户共用一条巨型传送带(一条 TCP 连接),但每个客户都拥有自己的“虚拟流水线”(流,Stream)。传送带上运送的是一种统一规格的快递箱(帧,Frame)——箱子上印着标准标签:货主单号(流ID)、包裹尺寸(长度)、货品类别(类型)、加急标记(标志位)。不同客户的箱子可以在同一条传送带上并排交错前进,互不挡路;老客户寄生地址册,寄信时只报编号;遇到异常还有一套报警与降级机制。这座物流中心的运转法则,就是 HTTP/2 流与帧的全部秘密。
一句话职业价值:HTTP/2 流与帧是理解和优化现代 Web 性能的底层钥匙,掌握它能让你在网络诊断、接口调优与架构决策中快人一步。
可视化演示
展示HTTP/2单TCP连接承载多流并发传输:HTML/CSS/JS/IMG四流同时传输,帧结构展示Length/Type/Flags/StreamID字段布局。
本节配有交互动效(进入视口后自动加载,约 3KB)。
多路复用流并发 单TCP连接承载多流(S1/S3/S5/S7)并发传输 HEADERS帧+多个DATA帧组成完整请求/响应 1 TCP = N Streams 解决HTTP/1.1队头阻塞问题
流与帧基础
新物流中心开业第一天,客户们站在传送带前,问的第一个问题是:流和帧到底有什么差别?管理员的解释很通俗:帧,是传送带上任何一个标准快递箱——它有自己的规格、标签和发货规则;流,则是为某位客户的一场完整对话临时开辟的“虚拟流水线”。换句话说,一场对话由若干帧组成,帧是台词,流是剧本。
旧邮局时代,一个客户发 20 个请求,相当于开 20 趟专线;新物流中心里,这 20 个请求全可以塞进同一条传送带上各自的流水线里。这在工程上把连接数从“请求数级”压缩到“常数级”:HTTP/1.1 时代,浏览器对同一域名通常只能开 6 条 TCP 连接,多出的请求只能排队等;HTTP/2 则允许一条连接上同时存在成千上万条流。
把这两个概念装进头脑,物流中心的地基就打下了:一条粗壮的传送带,上面同时跑着无数条透明的“逻辑轨道”,每条轨道里流动着一串标准包裹。接下来我们要看包裹本身长什么样。
- 并发请求量 M页面同时需要加载的资源个数,例如一张网页里有 20 张小图和脚本。
- 连接上限 NHTTP/1.1 浏览器对每个域名最多建立约 6 条 TCP 连接,连接之间互不共享。
- 总开销对比旧方案连接总量按请求量倍增,新方案无论多少流都只占 1 条连接,握手成本大幅收敛。
帧结构详解
传送带上的快递箱不是随便拿个纸箱就能上,新物流中心要求所有箱子统一使用印有标准标签的规格箱。标签只有 9 个字符长,却写清了所有分拣信息:包裹有多大、装的是什么货、有没有加急、属于哪个客户的哪条流。传送带上的分拣机器人只扫一眼标签,就知道把箱子拨到哪个出口。
这 9 个字符就是 HTTP/2 的帧头:前 3 字节记录负载长度,第 4 字节说明帧类型,第 5 字节是标志位,最后 4 字节写流 ID。帧类型一共十种,像是不同货品的大类——DATA 是货物本体,HEADERS 是货单说明,RST_STREAM 是中途退货单,GOAWAY 是整条传送带停运通知,WINDOW_UPDATE 是调整放行额度的调度单。
箱子规格统一后,传送带上的包裹不再有“异形件”,分拣速度自然就快。但箱子终究要送到对应客户手里——靠的是标签上的那一串单号。
- 帧头固定长度无论什么类型的帧,前 9 字节结构完全相同。
- 整帧长度帧头加负载,其中长度字段只统计负载部分。
- 开销随帧体变小而变大大量几字节的小帧会让 9 字节标签显得很贵,所以实践中建议合并小资源请求。
流生命周期
每个客户在物流中心都有单号规则:新客户从奇数单号开始,老客户每发一单就用一个新号,号码只增不减,绝不复用——这是为了保证传送带上同时出现的两个包裹不会因为单号相同而送错。服务端主动推送的货走偶数单号,与客户发起的奇数单号天然分开,互不干扰。
一条流从出生到消亡要经历四个状态:空闲(idle)、开启(open)、半关闭(half-closed)、关闭(closed)。正常情况下,请求帧全部发完进入半关闭,响应帧全部收齐进入关闭;但若中途出问题,可以用 RST_STREAM 帧立刻终止这条流;若整个连接要维护,则用 GOAWAY 帧通知所有流“请尽快结束当前事务”。
单号规则保证了整条传送带上秩序不乱:每个箱子都能准确回到它所属的流水线上。但多条流在同一根传送带上跑,怎么才能不互相拖后腿?这就是多路复用要解决的核心问题。
- 四字节流 ID 字段帧头中流 ID 占 4 字节,共 32 位。
- 最高位保留最高位用于扩展保留,剩余 31 位参与计数编码。
- 排除 0 号0 号流保留给连接级控制帧(SETTINGS、PING、WINDOW_UPDATE 等),所以最大编号减 1。
多路复用机制
旧邮局最让人头疼的,是“一车一送”:一列马车从城东到城西,只能运一个客户的东西。新物流中心的传送带没有这个限制——不同客户的箱子按到达顺序交错摆放到同一条带上,甚至可以把 A 客户的大箱子和 B 客户的小箱子互相插空摆放。接收端根据箱上的单号,把属于同一客户的一串箱子重新拼成完整对话。
这就是 HTTP/2 多路复用的本质:帧交错传输,按流 ID 重组。HTTP/1.1 的队头阻塞,本质是“一次只能跑一个请求,排前面的慢请求把后面全部堵死”;HTTP/2 把阻塞范围从“整个连接”缩小到“单条流自身”。不过要提醒的是,这消除的是应用层队头阻塞——TCP 层若有丢包,整条传送带仍需一起等待重传,皮带破洞时所有客户都得等。
多路复用让传送带的装载率接近满格。但箱子一多,总有人想插队——于是物流中心又设了两道规则:贵宾通道和限量闸口。
- 总传输量一条连接上所有流的数据字节求和。
- 流量控制窗口接收方允许发送方在未确认前最多发送的在途字节数。
- 可用带宽链路每秒能实际搬运的字节数。
- 理想耗时吞吐被窗口与带宽二者中较小的那个限制,两者取小作为分母。
优先级与流控
传送带上的箱子并非完全一视同仁。客户可以在开单时声明依赖关系:比如“A 的 JS 必须在 HTML 之后到”,这就是流的依赖树;还可以给每条流分配权重 1 到 256,权重高的流在同等条件下分到更多传输机会。相当于物流中心给重要客户发了“加急牌”,但加急不等于无限插队。
另一道规则是限量放行。即便传送带很宽,接收方仓库也有容量上限——如果一口气塞进太多箱子,仓库堆不下只能丢弃。于是接收方通过 WINDOW_UPDATE 帧告诉发送方“我现在还能收多少”,发送方只敢发送额度内的数据。这就是流量控制,也叫背压机制:下游处理不过来时,上游主动减速。
限量放行避免了传送带被塞爆。但别忘了,物流中心的另一个成本大头是“写单”——每个包裹都要贴一张写满收发信息的货单。能不能少写点字?这正是下一节的故事。
- 流级窗口每条流单独分配的发送额度。
- 连接级窗口整个 TCP 连接共用的总在途字节上限。
- 生效窗口取小发送方实际可用额度受两层窗口双重约束。
头部压缩原理
老客户每次寄信都要把收件人全名、公司、门牌号、邮编、电话写一遍,既费纸又费时。物流中心想了个办法:收发两端各存一本“地址册”,常用的地址预先编好号,寄信时只需写“地址编号 + 不一样的那几个字段”。册子有两本:一本是印刷好、大家通用的静态表(61 项常见头,如:method: GET、:scheme: https);另一本是一张空白册,每次会话中遇到的新地址就登记进去,这就是动态表。
这套压缩方案叫 HPACK。除索引表外,它还叠加了哈夫曼编码:给出现频率高的字符分配更短的二进制码,让货单本身变“瘦”。两个机制叠加后,很多请求的头部可以从几百字节压到几十字节,尤其对小请求密集的页面收益显著。
地址册让写单成本大降。但物流中心铺这么大摊子,到底比旧邮局划算多少?我们来算一笔账。
- 明文头部体积未压缩前所有头部字段的字节总和。
- 压缩后体积经过索引表替换与哈夫曼编码后的最终字节数。
- 压缩率以比值方式量化节省空间。
性能对比分析
旧邮局每送一车货,马车都要重新出城、重新过安检、重新找路(TCP 握手 + TLS 握手);新物流中心这些手续只办一次,之后所有货都走同一条熟路。量化的收益来自三笔:第一笔是连接握手次数从 N 次降到 1 次;第二笔是排队长尾消失,20 个小图不再排队等待;第三笔是头部体积缩小,每个请求的字节数下降。
但物流中心的传送带不是万能的。如果网线质量差、丢包率高,传送带皮带频繁破损,一次破损就要整条带子停下来修补——此时多路复用的并行优势会被 TCP 重传拖累,甚至不如旧邮局“各走各的路”来得稳。单一大文件下载场景下,多路复用也派不上大用场。
算清了账,还得看得懂监控。万一传送带报警,调度员该如何快速定位是哪个环节出了岔子?
- HTTP/1.1 连接数浏览器并发连接的典型值。
- 单条连接握手开销TCP 三次握手与 TLS 握手合计约 2 至 3 个 RTT。
- 总节省少建的连接数乘以每次握手的固定成本。
故障排查实战
物流中心的监控室里有一排信号屏:某个客户单号非法、某个箱子超尺寸、某款货品数量异常、整条线需要紧急维护。HTTP/2 用错误码做同样的报警。最常见的几个:NO_ERROR(0) 是正常停机,PROTOCOL_ERROR(1) 表示有人不按协议规则玩,FLOW_CONTROL_ERROR(3) 是窗口额度算错了,REFUSED_STREAM(5) 是服务器忙不过来拒绝新建流,COMPRESSION_ERROR(7) 是地址册损坏,ENHANCE_YOUR_CALM(9) 则是客户端发帧发得太猛被服务器劝“冷静一下”。
排障时 Wireshark 是最趁手的工具:过滤 http2.stream == 3 只看某条流的全部帧,过滤 http2.type == 0x7 快速定位 GOAWAY。服务端调优方面,nginx 可通过 http2_max_concurrent_streams 限制并发流数避免资源耗尽;客户端则通过 SETTINGS 帧的 SETTINGS_MAX_CONCURRENT_STREAMS 告知服务器自己最多能处理多少条并发流。GOAWAY 是优雅停机的关键:先告诉所有流“请尽快结束”,排空剩余帧之后才真正断开连接。
至此,快递中心的全部运转逻辑已经讲完:统一规格的箱子、清晰有序的单号、交错并行的传送带、加急与限流、地址册压缩,以及一整套报警排障系统。掌握这些,你就能在网络世界里看懂 HTTP/2 的每一次“物流调度”。
- 最后一个已确认帧时间GOAWAY 帧中记录的 last_stream_id,表示服务端已处理到哪条流。
- 排空时间已接收但未处理完的帧继续跑完所需的时间。
- 优雅关闭总时长确认时间与排空时间之和,这段时间内旧流可以继续完成,新流不再被接受。
AI 应用场景
CDN 首屏性能调优
阶段:性能调优
解决的问题:传统 HTTP/1.1 下,页面加载上百个静态资源只能走 6 条连接排队,大量时间浪费在连接建立与队头阻塞上。HTTP/2 让所有资源请求在一条连接内并发交错传输,首屏时间大幅缩短。
具体案例:某前端团队将 CDN 域名切换为 HTTP/2 后,加载瀑布图从“一串串排队的长条”变为“一条粗壮的并行带”,DOMContentLoaded 时间从 2.8 秒降到 1.9 秒,首屏 LCP 提升约 32%。
移动端弱网接口聚合
阶段:网络架构设计
解决的问题:弱网环境下每次请求独立做 TCP+TLS 握手代价极高,而 HTTP/2 只需握手一次,后续所有请求共享连接;配合头部压缩,能让接口请求在低带宽、高延迟链路下更快发出。
具体案例:某 App 在弱网下批量拉取 20 个接口数据,从 HTTP/1.1 切换为 HTTP/2 后,接口总耗时由 4.5 秒降至 2.1 秒,主要收益来自 19 次多余握手的消除与头部体积下降。
服务端优雅升级与巡检
阶段:运维监控与故障排查
解决的问题:服务端发版需要重启连接,直接断开可能打断用户在途请求;通过 GOAWAY 帧优雅排空,可实现在线连接零中断切换。同时可以抓取帧数据定位慢请求瓶颈。
具体案例:SRE 团队在 nginx 发版时先发送 GOAWAY,等待存量流排空再重启,用户请求成功率保持 99.99%;后续用 Wireshark 过滤 http2.stream == 5 定位到一个头部压缩表异常导致的 COMPRESSION_ERROR。
Python 演示
import structfrom collections import Counter FRAME_TYPES = { 0x0: 'DATA', 0x1: 'HEADERS', 0x2: 'PRIORITY', 0x3: 'RST_STREAM', 0x4: 'SETTINGS', 0x5: 'PUSH_PROMISE', 0x6: 'PING', 0x7: 'GOAWAY', 0x8: 'WINDOW_UPDATE', 0x9: 'CONTINUATION'} def build_frame(stream_id, ftype, flags=0, payload=b''): length = len(payload) header = length.to_bytes(3, 'big') header += bytes([ftype, flags]) header += (stream_id & 0x7FFFFFFF).to_bytes(4, 'big') return header + payload def parse_frames(blob): pos = 0 streams = Counter() frames = [] while pos + 9 <= len(blob): length = int.from_bytes(blob[pos:pos + 3], 'big') ftype = blob[pos + 3] sid = int.from_bytes(blob[pos + 5:pos + 9], 'big') & 0x7FFFFFFF frames.append((FRAME_TYPES.get(ftype, hex(ftype)), sid, length)) streams[sid] += 1 pos += 9 + length return frames, streams def main(): blob = (build_frame(1, 0x1, 0x1, b'GET / HTTP/1.1') + build_frame(3, 0x1, 0x1, b'GET /css/app.css') + build_frame(1, 0x0, 0x0, b'<html>') + build_frame(5, 0x1, 0x1, b'GET /js/app.js') + build_frame(3, 0x0, 0x0, b'body{}') + build_frame(1, 0x0, 0x1, b'</html>')) frames, streams = parse_frames(blob) for fname, sid, n in frames: print('stream=%d type=%s len=%d' % (sid, fname, n)) print('each stream frame count:', dict(streams)) if __name__ == '__main__': main()
header += (stream_id & 0x7FFFFFFF).to_bytes(4, 'big')写入流 ID 时先遮掉最高位,保证只使用 31 位有效编号。int.from_bytes(blob[pos:pos + 3], 'big')读取 3 字节大端长度字段,得到负载字节数。streams[sid] += 1用计数器按流 ID 聚合,直观看到每条流各有多少个帧。blob = (build_frame(...) + ...)用字符串拼接方式构造“同一条连接上多流交错”的字节流,模拟多路复用。
常见误区
误区我听说 HTTP/2 有多路复用,请求已经可以并行发送,那队头阻塞应该再也不存在了吧?
正解HTTP/2 消灭的是应用程序层的队头阻塞,也就是请求级的排队;但底层仍然跑在 TCP 上,TCP 层的队头阻塞依然存在。只要一个 TCP 包丢了,在重传完成之前,整条连接的所有流都要等待。好比传送带的皮带破了一个洞,虽然不同客户的箱子各占一条虚拟轨道,但皮带是共用的,破损处会拖慢整条带子直到修补完成。
为何常见:市面上大量文章把“多路复用”简化成“消灭队头阻塞”,忽略了 TCP 这个底层事实,初学者容易被口号带偏。
误区帧头是不是 10 字节?长度字段有没有把帧头本身也算进去?
正解HTTP/2 帧头固定 9 字节,严格分为:3 字节长度(只描述负载,不含帧头)、1 字节类型、1 字节标志、4 字节流 ID。所以计算整帧开销时要用 9 加负载长度。这个数字与 TCP 头 20 字节、IPv4 头 20 字节等邻近协议的数字不同,不要混淆。
为何常见:网络协议里到处都是 4/8/12/20 字节这类数字,学习者容易记串。
误区HPACK 把头部压得密密麻麻,看起来和加密差不多,应该更安全了吧?
正解HPACK 只是无损压缩,从协议层面来说头部在进入 TLS 加密层之前仍然是可解析的明文结构。压缩改变的是字节长度,不是内容机密性。真实安全性完全由 TLS 提供;若明文运行 h2c,头部照样能被抓包看到全部内容。
为何常见:“压缩后看不出原文”与“加密不可读”在直觉上相似,容易让初学者形成安全错觉。
误区RFC 7540 定义了明文 HTTP/2(h2c),那我直接部署 h2c 不也能享受 HTTP/2 的好处吗?
正解虽然协议规范确实包含 h2c 明文模式,但主流浏览器全部只支持基于 TLS 的 h2(通过 ALPN 扩展协商),没有任何浏览器启用 h2c。因此在 Web 场景中,HTTP/2 实际上约等于“必须上 HTTPS”。
为何常见:规范文本与实践生态脱节,只读 RFC 不看浏览器支持矩阵,就容易得出错误结论。
面试题
Q1. HTTP/2 帧头固定是多少字节?
- A.8 字节
- B.9 字节← 正确答案
- C.10 字节
- D.12 字节
答案:B。HTTP/2 帧头固定 9 字节,其中长度字段占 3 字节、类型 1 字节、标志 1 字节、流 ID 4 字节。8/10/12 都是其他协议或臆造的数字,不是 HTTP/2 帧头长度。
Q2. 客户端发起的 HTTP/2 流 ID 使用什么编号规则?
- A.偶数编号
- B.奇数编号← 正确答案
- C.随机编号
- D.一直使用 0
答案:B。客户端发起的流使用奇数 ID(1、3、5…),服务端发起的流使用偶数 ID(2、4、6…)。0 号保留给连接级控制帧,随机编号和固定 0 都不符合协议规范。
Q3. 以下哪个帧专门用于动态调整流量控制窗口?
- A.SETTINGS
- B.PING
- C.WINDOW_UPDATE← 正确答案
- D.PRIORITY
答案:C。WINDOW_UPDATE 帧用于通知对方“我的接收窗口又腾出了多少空间”,是流量控制的核心机制。SETTINGS 用于协商参数,PING 用于探活,PRIORITY 用于设置优先级。
Q4. 一条 HTTP/2 TCP 连接最多能承载多大的流 ID 编号?
- A.65535
- B.2^16 - 1
- C.2^31 - 1← 正确答案
- D.无限制
答案:C。流 ID 字段占 4 字节,但最高位保留,所以可用编号上限为 2^31 - 1,约 21 亿。这个量级远大于 65535,也并非无限制。
Q5. 下列哪些属于 HTTP/2 的帧类型?
- A.DATA← 正确答案
- B.HEADERS← 正确答案
- C.RST_STREAM← 正确答案
- D.FIN
答案:ABC。DATA、HEADERS、RST_STREAM 都是 HTTP/2 的标准帧类型。FIN 是 TCP 协议中的标志位,不是 HTTP/2 帧类型,容易与 TCP 概念混淆。
Q6. 哪个场景最容易暴露 HTTP/2 在 TCP 层的队头阻塞问题?
- A.高带宽低延迟的局域网
- B.高丢包率的弱网环境← 正确答案
- C.大量小图片并行请求
- D.仅传输一个大文件
答案:B。丢包率高时,TCP 重传会让整条连接的所有流等待,多路复用优势被传输层队头阻塞抵消。局域网或小图片场景恰好是 HTTP/2 的强项;单一大文件场景与多路复用关系不大。
Q7. HTTP/2 相比 HTTP/1.1 的主要改进包括哪些?
- A.多路复用← 正确答案
- B.头部压缩← 正确答案
- C.二进制分帧← 正确答案
- D.强制使用 TLS 1.3
答案:ABC。多路复用、头部压缩(HPACK)、二进制分帧(帧取代文本行)是 HTTP/2 的三大核心特性。HTTP/2 并不强制 TLS 1.3,它只是实践中通常运行在 TLS 之上。
Q8. 收到一个未知类型帧时,HTTP/2 协议要求接收方如何处理?
- A.立即关闭连接
- B.当作协议错误上报
- C.忽略该帧并继续处理← 正确答案
- D.回送一条错误帧
答案:C。RFC 7540 规定,未知帧类型必须被忽略并继续处理,以保证前向兼容——未来新增帧类型时老实现不会被破坏。立即关闭连接或报错会让协议失去兼容性,回送错误帧没有标准依据。
Q9. 关于 GOAWAY 帧,下列说法正确的是?
- A.帧中的 last_stream_id 表示服务端已处理的最后一条流← 正确答案
- B.收到 GOAWAY 后客户端可以为新请求建立新连接← 正确答案
- C.GOAWAY 可携带 FLOW_CONTROL_ERROR 错误码← 正确答案
- D.发送 GOAWAY 后连接必须立即关闭
答案:ABC。GOAWAY 通过 last_stream_id 告知对端已处理到哪条流;客户端收到后应停止发起新流,但已建流仍可继续,新请求可以走新连接;错误码可以是标准错误码中的任意一种。连接并非立即关闭,而是优雅排空后才断开,因此 D 错。
Q10. HTTP/2 的 HPACK 头部压缩主要结合了哪两种技术?
- A.索引表 + 哈夫曼编码← 正确答案
- B.LZ77 + CRC 校验
- C.静态字典 + RSA 加密
- D.DEFLATE + 时间戳
答案:A。HPACK 依赖静态表与动态表组成的索引体系,并叠加哈夫曼编码压缩高频字符。LZ77 是 DEFLATE 的组件之一,但并不是 HPACK 的核心;RSA 是加密算法,与压缩无关。