HTTP/2 多路复用:服务端开了 h2,页面为什么还在排队

HTTP/2 最容易让人误会的一点,是“服务端开了 h2”不等于页面就自动享受到多路复用。瀑布图里如果资源还在一批一批地排队,要么请求根本没走到同一条连接上,要么页面组织方式还停留在 HTTP/1.1 时代。

那次压测我看到的就是这种现象:三十多个静态资源前六个同时起跑,后面的却像老式火车一样一节一节往后排。问题不在浏览器没支持 HTTP/2,而在资源拆分和域名策略把连接又重新分散了。运维同事盯着截图看了一眼,只问了我一句:"你这些资源到底走了几个域名?"

后面的排查基本就围着这个问题展开:HTTP/2 解决了什么,为什么同样的页面在 h2 下仍然可能排队,多路复用、域名分片、TLS 握手和旧时代的优化习惯到底该怎么重新算账。

先把老账翻出来:HTTP/1.1 到底卡在哪

先别急着下结论,把 HTTP/1.1 的老毛病过一遍再说。浏览器对同一个域名的并发连接数是有硬上限的,大多数浏览器给到 6 条左右。一个页面如果有三十个资源全挂在同一个域名下,浏览器一次只能并行拉 6 个,剩下二十几个只能排队等前面让出连接——这正是我瀑布图里看到的"一批一批"的来源。

也正因为这个限制,前几年大家的套路才会是拼命减少请求数:文件合并、图片雪碧图、把静态资源拆到好几个域名上(域名分片,domain sharding)。

域名分片的算盘打得很清楚:每个域名各自有 6 条连接的额度,拆成 3 个域名,理论并发数直接翻三倍。这在当年是被写进各种性能优化清单里的"标准动作",几乎没人会去质疑它——直到 h2 把它的前提抽掉,才显出它是个时代性的权宜之计。

但这笔账不是白赚的。每加一个新域名,都要重新走一遍 DNS 解析、重新建 TCP 连接,HTTPS 下还要多一次 TLS 握手,这些握手开销在移动网络这种高延迟场景下相当扎眼。域名分片本质是拿"多花几次握手"去换"少排队",是被连接数上限逼出来的妥协,不是什么白捡的优化。这个背景很关键——因为等下要讲的问题,根源就在这。

顺便我也把"为什么偏偏是 6 条"这个一直没深究的数字查了查。这个上限不是协议规定的,而是浏览器厂商定的经验值:连接开太少,页面并发不够;开太多,会给服务器和网络中间设备造成压力,早年的服务器扛不住每个用户几十条连接同时打过来。

6 这个数是各家浏览器在"够用"和"不把服务器压垮"之间试出来的平衡点,Chrome、Firefox 后来基本都收敛到了 6。也正因为它是"每个域名 6 条",域名分片才有空子可钻——换个域名就换一套 6 条额度。理解了这个数字的来历,就明白域名分片不是什么高明技巧,纯粹是钻浏览器限制的空子,本质还是在跟"连接贵"这件事死磕。

还有个容易混的点我这次也顺手厘清了:并发连接数上限是"每个域名"算的,不是整个浏览器一共 6 条。所以拆域名能突破,但也不是拆越多越好——DNS、握手的固定成本会随域名数线性增加,业界的经验是分到两三个域名收益就见顶了,再多反而是握手成本盖过了并发收益。

这条经验在 HTTP/1.1 下都已经是有上限的权衡,到了 h2 更是直接作废,可谓一代新协议把上一代的整套小算盘全部清零。把这些前提摸清楚,我才明白后面运维那句"你这些资源到底走了几个域名"为什么是直指要害——他问的不是数量本身,是我有没有意识到域名这件事在新旧协议下的意义已经彻底反过来了。

队头阻塞:HTTP/1.1 排队的真正原因

运维同事接着问了我一个问题:"你知道 HTTP/1.1 一条连接上为什么非得排队吗?"我当时含糊地答了句"响应要按顺序回吧",他说八九不离十。

HTTP/1.1 里其实有个叫 pipelining(管线化)的特性,理论上允许一条连接上连续发好几个请求、不必等前一个响应回来。但它有个死规定:响应必须严格按请求发出的顺序返回。只要排在前面的那个响应慢了,后面已经处理完的响应也得憋在队列里等——这就是 HTTP 层的队头阻塞(head-of-line blocking)。

再加上各路代理、中间设备对 pipelining 的实现参差不齐,实际用起来问题一堆,主流浏览器干脆默认就没开过这个特性。所以现实里 HTTP/1.1 就是一条连接同一时刻只处理一个请求响应对,想要并发,只能开更多连接——又绕回了前面 6 条连接的限制。

这也是我想通的一个环——多路复用要解决的,正是 pipelining 想解决却没解决好的那件事:pipelining 试图在一条连接上排队发多个请求,但因为响应必须按序回,等于没真正并行;HTTP/2 把请求切成带流 ID 的帧、允许乱序交错,才算真正拆掉了这个顺序枷锁。可以说 h2 的多路复用是 pipelining 的正确实现版本。

他补了一句我没想到的:队头阻塞其实分两层。HTTP/1.1 这层是应用层的,HTTP/2 待会讲的多路复用能解决;但下面还压着一层 TCP 层的队头阻塞——TCP 保证字节按序到达,一旦有个包丢了,哪怕后面的包已经先到了,也得在内核缓冲区里干等着重传,谁都别想往上交。HTTP/2 因为把所有请求塞进了同一条 TCP 连接,这层 TCP 队头阻塞反而更容易被感知到。这个问题要靠后面基于 UDP 的下一代协议才能根治,我只是听同事提过一嘴还在草案阶段的东西,具体怎么解目前用不上,今天先把这层背景记住就够。

这里我追问了一句:既然 HTTP/2 反而更容易撞上 TCP 层队头阻塞,那它到底还算不算进步?运维同事的答复很实在——分场景看。

网络质量好、丢包率低的时候,HTTP/2 把几十个请求压进一条连接,省下的建连和握手成本是实打实的,TCP 队头阻塞几乎感知不到。但在丢包率高的弱网下,情况会反转:HTTP/1.1 开了六条连接,一条上丢包只卡这一条,另外五条照跑;HTTP/2 全押在一条连接上,一丢包所有流一起被卡在下面。所以在极端弱网环境,理论上多条 HTTP/1.1 连接反而更抗丢包。

这是个不太直观但很关键的取舍:**多路复用把并发从"多条连接"收敛成"一条连接多条流",收益是省握手、省连接,代价是把所有鸡蛋放进了一个 TCP 篮子。**他说我们做的是国内电商中后台,用户网络大多还不错,这个代价能接受,但心里得清楚它存在,别把 HTTP/2 当成任何网络下都稳赢的银弹。

为什么瀑布图还是阶梯状

把老底捋完,我重新看了一遍瀑布图。这时候才意识到一件事:页面开没开 HTTP/2 是一回事,静态资源走的是不是同一个连接,是另一回事。

我们在 Network 面板里右键表头,勾出 Protocol 这一列。主文档 www.example.com 显示的是 h2,没问题;但静态资源 static.example.com 那一列,显示的却是 http/1.1。运维同事一看就明白了:"CDN 那边配置没跟上,静态资源域名的证书和协议栈还没切完,客户端跟它握手用的还是老协议。"

这下瀑布图的怪象就解释通了:主文档走 h2,理论上不该排队,但页面真正的大头——JS、CSS、图片——全挂在 static.example.com 上,而这个域名还在用 HTTP/1.1,老老实实卡着 6 条连接的老规矩。之前为了绕开域名限制特意做的静态资源域名拆分,此刻成了拖后腿的那个环节。

这里其实藏着一个很反直觉的地方,我当时也是愣了一下才反应过来:静态资源域名拆分,在 HTTP/1.1 时代是为了"绕开 6 条连接上限"的正经优化,但它的前提是这个域名跑在 1.1 上。一旦主站切了 h2、静态域名却还没跟上,这个优化的前提就塌了一半——你既没享受到 h2 的多路复用(因为静态资源还在 1.1),又背着分片带来的额外建连成本,两头不讨好。所以这个 bug 的本质不是"h2 没生效",而是"一次没走完整的迁移,让页面卡在了新旧两套优化逻辑的夹缝里"。

运维同事去改 CDN 上的协议配置,我则趁着这个间隙,把 HTTP/2 到底靠什么解决排队问题,从头理了一遍。

多路复用:一条连接怎么同时跑多个请求

HTTP/2 解决排队问题靠的是"流"(stream)这个概念。它把一条 TCP 连接拆成很多条逻辑流,每个请求响应对占一条流,流上的数据被切成一个个带着流 ID 的二进制帧(frame),这些帧在同一条连接上交错发送,到了对端再按流 ID 重新拼装回完整的请求或响应。

这里有个概念分层帮我理清了一直含糊的几个词:连接(connection)是最外层,一条 TCP 连接;连接里跑很多流(stream),一个请求响应对一条流;流里的数据再切成帧(frame),帧是 h2 传输的最小单位,每个帧头上都写着自己属于哪条流。所以"交错"是发生在帧这个层面的——A 流的几个帧、B 流的几个帧、C 流的几个帧,在同一条连接上你一段我一段地混着发,接收端凭帧头里的流 ID 就能把它们各自归位。想明白"连接—流—帧"这三层的包含关系,多路复用为什么不会乱、优先级和流量控制为什么能分别作用在流和连接两个层面,就都顺理成章了。

这意味着多个请求是真的能同时跑在一条连接上的,不是排队,也不需要靠开多条连接来凑并发。等静态资源域名的协议配置改完,我刷新页面再看瀑布图:几十个资源几乎齐刷刷一起开始下载,那种"一批一批往后挪"的阶梯感彻底没了。

这一下我算是真正体会到"多路复用"四个字改的是什么,不再是书上一句话背下来的概念。之前我对它的理解停留在"能同时发多个请求",但同时发多个请求 HTTP/1.1 靠开多条连接也能凑;h2 真正的不同是在一条连接上就做到了并行,省掉了为并发而多开连接、多做握手的全部成本。区别看着细微,落到几十个资源、几百毫秒 RTT 的真实页面上,就是瀑布图从阶梯变成齐头并进的那种肉眼可见的差别。

不过"几十个流同时跑"又冒出一个新问题:带宽是有限的,几十个流一起抢一条连接的带宽,凭什么决定谁先跑完?如果关键的 CSS 和首屏 JS 跟一堆无关紧要的图片平分带宽,首屏反而更慢了。

HTTP/2 为这个准备了优先级(priority)和流依赖(stream dependency)机制:每个流可以带一个权重(weight,1 到 256),还能声明依赖在另一个流上,浏览器据此告诉服务端"这些流之间谁重要、谁该先传"。

理论上浏览器会把 HTML、CSS、字体这类阻塞渲染的资源标成高权重,图片这类往后放。我在 Chrome 的 Network 面板里把 Priority 这一列勾出来看,确实能看到 Highest、High、Low 这些标记,CSS 是 Highest,图片大多是 Low。

但运维同事泼了盆冷水:优先级只是浏览器给服务端的"建议",服务端认不认、怎么调度,各家实现差得很远,有些 CDN 干脆忽略权重按到达顺序发。所以别指望配了优先级就万事大吉,它是个锦上添花的能力,不是可以依赖的硬保证——真要保关键资源,还是得靠 preload 这种在 HTML 里就把请求提前发出去的显式手段。

这也直接推翻了域名分片这套老经验:HTTP/2 下一条连接就能扛住海量并发请求,拆多个域名等于白白多建几条连接、多几次握手,还会打散本该共享的头部压缩状态和优先级调度。

这里"打散共享状态"值得多说一句,因为它是很多人只知道"h2 不用分片"却说不清为什么的关键。HPACK 的动态表、流的优先级依赖树,都是绑在单条连接上的:同一条连接上第二个请求才能复用第一个请求进过表的头,优先级也只有在同一条连接上排在一起才谈得上谁先谁后。

你把资源拆到三个域名,就是三条独立连接、三张互不相通的动态表、三套各自为政的优先级,头压缩的复用率和调度的合理性全被切碎了。所以域名分片在 h2 下不只是"没必要",是会主动破坏 h2 好几个核心机制的收益——这比"多几次握手"这个表面代价要深一层,也是为什么这条经验值得单独拎出来记牢。

所以我们后面商量的方案是,等 CDN 那边协议都切好,静态资源要收敛回一个域名,而不是像以前那样拆。域名分片在 HTTP/2 下从优化变成了负优化,这一点当场就被瀑布图证明了。

顺带一提,二进制分帧还有个副作用:HTTP/2 的请求头请求体全变成了二进制帧,不再是 HTTP/1.1 那种一眼能读的纯文本,想拿 telnet 手敲一个请求调试是行不通了,只能靠浏览器开发者工具,或者 curl --http2 -v 这类支持 h2 的工具去看。

这个变化我一开始还有点不适应——HTTP/1.1 那种纯文本协议的好处是"人类可读",出问题时 telnet 连上去手敲几行就能试,抓包也一眼看懂。h2 换成二进制帧,可读性是牺牲掉了,但换来的是解析更高效、也不再有 HTTP/1.1 文本解析里那些模棱两可的边界情况(比如换行符、大小写、空白的各种歧义)。

这算是协议成熟的一个典型取舍:早期协议为了让人好上手用文本,用久了发现文本解析既慢又容易出安全问题,成熟阶段就转向机器友好的二进制。代价是调试门槛高了,得靠专门的工具,但对协议本身的健壮性是值得的。想通这一层,我对"为什么好好的文本协议要改成二进制"这种当时觉得多此一举的设计,也算有了体谅。

怎么确认一条连接真的在跑 h2

排查这类问题,第一步永远是先确认"到底走没走 h2",别凭感觉。我这次攒了几个手段,按从轻到重排。

最轻的是 Chrome 的 Network 面板,右键表头把 Protocol 列勾出来,h2 还是 http/1.1 一目了然,前面查静态资源域名就是靠它。它还有一列 Connection ID,把这列打开能看到哪些请求共用了同一条连接——如果你以为在复用、这列却给出一堆不同的 ID,那就是连接没复用起来,得回头查是不是域名不一致或者证书没配对。

命令行上最顺手的是 curl:

1curl -I --http2 -s -o /dev/null -w '%{http_version}\n' https://static.example.com/app.js

%{http_version} 直接打出 2 还是 1.1,写进巡检脚本每天扫一遍,协议悄悄降级了能第一时间发现。

想看更细的帧级交互,运维同事推荐了 nghttp -v,它把 HEADERS 帧、DATA 帧、SETTINGS、WINDOW_UPDATE 这些一帧帧打出来,连流 ID 和优先级都带着,是真正能看清"多路复用在这条连接上怎么交错跑"的工具。第一次用它看输出的时候,前面读到的那些概念——流 ID、帧、SETTINGS 里的并发流上限和窗口大小——全都活生生地打在屏幕上,比看多少遍文字都实在。

最后是 Chrome 自带的 chrome://net-internals/#http2,能列出当前浏览器所有活跃的 h2 会话、每条会话上开了哪些流。有一次我怀疑某个请求莫名其妙新建了连接而不是复用,就是在这个页面里对着会话列表才看明白:那个域名的证书 SAN 里没覆盖到,浏览器判定成了不同源,只好新开连接。这些工具平时用不上,但一旦"开了 h2 却没生效",它们比盯着瀑布图猜要快得多。

一条连接能同时开多少流:并发流上限与流量控制

多路复用听起来是"想开多少流开多少流",其实不是。这是我这次读细节才纠正过来的一个想当然。

HTTP/2 里有个设置项叫 SETTINGS_MAX_CONCURRENT_STREAMS,服务端会告诉客户端"这条连接上同时最多允许多少条活跃流"。Nginx 默认给到 128,很多 CDN 也是一百多。也就是说一条连接同时并行的请求有个上限,超出的请求得等前面的流结束、腾出名额才能开。

对绝大多数页面这个上限够用,几十个资源远没到 128;但如果某个页面短时间内要发几百个请求(比如一次性加载海量小图、或者某种轮询打得很密),就可能撞到这个上限,表现出来又是"莫名其妙排队"。所以真遇到 h2 下还排队,除了查协议有没有降级,也得想想是不是撞了并发流上限。这也从另一个角度说明,h2 下"请求便宜"是相对的,不等于"请求免费到可以无限发"——设计接口和加载策略时,一次性甩出几百个请求本身仍然是要克制的。

比上限更容易被忽略的是流量控制(flow control)。HTTP/2 在连接和每条流两个层面都有一个接收窗口,接收方通过 WINDOW_UPDATE 帧告诉发送方"我还能再收多少字节"。这个机制是为了防止一个慢消费者被快生产者用数据淹没,本身是好事。

但默认窗口不大(初始 64KB 左右),如果服务端或某些中间层没把窗口调大,大文件传输会因为窗口频繁耗尽、频繁等 WINDOW_UPDATE 而跑不满带宽。这类问题在浏览器里几乎碰不到(浏览器窗口调得很激进),但在服务端到服务端、或者自己写 h2 客户端时是真会踩的。我们前端用不着调这个,但知道"h2 传大文件慢"可能不是带宽问题而是窗口问题,方向上就不会跑偏。

这两个机制放一起看,能纠正一个初学者很容易有的错觉:多路复用不是"无限并发",它是"在一条连接上、受并发流数和流量窗口约束的、更省成本的并发"。理解了约束在哪,才不会在它撞到边界时又开始怀疑是不是协议没生效。

我把瀑布图的事跟后端同事提了一嘴,他顺口问了句:"那我们是不是可以不用太在意请求头大小了,反正你说会压缩?"这个问题把我问住了,我回去查了查才敢答他。

HTTP/2 的头部压缩用的是 HPACK 算法,思路是维护一张动态表:一条连接上第一次发出某个头(比如完整的 User-Agent)会整个传一遍,之后同一条连接上再发相同的头,只需要传一个索引号。一个页面几十上百个请求,每个都带着一坨重复的 Cookie 和 UA,这笔省下来的字节量相当可观——HTTP/1.1 里这些头是每个请求都要明文重发一遍的,纯粹浪费。

我回去查得更细一点才发现 HPACK 不止一张表。它其实是"静态表 + 动态表"配合霍夫曼编码三件套。

静态表是协议里写死的一份常见头列表,六十来项,:method: GET:status: 200content-type 这些高频头连名字带值都预先编好了号,两端都认,第一次发就能直接用索引,一个字节顶一整行。动态表才是连接建立后动态往里塞的、这条连接上出现过的头。就算某个头的值第一次出现、进不了索引,HPACK 还会用霍夫曼编码把这些字符串再压一道——所以哪怕是全新的头,传过去也比明文小。

这套设计的巧妙之处在于,它是专门为 HTTP 头这种"高度重复、字段名固定"的数据量身定的,通用压缩算法(比如 gzip)在这种小而碎的场景反而占不到便宜。

顺带说一句为什么 HTTP/2 不干脆对头也用 gzip——这正是 HPACK 存在的原因。HTTP/2 之前有过一个用 gzip 压缩头的方案叫 SPDY,后来被发现存在 CRIME 这类安全攻击:攻击者通过反复注入内容、观察压缩后的体积变化,能一点点猜出 Cookie 里的敏感字节。

HPACK 用查表加霍夫曼、刻意避开了那种"压缩比泄露内容"的路子,算是拿安全性换了一点压缩率。这段是我查资料时顺出来的,本来只当个冷知识,但它解释了一个很自然的疑问——既然都要压,为什么不复用现成的 gzip,答案是安全上踩过坑。顺便也解释了 HTTP/2 名字里那点渊源:它很大程度上是把 Google 那套 SPDY 实验的成果标准化、再补齐安全短板的产物,SPDY 本身在标准落地后就退场了。

但我跟他说,这不代表 Cookie 能随便塞。第一,动态表的头第一次出现还是要全量传的;第二,Cookie 会跟着同域下的每个请求发出去,包括那些压根用不上它的静态资源请求,静态资源域名最好做到零 Cookie;第三,头部过大照样容易撞上服务端的限制,比如 Nginx 的 large_client_header_buffers,超了直接报 400 Request Header Or Cookie Too Large——这个错我们线上是真见过的。

所以"反正会压缩"不能当成往 Cookie 里乱塞业务数据的理由,压缩省的是重复传输的成本,不是给你开绿灯乱堆数据。而且"静态资源域名零 Cookie"这条,在 h2 下反而更重要了:我们要把静态资源收敛回一个域名,如果这个域名跟主站共用了带 Cookie 的父域,那每个静态资源请求都会白白背上一坨用不着的 Cookie,几十个请求叠起来就是实打实的浪费。所以收敛域名的同时,我们特意确认了静态资源用的是一个独立的、不种业务 Cookie 的域名,两件事得配合着做。

还有一层他提醒我别忽略:动态表本身是有大小上限的,默认 4KB 左右,两端会通过 SETTINGS_HEADER_TABLE_SIZE 协商。这意味着能被索引缓存的头是有限的——如果你的请求里塞了大量各不相同的头,把动态表挤爆了,早先进表的条目会被挤出去,下次再发又得全量传一遍,压缩收益就打折了。

更要命的是,如果每个请求的 Cookie 都因为带了动态业务数据而各不相同,那它根本进不了"重复命中"的正循环,HPACK 对它就基本失效了。所以 HPACK 省钱的前提是"头高度重复",而不是"头无论怎样都能压小",这跟"Cookie 该保持稳定、静态资源该零 Cookie"其实是同一件事的两个说法。这个细节让我对"压缩"两个字的理解具体了很多——它不是魔法,是有明确适用前提的工程手段。

TLS 与 HTTP/2 绑在一起这件事

运维同事回来的时候我们顺便聊了另一件事:为什么 HTTP/2 一定要跟 HTTPS 一起上。技术规范里 HTTP/2 本身不强制要求加密,理论上存在明文跑 h2 的方式,但主流浏览器(Chrome、Firefox)压根就没实现这条路,它们只在 TLS 之上启用 HTTP/2。也就是说实际能用的 HTTP/2,等于是和 TLS 强绑定的。

这背后是个很现实的考量:中间设备(各种代理、网关、老旧的防火墙)对 HTTP/2 这种新协议未必认得,明文跑容易被中间设备当成异常流量强行拦截或者搞坏;裹在 TLS 里就是一段密文,中间设备干涉不了,只能原样转发。这个现象有个专门的名字叫"协议僵化"(protocol ossification)——网络里塞满了对老协议做了各种假设的中间设备,任何不裹加密的新协议都得先趟一遍这些设备的雷,加密恰好把新协议藏起来,绕开了这层阻力。

这也是为什么这次全站切 HTTPS 之后,HTTP/2 基本是顺手白捡的——浏览器一看是 TLS 连接,握手阶段的 ALPN 扩展协商一下双方都支持 h2,直接就用上了,不用额外配置太多东西。反过来说,如果哪天要抛弃 HTTPS 回退 HTTP,HTTP/2 的红利也跟着一起没了,这两个已经是绑定套餐。

ALPN(Application-Layer Protocol Negotiation)这个词我原来只在配置文档里瞟到过,这次专门弄清了它是怎么工作的。它是 TLS 握手里的一个扩展:客户端在发 Client Hello 的时候,就把自己支持的应用层协议列表塞进去,比如 h2http/1.1;服务端在 Server Hello 里回一个它选中的协议。整个协商跟着 TLS 握手一起完成,不额外多一个往返。

妙就妙在这里——不需要先建一条 HTTP/1.1 连接、再发 Upgrade 头去升级,那种老式升级方式要多一个往返,还容易被中间设备吃掉。ALPN 让"这条连接说 h2 还是 h1.1"在握手那一刻就定死了。

运维同事补充说,正因为协商藏在 TLS 握手里,你要在 Nginx 上开 h2,前提是这台 Nginx 编译时带了支持 ALPN 的 OpenSSL 版本,版本太老的 OpenSSL 没有 ALPN,只能退回到早就没人用的 NPN 甚至干脆上不了 h2——这也是为什么老服务器升 h2 有时候得先升底层的 OpenSSL。我们线上这次没踩这个坑,是因为运维早前统一升过一轮基础库,属于运气好在了看不见的地方。

Server Push:我们商量了一下,没敢上

协议这块聊完,运维同事提了句 CDN 那边支持 Server Push,问我们要不要试试——服务端可以在客户端还没请求的时候,主动把资源推过去,理论上能省掉一整个请求往返。

我们商量了一下,最后决定先不上,理由有三个。

一是 Server Push 不感知浏览器缓存,服务端不知道客户端本地是不是已经缓存过这个资源,照推不误,用户第二次访问时这笔推送就是纯浪费带宽。理论上有个 Cache-Digest 提案想让客户端把自己缓存了啥告诉服务端、让 Push 变聪明,但那东西还停在提案阶段,没法指望。

二是推送时机和优先级不好把控,如果推早了会占掉首屏 HTML 本该独享的带宽,反而拖慢关键路径。三是各家 CDN、各个 Nginx 版本对 push 的支持程度和配置方式都不太一样,调起来成本不低,双十一前这个节骨眼不适合冒险。

还有一点让我们更谨慎:Server Push 一旦推错,浪费的不只是带宽,还会占掉那条连接的流量窗口和并发流名额,反过来挤压真正需要优先下载的首屏资源。也就是说推得不准,不是"没帮上忙"这么简单,而是"帮了倒忙"。这种"用错了会变负优化"的特性,在双十一这种一分钟都不能出岔子的节点上,风险收益比明显不划算。

比起 Server Push,我们更倾向声明式的资源提示,效果可控、可解释:

1<link rel="preload" href="/static/app.css" as="style">
2<link rel="preload" href="/static/critical.js" as="script">
3<link rel="prefetch" href="/static/next-page.js">

preload 是告诉浏览器"这个资源当前页面马上要用,提前下载",prefetch 是"下一个页面大概率要用,趁浏览器空闲先下"。这两个我们已经在用,收益能拿数据说话,比把决策权完全交给服务端的 Server Push 让人放心。

运维同事还提到一个更微妙的判断:Server Push 和 preload 其实解决的是同一个诉求——把关键资源提前到浏览器"发现"它之前就开始传,省掉浏览器解析 HTML、发现 <link>、再发请求这一整个往返。区别在于谁来决策、什么时候开始。

preload 是浏览器读到 HTML 里那行 <link> 才开始,还是慢了半拍,得等 HTML 的头几个字节到达;Server Push 理论上更激进,服务端在返回 HTML 的同时就把 CSS、JS 一起推下来,连 HTML 都不用等解析。听上去 Push 更优,但那三条缺点——不感知缓存、时机难控、各家实现割裂——足以把这点理论优势吃掉。

他给的结论我记下来了:**在 HTTP/2 这个阶段,能用声明式 preload 解决的,就别赌服务端 Push;Push 是那种"看起来很美、真上生产要填一堆坑"的特性。**至于比 preload 更早的介入点,得等浏览器把 103 Early Hints 这类东西铺开,那还早,眼下不用操心。

资源策略要不要跟着重新算

瀑布图的事解决之后,我们顺势又讨论了一圈资源打包策略。以前 webpack 恨不得把所有代码打成一个 bundle.js,减少请求数;HTTP/2 下这套逻辑该反过来了——按路由、按依赖拆成多个小 chunk,改一个页面的代码只让对应的小 chunk 缓存失效,其余 chunk 照旧命中缓存,命中率高很多。

多个小文件并行下载在 HTTP/2 下几乎没有额外连接成本,跟 HTTP/1.1 时代的思路完全相反。以前一个大 bundle 最难受的地方就在缓存粒度太粗:改一行业务代码,整个几百 KB 的 bundle hash 全变,用户得把没动过的第三方库、公共代码全重新下一遍。拆开之后,动的只是那一小块,其余的缓存原封不动,对回访用户的体验提升是实打实的。

但"拆得越碎越好"也不对,这里有个边界得自己拿捏。文件拆得太碎,每个文件虽然没有连接成本,却各自带一份 gzip 头开销、各自要过一遍浏览器的资源发现和调度,几百个几 KB 的小 chunk 反而会让请求管理本身变成负担;而且 gzip、br 这类压缩算法对小文件的压缩率明显不如大文件,一个函数单独成文件时,压缩基本没什么可省的。

所以我们最后定的粒度不是无脑细拆,而是拿 webpack 的 splitChunks 按几条清晰的线来分:第三方依赖单独成一个 vendor chunk(改动少、缓存周期长),公共模块抽一个 common chunk,剩下按路由做异步拆分。这样既吃到了 HTTP/2 并行下载和精细缓存的好处,又没有碎到被小文件的固定开销反噬。从"合并到极致"到"按缓存生命周期分组",变的不是拆不拆,而是拆的依据从"凑请求数"换成了"谁跟谁的变更频率一致"。

那次讨论完我特意做了个对照验证,把静态资源收敛回一个域名前后的瀑布图各存了一张。收敛前,资源分散在两个域名上,两组各自要走一遍 DNS、TCP、TLS,Timing 面板里能看到重复的建连开销;收敛到一个域名后,除了第一个请求建连,后面全部复用同一条 h2 连接,Initial connection 和 SSL 两段直接空掉了。

数字摆在一起,"域名分片在 h2 下是负优化"就不再是从书上抄来的一句断言,而是我们自己环境里量出来的结论。不过这里也留了个尾巴:收敛域名有个前提是证书得覆盖到这个域名,而且第三方资源(比如统计脚本、字体)本来就在别人的域名上,你收敛不了它们,它们各自建各自的连接是没办法的事。所以"收敛回一个域名"针对的是自家可控的静态资源,不是把整个页面的所有请求都塞到一个域名下,这个边界得分清。

雪碧图这类合并小图标的手段,我们也重新讨论了一遍要不要继续用。结论是必要性明显下降了:小图标在 HTTP/2 下并发下载的成本很低,不再需要靠合并成一张大图来省请求数;但雪碧图还有别的考量,比如减少一些渲染层面的开销、兼容老旧场景,所以没有一刀切地砍掉,只是不再把它当作"减少请求数"的主力手段。

顺带说一句,图标这块我们其实更愿意往 SVG sprite 或者字体图标那条路走,矢量、清晰、还能用 CSS 控制颜色,跟 h2 也不冲突,只是这属于另一个话题,那天没细展开。

这里也能看出一个规律:很多 HTTP/1.1 时代的"性能技巧",在 h2 下要么失效、要么被更本质的方案替代——雪碧图的性能理由没了,剩下的价值(矢量、可控色)反而被 SVG 这种更现代的方案做得更好。技术往前走的时候,旧手段往往不是被推翻,而是被抽走了它赖以成立的前提。

跟雪碧图一个逻辑的还有"把小 CSS/小图内联进 HTML"这套老手段。HTTP/1.1 时代,为了省掉一个请求的往返,大家喜欢把首屏关键 CSS 直接内联进 <style>、把小图标转成 base64 塞进 CSS。

在 h2 下这笔账也得重算:内联省掉的那一个请求,收益变小了(h2 下多一个请求几乎不额外花钱),而代价却一直都在——内联进 HTML 的内容没法被单独缓存,每次 HTML 变了就得重新下载一遍,base64 还会让体积比原始二进制大三成左右。而且入口 HTML 我们本来就设成 no-cache,内联进去的东西等于永远不走缓存,对那种其实很少变的图标来说亏得更明显。

所以我们的判断是:**首屏关键路径上、体积很小、且几乎不变的东西,内联仍然划算(省的是关键渲染路径上的往返);除此之外,h2 下更倾向让资源各自成文件、各自走缓存。**这跟拆包那条结论是一体的——判断依据始终是"这段内容的变更频率和缓存价值",而不再是"能不能少发一个请求"。

把这几件事串起来看,HTTP/1.1 时代那一整套"减少请求数"的优化哲学——合并、雪碧图、域名分片、内联——在 h2 下几乎被系统性地重新定价了。它们当年都是为了绕开"连接贵、请求贵"这个前提而生的补偿手段,而 h2 恰恰把这个前提给拆了。

所以升级 h2 真正麻烦的不是开个配置,而是要有意识地把这些沉淀了好几年、写进团队肌肉记忆的"优化常识"一条条拿出来重新审一遍,有些该留、有些该反着来。这层认知上的翻转,比协议本身怎么工作更值得记——它提醒我,任何"最佳实践"都绑着它诞生时的技术前提,前提变了,实践就得跟着重新算账,而不是当成天经地义的教条抱着不放。

长缓存这套跟拆包是配套的,静态资源文件名带上内容 hash,配合长 Cache-Control

1# 静态资源:文件名带 hash,强缓存一年
2Cache-Control: public, max-age=31536000, immutable
3
4# HTML 入口:不缓存或短缓存,保证能拿到最新的资源引用
5Cache-Control: no-cache

webpack 里对应 [contenthash]

1output: {
2  filename: 'js/[name].[contenthash:8].js',
3  chunkFilename: 'js/[name].[contenthash:8].js'
4}

这里有个坑我们组之前踩过:[hash] 是整次构建算一个 hash,随便改一个文件所有资源的 hash 全变,长缓存直接白做;[chunkhash] 按 chunk 维度算,比 [hash] 好但样式抽出来后还是容易被牵连;[contenthash] 按文件内容本身算,粒度最细,才是真正该拿来做长缓存的那个。

HTML 入口本身绝对不能长缓存,否则改了代码用户手里还是旧 HTML、引用的还是旧资源地址,整条长缓存链直接断掉。这条跟前面拆包那节是配套的:拆得再精细、hash 算得再准,只要入口 HTML 被缓存住了、拿不到新的资源引用,后面的一切精细缓存都白搭。所以我们的规矩很死——静态资源随便长缓存,入口 HTML 永远 no-cache,让它每次都回服务端确认一下,代价只是一个很小的 304,换来的是整条缓存链的正确性。

收工前的确认

协议配置改完,我们又刷了几次页面确认。Network 面板里右键调出 Protocol 列,这回主文档和静态资源全显示 h2;再看瀑布图,同域名下的资源基本同时起跑,不再是六个一批的阶梯状。

运维同事提醒我一句容易漏掉的事:HTTP/2 是端到端的,链路上任何一段降级都会拖累整体——比如浏览器到 CDN 这段走的是 h2,但 CDN 回源到我们自己的源站可能还是 HTTP/1.1,回源那段照样吃 1.1 的连接限制。这次我们把 CDN 到源站这段也确认了一遍,确实也升级过了,才算真正端到端都在 h2 上跑。

他还补了个细节:其实浏览器到 CDN 这段用户能不能吃到 h2,跟用户的浏览器版本、甚至用户到 CDN 之间有没有老旧代理都有关。有些企业内网的代理不认 h2,会把连接悄悄降级回 1.1,这种个例在监控里就是一小撮用户的 TTFB 莫名偏高。真要较真,得靠前端埋点把每次请求实际用的协议版本(PerformanceResourceTiming 里的 nextHopProtocol)也采回来,才能知道线上到底多少比例的用户真享受到了 h2,而不是只看我们自己机器上一切正常就万事大吉。这个埋点我记下来了,打算下次性能优化专项时补上。

不过他也提醒我别把 HTTP/2 当万能药:如果 JS 包本身就大,协议再好也省不掉解析和执行的成本;图片没压缩,传输照样慢;接口一个个串行请求,网络层协议再强也会被业务流程拖住。所以真出现页面慢的问题,还是得先打开 Network 和 Performance 面板确认瓶颈具体卡在哪一段,不能看见"开了 HTTP/2"就默认万事大吉。

他这话我很认同——HTTP/2 优化的是"传输通道"这一层,而页面慢可能卡在传输、解析、执行、渲染任意一层。通道拓宽了,如果货物本身太重、或者装卸流程太乱,整体照样快不起来。这也是为什么我一开始那张瀑布图,光靠开 h2 并不能自动解决——它真正的病根是静态资源域名协议没跟上,属于"通道这层有一段没修好",恰好是 h2 能管的范围,所以修完立竿见影;但要是当初病根在 JS 包过大,开不开 h2 都没用。分清"这个慢属于哪一层",永远是优化的第一步,协议只是其中一层的工具。

改完之后我们又跑了一轮压测做对照。同一个后台首页,同样的资源,静态域名切到 h2、域名收敛之后,资源加载那一整段的耗时在预发环境降了差不多三分之一,最直观的就是原来那三十多个资源"六个一批"要排五六批,现在几乎一批就冲出去了。这个数不算惊天动地,但它印证了一件事:这次的收益不是玄学,是把"通道被人为掐成 1.1、还额外背了分片成本"这个具体的浪费给堵上了。数字能对上原理,我心里才算真正踏实——不是"看起来快了",而是"知道快在哪、快了多少"。

那天下午从一张异常的瀑布图查起,最后查到的是静态资源域名协议没跟上、域名拆分策略该收回、雪碧图和打包策略都要跟着协议能力重新算一遍账。双十一压测前的这轮排查,让我对 HTTP/2 的认识从"听起来能提速"变成了"具体改了哪一层、能省下什么、又省不下什么"。协议是地基,地基强了,资源怎么拆、怎么缓存这些活还是得自己一项项算清楚。