
如果你维护过一段时间服务端大概率见过这种场面页面要加载几十个静态资源浏览器同一时间却只能在一个域名上开 6 个连接后面的请求只能排队。HTTP/1.1 时代还有个 Pipelining 机制理论上能在一条连接里连续发多个请求但因为响应必须按顺序返回、中间代理实现混乱实际基本没人敢开。HTTP/2 的核心改变就在这里——通过一层二进制分帧层把请求和响应的每个部分拆成独立的帧再用多路复用让它们在一条 TCP 连接里交错发送。这篇内容我会拿真实代码演示 HTTP/2 的二进制分帧、多路复用到底怎么工作也会讲清楚它和常说的 select、epoll 这类 IO 多路复用到底是不是一回事。适合正在排查连接瓶颈、准备做 HTTP/2 迁移或者想看懂抓包里各种帧的开发者。1. HTTP/2 为什么会出现HTTP/1.1 时代的三个死穴1.1 连接数上限与队头阻塞HTTP/1.1 的模型很直白每个请求基本对应一个请求-响应周期连接用完可以 Keep-Alive 复用但一条连接上始终是排队处理。浏览器为了限制资源占用对同一个域名默认只保持 6 个 TCP 连接不同浏览器略有差异页面里几十个请求就得塞进这些连接里一个个跑。某张图片、某个接口慢一点排在后面的所有请求都跟着等这就是应用层的队头阻塞Head-of-Line Blocking。这不是服务器故意刁难而是协议本身没有一个机制让多个请求在同一条连接上并行交错。你可能想问为什么浏览器不多开几个连接开多了TCP 握手、TLS 握手、慢启动的开销都会放大服务端的内存上下文切换也会被拖累而且每多一条连接就是在和网络争抢带宽效果并不划算。所以真正的问题不是连接数够不够而是单条连接能不能承载更多并行请求。1.2 头部冗余和明文协议本身的低效另一个被低估的问题是头部。一个典型请求里Cookie、User-Agent、Accept、Referer 这些字段动辄几百字节而且每次请求几乎原样重发。HTTP/1.1 没有头部压缩机制静态资源多的页面头部体积能占到总传输量的 30% 以上。小接口更明显可能实际数据只有 200 字节头却有 400 字节一来一回全在传重复信息。再加上 HTTP/1.1 是行文本协议解析器要按 CRLF 逐行找边界字段多了边界判断也烦琐代理改包转发更麻烦。1.3 SPDY 实验与 HTTP/2 的诞生HTTP/2 并不是凭空设计出来的它的很多特性在 SPDY 里已经验证过。Google 早年为了降低 Web 页面的加载延迟在 Chrome 和自己的服务器上用了 SPDY核心思路就是二进制分帧、多路复用、头部压缩。IETF 在 SPDY 的经验基础上推出了 HTTP/22015 年正式发布 RFC 7540之后主流浏览器和服务器逐步铺开。这里有个容易被忽略的细节HTTP/2 没有改动 HTTP 的语义。方法、路径、状态码、Header 这些概念都保留着改的只是它们如何在网络上传输。所以对业务代码来说通常不需要改任何接口逻辑只需要在网关或者服务器软件上开启 HTTP/2。这也是它能快速铺开的原因——升级协议但不动业务。2. 二进制分帧层多路复用的地基2.1 流、消息、帧三个概念先分清要理解二进制分帧先把三个概念拆开流Stream、消息Message、帧Frame。帧是 HTTP/2 最小的数据传输单位一个帧头加一段负载消息由若干帧组成语义上对应一个完整的 HTTP 请求或响应流是承载消息的虚拟通道每条流有一个唯一 ID。打个比方帧像快递包裹消息像一箱货流像这箱货专属的运输线路。一个请求发起时客户端打开一条流在这条流上发送 HEADERS 帧消息头部、DATA 帧消息体服务端在同一条流上回复响应帧。多条流可以同时存在属于不同流的帧在网络上是交错排布的接收端根据帧头里的 Stream ID 把它们分拣出来又拼回完整消息。这就是“分帧”的意义单条连接不再是一根只能走一辆车的窄路而是多条逻辑车道共用同一条物理道路。2.2 9 字节帧头逐位拆解每个帧开头都有一个 9 字节的定长帧头这是二进制协议比文本协议高效的关键。帧头的字段分布为字段长度说明Length3 字节帧负载的长度最大 16777215 字节Type1 字节帧类型Flags1 字节标志位如 END_STREAM、END_HEADERSR Stream Identifier4 字节1 位保留位加 31 位流 ID看到这个结构就明白接收方拿到前 9 个字节就知道这一帧多大、什么类型、属于哪条流完全可以按字节块去切分不需要像文本协议那样逐行扫描。HTTP/2 的高吞吐基础就在这里长度是前置的、定长的缓冲区管理可以更高效。有一点容易看漏Stream Identifier 用 31 位不是 32 位。最高位保留必须为 0。客户端发起的流 ID 是奇数服务端推送的流 ID 是偶数0 号流不用来传数据只用于连接级别的控制帧。所以只要看 ID 的奇偶就能判断谁主动开启了这条流。2.3 常见帧类型与用途速查表帧类型十六进制用途DATA0x0传输 HTTP 消息体正文HEADERS0x1打开流携带 HTTP 头部包含 :method、:path 等伪头部字段PRIORITY0x2设置或调整流的依赖关系和权重RST_STREAM0x3立即终止某条流常用于取消请求或上报错误SETTINGS0x4协商连接级参数如初始窗口大小、并发流上限PUSH_PROMISE0x5服务端推送前的预告告诉客户端即将在偶数流上推送数据PING0x6连接级健康检查测量 RTT也能检测链路是否还活着GOAWAY0x7服务端通知客户端停止新建流准备优雅关闭连接WINDOW_UPDATE0x8调整流量控制窗口告诉对端可以继续发送多少字节CONTINUATION0x9HEADERS 太大时用于继续携带剩余头部块日常调试中接触最多的是 HEADERS、DATA、SETTINGS、WINDOW_UPDATE。抓包时看到 HEADERS 后面跟着几个 DATA再看到对端发来的 WINDOW_UPDATE基本就是一次正常的数据交换过程。另外要注意头部压缩后的 HPACK 块也是放在 HEADERS 和 CONTINUATION 里的所以一个头部很大的请求可能会拆成多个帧传输接收方需要等 END_HEADERS 标志出现才算收到完整头部。2.4 为什么二进制分帧优于文本行解析HTTP/1.1 是纯文本协议用空格、冒号、CRLF 分隔各个部分解析器必须一点一点读、找边界、处理转义字段顺序稍微变化解析路径就不同。HTTP/2 的帧头是定长二进制长度、类型、流 ID 都是固定字段解析起来就是“读 9 字节头-算长度-读负载”天然适合高吞吐和零拷贝实现。更重要的是可扩展性。文本协议要在不破坏原有解析的前提下加新字段很麻烦二进制协议有 8 位的 Type 字段未来注册新的帧类型就能扩展功能兼容性处理也集中到“遇到未知帧类型”这一种情况。HTTP/2 后来能平滑支持很多新扩展跟这个设计直接相关。2.5 HPACK头部压缩的另一块拼图二进制分帧解决了并发问题头部压缩则解决传输浪费问题。HTTP/2 使用 HPACK 压缩头部原理不复杂维护一张静态表常见 header 名和值的固定索引、一张动态表本次连接过程中双方共同维护的实时索引再用 Huffman 编码压缩字段值。第一次传输 Authorization 这种长值没法压缩多少但第二次同样内容只要发一个索引号省下来的字节非常可观。需要留意HPACK 压缩的上下文和连接绑定。服务端背后挂多台实例时动态表是各自独立维护的不存在跨连接共享所以负载均衡算法对压缩率也有影响——同一台机器上长连接越多、复用越多压缩收益越大。3. 多路复用一条连接上的并发魔法3.1 多路复用如何工作多路复用这个词听起来玄乎实际场景很好理解。假设客户端要同时请求 4 个接口 A、B、C、D在 HTTP/1.1 里它们只能分到不同连接上或者在同一连接上排队。HTTP/2 里客户端在一条 TCP 连接上建立 4 条流每条流的帧互相穿插先发流 1 的 HEADERS再发流 2 的 HEADERS等接口数据到了又发流 1 的 DATA 和流 3 的 HEADERS。服务端不需要等流 1 完整接收就可以开始处理流 2、流 3。接收端重组数据时并不困难每个帧都带着 Stream ID按 ID 存进对应流的缓冲区等消息里的帧齐了就拼起来。这样单条连接上就可以同时承载大量请求延迟高的接口不会堵住后面所有请求。浏览器同域连接数上限的约束也被绕开了因为一个域名只需要一条或者少数几条连接就够了。3.2 和 HTTP/1.1 Pipelining 到底差在哪HTTP/1.1 确实提出过 Pipelining允许客户端在一个连接里连续发送多个请求不用等上一个响应。但它的硬伤是响应必须按请求顺序返回。也就是说即使服务端已经处理完了第 2 个请求也得等第 1 个请求的响应发完才能发第 2 个。一旦第 1 个请求是个慢接口后面全被堵住这就是它天然存在的队头阻塞。HTTP/2 的流之间互相独立没有这个约定响应可以乱序到达。慢请求就慢慢等它的 DATA快请求的 HEADERS、DATA 可以提前返回。浏览器端也不需要对消息做额外的顺序调度因为每条流本来就是独立的逻辑会话。这个差异比连接数上限更本质是协议模型从“串行队列”变成“并行通道”的关键。3.3 应用层多路复用 vs IO 多路复用每次讲 HTTP/2 多路复用总有人把它和 Linux 的 IO 多路复用select、poll、epoll搞混。这两个词只是中文都叫多路复用层次完全不一样。IO 多路复用是操作系统提供的事件通知机制让一个线程同时监听成百上千个 socket 文件描述符哪个 socket 有数据可读、可写内核就通知用户程序去处理。这是服务端解决 C10K 的基础避免为每个连接开一个线程。HTTP/2 的多路复用是应用层的机制解决的是“一条 TCP 连接里怎么并发传多个 HTTP 消息”的问题。两者不冲突反而是配合关系。一个 HTTP/2 服务端要同时撑住几万个 HTTP/2 长连接依然要靠 epoll 来分发事件而客户端拿到一个 HTTP/2 长连接之后才能在上面用多路复用塞进几十个并发请求。顺带说一句网上偶尔能搜到 I2C 多路复用这类词那是硬件总线层面的开关芯片用来在一条 I2C 总线上选通不同设备和 HTTP/2 更是两码事。看到“多路复用”四个字先确认说的是哪一层再去套模型。3.4 流量控制与优先级多路复用不乱套的保障流一多就得防止某条流把带宽和缓冲吃光。HTTP/2 提供了基于窗口的流量控制原理类似 TCP 的滑动窗口每条流和整个连接都有窗口大小发送方只能发送窗口允许的字节数收到 WINDOW_UPDATE 帧后再继续发。初始窗口是 65535 字节双方也可以通过 SETTINGS_INITIAL_WINDOW_SIZE 调大比如把窗口调到 1MB 减少小帧往返。优先级则是靠 HEADERS 帧上的 PRIORITY 标志和 PRIORITY 帧来维护。每个流可以声明自己依赖哪个流、权重多少浏览器加载页面时会给首屏关键资源更高的优先级图片和脚本可以排到不同的依赖树上。服务端的调度算法可以根据这些信息安排发送顺序。不过要注意流控和优先级都不是强制 QoS具体怎么调度还是服务器自己实现所以实际表现会因服务器而异。4. 实战写一个 HTTP/2 多路复用 Demo4.1 准备证书与运行环境先说明HTTP/2 有两种工作模式h2基于 TLS和 h2c明文 TCP。主流浏览器只支持 h2所以生产环境基本都用 TLS本地调试如果不想碰证书可以用 curl 加参数测 h2c但 Node.js 的 http2 模块默认也支持 createServer 监听明文 h2c。为了更接近真实部署下面直接用 TLS 模式演示。openssl req -x509 -newkey rsa:2048 -keyout localhost-key.pem -out localhost-cert.pem -days 365 -nodes -subj /CNlocalhost这段命令生成一个自签名证书-nodes表示私钥不加密调试更顺手。证书只在本地用位置放在项目目录之后 Nginx 配置也是同一套。4.2 服务端代码与关键点说明const http2 require(http2); const fs require(fs); const server http2.createSecureServer({ key: fs.readFileSync(localhost-key.pem), cert: fs.readFileSync(localhost-cert.pem) }); const delay (ms) new Promise(resolve setTimeout(resolve, ms)); server.on(stream, async (stream, headers) { const method headers[:method]; const path headers[:path]; const wait parseInt(headers[x-delay] || 0, 10); console.log([${new Date().toISOString()}] ${method} ${path} delay${wait}); // 模拟接口耗时故意让不同请求在不同时间返回 await delay(wait); stream.respond({ :status: 200, content-type: application/json; charsetutf-8 }); stream.end(JSON.stringify({ path, wait, now: Date.now() })); }); server.listen(8443, () { console.log(HTTP/2 server listening on https://localhost:8443); });注意几个点http2.createSecureServer和回调里的stream对象是 HTTP/2 的核心概念每次收到请求就是一条新流不需要像 Express 那样按请求来路由。headers里的:method、:path是伪头部字段表示 HTTP 语义信息。x-delay是我们自定义头用来模拟不同接口的延迟后面客户端全靠它验证乱序返回。4.3 客户端并发请求与耗时对比const http2 require(http2); const client http2.connect(https://localhost:8443, { rejectUnauthorized: false // 自签名证书环境下跳过校验 }); const send (path, wait) new Promise((resolve) { const req client.request({ :method: GET, :path: path, x-delay: String(wait) }); let body ; req.on(data, (chunk) { body chunk; }); req.on(end, () resolve(JSON.parse(body))); req.end(); }); (async () { const start Date.now(); const tasks [ send(/api/a, 300), send(/api/b, 100), send(/api/c, 200), send(/api/d, 50) ]; const result await Promise.all(tasks); const cost Date.now() - start; console.log(返回结果, result); console.log(总耗时, cost ms); console.log(若串行执行理论耗时为 30010020050 650ms); client.close(); })();这段代码一次发起 4 个请求每个请求通过x-delay头让服务端故意延迟 300ms、100ms、200ms、50ms。如果连接是串行的总耗时应该是 650ms 左右如果多路复用生效4 条流并行发送总耗时接近最慢的那条 300ms。实际跑下来基本在 300ms 上下说明请求确实在同一条连接里并发交错。运行结果里还会看到返回顺序不固定服务端在 100ms 左右先把/api/b返回了50ms 的/api/d可能最后才打印这就是流独立调度的直观证据。4.4 用 DevTools 和 curl 验证连接复用业务代码跑通后怎么证明客户端真的只建了一条连接最简单的是 curl。带上-v参数握手阶段会输出 ALPN 协商结果curl -k -v --http2 https://localhost:8443/api/a -H x-delay: 100正常情况下日志里会出现ALPN: server accepted h2说明当前会话是 HTTP/2。把--http2去掉或者服务端没开 http2则会显示HTTP/1.1。注意本地自签名证书记得加-k如果用的是受信任证书则不需要。生产环境排查时也可以直接用curl -I看响应头里的HTTP/2 200状态行来判断协议版本。浏览器里可以用 DevTools 的 Network 面板右键表头勾选 Protocol能看到请求的协议列都是 h2。部分 Chrome 版本可以在实验选项里打开 Connection ID显示每个请求落在哪条连接上。更严谨的做法是 Wireshark 抓包确认下一节细说。4.5 Nginx 开启 HTTP/2 的配置参考生产环境最常见的做法是让 Nginx 作为边缘节点把 HTTP/2 的功劳放在接入层后端节点保持 HTTP/1.1业务代码完全不用动。配置分两代写法取决于 Nginx 版本。老版本1.25.1 之前server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/nginx/certs/localhost-cert.pem; ssl_certificate_key /etc/nginx/certs/localhost-key.pem; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Connection ; } }新版本server { listen 443 ssl; http2 on; server_name example.com; ssl_certificate /etc/nginx/certs/localhost-cert.pem; ssl_certificate_key /etc/nginx/certs/localhost-key.pem; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Connection ; } }Nginx 1.25.1 之后http2 从 listen 参数变成了独立的指令如果你直接用老语法版本升级后可能报未知参数。一处小坑值得提前知道。还要留意Nginx 和后端 Node 服务之间通常走 HTTP/1.1所以我在 proxy 配置里写了proxy_http_version 1.1并清空 Connection 头避免 Keep-Alive 语义和上游冲突。浏览器到 Nginx 是 h2Nginx 到后端是 HTTP/1.1这种混搭完全正常。5. 常见问题与排查经验5.1 问题速查表现象可能原因排查/处理curl -v 显示 HTTP/1.1服务端未开启 http2或客户端不支持 h2升级 curl检查 Nginx 的 listen/http2 on确认 ALPN 协商结果浏览器 DevTools Protocol 列是 http/1.1页面域名没有协商 h2或中间层降级清缓存强刷确认没有负载均衡器把 ALPN 剥掉抓包看不到 HTTP/2 帧TLS 加密默认看不到明文导出 SSLKEYLOGFILE在 Wireshark 配置 Pre-Master Secret多个请求仍然排队中间代理放行连接有限制后端连接池不足客户端实现老旧检查客户端到边缘节点是否真的 h2检查代理是否保持长连接多路复用下慢请求仍拖累整体TCP 层丢包或拥塞导致 TCP 队头阻塞优化丢包率弱网场景考虑 HTTP/3Nginx 更新后提示 http2 参数问题新版本语法变化改用http2 on;指令这些坑我基本都踩过一轮。最隐蔽的是中间代理用户到 CDN 是 HTTP/2CDN 回源可能降级成 HTTP/1.1反过来也有回源是 HTTP/2 但边缘给用户只提供 HTTP/1.1 的情况。排查时不要只盯着最后一跳把整个链路的协议版本打出来看。5.2 抓包验证 HTTP/2 帧的几个实用姿势想亲眼看帧Wireshark 是首选。步骤先开一个终端设置环境变量然后启动 curlexport SSLKEYLOGFILE/tmp/h2keys.log curl -k -v --http2 https://localhost:8443/api/a -H x-delay: 100这个环境变量会把手套阶段的密钥记到文件里Wireshark 里打开 Preferences - Protocols - TLS把 (Pre)-Master-Secret log filename 指向同一文件。然后抓本机回环口的包过滤http2就能看到 HEADERS、DATA、SETTINGS、WINDOW_UPDATE 这些帧了。看的时候重点分清帧的归属每行前面会显示 Stream ID比如 Stream ID 1 的 HEADERS 和 DATA 就属于同一条流。把 4 个请求一起发起来能在抓包里看到不同 Stream ID 的帧互相穿插这就是多路复用最直观的证据。5.3 性能调优与迁移建议如果你现在还在 HTTP/1.1 上迁移前先做三件事第一把证书、TLS 版本和 ALPN 配置好第二用一个测试域名压量对比首屏几个关键请求的耗时第三准备监控连接数和协议版本分布。HTTP/2 对“大量小请求、首屏资源多”的页面收益最明显因为头部压缩和多路复用同时作用对大文件下载、播放这类长连接场景收益有限有时还可能因为多路复用和流控导致单个流带宽变窄。很多前端资源优化策略要做调整。HTTP/1.1 时代为了减少请求数把 JS、CSS 拼命合并HTTP/2 下请求成本大幅下降过度合并且会牺牲缓存粒度改一次就要重新下载整包。域名分片把资源分散到多个子域反而有害它强迫浏览器建立更多连接触发连接上限的假设已经不成立了。资源合并的分寸建议压测后按实际情况定。提示Nginx 1.25.1 之后不要再把 http2 写在 listen 指令里应单独使用http2 on;。这块语法坑在版本升级时很容易翻车。6. 写在最后的个人体会我开始用 HTTP/2 的时候最大的感受不是单个请求变快而是首屏的“等待感”消失了。HTTP/1.1 下慢接口会堵住后面的脚本下载换成 HTTP/2 后慢请求自己慢慢走快的资源早就返回了。后来做协议类调试多了越来越意识到HTTP/2 的分帧和多路复用看起来是性能优化本质上是在调整整个传输模型的并发能力让一条连接真正变成多条逻辑通道而不是靠堆连接数硬扛。最后分享一个调试小技巧怀疑多路复用没生效时别光看 Network 面板直接在 Nginx 日志里记$http2变量确认每个请求是否走了 h2再用 tcpdump 抓一下看对端 IP:port 是不是同一个。很多“HTTP/2 没用上”的怪问题最后都出在中间代理或旧客户端上。技术上的结论可以大胆应用但生产环境的每一步替换还是要用数据和监控说话。