HTTP/2把HTML切成几块,不由服务器一家说了算
本文目录
- 上一次数到的“十三块”,究竟是谁切的?
- 这一轮的量尺换成了什么
- 131个站是怎么收到99个的
- 那个16384其实是我自己写的
- 服务器在握手那一刻,到底告诉了你什么?
- 六个参数的现网分布
- 服务器推送已经查无此人
- 这不是互联网的分布,是三家平台的分布
- 说能收一千六百万字节的帧,为什么只发八千一百九十二?
- 收和发是两个方向
- 8192这个数字的出处
- 把我这边的上限抬到一兆,99个站会怎么反应?
- 先踩一个坑:SETTINGS要等对方点头
- 会动的那十个站
- arcteryx那一格看起来自相矛盾
- 为什么帧数掉了七成,中位数却纹丝不动?
- 下载那段时间真的变快了吗,还是我把顺序量成了效果?
- 第一轮的数据好看得可疑
- 把顺序翻过来重跑一轮
- 同一张表里,首字节的行为完全相反
- 这条判据比上一轮多了半句
- 那些少一个字节的帧,是从哪儿冒出来的?
- 65535不是8192的整数倍
- 一个帧头九字节,送一个字节的正文
- Netlify那批碎得没有规律的帧
- 在自己的nginx上,这三个上限能不能一个个拆开?
- 实验台怎么搭的
- 那个跟http2毫不相干的隐形天花板
- 顺手排除掉的一个变量
- 这件事对Googlebot和真实用户,各值多少?
- 对抓取单个页面来说,影响很小
- 三类真会疼的场景
- 我这套量法在哪几处会骗人?
- 五个已经踩实的坑
- 三处说在前头的边界
- 自己站上怎么量一遍,该动哪一行
- 先看自己发的是多大的帧
- 两个指令必须一起改
- 什么时候不该动
- 常见问题解答
- 帧大小和网页加载速度到底有多大关系?
- 把http2_chunk_size调大会不会有副作用?
- 我用了Cloudflare,改源站的nginx还有意义吗?
- 流控窗口65535会不会成为瓶颈?
- 为什么有的站宣告能收16MB的帧,自己却只发8KB?
- 服务器推送现在还能用吗?
- MAX_CONCURRENT_STREAMS是100,页面有250个请求会不会排队?
- 用curl能量出帧大小吗?
- 这批数字能代表我的站吗?
- 权威参考资料
摘要:131个海外品牌站的首页各抓两轮,可对照的99个。服务器宣告的最大帧尺寸,84个站写的是协议上限16777215;它们实际发出的帧却有51.4%恰好是8192字节。把客户端那一侧的上限抬到1048576之后,只有10个站给出了更大的帧,82个一点没变。下载窗口中位数从0.1405秒掉到0.0803秒,顺序反过来重跑一轮,比值还是1.51——它认设置不认先后;同一份数据里的首字节恰恰相反,只认先后。
上个月量首页字节流的时候,我记了一个数:响应体平均分成13块到达。当时那句话写得挺顺,现在回头看,它其实什么都没说清楚。
因为“块”不是网络上真实存在的东西。那是HTTP客户端从操作系统的接收缓冲里一次读出来多少字节,读多少算一块。换一个缓冲大小,块数立刻变一个样。真正在网线上有边界、有长度字段、有编号的,是帧——HTTP/2官方FAQ里对帧与流的解释说得很清楚,帧是这个协议里最小的通信单位。
于是这一轮我把量尺换掉了:不再用现成的HTTP客户端,直接拿h2这个协议库手搓一个,自己完成TLS的ALPN协商、自己发SETTINGS、自己收每一个DATA帧、自己发WINDOW_UPDATE。这样每一帧的长度和到达时刻都是原样记下来的,中间没人替我合并。131个站,每站两组设置背靠背各打一次,整套跑两轮,第二轮把两组的先后顺序反过来。
结果比预想的有意思:这个数字有一半根本不在服务器手里。
上一次数到的“十三块”,究竟是谁切的?
这一轮的量尺换成了什么
131个站是同一份清单,跟前几轮一样,都是海外品牌电商与独立站的首页。每个站按顺序打两次:第一次用默认参数,第二次把客户端能改的两个上限抬到很大。两次之间隔2秒,全程单线程。并发这件事我上一轮已经栽过一次——两个工作线程就能把自己打成连续12个429,所以这次从头到尾没敢碰。
之所以要自己写客户端,是因为现成的库都会替你做一件好事:把收到的帧攒起来,凑够一批再交给上层。对写业务代码的人这是体贴,对做测量的人这是灾难。同样的道理在用curl -I查响应头、结果和GET对不上那一轮里已经出现过一次:工具替你做的简化,会被你当成被测对象的性质。
131个站是怎么收到99个的
漏斗是这样收的:131个站里,TLS握手时ALPN谈成h2的123个,谈成http/1.1的5个,还有3个连不上。剩下的里面,第一轮拿到200的111个。再剔掉响应体不足20000字节的11个——那些是地区跳转页和风控中间页,innisfree只回了751字节,aliexpress是828,temu是1349,拿它们量帧毫无意义。最后两组都拿到200、且响应体都够大的,99个。被挡在门外的还有8个403、1个418。
那个16384其实是我自己写的
然后是那个让我重量一遍的数。用普通HTTP客户端流式读同一批站,块大小的众数是8192,上限卡在16384,从没超过。当时我以为16384是服务器的选择。手搓客户端之后才看明白:16384是我自己在SETTINGS里宣告的默认值,服务器发不出比它更大的帧。至于8192,那是另一回事,下面会讲。
换句话说,上一轮那个“13块”,一部分是操作系统的读缓冲切的,一部分是我自己的协议参数切的,服务器只贡献了其中一层。读数没错,错在我以为它描述的是对方。
服务器在握手那一刻,到底告诉了你什么?
六个参数的现网分布
HTTP/2连接建起来的第一件事,双方各发一个SETTINGS帧,把自己这边的几条规矩摊开。这几个值平时没人看,因为浏览器和CDN都替你处理了。把99个站的这一帧逐个拆开,分布长这样。
| 参数 | 含义 | 最常见的值 | 站数 | 其余 |
|---|---|---|---|---|
| MAX_FRAME_SIZE | 我最大能收多大一帧 | 16777215 | 84 | 1048576有9个,16384有6个 |
| INITIAL_WINDOW_SIZE | 每条流我先放你发多少字节 | 65536 | 82 | 1048576有10个,65535有4个 |
| MAX_CONCURRENT_STREAMS | 同时最多几条流 | 100 | 85 | 128有11个,160有3个 |
| MAX_HEADER_LIST_SIZE | 请求头最长多少 | 压根不宣告 | 87 | 宣告的12个,最大2097472 |
| ENABLE_PUSH | 要不要服务器推送 | 0 | 99 | 无一例外 |
| HEADER_TABLE_SIZE | 头压缩动态表多大 | 4096 | 96 | 8192有3个 |
这六个值两轮之间一个字都没变,翻面数为0。它们属于那种“抓一次就够”的读数,跟时间无关,跟当次网络运气也无关。
这里有个方向问题必须先说清楚,不然整张表会被读反:服务器宣告的这几个值,管的是“它愿意从你这儿收什么”,不是“它会给你什么”。比如那个INITIAL_WINDOW_SIZE,约束的是我往它那边发数据时一次能发多少;决定它发给我多少的,是我这边宣告的窗口。表里六行都得按这个方向读。
服务器推送已经查无此人
先说最没悬念的一行:ENABLE_PUSH在99个站上全是0。服务器推送这个当年被写进无数篇“HTTP/2六大特性”的功能,在这批站上一个都没开。Chrome在2022年就把它从默认支持里拿掉了,如今连宣告都懒得宣告,直接摁死。教程里还留着它,现网里已经查无此人。想提前把资源拉下来,今天走的是preload这一类资源提示那条路。
MAX_HEADER_LIST_SIZE那一行也有意思:87个站压根不说自己能收多长的请求头。不说的意思是“没上限”,实际上当然有,只是到了才给你报错。这跟HTTP/3那边68%的站把动态表宣告成0是同一种沉默——那一轮量的是头压缩这一维,这一轮量的是数据这一维,握手的是同一帧,管的是两件事。至于请求头本身有多重,同一份Cookie在一条连接上重发五十遍那一轮算过它的账。
这不是互联网的分布,是三家平台的分布
真正扎眼的是第一行。RFC 9113对这个参数的规定是取值范围16384到16777215,默认16384。84个站直接把它顶到了16777215,也就是“你想发多大发多大”。另外9个写1048576。老老实实留在16384这个默认值上的,只有6个。
按服务器分组看,这个分布整齐得不像自然形成的:Cloudflare后面的74个站,73个是同一组值(窗口65536、帧上限16777215),只有1个例外。Netlify的6个和Vercel的3个又是另一组,清一色1048576配1048576。这三家加起来占了83个站——所谓“现网的HTTP/2参数分布”,其实就是三家平台的默认配置分布。服务器配置那份必看清单里列的那些项,到了托管平台上大半已经不由你决定了。
说能收一千六百万字节的帧,为什么只发八千一百九十二?
收和发是两个方向
这里要先分清一件事,我自己也是量到一半才彻底捋顺:SETTINGS里宣告的MAX_FRAME_SIZE,说的是“我愿意收多大的帧”,不是“我会发多大的帧”。发多大,受对方宣告的上限约束,同时受自己内部的实现约束。两个方向完全独立。
于是就有了这批数据里最直白的一组反差。
| 服务器宣告能收 | 它自己实际发出的最大帧 | 站数 |
|---|---|---|
| 16777215 | 8192 | 83 |
| 1048576 | 16384 | 6 |
| 16384 | 16384 | 4 |
| 1048576 | 8217 / 14311 / 7084 | 各1 |
| 16384 | 2048 / 4329 | 各1 |
| 16777215 | 7535 | 1 |
宣告自己能收一兆以上的站有93个,其中85个发出来的最大帧不超过8192字节。差了整整128倍。
8192这个数字的出处
把99个站的全部1981个帧倒在一起数,8192出现1019次,占51.4%;16384只有43次,占2.2%;还有40个8191、91个长度为0的结束帧。按站看,79个站的主力帧大小就是8192。Cloudflare那74个站里,70个是这个数。
8192从哪儿来的?我在自己的服务器上找到了答案,那台机器跑的是nginx1.30.4。nginx的http2_chunk_size指令控制的就是它往外发的DATA帧最大多大,出厂默认值正好是8k。这个指令的文档只有两行,说明里连“帧”字都没提,写的是“限制响应体分块的大小”。
所以现网那条整整齐齐的8192线,多半不是谁精心调过,而是一个没人动过的出厂值,被三家平台原样带进了几万个站。这跟nginx出厂只压HTML这一种类型是同一个故事的两页:默认值不是中立的,它替你做了选择,而且做得很轻。
把我这边的上限抬到一兆,99个站会怎么反应?
先踩一个坑:SETTINGS要等对方点头
第二组设置是这样:MAX_FRAME_SIZE写1048576,INITIAL_WINDOW_SIZE写16777216,并且把连接级的流控窗口也一起抬上去。这里有个细节值得单记一笔——SETTINGS发出去之后必须等对方回ACK再发请求,否则服务器还在按旧值算,流会卡死在65535字节上一动不动。我第一版就是这么写的,眼看着五个站整整齐齐地停在65535,还以为是对方限流。
这种“看起来像对方的问题,其实是自己没走完握手”的失手,跟整站开了HTTP/3、第一个请求却永远走不到那一轮撞见的是同一种时序陷阱:协议里有些能力必须先被对方确认过,才算真的可用。
会动的那十个站
| 站点 | 平台 | 默认组最大帧 | 大帧组最大帧 | 帧数变化 |
|---|---|---|---|---|
| arcteryx.com | 未标识 | 16384 | 37259 | 10 → 7 |
| anker.com | Netlify | 16384 | 32768 | 39 → 10 |
| eufy.com | Netlify | 16384 | 32768 | 53 → 13 |
| soundcore.com | Netlify | 16384 | 32768 | 37 → 10 |
| babybjorn.com | Netlify | 8217 | 32768 | 33 → 5 |
| vuoriclothing.com | Netlify | 7084 | 29822 | 19 → 4 |
| buckmason.com | Vercel | 16384 | 24825 | 11 → 6 |
| traeger.com | Vercel | 16384 | 23836 | 11 → 7 |
| kotn.com | Vercel | 16384 | 18803 | 10 → 8 |
| lookfantastic.com | Cloudflare | 16384 | 32103 | 8 → 6 |
只有这10个站真的给出了更大的帧,另外82个站一点没变。Cloudflare那74个站里只有1个动了,其余73个不管我怎么宣告,照发8192。剩下7个站的最大帧确实变了,但方向是变小——assos从16384变成7255,rapha从14311变成11566,那是碎帧的随机性,不是设置的作用。
会动的这批里有八个在Netlify和Vercel后面,它们的服务器自己就宣告了1048576级别的上限,本来就打算发大帧,只是被我这边16384的默认值摁住了。Netlify那几个的变化最戏剧,babybjorn从33帧掉到5帧,vuoriclothing从19帧掉到4帧,eufy从53帧掉到13帧。剩下两个是lookfantastic和arcteryx,后者那一格特别值得单说。
arcteryx那一格看起来自相矛盾
它宣告自己只收16384,发出来的却是37259字节一帧。这不矛盾——宣告的是收,实际的是发,两个方向本来就各管各的。只是把这两个数并排放在监控面板上,很容易被读成同一件事,然后得出“这个站违反了自己的声明”。响应头自相矛盾那一轮里,有相当一部分“矛盾”也是这么读出来的:两个字段本来管的就不是一件事。
为什么帧数掉了七成,中位数却纹丝不动?
看完上面那张表,很容易得出一个印象:把客户端上限抬上去,帧数会大幅下降。但全样本的帧数中位数是15降到14,压缩比的中位数是1.0。
也就是说,一半以上的站在这件事上完全没有反应。剧烈变化只集中在那10个站身上,而它们几乎都不在Cloudflare后面。这是分布不看形状会骗人的典型例子:只报“帧数中位数从15降到14”,读者会以为这个开关没用;只报babybjorn的33降到5,读者会以为这是普遍规律。两句话都对,也都不完整。
所以正确的说法是这样的:帧大小由三方共同决定——客户端宣告的上限、服务器实现的上限、以及服务器内部输出缓冲的大小,谁小听谁的。现网99个站里,绝大多数是后两个在生效,客户端那一侧根本轮不上说话。这个结论我在自己的机器上单独验过一遍,见后面那节。
下载那段时间真的变快了吗,还是我把顺序量成了效果?
第一轮的数据好看得可疑
第一轮跑完,数据很漂亮:默认组的下载窗口中位数0.1419秒,大帧大窗组0.0809秒,比值1.4841,99个站里有85个是后者更快。快了将近一半,帧数还没怎么变,听上去像是流控窗口在起作用。
问题在于,两组请求是背靠背打的,大帧组永远排在默认组后面2秒。第二次访问同一个域名,DNS查过了、TLS会话票据可能复用了、边缘节点也可能刚把这份文档缓存热了。这些都会让第二次更快,跟我改了什么设置一点关系没有。
把顺序翻过来重跑一轮
要把这两件事分开,只能把顺序反过来再跑一整轮。所以原定的“第二轮重复”被我砍掉了,换成了反序轮:先打大帧组,再打默认组,其余一切不变。
| 怎么切这批读数 | 下载窗口中位 | 首字节中位 | 备注 |
|---|---|---|---|
| 第一轮的默认组(先打) | 0.1419秒 | 0.9063秒 | 大帧组更快的站85/99 |
| 第一轮的大帧组(后打) | 0.0809秒 | 0.6743秒 | 比值1.4841 |
| 第二轮的大帧组(先打) | 0.0793秒 | 1.1636秒 | 大帧组更快的站83/98 |
| 第二轮的默认组(后打) | 0.1376秒 | 0.5962秒 | 比值1.5139 |
| 两轮合并,只按“第几个请求”分 | 0.1194对0.1188 | 1.0113对0.6428 | 各198个读数 |
| 两轮合并,只按“哪组设置”分 | 0.1405对0.0803 | 随先后而定 | 各198个读数 |
顺序反过来,结论纹丝不动。最后两行是把两轮的读数重新聚合出来的,每一档里两种设置各占一半、先打后打也各占一半。
这张表可以当判据用。按先后分,下载窗口是0.1194对0.1188,差0.5%,等于没差;按设置分,是0.1405对0.0803,差1.75倍。下载窗口认设置,不认先后。
同一张表里,首字节的行为完全相反
第一个请求1.0113秒,第二个0.6428秒,快了36%。拆到每一轮看更清楚——第一轮里默认组0.9063秒、大帧组0.6743秒;第二轮里大帧组1.1636秒、默认组0.5962秒。谁在前谁慢,跟设置一点关系都没有。
如果我只跑了第一轮就收工,会同时得出两个结论:“把窗口开大,下载快48%”和“把窗口开大,首字节也快25%”。第一句是真的,第二句是我自己的实验安排。而这两个数字长在同一张表上,肉眼分不出哪个是哪个。首字节那一段本来就是整条链路上最贵也最容易被别的因素带偏的一段,多层缓存怎么同时影响它那一轮已经拆过一遍。
这条判据比上一轮多了半句
上一轮的结论是“取自某个时刻的读数必须跑两遍”。这一轮补上后半句:跑第二遍的时候,得把你自己引入的那个变量也翻个面,否则跑十遍也只是把同一个偏差重复十遍。做页面抖动基线那一轮踩的是同一类坑,只是那次的变量是时间,这次是顺序。
那些少一个字节的帧,是从哪儿冒出来的?
65535不是8192的整数倍
把帧序列一行行看下去,会撞见一个很怪的现象。burrow.com默认组的帧长序列里,8192出现56次,8191出现4次。dreametech.com是37个8192配3个8191。anker.com更夸张,出现过16384、16383、1这样的三连。整整34个站的序列里都有这种“少一个字节”的帧。
一开始我以为是自己记账错了。在实验台上复现之后才明白:这是流控窗口留下的指纹。
HTTP/2的流控窗口默认是65535字节,服务器每发一个字节可用窗口就减一,减到0就必须停下来等客户端发WINDOW_UPDATE。8192乘8等于65536,比窗口多出整整1个字节。所以每发够7个满帧,第8帧就只能发8191,剩下那1个字节要等下一个窗口周期。16384乘4同样是65536,于是变成三个16384加一个16383。
这件事我在自己的机器上按开关验了一遍:客户端保持默认窗口时,同一个文件的序列是70个8192加10个8191;把窗口抬到16兆之后,变成80个整齐的8192加一个尾巴。那个多出来的字节不见了,因为窗口不再是瓶颈。
一个帧头九字节,送一个字节的正文
anker那串“16384、16383、1”就更直白了:窗口耗尽,剩下1个字节单独占了一帧。帧头九字节,正文一字节,这买卖做得相当亏。
真实场景里这种碎帧不多,34个站里只有4个出现过1到4字节的碎帧,eufy有5个,anker有4个。但它是个很好的路标——看见这种帧,说明这条流当时正被流控卡着,而不是网络本身慢。
Netlify那批碎得没有规律的帧
Netlify后面几个站,默认组的帧长干脆碎得看不出规律,291、103、58、145这样的长度混在一起。它们的服务器本来准备发大帧,被65535的窗口切成了随发随停的碎片。窗口一开大,立刻变成整齐的32768。
这也解释了另一个现象:默认组的帧数在两轮之间并不稳定,相对差中位数3.03%,其中14个站超过20%,全是这一批。同一个字段,在一组设置下是常量,在另一组设置下是随机变量。
在自己的nginx上,这三个上限能不能一个个拆开?
实验台怎么搭的
现网数据只能看到结果,看不到机制。要证明“三方取小”,得有一台自己说了算的服务器。
我在自己那台机器上另起了一个独立的nginx实例,跑在几个不占用的端口上,彼此之间只差一个指令,服务同一个655966字节的静态HTML。客户端这一侧分三档:默认的16384、中间档32768、以及1048576。
| 服务器配置 | 客户端宣告16384 | 客户端宣告32768 | 客户端宣告1048576 |
|---|---|---|---|
| http2_chunk_size 4k | 4096 / 161帧 | 4096 / 161帧 | 4096 / 161帧 |
| http2_chunk_size 8k(出厂值) | 8192 / 81帧 | 8192 / 81帧 | 8192 / 81帧 |
| http2_chunk_size 32k | 16384 / 41帧 | 32768 / 21帧 | 32768 / 21帧 |
| http2_chunk_size 64k | 16384 / 41帧 | 32768 / 21帧 | 32768 / 21帧 |
| 64k加output_buffers 4 128k | 16384 / 41帧 | 32768 / 21帧 | 65536 / 11帧 |
前两行说明:服务器把chunk_size压得低的时候,客户端怎么抬都没用。第三第四行说明:客户端上限低的时候,服务器配得再大也上不去。
那个跟http2毫不相干的隐形天花板
最后一行是这一节真正的收获。把http2_chunk_size从8k改到64k,客户端也开到1兆,帧却死死卡在32768上不去。翻了一圈才想到去看output_buffers这个指令的默认值——它是2 32k,也就是nginx一次从磁盘读出来往下游送的那块内存最大32k。把它改成4 128k,帧立刻变成65536,帧数从21掉到11。
只调http2_chunk_size是调不动的,还有一个名字里连http2都没有的指令挡在前面。这类“配置写了、语法也通过了、实际没生效”的形态,nginx配置每次reload都通过、副作用却悄悄发生那一轮收集过好几种。
顺手排除掉的一个变量
我一度怀疑是sendfile在作怪——毕竟走sendfile的时候nginx不经过用户态缓冲。于是加了一组开、一组关:两组结果一字不差。跟它无关。
这台实验台还顺带解释了现网那83个站。它们宣告16777215、实际发8192——如果后端是nginx或者nginx派生的实现,8192就是那个没人动过的出厂值,跟宣告的16777215完全是两码事,一个管收,一个管发。
这件事对Googlebot和真实用户,各值多少?
对抓取单个页面来说,影响很小
先泼一盆冷水。抓一个页面就是一个请求,帧多帧少只影响这一个响应体在网络上被切成几段。按这批数据,默认组的下载窗口中位数是0.1405秒,大帧组是0.0803秒,绝对差值0.06秒。而前几轮量过的首字节等待,中位数在1秒上下。省下的0.06秒,放在1秒的等待面前基本听不见响。
Google自己也把话说得很轻。Googlebot开始支持HTTP/2的那份公告里写的是,切到HTTP/2可能会替双方省一些计算资源,但不影响索引和排名。这话到今天仍然是官方口径。抓取预算真正的大头在别处,142个站里只有52个能正确回304那一轮量的才是能省下整份文档的那种浪费。
三类真会疼的场景
第一类是响应体本身很大的接口和页面。0.06秒是100KB级文档的差值,量级会跟着体积走。这批站里eufy的首页是349256字节,默认组53帧,大帧组13帧,下载窗口2.3099秒对0.5996秒。同一份东西,差了1.7秒。页面本身太肥当然是更该先解决的问题,首页HTML的字节预算那一轮算过它的代价,Googlebot那条抓取上限也在同一个方向上。
第二类是移动端的弱网。帧多意味着协议开销的次数多,81帧对11帧,多出来的那七百多字节帧头倒是小事;真正的代价是流控窗口在往返时延高的链路上更容易成为瓶颈——窗口65535字节,往返200毫秒的链路上,光靠等WINDOW_UPDATE,理论吞吐上限就压在327KB每秒左右。
第三类是并发流那条线。85个站宣告的MAX_CONCURRENT_STREAMS是100,而上一轮用真浏览器量20个首页时,第20秒的总请求数中位数是250,16个站超过100,parachutehome一次跑出654个。
这不等于“要排三轮队”——请求本来就是分批发起的,浏览器自己也有调度——但当页面真的在某一瞬间想同时要一百多个资源时,多出来的那些只能等前面的流关掉。资源优先级和懒加载在这个位置上才真正有用武之地:它决定谁先挤进那100条流。至于那些下载了却一行都没用上的样式,CSS覆盖率那一轮的结论是先删掉,比调帧划算得多。
我这套量法在哪几处会骗人?
五个已经踩实的坑
| 坏在哪 | 差点写出的错结论 | 怎么发现的 |
|---|---|---|
| 把客户端默认的16384当成服务器的选择 | 现网帧大小上限是16384 | 手搓客户端把宣告值改掉,上限跟着动 |
| SETTINGS没等ACK就发请求 | 五个站限流,卡在65535 | 加等待后同样的站一路跑到底 |
| 两组请求固定先后 | 开大窗口首字节也快25% | 反序跑一整轮,首字节跟着顺序翻面 |
| 只看中位数 | 抬上限没用,帧数中位只降1 | 看分布,10个站降幅七成以上 |
| 把宣告的MAX_FRAME_SIZE当成发送能力 | arcteryx违反自己的声明 | 协议里这个值只约束收,不约束发 |
三处说在前头的边界
一是样本偏向大平台。99个站里83个在Cloudflare、Netlify、Vercel后面,所以这份分布更像“三家平台的默认配置分布”,不是“全互联网的分布”。自建服务器的样本只有个位数。
二是本机到这些站的链路是固定的。所有时间读数都带着这条链路的特征,换一个地区重量,绝对值一定不一样。能跨链路复用的是相对关系,不是绝对秒数。
三是有些字段在不同设置下的稳定性不一样。响应体字节数老老实实,两轮相对差中位数0.03%;大帧组的帧数相对差中位数0.00%;默认组的帧数却有3.03%,14个站超过20%。同一张表里放着常量和随机变量,不标出来就会被一起当成事实。抓取与渲染分几步那一轮吃过一样的亏:不同阶段的读数不能混在一列里比。
自己站上怎么量一遍,该动哪一行
先看自己发的是多大的帧
最省事的办法是拿任意一个支持HTTP/2的抓包工具看DATA帧长度,或者像我这样用h2库写二十行客户端。判据只有一条:如果主力帧是8192、服务器又是nginx,那就是出厂值在生效,这件事你从来没管过。
接着看响应体有多大。页面和接口普遍在100KB以内的,这一节可以直接跳过去——81帧和11帧的差别在那个体积上不值一提。真正值得调的是那些动辄几百KB的接口、大JSON、以及首页HTML特别肥的站。顺带一提,静态资源带哈希之后一年掉一半那件事跟这里无关,但同属“上线之后没人再看一眼”的那类默认行为。
两个指令必须一起改
nginx上是这样:
http2_chunk_size 32k; # 出厂8k,只改这个最多到32768
output_buffers 4 128k; # 出厂2 32k,不改它就是那个隐形天花板改完必须实测帧长,别信配置文件。我的实验台上,只改第一行、把它设到64k,帧仍然停在32768,看配置以为生效了,量一遍才知道没有。
保哥前阵子给一个做户外装备的独立站看后端接口,商品列表的JSON一次吐将近400KB,链路上帧数常年在50上下。把这两个指令一起调完,帧数掉到个位数,接口的端到端时间在他们自己的监控里少了大约十分之一秒。不是什么惊天动地的优化,但它属于改一行配置就拿到的那类,跟重构前端不是一个投入量级。
什么时候不该动
帧越大,单帧的内存占用越大,并发连接多的时候内存曲线会抬起来;而且大帧会让同一条连接上的多路复用变粗糙——一个大帧发出去,其他流就得等它发完。Cloudflare那篇讲nginx上HTTP/2优先级的复盘里说的缓冲与调度冲突,就是这个方向的代价。静态大文件为主的站调大合适,接口碎、并发高的站别乱动。
还有一种情况是前四步基本不用做:用了CDN。Cloudflare后面那73个站,我把客户端上限抬到1兆,一个字节都没多给。你的nginx配了什么,在边缘那一层被重新打包,用户拿到的是边缘的参数。这时候能改的只有你和源站之间那一段,而那一段用户根本走不到。
常见问题解答
帧大小和网页加载速度到底有多大关系?
取决于响应体多大。这批99个站的首页体积普遍在100KB上下,默认组和大帧组的下载窗口中位数差0.06秒;而eufy那个349256字节的首页,差值到了1.7秒。响应体越大这件事越值钱,小文档上它基本等于零。
把http2_chunk_size调大会不会有副作用?
有两个。一是单帧内存占用变大,高并发时内存曲线会抬;二是一条连接上多路复用的粒度变粗,一个大帧发出去期间其他流得等着。所以静态大文件站适合调,接口碎、连接多的站要谨慎。默认的8k是个偏保守但很安全的值。
我用了Cloudflare,改源站的nginx还有意义吗?
对终端用户几乎没有意义。这批数据里Cloudflare后面73个站不管客户端怎么宣告都发8192,说明边缘那一层是自己重新打包的。改源站只影响回源那一段,能省的是CDN和你之间的开销,用户和爬虫都走不到那里。
流控窗口65535会不会成为瓶颈?
在往返时延高的链路上会。窗口65535字节意味着服务器发满这么多就得停下来等确认,往返200毫秒的链路上理论吞吐大约压在327KB每秒。国内访问海外源站、或者移动网络下比较容易撞上。判据是看帧序列里有没有“少一个字节”的帧和1到4字节的碎帧,有就说明窗口正在咬人。
为什么有的站宣告能收16MB的帧,自己却只发8KB?
因为这两件事在协议里本来就是分开的。SETTINGS里的MAX_FRAME_SIZE只约束对方发给你的帧有多大,不约束你发出去的帧有多大。发多大由自己的实现和配置决定,nginx上就是http2_chunk_size和output_buffers这两个值里较小的那个。
服务器推送现在还能用吗?
不能。这批99个站宣告的ENABLE_PUSH全是0,一个例外都没有。Chrome在2022年就把它从默认支持里拿掉了,现在的教程里如果还在讲“HTTP/2的服务器推送能加速首屏”,那部分内容已经过期了。
MAX_CONCURRENT_STREAMS是100,页面有250个请求会不会排队?
不会按250除以100那样简单排三轮。请求是随着解析和渲染陆续发起的,浏览器自己也有调度和优先级,真正同时在飞的流通常远少于总数。只有在某一瞬间确实想同时开一百多条流时才会碰到这个上限,此时后来的请求要等前面的流关闭。关键渲染路径上哪些请求在阻塞比这个上限本身更值得先看。
用curl能量出帧大小吗?
不能直接量。curl即使编译了nghttp2,verbose输出里也只给流的状态变化,不给每个DATA帧的长度。要拿到帧级数据得用抓包工具解TLS,或者自己拿h2这类协议库写一个客户端——后者反而更省事,二三十行就够。
这批数字能代表我的站吗?
结构能,数值不能。“帧大小是三方取小”“宣告和实做是两回事”“Cloudflare后面客户端说了不算”这几条是机制,换个样本也成立。至于8192占51.4%、下载窗口中位0.1405秒这些,只描述这99个站在这条链路上的那一刻。
权威参考资料
本文标题:《HTTP/2把HTML切成几块,不由服务器一家说了算》
本文链接:https://zhangwenbao.com/http2-frame-size-flow-control-window-audit.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0