
去年排查一个诡异的线上问题服务端日志显示响应早就写完了客户端却一直卡在等待直到超时才报错。抓包一看TCP 层数据确实已经到达对端问题不在传输层而在更上层——那串字节流里HTTP/2 的帧边界没有按协议预期被解析。从那天起我开始认真啃 HTTP/2 的帧格式也顺手把 python-hyper 生态里的hyperframe库用了个遍。这篇文章就是基于那段排查经历整理出来的从帧头 9 个字节怎么读到用 hyperframe 构造和解析真实帧再到联调时容易翻车的几个细节一次说清楚。适合正在做网络协议开发、自研代理、或者单纯想把 HTTP/2 抓包数据看明白的读者。1. 为什么你要关心 HTTP/2 帧的物理细节1.1 从一次数据到了但连不上的排查看起先说那个线上问题。我们有个内部网关基于 HTTP/2 做长连接转发。某天监控报警说上游服务返回之后下游迟迟收不到响应连接数一路涨到接近上限。一开始怀疑是业务线程阻塞后来用 tcpdump 在网关两侧同时抓包对比发现一个很有意思的现象上游已经把那串字节完整推给了网关网关的 socket 缓冲区也确实拿到了这些数据但应用层就是没有把响应吐出去。问题出在哪HTTP/2 是一个二进制的分帧协议TCP 拿到的是一段连续的字节流协议栈必须自己从字节流里切出一帧一帧来。如果切帧的逻辑有bug比如某帧声明了 20 字节的长度但实际收到的 payload 不够解析器就会一直等下去表现为数据明明到了请求却挂住。那次事故最后定位到是代理层对 CONTINUATION 帧处理不完整导致一个 HEADERS 帧永远没闭合。这让我意识到不管上层封装得多么友好帧的物理格式你迟早得看懂。1.2 帧是 HTTP/2 的最小通信单元HTTP/2 相比 HTTP/1.1最大变化是把一个连接变成了可以同时跑多个请求的多路复用通道。这里有一个清晰的层级关系连接Connection是 TCP 之上的一个 HTTP/2 会话流Stream是一条虚拟的双向通道而帧Frame是连接上最小的通信单位。一个请求可以拆成 HEADERS 帧、DATA 帧放在一个流里多个流又可以交错地在一个连接上传输。帧负责承载数据流负责标识这些数据属于哪个请求。TCP 是纯粹的字节流没有消息边界所以 HTTP/2 必须自己解决从哪开始、到哪结束的问题。它的方案很直接每个帧前面固定放一个 9 字节的帧头帧头里第一个字段就是 payload 长度。解析器读满 9 字节根据长度字段再读相应字节如此往复就像一节节车厢拼成整列火车——TCP 是铁轨帧是车厢帧头是车厢上贴的标签。理解了这一点你就明白为什么帧级解析工具在网络排障里那么重要抓包软件帮你把车厢一节节标好了但你自己写的代码得学会怎么切。1.3 hyperframe 在 python-hyper 生态里的定位python 生态里有几个经常被提到但容易搞混的库h2、hpack、hyperframe它们都属于 python-hyper 项目。简单说hyperframe管帧的序列化和反序列化只负责字节层面的事情不关心 HTTP 语义hpack管 HEADERS 帧里的头部压缩与解压h2把帧和 HPACK 串起来实现了完整的状态机让你能直接收发 HTTP/2 请求。我习惯把 hyperframe 理解为帧级显微镜。它不像 h2 那样帮你把连接都管好而是给你一把能逐帧拆解的镊子——你喂给它一串字节它告诉你这是 SETTINGS 还是 PING、stream_id 是多少、flags 有哪些、payload 是什么。这种粒度在日常业务代码里少见但在自研中间件、协议测试、抓包分析脚本里几乎是标配。2. 拆开 9 字节帧头每个 bit 都在说话2.1 帧头五要素Length / Type / Flags / R / Stream ID每个 HTTP/2 帧的前 9 个字节是帧头long long ago 我第一次对着 RFC 7540 啃的时候觉得这 9 字节很神秘其实逐个拆开看非常简单字段长度说明Length3 字节24 位帧 payload 的长度不包含帧头本身Type1 字节8 位帧类型0x0 到 0x9 共 10 种Flags1 字节8 位帧类型相关的标志位R1 位保留位必须为 0收到非 0 应视为 PROTOCOL_ERRORStream Identifier4 字节中的 31 位流标识0 表示连接级帧有几个设计细节值得啰嗦一下。Length 字段设计成 24 位而不是 32 位是因为它最多能表示 2^24 - 1 16777215 字节也就是约 16MB。这个上限和 SETTINGS 帧里可选配置的 MAX_FRAME_SIZE 是对应的——你可以在 SETTINGS 里把单帧上限调到 16MB但不能再大。Stream Identifier 只有 31 位最高位被 R 占用这意味着流 ID 最大是 2^31 - 1超过之后理论上应该开新连接虽然现实中很难遇到。还有一个特别容易忽略的点Stream ID 为 0 的帧是连接级控制帧只能出现 SETTINGS、PING、GOAWAY、WINDOW_UPDATE 这几种。如果你在解析的时候发现一个 DATA 帧的 stream_id 是 0不用犹豫这连接已经被污染了。Wireshark 里看 http2 层的流前几个 stream_id 为 0 的帧全是这一类连接自己的声音。2.2 十种帧类型与常用 FlagsRFC 7540 定义了 10 种帧类型实际工作中常见的大概一半。我列个速查表建议收藏Type 值帧名称作用常见 Flags0x0DATA传输请求体/响应体END_STREAM(0x1)、PADDED(0x8)0x1HEADERS打开流并携带请求头/响应头END_STREAM、END_HEADERS(0x4)、PADDED、PRIORITY(0x20)0x2PRIORITY调整流的优先级无payload 固定 5 字节0x3RST_STREAM终止一个流无0x4SETTINGS连接级参数协商ACK(0x1)0x5PUSH_PROMISE服务端主动推送很少用END_HEADERS、PADDED0x6PING心跳/往返时延测量ACK(0x1)0x7GOAWAY优雅关闭连接通知对端停止新建流无0x8WINDOW_UPDATE流量控制窗口增加无0x9CONTINUATION续传未装完的 HEADERS 块END_HEADERSFlags 是连着 Type 一起理解的。同一个标志位在不同帧里含义完全不同最典型的就是 0x1在 DATA 帧里表示这是流的最后一帧在 SETTINGS 和 PING 帧里表示这是确认帧 ACK。所以解析时不能只读 flags 的 bit 值还要结合 type 才能翻译成人话。2.3 用 FrameHeader 解析一段裸字节搞清楚了字段直接上代码。假设你在抓包里看到一帧的完整 9 字节帧头是下面这串 HEX00000c010400000003手工拆一下前 3 字节00000c是 Length12第 4 字节01是 Type1HEADERS第 5 字节04是 Flags4END_HEADERS后 4 字节00000003是 Stream ID3。这一帧的意思是流 3 上送来一个 12 字节的头部块这是一段 HEADERS 的最后一块。用 hyperframe 来验证from hyperframe.frame import FrameHeader raw bytes.fromhex(00000c010400000003) fh FrameHeader.parse(raw) # 返回 FrameHeader 对象 print(fh.length) # 12 print(fh.type) # 1对应 HEADERS print(fh.stream_id) # 3不同版本里fh.flags的展示形式可能略有差异有的是 set有的是枚举集合但这不影响我们逐字段验证。一个更省事的方式是直接用Frame.parse把整个帧解析成对象下一步就干这个。3. 用 hyperframe 把帧读成人类能懂的东西3.1 快速上手安装并解析第一个帧先说安装hyperframe是纯 Python 库没有依赖pip 一下就完事pip install hyperframe如果你想跑完整一点顺手把h2也装上后面联调要用pip install h2装好之后把一个完整的 HEADERS 帧丢给它解析from hyperframe.frame import Frame # Length12, Type1(HEADERS), Flags4(END_HEADERS), Stream3 # payload 是 12 个占位字节纯演示用 raw bytes.fromhex(00000c010400000003000102030405060708090a0b) frame, consumed Frame.parse(raw) print(frame) # HeadersFrame stream_id:3 flags:END_HEADERS print(frame.stream_id) # 3 print(frame.flags) # 依赖版本可以看到包含 END_HEADERS print(consumed) # 9 12 21表示这帧占 21 字节Frame.parse返回两个值解析出的帧对象以及这帧总共消耗了多少字节帧头 9 字节 payload 长度。这个设计很关键因为它正好匹配了我在开头说的那件事——从字节流里切帧时你必须知道每一帧的准确边界才能让指针安全地往下挪。consumed就是那个边界。3.2 高频帧的 payload 长什么样不同的帧解析完 body 之后的展示逻辑完全不一样。我把工作中最常碰到的几类列出来每一个都对应一个实际用法。SETTINGS 帧以键值对形式携带连接级参数。hyperframe 解析之后会生成一个字典键是Settings枚举。常见的键有HEADER_TABLE_SIZEHPACK 动态表最大大小默认 4096INITIAL_WINDOW_SIZE流级流量控制窗口初始值默认 65535最大上限 2^31 - 1MAX_FRAME_SIZE单个帧最大 payload默认 16384MAX_CONCURRENT_STREAMS最大并发流数HEADERS 帧payload 是被 HPACK 压缩过的头部块。这里必须强调一点Hyperframe 只把它当字节串不会帮你解压成 HTTP 头因为解压需要维护 HPACK 动态表的上下文那是 hpack 库的职责。你要是往 Hyperframe 的解析结果里找content-type那注定找不到。DATA 帧承载 HTTP 消息体payload 就是原始字节没有特殊结构。解析出来后充其量能看到长度和是否带 END_STREAM。WINDOW_UPDATE 帧payload 固定 4 字节高 31 位表示窗口增量。这帧会把增量值解析到对象的window_increment属性里。注意增量最低是 1如果为 0 会被对端认为是流控错误。PING 帧payload 固定 8 字节是任意不透明的回显数据。收到 PING 必须原样把这 8 字节放进 ACK 帧里送回去这是测量 RTT 的机制。GOAWAY 帧携带 4 字节last_stream_id和 4 字节错误码然后是一段可选的 debug 数据。它的作用是告诉对端这连接我要关了请不要再新建流。RST_STREAM 帧4 字节错误码用于快速终结某个流。用 hyperframe 的好处是这些帧类型解析完都是一等对象你统一打印就能看到类型和关键属性不需要自己对字节做一堆判断。3.3 解一段真实抓包数据实战中你拿到的往往不是单独一帧而是一段连续的字节。比如从 socket 里 recv 回来的 buffer 里可能一次包含好几个帧。循环解析是标准玩法from hyperframe.frame import Frame # 假设 sock.recv() 拿到的数据已经完整 data b\x00\x00\x12\x04\x00\x00\x00\x00\x00... # 演示数据 frames [] offset 0 while offset len(data): frame, consumed Frame.parse(data[offset:]) frames.append(frame) offset consumed for f in frames: print(f)这里有个小程序开发者的直觉坑Frame.parse在数据不足时大概率会抛异常所以不要把 recv 的数据直接丢进去。要先自己组包确认每个帧都收完整了再 parse如果只收到半个帧要么攒着要么用帧头里的 Length 字段算好还差多少字节再 recv。这个半包问题在写代理、写抓包脚本时几乎必然遇到提前打好预防针。4. 自己发一帧从构造到上线的完整链路4.1 为什么选本地 h2 服务端而不是公网站点说到自己动手发帧很多人第一反应是拿公网 https 网站练手。但这里有个现实问题HTTP/2 的大规模部署基本都基于 TLS也就是 h2明文版叫 h2c很少见你要先完成 TLS 握手才能在安全通道里发 HTTP/2 帧。TLS 本身没啥但会把演示变得很长而且大部分 CDN 节点对奇怪的握手行为也比较敏感。我建议在本地起一个用 h2 库实现的迷你服务端然后客户端用裸 socket 连上去手动发 preface 和 SETTINGS。整个过程完全可控Wireshark 抓 loopback 口就能看到你来我往的帧非常适合理解握手顺序。如果你就是想对真实站点练手也可以但记得先配好 TLS别在明文 socket 上浪费时间。4.2 三步构造一帧先设 stream_id再装 payload最后加 flagshyperframe 构造帧的套路非常统一拿 SETTINGS 帧举例from hyperframe.frame import SettingsFrame, Settings settings SettingsFrame(stream_id0) settings.settings[Settings.INITIAL_WINDOW_SIZE] 65535 settings.settings[Settings.MAX_FRAME_SIZE] 16384 data settings.serialize() print(data.hex())这三步的逻辑是先确定帧类型和它所在的流——SETTINGS 必须是连接级所以 stream_id 固定为 0然后往里填 payload 数据SETTINGS 的特殊之处是它的 payload 是键值对集合你直接往settings字典里放枚举键即可最后序列化serialize()会自己把帧头算出来组装成完整字节。再构造一个 PING 帧from hyperframe.frame import PingFrame ping PingFrame(stream_id0) ping.opaque_data babcdefgh # 必须是 8 字节 data ping.serialize() print(data.hex())PING 帧的 payload 必须恰好 8 字节多一个少一个序列化时都会出问题。如果你想让帧带上标志位比如给 SETTINGS 帧加 ACK就直接操作flags集合settings.flags.add(ACK)这种对象模式写起来很顺但别忘了序列化后的字节才是真正的协议数据调试的时候多打印 hex 和 WireShark 对照能少踩很多坑。4.3 裸 socket 完成 HTTP/2 握手客户端连接服务端、发送 preface 和 SETTINGS然后读回服务端的 SETTINGS这是 HTTP/2 连接建立的必经之路。HTTP/2 有一个固定前奏24 字节的 client connection preface内容是PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n有人问为什么会有这么个奇怪的字符串。其实是协议设计上的一个保险如果一个客户端误把 HTTP/1.1 请求发到只支持 HTTP/2 的端口服务端看到这个以PRI开头的 假 HTTP 方法 就知道协议不匹配可以立刻拒绝不会误以为是正常的 HTTP/1.1 请求。前奏之后紧跟着的通常是一个不带 ACK 的 SETTINGS 帧声明自己的连接级参数。我用 h2 库在本地起一个服务端监听 8080import socket from h2.config import H2Configuration from h2.connection import H2Connection server_sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_sock.bind((127.0.0.1, 8080)) server_sock.listen(1) print(waiting...) conn_sock, _ server_sock.accept() config H2Configuration(client_sideFalse) conn H2Connection(configconfig) conn.initiate_connection() data conn_sock.recv(65535) events conn.receive_data(data) for event in events: print(server got event:, event) output conn.data_to_send() conn_sock.sendall(output)客户端这边用裸 socket 连接手动发送 preface 和用 hyperframe 构造的 SETTINGS 帧import socket from hyperframe.frame import Frame, SettingsFrame, Settings PREFACE bPRI * HTTP/2.0\r\n\r\nSM\r\n\r\n settings SettingsFrame(stream_id0) settings.settings[Settings.INITIAL_WINDOW_SIZE] 65535 settings.settings[Settings.MAX_FRAME_SIZE] 16384 settings_bytes settings.serialize() client socket.create_connection((127.0.0.1, 8080)) client.sendall(PREFACE settings_bytes) resp client.recv(65535) # 服务端返回的第一段也是 preface跳过它再解析帧 server_frames_data resp[len(PREFACE):] while server_frames_data: frame, consumed Frame.parse(server_frames_data) print(client got frame:, frame) server_frames_data server_frames_data[consumed:] client.close()配合起来看你会在客户端打印出服务端发来的 SETTINGS 帧里面就包含了服务端允许的并发流数、窗口大小等参数。到这里一次从零发帧的握手就真正跑通了。4.4 验证闭环用 Wireshark 抓帧对比很多搞开发的人把 Wireshark 当抓包工具用其实它更是验证协议实现的利器。把上面程序跑起来的同时在 Wireshark 里选 loopback 网卡过滤条件写http2你会看到清晰的帧序列客户端 preface、客户端 SETTINGS、服务端 preface、服务端 SETTINGS每一帧的 Length、Type、Flags、Stream ID 都和代码打印的一一对应。我自己的习惯是把客户端 serialize 出来的字节print(data.hex())之后和 Wireshark 里的 Raw 数据逐字节比对。有一次我发现一个幽灵bug代码里明明设置的是MAX_FRAME_SIZE 16384抓包里却是 16385查了半天才发现是我在某个配置文件里给数值加了 1。这种问题不抓包很难发现因为 h2 库高层 API 不会主动帮你校验这种合法但不一致的选择只有帧级字节对比能揪出来。5. 帧级联调时最容易翻车的五个细节写帧级代码跑通hello world不稀奇真正的坑全在细节里。下面这几个问题全是实际联调中踩过的照着检查能省下大量时间。5.1 SETTINGS 的 ACK 不是可选项连接建立后双方都会收到对方发来的 SETTINGS 帧。协议要求收到不带 ACK 标志的 SETTINGS 帧之后必须回一个带 ACK 标志的 SETTINGS 帧里面不能带任何参数。很多人觉得这是小事忘了回结果连接在某些严格的实现里会一直等或者直接超时。更隐蔽的是服务端在发完自己的 SETTINGS 之后可能还在等待客户端的 ACK 才允许后续流创建。所以联调时第一件事就是检查客户端有没有在收到服务端 SETTINGS 之后发一个空载的SETTINGS ACK帧。5.2 帧长度超限与 GOAWAY 的翻脸每个连接都会通过 SETTINGS 里的MAX_FRAME_SIZE声明自己能接受的最大帧 payload默认 16384最大可以调到 16777215。如果你忽略了对方的这个参数直接发一个大帧对方会回一个 GOAWAY 帧错误码是FRAME_SIZE_ERROR0x6然后把整条连接关掉。更有意思的是这个字段在帧头 Length 里已经写死了对方的解析器会先看 Length 再看你 SETTINGS 里声明的上限——发出去的帧大小必须双方协商一致才能活下来。5.3 Stream ID 的奇偶规矩和同步边界HTTP/2 的流 ID 有明确的奇偶规则客户端发起的流 ID 必须是奇数服务端发起的是偶数。如果违反对方直接按PROTOCOL_ERROR处理。此外新流的 ID 必须比之前用过的所有流 ID 都大不能复用已关闭流的 ID。偶尔能看到客户端用奇数流 ID 发请求服务端用偶数流 ID 做 push这都符合协议。但如果你在写代理要特别小心不要把自己中转的流 ID 计算错了——奇偶颠倒、ID 回退都是会直接断连的硬伤。5.4 HEADERS 拆帧与 CONTINUATION 的续命这是我在文章开头那个线上故障里的根源也是新手最容易忽略的地方。一个 HEADERS 帧的 payload 上限受双方协商的MAX_FRAME_SIZE限制但 HPACK 压缩后的头部块可能很大cookie、token 一多就会超这时候协议允许你拆成多段第一段用 HEADERS 帧发后面几段用 CONTINUATION 帧发只有最后一段带END_HEADERS标志。解析时必须把 HEADERS 和后面的所有 CONTINUATION 拼起来才能得到一个完整的 HPACK 块。如果解析器只处理了一个 HEADERS 帧就宣布头部收完了那块的剩余部分会被当成下一个帧的起始字节整个流就乱了。写解析器的时候一定要维护当前正在组装头部块的状态别看到 HEADERS 就急着消费。5.5 HPACK 上下文依赖单帧无法自解释这一点和 hyperframe 本身的定位强相关HEADERS 帧 payload 是 HPACK 编码的其中会用索引引用动态表里之前出现过的头部字段。也就是说单独拎出一帧 HEADERS不结合这之前所有帧的状态你根本解不出完整的 HTTP 头。所以用 hyperframe 做帧级解析时看到frame.data里的二进制不要太惊讶——这不是 bug是它的职责边界帧层负责搬运压缩层负责解释。如果你真的需要拿到 HTTP 头的键值得同时使用 hpack 的Decoder并维护它的上下文这是另一套 API。6. 帧级 API 的选型建议什么时候用哪种工具最后聊聊工具选型。每次写协议相关代码都会有人问到底该用 Wireshark 还是 h2 库还是 hyperframe我的建议取决于你在做什么。工具/库擅长场景不擅长的场景Wireshark线上问题定位、协议教学、看真实流量行为自动化处理、自研中间件集成h2 库高层业务代码里直接实现 HTTP/2 客户端/服务端、代理精细控制帧级字节、排查帧格式问题hyperframe帧级协议解析脚本、帧格式验证、教学演示、中间件底层处理 HTTP 语义、自动维护连接状态手写字节解析完全掌控格式、加深对协议的理解生产环境重复造轮子、维护成本高我的经验是日常排查问题先用 Wireshark 看宏观行为需要写自动化工具时如果只关心这一帧是什么、长度多少直接上 hyperframe如果已经确定要管理连接、处理流生命周期别硬用 hyperframe 去拼状态机直接上 h2 库。很多人在协议开发里痛苦不是因为代码写得不好而是工具粒度没选对——拿显微镜看城市地图自然哪哪都不对。最后再分享一个小技巧。排查 HTTP/2 疑难杂症时我通常会在关键节点打一条日志把 recv 到的原始字节用.hex()打印出来同时开 Wireshark 抓同一段流量。然后两边一对照基本 10 分钟内就能定位是发送端的问题还是接收端的问题。这个习惯救过我很多次也希望对你有点用。