同样是4096的表,HTTP/3比HTTP/2早678字节就把SEO红利丢光了

同样是4096的表,HTTP/3比HTTP/2早678字节就把SEO红利丢光了
张文保 27 分钟阅读 3,664 阅读
本文目录
  1. 把47个站的握手参数拉出来,68%宣告的动态表是0
  2. 先证明这个0不是解析错误
  3. 按CDN归属看,答案更整齐
  4. 顺手量到的另一件事:13个站没说自己能收多大的头
  5. 为什么HTTP/3的默认值是0,而HTTP/2的同一个默认值是4096?
  6. 规范这么定不是拍脑袋
  7. 动态表是0,到底差多少?
  8. 先说这组数据怎么保证是真的
  9. 然后是那条曲线
  10. 中间那几档还藏着一个更别扭的现象
  11. 在真实nginx上量一遍,第二个请求就掉到18字节
  12. 跟本地模拟对一对,还发现一个差异
  13. 同样是4096的表,为什么QPACK比HPACK早678字节就没了红利?
  14. 678字节是什么概念
  15. 那条断崖为什么不是斜坡?
  16. 为什么QPACK的那条线更靠前
  17. 拆分Cookie这一招,在HTTP/3上还灵吗?
  18. 过线之前拆,是纯亏
  19. 过线之后拆,能救回一大截
  20. 所以这一招的正确用法是有前提的
  21. 服务器日志能不能拿来算这笔账?
  22. 这笔账该怎么处理?
  23. 第一类:Cookie已经越过2400字节的电商站
  24. 第二类:自己管服务器、且真的开了HTTP/3的站
  25. 第三类:在CDN后面、以为切了协议就自动更快的站
  26. 一张对照表收尾
  27. 常见问题解答
  28. 我怎么知道我的站宣告的动态表容量是多少?
  29. 动态表容量能在nginx里配置吗?
  30. Cloudflare把它设成0,我的站是不是变慢了?
  31. 那我干脆不用HTTP/3,回到HTTP/2是不是更好?
  32. 为什么第一个请求那么大,之后就变成十几个字节?
  33. 把Cookie拆成多个字段,会不会影响服务端读取?
  34. 这件事对SEO到底有多大影响?
  35. 权威参考资料

摘要:把47个能连上HTTP/3的站的握手参数逐个拉出来,其中32个宣告的QPACK动态表容量是0,占68.1%,Cloudflare后面那24个站无一例外。表是0意味着请求头的复用能力被完全关掉:一份2000字节的Cookie连发20个请求,表开着均摊191字节,表是0就是1786字节,差9.3倍。更意外的是同为4096的表,QPACK的红利在Cookie长到2394字节时就断了,而HPACK撑到3072——同一个数字的表,HTTP/3比HTTP/2早678字节交卷。这不是谁实现得差,是RFC 9114明写的取舍:拿压缩效率换延迟。

上一篇拆的是能不能走到HTTP/3上去——结论是一次访问里的第一个请求走不到,而爬虫抓页面通常只发那一个请求。

那篇写完,保哥心里有个疙瘩没解开:对于那些确实走到了HTTP/3上的请求,它们到底拿到了什么?

这个问题看起来不该有悬念。HTTP/3是HTTP/2的后继者,头部压缩这块用的是QPACK,而QPACK是照着HPACK改的。按常识推,同一件事上HTTP/3应该不比HTTP/2差。

结果一测,两个数字都跟常识反着来。

把47个站的握手参数拉出来,68%宣告的动态表是0

先说数据是怎么拿到的,因为这一步决定了后面所有结论的可信度。

HTTP/3的连接建立起来之后,双方会各自在控制流上发一个SETTINGS帧,把自己这一侧的参数告诉对方。这里面有一个参数叫 SETTINGS_QPACK_MAX_TABLE_CAPACITY,它规定了对端可以用多大的动态表来压缩发给我的头

保哥用一个纯Python的QUIC客户端,对125个域名逐个发起真实的h3握手,把服务端发过来的SETTINGS帧解出来。49个握上了手,47个拿到了完整的SETTINGS。分布如下:

QPACK动态表容量站数占比
0(没宣告,按规范默认值算)3268.1%
4096510.6%
1638412.1%
65536919.1%

68.1%这个数字第一眼看上去像是我解析错了。所以在往下写之前,得先证明这个0是真的。

先证明这个0不是解析错误

对账方式是找一个已知答案的对照物:保哥在自己的服务器上起了一个nginx 1.28.3的实例,开着HTTP/3,用同一个客户端去连,看读出来的是什么。

读出来是 QPACK_MAX_TABLE_CAPACITY = 4096QPACK_BLOCKED_STREAMS = 128。这两个数正是nginx源码里写死的出厂值。同一段解析代码,在nginx上读出4096,在Cloudflare上读出没有这一项——那就是真的没有。

再看另外两个参数的分布,也能佐证解析是对的:

参数取值分布
QPACK_BLOCKED_STREAMS0 → 32站;100 → 10站;128 → 3站;64与512各1站
MAX_FIELD_SECTION_SIZE131072 → 23站;65536 → 6站;32768 → 4站;1048576 → 1站;未宣告13站

注意第一行:宣告表容量为0的那32个站,BLOCKED_STREAMS 也全是0。这两个参数是配套的——表都不给,自然也不会允许流被压缩阻塞。32和32完全重合,这个内部一致性说明这批站是明确关掉了这套机制,不是漏配了某一项。

按CDN归属看,答案更整齐

服务器与CDN站数表容量取值
Cloudflare24全部为0
Google Frontend465536三个,0一个
nginx54096两个、65536两个、0一个
Tengine(阿里系)216384、4096
腾讯EdgeOne14096

Cloudflare那一行是24比0,一个例外都没有。这不是配置疏漏,是产品层面的统一决策。

而Google Frontend那边3个站给了65536——64KB的动态表,是nginx出厂值的16倍。同一件事,两家全球最大的边缘网络给出了完全相反的选择。

顺手量到的另一件事:13个站没说自己能收多大的头

既然SETTINGS帧都解出来了,另一个参数也顺带看了:MAX_FIELD_SECTION_SIZE,服务端用它告诉客户端我最多接受多大的一份请求头

取值换算站数
131072128 KB23
6553664 KB6
3276832 KB4
10485761 MB1
没宣告视为无限制13

这一栏跟之前在HTTP/1.1与HTTP/2上量到的那道墙正好接上。那次现网普查测出来的阈值中位数在16KB附近,最低的一个站6912字节就开始拒绝。

而这里HTTP/3这一侧,多数站给的是128KB——比TCP那一侧宽了将近8倍。更微妙的是有13个站压根没宣告这个参数,按规范就是不设限。

把两组数放一起,会得到一个挺古怪的结论:同一个站,同一份超大的请求头,走HTTP/2可能被拒,走HTTP/3可能就过了。这也解释了那类最难缠的报障——同一个用户,有时候能打开,有时候打不开,而两次的区别只是浏览器那一刻选了哪条路。

为什么HTTP/3的默认值是0,而HTTP/2的同一个默认值是4096?

这里得回到规范。RFC 9204第5节对这个参数的说明只有两句话,但这两句话把整件事说透了:

SETTINGS_QPACK_MAX_TABLE_CAPACITY (0x01): The default value is zero. See Section 3.2 for usage. This is the equivalent of the SETTINGS_HEADER_TABLE_SIZE from HTTP/2.

两个信息点:默认值是零,以及它等价于HTTP/2的SETTINGS_HEADER_TABLE_SIZE

而HTTP/2那个参数的默认值,写在RFC 9113里,是 4096

HTTP/2(HPACK)HTTP/3(QPACK)
参数名SETTINGS_HEADER_TABLE_SIZESETTINGS_QPACK_MAX_TABLE_CAPACITY
规范默认值40960
什么都不配的后果压缩正常工作动态表完全不可用

规范自己说这两个参数等价,却给了两个相反的默认值。这一条是整篇文章里最值得记住的:同一个机制从HTTP/2迁到HTTP/3,默认状态从开着变成了关着。

规范这么定不是拍脑袋

动态表在QPACK里比在HPACK里危险得多。HPACK跑在TCP上,数据严格按序到达,编码器往表里插一条、解码器就一定先收到那一条。QUIC是乱序的,装着表更新指令的那个包可能比引用它的那个请求晚到——解码器拿到一个引用,发现表里还没这一项,只能把整条流挂起来等。这就是队头阻塞,而HTTP/3存在的全部理由就是消灭它。

所以规范把默认值定成0,等于说:你要用这个提速功能,得自己明确开口要,因为它有可能把HTTP/3最大的优点抵消掉。

Cloudflare那24个站的选择,放在这个背景下就完全说得通了。作为一个要面对全世界各种糟糕网络的边缘网络,它选择了保延迟、放弃压缩率。这是个理性的决定——只是站长这一侧多半从来不知道自己在这笔交易里被替着签了字。

动态表是0,到底差多少?

知道有这个开关是一回事,知道它值多少钱是另一回事。

保哥用QPACK的编码器实现直接量了一遍:构造一份典型的浏览器请求头(12个字段,含一份2000字节的Cookie),在同一条连接上连发20个请求,只改动态表容量这一个变量。

先说这组数据怎么保证是真的

编码器算出来的字节数,如果没人验证它能被还原成原来的头,那就只是一串数字。所以每一轮编码都同时跑一个真实的QPACK解码器,把编码器流和解码器流双向喂回去,再把解码出来的头字段跟输入的逐条比对

全部档位、全部请求,解码结果与输入不一致的次数是 0。这组数才敢往下用。

然后是那条曲线

动态表容量20个请求合计均摊每请求
035720 B1786.0 B
25634570 B1728.5 B
102430833 B1541.7 B
204830774 B1538.7 B
307231167 B1558.3 B
40963829 B191.4 B
81923829 B191.4 B
655363829 B191.4 B

从0到3072,均摊字节从1786慢慢降到1538再回升到1558——基本没动。从3072到4096,一步从1558掉到191.4。差8.1倍,而且此后再加表容量一个字节都不再省。

把两端接起来看:动态表0与4096之间,同样这20个请求,差 9.3倍

这条曲线的形状很重要:它不是斜坡,是台阶。给了一半的表容量,拿到的不是一半的收益,是零收益。这一点在下面第六节会解释原因。

中间那几档还藏着一个更别扭的现象

把表容量从1024加到2048再加到3072,均摊字节是1541.7 → 1538.7 → 1558.3。加到3072那一档,反而比2048那一档多花了20字节

这不是测量误差——每一档都跑了20个请求、都做了解码校验、结果可重复。它反映的是一件很实际的事:

  • 表容量不够装下那条最大的Cookie时,Cookie本身进不了表
  • 但那些小字段(accept-languagesec-fetch-* 这些)还是会进去
  • 进表要发编码器流上的指令,指令本身也占字节
  • 表越大,塞进去的小字段越多,指令开销越大,而真正吃字节的那条大Cookie一次都没省到

于是就出现了这个局面:给一点点表容量,比一点不给还差。付了管理成本,没拿到主要收益。

这一条对做技术选型的人有实际意义:如果哪天这个参数变得可配置了,不要想着折中给个中间值。要么给足到能装下最长那个字段,要么干脆给0——中间地带是最贵的。

在真实nginx上量一遍,第二个请求就掉到18字节

编码器层面的数字再干净,也得回到真实服务器上验证。

保哥在服务器上起了一个独立的nginx实例(不碰生产站),开着HTTP/3,用Lua把服务端实际收到的东西回显出来:协议版本、Cookie的实际字节数、nginx记录的请求长度。然后在同一条h3连接上连发12个请求。

Cookie长度nginx记录的请求长度序列
2000字节1706 → 22 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 19 → 19
529字节586 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 19 → 19

第一个请求1706字节,第二个22字节,之后一路18。降了95倍。而这12个请求里,服务端回显的Cookie字节数全程是同一个数,一个字节都没少——内容一样,只是不用再重发一遍了。

跟本地模拟对一对,还发现一个差异

把真实nginx的序列和本地编码器的模拟放在一起:

来源第1个第2个第3个之后
本地QPACK模拟(表4096)178617911414
真实nginx(表4096)1706221818
本地HPACK模拟(表4096)1780121212

本地模拟里,QPACK的第二个请求还是全量的1791字节,第三个才降下来;真实nginx上第二个就降到了22。原因是编码器要不要冒险引用一条还没被对端确认的表项,是个策略问题——保守的实现会等确认,激进的实现敢赌。

这个差异本身是个方法论提醒:协议库的模拟能算出上界和下界,但具体落在哪儿要看服务端的实现策略。两条路都跑一遍,对不上的地方恰恰是最值得看的地方。

顺带注意HPACK那一行:它第二个请求就到12字节,比QPACK少一轮预热,稳态字节数也更小。这已经是第二个HTTP/3不如HTTP/2的地方了。

同样是4096的表,为什么QPACK比HPACK早678字节就没了红利?

这是本文最反直觉的一条,也是保哥没料到的一条。

之前量HTTP/2的时候发现过一条界线:Cookie头长到3072字节附近,HPACK的动态表红利就归零了。原因是条目大小算的是字段名长度加字段值长度再加32字节,太大的条目塞不进4096的表。

那么QPACK呢?规范里条目大小的算法是一模一样的:

The size of an entry is the sum of its name's length in bytes, its value's length in bytes, and 32 additional bytes.

同样的算法、同样的4096,界线应该在同一个位置。保哥用二分法扫了一遍,结果是:

压缩机制表容量红利消失的Cookie长度
HPACK(HTTP/2)40963072 字节
QPACK(HTTP/3)40962396 字节

二分的精确落点是:Cookie长2394字节时还有4.75倍红利,长到2398字节就只剩1.38倍。4个字节之内翻脸。

也就是说,同一个数字的表、同一条条目大小公式,QPACK比HPACK早678字节就交卷了。

678字节是什么概念

放到真实的Cookie尺寸上看这个差距。保哥在实验台上用真实Chrome访问了一次,浏览器带过来的本站Cookie是529字节——这是一个内容站的量级。而电商站是另一个世界:之前测过的某个化妆品站,在真实Chrome里是41条、4176字节

站点类型典型Cookie长度HPACK 4096QPACK 4096
内容站/博客500上下有红利有红利
轻量商城2000上下有红利有红利
装了3到5个营销工具的独立站2400到3000还有红利已经没了
大型电商4000以上都没有都没有

中间那一档正好卡在两条界线之间。这一档的站,从HTTP/2切到HTTP/3的那一刻,请求头压缩这笔账会变差,而没有任何一个监控指标会告诉他们。

那条断崖为什么不是斜坡?

把上面两组现象放在一起看,其实是同一个机制的两个切面:表容量给一半没有一半的效果,Cookie长一点点就从有红利变成没红利。两处都是台阶不是斜坡。

原因在于动态表的工作方式是全有或全无的。一条Cookie只有两种命运:

  • 塞得进表:第一次发全文,之后每次发一个索引,几个字节
  • 塞不进表:每一次都发全文,只有Huffman编码能省一点

没有中间状态。Cookie是一个不可分割的字段,它要么整条进表,要么整条不进。所以只要它的条目大小(值长度加名字长度再加32)越过了实现愿意分配的那条线,收益就直接归零,而不是打个折。

为什么QPACK的那条线更靠前

规范里给了方向性的解释。RFC 9114第4.2.1节介绍QPACK时写的是:

[QPACK] describes a variation of HPACK that gives an encoder some control over how much head-of-line blocking can be caused by compression. This allows an encoder to balance compression efficiency with latency.

关键词是 balance compression efficiency with latency——在压缩效率和延迟之间做平衡。规范没有承诺QPACK的压缩效率不低于HPACK,它明说了这是一笔交易。

具体到实现层面,QPACK编码器要给未来的表更新留出余量:一条超大的条目一旦插进去,会把表里已有的其他条目全部挤出去,而那些条目正被其他并发的流引用着。所以编码器实现会比HPACK更保守地判断这条值不值得插。678字节这个差值,就是保守程度的实测体现。

本文只描述这个现象和它的位置,不去猜具体某个实现内部的阈值公式。对做站的人来说,能记住的判据是一句话:请求头里最长那个字段,加32之后如果超过表容量的六成,在HTTP/3上就别指望压缩了。

拆分Cookie这一招,在HTTP/3上还灵吗?

HTTP/2那边有一个很好使的办法:把一条超长的Cookie拆成多个 cookie 字段发出去,每条都变小了,就都能进表。RFC 9114在HTTP/3上也明确允许这么干:

To allow for better compression efficiency, the Cookie header field ([COOKIES]) MAY be split into separate field lines, each with one or more cookie-pairs, before compression.

保哥把这招在QPACK上试了一遍,10个请求一组,同样带解码校验:

Cookie总长不拆拆2条拆3条拆4条拆6条
1000 B21572174218722022226
2000 B36893937430241454309
3000 B2340413592110121017611751
4000 B3071418821221091865818527

这张表得竖着读,因为上下两半的结论是相反的。

过线之前拆,是纯亏

1000字节和2000字节这两行,拆得越碎总字节越多。1000字节从不拆的2157一路涨到拆6条的2226;2000字节更明显,从3689涨到4309,多花了17%。

原因不难理解:原来一条能进表的东西,拆开之后变成好几条,每一条都要占一个索引位、都要在表里单独记一份,管理成本比省下来的多。

过线之后拆,能救回一大截

3000字节这一行是全表最戏剧的:不拆是23404字节,拆成4条降到10176,省了2.3倍。4000字节那一行也一样,拆2条就从30714掉到18821。

逻辑很简单:整条塞不进表的东西,切小之后每一块都塞得进去了,红利就回来了。

所以这一招的正确用法是有前提的

你的Cookie总长该不该拆理由
低于2400字节不要拆本来就有红利,拆了净亏
2400到3000拆3到4条把每条压回能进表的尺寸
超过3000先减肥,再考虑拆拆只是止损,减掉才是治本

要提醒一句:这个动作的实施位置不在服务器上,而在客户端或者边缘。服务器改不了浏览器怎么发Cookie。真正能落地的手段还是把Cookie本身减下来——干掉一条1500字节的,比删四十条小的都管用。

服务器日志能不能拿来算这笔账?

不能。而且它给出的错误答案,方向是反的。

保哥在实验台上做了一次干净的对照:真实Chrome访问同一个地址两次,第一次走HTTP/2、第二次走HTTP/3,服务端把收到的东西全部回显出来。

指标第1次(HTTP/2)第2次(HTTP/3)
服务端收到的Cookie字节529529
头字段条数1413
nginx记录的请求长度88430

Cookie回显两次都是529,说明浏览器发的东西一样、服务器收的东西也一样。但nginx的请求长度变量,一个记884、一个记30,差29倍。

再看另一组,用可控的客户端逐档发不同长度的Cookie:

构造的Cookie长度HTTP/2记的请求长度HTTP/3记的请求长度
200295337
10001095931
200020951697
400040953202

这一组的Cookie回显在全部6个档位上跟构造值分毫不差,所以数据本身是可信的。但两列数字的含义并不一样:HTTP/2那列基本贴着原始长度(curl每次新建连接,压缩没机会生效),HTTP/3那列已经是QPACK编码后的字节。

结论很直接:这个变量在两种协议下记的不是同一个东西,任何跨协议的横向对比都是错的。如果一个站从HTTP/2切到HTTP/3,运维看日志会以为上行流量降了一大截,实际上用户发的字节一点没变,只是账本换了记法。

这类问题保哥碰到过好几次了,形状都一样:换一个工具去查同一件事,答案就变了。判据永远是同一条——让被测那一方把它实际收到的东西回显出来,跟你发出去的对账,而不是相信中间任何一个环节的记账。

这笔账该怎么处理?

先说一句让人放松的:对绝大多数内容站,这件事的实际影响接近于零。500字节量级的Cookie,在哪种压缩机制下都在红利区内,动态表是0也就是每个请求多几百字节,对一个正常页面来说不值一提。

真正要认真对待的是这三类站。

第一类:Cookie已经越过2400字节的电商站

这一类是唯一会被HTTP/2与HTTP/3那678字节差值真实咬到的。判据很简单,在浏览器控制台里跑一行:

document.cookie.length

这个数不含HttpOnly的那些,所以真实值还要更大。超过2000就该去数一数每一条各占多少了。减Cookie的正确顺序是先揪最大的那三五条,而不是删条数。

第二类:自己管服务器、且真的开了HTTP/3的站

nginx出厂给的是4096,够用但不宽裕。如果你的站请求头本来就重,这个值目前没有暴露成可配置项——这也是选择HTTP/3时该知道的一个约束。nginx这类配置改完不报错的地方特别多,凡是文档里没写成指令的参数,都不要假设自己能调。

第三类:在CDN后面、以为切了协议就自动更快的站

这一类人数最多。Cloudflare那24个站的表容量全是0,意味着在它后面的站,HTTP/3的头部压缩红利这一项从来就没打开过,而这个决定不在站长手里,控制台上也找不到对应的开关。

这不是说Cloudflare做错了——用压缩率换掉队头阻塞风险,对一个要服务全球各种网络的边缘网络是合理的取舍。但站长该知道自己拿到的是哪一半:你拿到的是HTTP/3的延迟特性,没拿到它的压缩特性。

一张对照表收尾

你想要的HTTP/3给不给取决于
丢包时不拖累其他请求协议本身,无需配置
切换网络时连接不断协议本身,无需配置
请求头只发一次看服务端脸色对端宣告的动态表容量
爬虫抓的那份HTML也走h3基本不给发现机制的限制

两篇合起来的账是这样的:HTTP/3在传输层给的那些好处是真的、而且免费;但它在应用层许诺的那笔压缩红利,68%的站上默认关着,剩下的还要看请求头有没有超过2400字节。而这两样东西,爬虫抓的那一个请求一样都碰不到。

常见问题解答

我怎么知道我的站宣告的动态表容量是多少?

需要一个能连HTTP/3并读出SETTINGS帧的客户端。系统自带的curl多半没编译HTTP/3支持(用 curl --version 看Features那行有没有HTTP3字样)。Python生态里有纯软件实现的QUIC库可以做这件事,几十行代码就能把SETTINGS打出来。浏览器开发者工具看不到这个参数,它只显示协议名。

动态表容量能在nginx里配置吗?

目前不能。nginx的HTTP/3模块把这个值写死在源码里,出厂是4096,配置文件里没有对应的指令。所以自建nginx的站在这一项上没有调节余地,好在4096对多数场景够用。

Cloudflare把它设成0,我的站是不是变慢了?

请求头这一侧确实少了一笔可观的节省,但整体是快是慢要看你的请求头有多重。Cookie在500字节以内的站,这笔差价小到测不出来;Cookie到了两三千字节的站,每个请求要多发一两千字节的上行——而上行带宽在移动网络上通常只有下行的几分之一。判据是先量自己的Cookie有多大,而不是先判断CDN的选择对不对。

那我干脆不用HTTP/3,回到HTTP/2是不是更好?

不必这么极端。HTTP/3在丢包环境下的表现、连接迁移这些好处是实打实的,而且不需要任何配置。压缩这一项只是它没兑现的那部分。正确的做法是把请求头本身减下来——Cookie小了,两种协议下都省,而且对第一个请求也有效。

为什么第一个请求那么大,之后就变成十几个字节?

因为动态表要预热。第一个请求必须把每个字段的完整内容发过去,同时告诉对端把它们记进表;从第二个请求开始,只发一个索引号就够了。这也意味着只发一个请求就断开的客户端,永远只付得起第一次那个价钱,压缩机制对它毫无意义。

把Cookie拆成多个字段,会不会影响服务端读取?

不会。RFC 9114规定解码之后必须把多个cookie字段用 ; 拼回一条,再交给应用。所以后端代码读到的还是完整的一条,跟没拆一样。但这个动作只能在客户端或边缘做,服务器改不了浏览器发头的方式。

这件事对SEO到底有多大影响?

直接影响很小,因为爬虫多半连不到HTTP/3上。间接影响落在真实用户的体验指标上——上行字节少了,弱网环境下的请求发出得更快。把它当成一项用户体验优化来做,别指望它改善抓取。真要改善抓取,力气应该花在响应时间和缓存命中上。

权威参考资料

分享到
标签
版权声明

本文标题:《同样是4096的表,HTTP/3比HTTP/2早678字节就把SEO红利丢光了》

本文链接:https://zhangwenbao.com/qpack-dynamic-table-zero-http3-header-compression-seo.html

版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交