一、数据传输单元-帧
1. 通用定义(网络层面)
帧 = 一段固定格式、独立完整的一小块数据 网络传输时,不会把一整份超大文件一次性丢出去,而是切碎成一小块一小块,每一小块就是一帧。
举生活类比
你要寄一整套百科全书(完整请求 / 响应数据):
- 把每一页单独装进标准快递小盒子(帧);
- 每个盒子外面贴统一格式标签(帧头:长度、类型、编号);
- 一堆盒子混杂装车运输(多路复用,不同请求的盒子混在一起传);
- 收件人看每个盒子标签,区分这一页属于哪本书、是什么内容。
这里的标准小盒子 = HTTP2 的帧。
2. HTTP2 里帧的专属特点
- 最小传输单元 HTTP2 所有数据(请求头、响应体、推送预告、心跳、流量控制)全部拆成帧传输,不存在完整报文直接发送。
- 每个帧都有固定 9 字节头部标签 标签写清楚 4 件关键信息:
- 这块帧的数据有多长;
- 这是什么类型的帧(HEADERS 头帧、DATA 数据帧、PUSH_PROMISE 推送帧等);
- 标记位(是否是数据结尾);
- Stream 流 ID:这块数据属于哪一次请求。
- 不同请求的帧可以交错乱序传输 页面 A 的 DATA 帧、图片 B 的 HEADERS 帧、推送资源的 PUSH_PROMISE 帧,可以混在一起传输,依靠帧里的流 ID 区分归属,解决 HTTP1.1 排队阻塞问题。
3. 对比 HTTP/1.1 彻底分清
- HTTP/1.1:一整个请求 / 响应是一整段连续文本字符串,没有切割成小块,只能完整排队传输;
- HTTP/2:把请求、响应切碎成无数独立帧小块,带标签、可乱序穿插传输。
二、HPACK 压缩(静态表、哈夫曼、动态表)
前置背景
HTTP/1.1 每次请求重复带一堆重复头: Host: xxx、User-Agent、Accept-Encoding、Cookie 每次完整传输,流量浪费巨大;HPACK 就是专门压缩请求头的算法,由三部分配合工作。
1、静态表(内置字典,客户端 + 服务器都自带)
大白话解释
官方提前写死一张固定表格 ,表里存了 61 组全世界网站通用的 Header 键值对,每个条目分配一个固定数字编号。
举几个表里现成的条目:
- 编号 2:
GET / - 编号 3:
POST / - 编号 8:
Host: - 编号 12:
User-Agent: - 编号 16:
Accept-Encoding: gzip, deflate
怎么用?
客户端要发送 Host: douyin.com
- 键
Host:在静态表里编号 8; - 只需要传给服务器一个数字 8 ,不用完整传输
Host:这一串文字; - 服务器自带一模一样的静态表,看到数字 8,自动还原出
Host:。
优点
最省流量,只用一个数字代替一长串字符串。
2、哈夫曼 Huffman 编码(压缩字符串正文)
适用场景
静态表里没有 的内容,比如域名 douyin.com、Cookie 字符串、自定义 Header 值。 这些文字没法用数字查表,就用哈夫曼压缩。
通俗原理
统计高频出现的字符,给常用字符分配更短的二进制 01 编码 ,冷门字符用长编码。 举例: o、m、c 在域名里经常出现,只用 2 位二进制表示; 少见符号比如 #、& 用长编码。
作用
把长字符串压缩成很短的二进制流,大幅减少字节数。
3、动态表(双方实时共享的临时字典)
大白话解释
静态表只有 61 条固定通用 header,我们自己业务的专属头(比如 token: xxx、自定义埋点字段)不在静态表里。 动态表是客户端和服务器通信过程中,动态新增、实时同步的一张临时表格。
工作流程举例
- 客户端第一次发请求,携带头
token: abc123- 静态表里没有这条,先用哈夫曼压缩字符串发给服务器;
- 发送完成后,客户端、服务器同时把
token: abc123存入各自的动态表,分配一个临时数字编号;
- 第二次请求再带
token: abc123- 不用传完整字符串,只传动态表里的临时数字编号;
- 服务器查表直接还原,流量极致压缩。
补充特性
- 动态表有最大容量限制,满了之后会淘汰最久没用的旧条目;
- 动态表只在当前 TCP 连接生效,连接断开直接清空,下一条连接重新生成。
三者配合完整流程(举真实例子)
客户端发送请求头:Host: douyin.com
Host:键存在静态表,只传数字 8;- 值
douyin.com不在静态表,用哈夫曼编码压缩后传输; - 本次完整键值对存入动态表;
- 下次再发
Host: douyin.com,直接传动态表的数字编号,不需要传输任何文字。
三、并发传输
1. 先看反面:HTTP/1.1 做不到
HTTP/1.1 一条 TCP 连接串行处理请求,分两种情况:
(1)短连接(默认老模式)
发请求 1 → 等完整响应 1 → 关闭 TCP;再新建 TCP 发请求 2。
(2)长连接 Keep-Alive
复用一条 TCP,但必须排队 : 先发完整请求 1 → 服务器完整返回响应 1; 才能发完整请求 2 → 等响应 2。 队头阻塞问题:如果请求 1(大图片)传输很慢,后面请求 2、3 就算很小,也必须排队等待,无法并行传输。
简单总结:HTTP/1.1 一条 TCP 同一时间只能处理1 个请求。
2. HTTP/2 单 TCP 并发多请求是什么意思
浏览器和服务器之间只建立 1 条 TCP 长连接 ,所有页面资源(html、css、图片、接口、视频分片)全部通过这唯一一条 TCP 传输,且多个请求同时穿插收发,不用排队等待。
实现核心:二进制帧 + Stream 流 ID
- HTTP/2 把每个请求拆成大量二进制小帧;
- 给每一个独立请求分配唯一
Stream ID; - 一条 TCP 管道里,不同 Stream 的帧可以乱序、交错发送。
举直观例子
页面同时需要 3 个资源:页面 HTML(流 1)、头像图片(流 2)、接口数据(流 3) 传输顺序可以是: 流 1 头部帧 → 流 2 头部帧 → 流 2 数据帧 → 流 1 数据帧 → 流 3 头部帧 → 流 3 数据帧 服务器收到帧后,根据 Stream ID 区分属于哪个请求,分别组装成完整响应返回。
哪怕流 1 的大文件传输卡顿,流 2、流 3 的小数据帧依旧可以正常收发,不会被前面慢请求阻塞,这就是多路复用。
疑问 1:并发处理多个请求,确实需要服务器支持并行逻辑,但光靠服务器并行处理,解决不了 TCP 管道里排队阻塞的问题
你设想的场景: 不用二进制帧,依旧用 HTTP1.1 文本报文;服务器多线程并行解析多个请求。 这里有个致命缺陷:TCP 是字节流,无边界。 举例子:客户端一次性把 2 个完整文本请求塞给 TCP 缓冲区:
GET /a HTTP/1.1\r\nxxx\r\n\r\n
GET /b HTTP/1.1\r\nxxx\r\n\r\n
数据在 TCP 通道里是连续的字节串,没有分割标记区分 "请求 A 完整结束、请求 B 开始"。 服务器内核收到一堆连续字节,必须逐字符扫描\r\n\r\n分割出完整请求报文,没读到完整的一段文本请求前,线程根本无法开始处理这个请求。
极端情况:请求 A 是一张超大图片,报文分段分批发到服务器,TCP 管道里塞满请求 A 的字节;哪怕请求 B 的小报文早就发送完成,混杂在后面,服务器也得等请求 A 完整分割完毕,才能读取、解析请求 B。 这就是 HTTP1.1 无法根治的队头阻塞,和服务器是不是多线程并行处理毫无关系。
疑问 2:二进制帧是解决「TCP 字节流无边界」的底层载体,是实现单连接并发的必要前提
HTTP/2 的二进制帧自带固定 9 字节帧头,提前标注了:当前帧数据长度、帧归属的流 ID。 带来两个关键能力,是纯文本报文做不到的:
- 天然报文边界,不用扫描分隔符 服务器读取 9 字节头部,立刻知道这一帧一共多少字节,直接截取对应长度数据;不需要逐字符查找换行符,哪怕不同请求的帧交错混在一起,也能瞬间拆分。
- 通过 Stream ID 隔离不同请求 每一条独立请求分配唯一流 ID,不同请求的数据被切割成独立帧。 传输顺序可以完全打乱:流 1 的一段数据帧、流 2 的完整头部帧、流 3 的数据帧交替传输。 服务器收到任意一帧,根据流 ID 丢给对应线程处理,不需要等待某一个请求的全部数据到达。
对比你的假设:只用二进制报文、不用帧结构,依旧不行
如果你只是把整个请求整体转为二进制、不拆分为带长度标记的独立帧: 本质还是一整块连续二进制数据流,依旧没有分段边界。大块请求的二进制数据占满 TCP 通道时,后面小请求的二进制块依然会被阻塞,服务器再怎么多线程并行也无法提前读取后面的请求。
一句话区分两个角色(分清谁负责什么)
- 服务器多线程 / 协程 :负责并行执行业务逻辑(并发计算、查库);
- 二进制帧结构 :负责在 TCP 传输层实现多请求数据交错传输、互不阻塞; 二者缺一不可。没有二进制帧,单 TCP 管道天然存在队头阻塞,服务器再强的并行能力也无从发挥。
极简总结
- TCP 是无边界字节流,HTTP1.1 文本报文靠换行分割,必须读完一整个请求才能处理,会发生队头阻塞;
- 二进制帧自带长度字段,天然区分数据边界,搭配 Stream ID 可以把多个请求拆成碎片乱序传输;
- 服务器并行只能加快业务处理,不能改变 TCP 流的排队特性;二进制帧才是实现单 TCP 并发多请求的底层基础。
四、服务端主动推送数据
1.先讲核心前提:推送必须基于客户端一次正常请求
HTTP 原生模型永远是「客户端先发请求」,HTTP2 推送不能凭空推送,规则:
- 客户端发正常请求(比如请求首页
GET /index.html); - 服务器解析首页后,预判页面一定会依赖配套资源(css、js、图片);
- 服务器主动把这些配套资源推送给客户端,不用等客户端再单独发请求。
举个场景: 浏览器请求首页 index.html,页面里引用了 style.css、main.js。 HTTP1.1 流程: 浏览器拿到 html,解析到 css/js → 再分别发送 2 次请求下载文件。 HTTP2 Push 流程: 服务器收到 html 请求后,不等浏览器解析页面,主动把 css、js 一起发给浏览器,省去后续两次请求。
2.底层怎么实现?依靠 HTTP2 二进制帧(PUSH_PROMISE 帧)
推送的核心载体是 PUSH_PROMISE 二进制帧,拆解工作流程:
-
客户端发起普通请求 客户端发送 HEADERS 帧(Stream A,流 ID 奇数,客户端创建的流)请求
/index.html。 -
服务器发送 PUSH_PROMISE 推送承诺帧 服务器判断需要推送
style.css:- 新建一个推送流 Stream B(偶数流 ID,服务器主动创建);
- 发送
PUSH_PROMISE帧给客户端,帧内包含两件事: ① 要推送的资源路径/style.css; ② 本次推送对应的父流 ID(也就是请求 html 的 Stream A)。
PUSH_PROMISE = 推送预告:先告诉客户端 "我马上给你推送这个文件"。
-
客户端确认是否接收推送 客户端本地有缓存:如果本地已经存在最新的
style.css,可以发送RST_STREAM帧直接拒绝本次推送,服务器就停止传输该资源; 如果没有缓存,客户端静默等待接收推送数据。 -
服务器推送资源完整数据 服务器通过刚刚创建的偶数推送流 Stream B,发送 HEADERS 帧(响应头)+ DATA 帧(css 文件二进制内容),把完整资源推送到客户端。
-
客户端缓存推送资源 客户端把推送过来的 css 存入本地缓存,后续解析 html 需要加载 css 时,直接读取缓存,不再发送网络请求。
3.关键细节补充
1、流 ID 规则区分普通流、推送流
- 奇数 StreamID:客户端主动创建(正常页面、接口请求);
- 偶数 StreamID:服务器主动创建(仅用于 Push 推送资源)。
2、推送的限制(为什么现在很少用 Push)
- 推送是服务器预判,很容易过度推送:客户端资源已经缓存,服务器还强行推送,浪费带宽;
- 无法精准感知客户端本地缓存状态,只能盲目推送;
- HTTP3(QUIC)直接废弃了 Server Push 功能,主流网站(抖音、B 站)基本关闭 HTTP2 推送。
4.极简总结
- HTTP2 服务端推送不能主动凭空发资源,必须依托客户端的一次原始请求;
- 核心依靠
PUSH_PROMISE二进制推送预告帧,服务器提前告知客户端要推送的资源路径; - 服务器新建偶数 ID 的推送流,主动传输配套 css/js/ 图片资源;客户端可根据本地缓存拒绝推送;
- 作用:提前推送页面依赖资源,省去客户端二次请求,优化加载速度;但因过度推送问题,线上很少开启。