网络优化

一、技术难点

Day26 讲的是发现段 :一个资源什么时候被浏览器知道、按什么优先级排队。这一篇讲它后面那一段:字节已经上路了,从服务器的网卡到用户的屏幕,这中间发生了什么。

先给结论:网络层的每一项"优化"都不是免费的,它要么拿另一个指标去换,要么依赖一个你从未验证过的前提。 本文要拆掉的三个前提,都是在团队里被当作常识的口头禅:

  1. 「换成 HTTP/2 就更快了 」------实测(transport-hol.mjs)在 2% 丢包、少量大资源的场景下,HTTP/2 比 HTTP/1.1 的六连接慢 19%(1128ms vs 948ms);把丢包提到 5%,差距还在扩大。
  2. 「缓存就是命中率 」------实测(cache-ledger.mjs)全站 304 校验把字节从 160.31MB 压到 17.67MB(省 89% ),可阻塞时间只从 968ms/访问降到 427ms/访问(省 56%),而且比教科书基线还慢 106%。304 省的是字节,不省往返。
  3. 「TTL 设长一点命中率就高 」------实测(cache-ledger.mjs)TTL 60s 配 60s 的请求间隔,边缘命中率是 0.0% ,和完全不缓存一模一样。命中率是台阶函数,不是连续曲线。

本文所有数字都来自三份零依赖 Node 脚本的实跑输出,每份都带不变量断言(合计 245 条),断言失败即 exit 1。和 Day25/Day26 一样,模型是确定性的简化模型(参数与边界在文末「运行环境」写清楚了),它的价值是把不同策略放进同一个坐标系里比较,而不是预测某台机器上的绝对毫秒数。

注意边界:本篇只讲传输段与连接段 ------协议多路复用、丢包恢复的作用域、连接建立与慢启动、缓存与边缘。Day26 已经讲完"发现段"(谁先被发现、preload 插队),SSR/SSG/ISR 留给 Day28,拆包留给 Day30,图片格式与字体留给 Day31。四篇加起来才是完整的加载链路,混着讲的结果通常是四件事都说不透。

难点一:协议升级买到的不是"更快",是"更少的串行"

HTTP/2 相对 HTTP/1.1 的真实增量,被三条广告词概括成了"多路复用、头部压缩、服务端推送"。但把这三条翻译成延迟 ,它们的作用全是同一个:消除"必须等前一件事做完"。

  • 多路复用 消除的是 HTTP/1.1 的"连接级串行"------一条连接同一时刻只能有一个请求在飞。所以它对小资源多 的页面收益最大:60 个 4~12KB 的资源,H1.1 要分 6 条连接各串 10 个请求,每个请求至少一个 RTT,实测 675ms;H2 一次性把 60 个流全推出去,486ms(-28%)。
  • 头部压缩(HPACK)消除的是重复头部的字节:实测 60 个请求的明文头部(含 Cookie)与压缩后差 39KB(522.0KB vs 483.0KB),在 12Mbps 链路上就是 26ms。这是个小钱,但小资源场景下它恰好就是主要成本。
  • 服务端推送 已经从 Chrome 移除(106 版本),Link: rel=preload 的 103 Early Hints 是它的替代品。这属于 Day26 的"发现段",本篇不重复。

反过来说:当页面只有 6 个大资源时,"连接级串行"本来就不是瓶颈 。6 个 150~250KB 的资源在 H1.1 上正好一条连接一个,6 条连接并行,谁也不挡谁。这时候 H2 的收益就只剩头部压缩那几十 KB------而它还要倒赔一样东西(难点三)。实测:

场景 协议 全部完成 相对最好
A 多小资源(60 × 4~12KB) HTTP/1.1 六连接 675ms +51%
HTTP/2 单连接 486ms +9%
HTTP/3 单连接 446ms ---
B 少大资源(6 × 150~250KB) HTTP/1.1 六连接 926ms ---
HTTP/2 单连接 948ms +2%(更慢)
HTTP/3 单连接 908ms ---

同一份 1.2MB 的字节,H2 在场景 A 领先 28%,在场景 B 落后 2%。 协议不是能力,是分配策略------它优化的是"串行"这件事,不是"带宽"这件事。

难点二:丢包时停顿的「作用域」,才是 H2 与 H3 真正的分水岭

HTTP/2 把"连接级串行"从应用层 搬走了,但没动传输层 。TCP 是字节流,它必须按序交付:一个 TCP 段丢了,后面所有已经收到的字节都得在接收缓冲区里等着,直到丢的那段重传回来。所有流一起等。

HTTP/1.1 的六条连接不是"多路复用",但它有六个独立的丢包作用域------一条连接卡住,另外五条照跑。于是出现了一个反直觉的结论:

在丢包网络上,H1.1 的六条连接比 H2 的单连接更抗丢包,因为它的失败域更小。

实测(2% 丢包,场景 B):

协议 干净网络 2% 丢包 恶化倍数 单次丢包最多连累几条流
HTTP/1.1 六连接 926ms 948ms 1.02× 1
HTTP/2 单连接 948ms 1128ms 1.19× 6
HTTP/3 单连接 908ms 981ms 1.08× 1

H2 在干净网络上比 H1.1 慢 22ms,在 2% 丢包网络上慢 180ms 。这不是噪声:它是"把 100 个鸡蛋放进一个 TCP 字节流"的必然账单。

HTTP/3 的 QUIC 把「流」做进了传输层,重传以流为单位:一个包丢了只挡住它自己那条流。同一个 2% 丢包场景里,量一下"一次丢包到底拖住了多少条流 × 多久"(本文称之为流·毫秒,数值越小越抗丢包):

场景 网络 H1.1 H2 H3 H2/H3
A 多小资源 2% 1000 7200 120 60.0×
A 多小资源 5% 1920 16920 400 42.3×
B 少大资源 2% 160 960 160 6.0×
C 域名分片 2% 320 1880 200 9.4×

场景 A 里 H2 的一条连接上挂着 60 条流:每丢一个包,60 条流一起停 40ms ------7200 流·毫秒,是 H3 的 60 倍。5% 丢包时 H2 的全部完成时间是 863ms,比 H1.1 的 735ms 慢 17% ,比 H3 的 537ms 慢 61%。

这就是为什么"我们上了 H2/H3"这句话本身没有信息量。要问的是:你的用户在什么网络里,你的页面是"很多小资源"还是"少量大资源"。 这两个维度决定了协议升级是赚还是赔。

还有一条藏在 H3 里的现实:首次访问根本用不上 QUIC 。浏览器要先通过 TCP 拿到 Alt-Svc 响应头,才知道这个源支持 h3。所以第一次访问是"TCP + TLS 1.3 的 100ms 握手",第二次起才降到 QUIC 的 60ms(省 40ms),0-RTT 恢复能再省一个 RTT 但只能用于幂等请求(否则可被重放)。H3 的收益是"从第二次访问开始"的收益,而它需要你的 CDN 真的支持、你的 TLS 证书真的配好、你的客户端真的会缓存 Alt-Svc。

难点三:单连接的慢启动,是一笔被集体忽略的税

TCP 不知道链路有多快,它从初始拥塞窗口 开始猜。RFC 6928 把这个值定成 10 个 MSS ≈ 14KB,然后每个 RTT 翻倍。实测(conn-budget.mjs,链路容量 60KB/RTT):

第 N 个 RTT cwnd 本 RTT 实收 累计 受谁限制
1 14KB 14KB 14KB 拥塞窗口
2 28KB 28KB 42KB 拥塞窗口
3 56KB 56KB 98KB 拥塞窗口
4 112KB 60KB 158KB 带宽
5 224KB 60KB 218KB 带宽

前三个 RTT,链路是闲着的------不是没数据可发,是 TCP 不敢发。把这条账放进协议对比里,就有了 H2/H3 最真实的代价:

less 复制代码
H1.1: 6 条连接 → 聚合初始 cwnd 84KB → 首个 RTT 实际能用 60KB(链路容量的 100.0%)
H2  : 1 条连接 → 聚合初始 cwnd 14KB → 首个 RTT 实际能用 14KB(链路容量的 23.3%)
H3  : 1 条连接 → 聚合初始 cwnd 14KB → 首个 RTT 实际能用 14KB(链路容量的 23.3%)

注意 H3 并不例外:QUIC 的拥塞控制也是"每连接一份",不是"每流一份"。 多路复用解决的是队头阻塞,不解决"我还不知道自己能发多快"。所以 H2/H3 的第一个 RTT 只用到链路容量的 23.3%,而 H1.1 的 6 条连接加起来是 100%------这就是为什么在"少大资源"场景里 H1.1 能反超(926ms vs 948ms)。

这条税只在冷启动上收,而且它解释了本文后面每一个"连接数"相关的结论:

  • preconnect 省的是握手,不省慢启动。 实测一个 250KB 资源:冷连接 100ms 握手 + 6 个 RTT = 340ms;preconnect 已建连 0ms 握手 + 仍然是 6 个 RTT = 240ms;复用一条已经热起来的连接 0ms + 5 个 RTT = 200ms。preconnect 省 100ms,但数据阶段的 6 个 RTT 一个都没少------它没有为你预热拥塞窗口。
  • 换源站的隐藏成本是"慢启动重来一遍"。 60KB 的资源:热连接 1 个 RTT,冷连接 3 个 RTT(多 80ms);1MB 的资源:热连接 18 个 RTT,冷连接 19 个(多 40ms)。资源越小,这条税越贵。
  • 连接闲置就会被回收,回收了就得重交税。 Chrome 空闲套接字 300s、Firefox 115s、Safari 60s、nginx keepalive_timeout 默认 75s、AWS ALB 默认 60s。取最保守的 60s:用户只要停顿超过一分钟,下次导航就得从冷连接 + 慢启动重来。

难点四:域名分片的收益取决于 BDP,不是分片数

H1.1 时代"把资源拆到 4 个域名"是因为浏览器对同源只开 6 条连接。H2/H3 之后这句话被简化成"分片没用了"。这个简化是错的,但错的方向和你想的不一样。

分片在 H2/H3 下唯一的收益是:多买几份初始拥塞窗口 。N 条连接就有 N 份独立的 cwnd,早几个 RTT 打满管道。可它的收益完全取决于 BDP(带宽 × 时延)------链路容量每 RTT 能装多少字节:

链路容量/RTT 折算带宽 1 连接 2 分片 4 分片 6 分片 带宽下限 分片最多省
60KB ≈12Mbps 19 18 18 18 18 1 个 RTT(40ms)
150KB ≈30Mbps 10 9 8 8 7 2 个 RTT(80ms)
500KB ≈100Mbps 7 6 5 4 3 3 个 RTT(120ms)
1250KB ≈250Mbps 7 6 5 4 1 3 个 RTT(120ms)

(交付 1MB 需要的 RTT 数,初始 cwnd 14KB。注意 250Mbps 那行:1MB 的带宽下限只有 1 个 RTT,单连接要 7 个------BDP 越大,慢启动的爬升越贵,分片的收益越大。)

而在 12Mbps 的链路上,分片总共只省 1 个 RTT,因为:

复制代码
分片数 | 握手次数 | 握手总成本 | 首个 RTT 聚合实收
1      | 1        | 100ms      | 14KB
2      | 2        | 200ms      | 28KB
4      | 4        | 400ms      | 56KB
6      | 6        | 600ms      | 60KB(已被链路封顶)

6 分片的聚合初始窗口是 84KB,可链路每 RTT 只装得下 60KB------多出来的 24KB 一分钱都换不到。 这就是"低 BDP 下分片无收益"的算术原因,而它同时还要付 6 次握手(600ms 的 CPU/服务端成本,虽然可以并行所以不直接体现为延迟)。

于是正确的结论不是"分片死了",而是:分片的收益从"绕过 6 连接上限"变成了"更早打满大 BDP 管道",而后者有更便宜的替代品------preconnect(省握手)+ 连接复用(保留已经热起来的 cwnd)+ 让 CDN 与源站保持长连接(origin 侧的慢启动由 CDN 承担)。

难点五:缓存不是"命中率",是四笔必须同时记的账

打开任何一个团队的性能看板,"缓存命中率 92%"这种数字都在。它是四个数里最没用的那一个。 因为缓存策略真正在做的是一个四维取舍:

要记的量 单位 谁在看
阻塞毫秒 ms/访问 用户(LCP、INP)
线上字节 KB 用户流量、你的 CDN 账单
请求数 次 服务端 QPS、连接数、RTT 次数
陈旧窗口 s 产品(用户看到的版本对不对)

第四条几乎永远不在看板上,而它恰恰是前三条的代价来源。实测 30 天 × 每天 6 次访问(180 次回访),六种策略:

策略 阻塞总计 每次访问 线上字节 请求数 最坏陈旧窗口
P1 全都 no-store 174.24s 968ms 160.31MB 1620 0s
P2 全都 no-cache(304 校验) 76.86s 427ms 17.67MB 1620 0s
P3 教科书基线 37.26s 207ms 17.67MB 630 0s
P3b 同上但去掉 immutable 37.26s 207ms 17.67MB 630 0s
P4 HTML 上 s-w-r,图片也上 18.80s 104ms 17.67MB 630 3.5h
P5 全都 max-age=31536000, immutable 8.37s 46ms 1.24MB 188 715.4h

(P3 基线:带 hash 的资源 max-age=31536000, immutable;HTML no-cache;图片 max-age=3600;接口 no-store。)

读这张表有三种读法,只有第三种是对的:

  • P1 → P2 :no-store 换成 no-cache,字节从 160.31MB 掉到 17.67MB(-89% ),阻塞从 968ms 降到 427ms(-56% )。看起来是"缓存生效了"。但请注意 P2 的请求数一个都没少(都是 1620) :304 是"不带 body 的响应",不是"不用发请求"。每一次 304 都要付一个完整的 RTT。
  • P2 → P3 :同样 17.67MB 字节,阻塞从 427ms 降到 207ms(-51% ),请求数从 1620 降到 630。字节数一模一样,延迟差一倍------差的全在"哪些资源可以完全不发请求"上。这就是"命中率 100%"和"零请求"的区别:前者是 P2,后者是 P3。
  • P3 → P4 :请求数、字节数完全不变 (都是 630 / 17.67MB),阻塞从 207ms 降到 104ms(-50% )。这里没有任何"优化"发生,只是把 HTML 的一次阻塞式校验 换成了 stale-while-revalidate 的后台校验。省下的 103ms/访问,代价是那一栏 0s 变成了 3.5h。

P5 是最值得看的一行。 它的每项指标都最好:阻塞 46ms(-78%)、字节 1.24MB(-93%)、请求 188 次(-70%)。它只有一个问题:最坏陈旧窗口 715.4h ≈ 29.8 天。 因为它把入口 HTML 也写成了 immutable------用户第一次打开之后,在缓存过期之前永远不会再验证一次。这不是缓存策略,这是"给用户发一个不可撤销的版本"。

难点六:TTL 与请求频率的关系是一条台阶,不是一条曲线

"缓存时间设长一点,命中率就高一点"------这句话让无数人把 TTL 从 60s 调到 120s,然后又调回来。真相是命中率对 TTL 的函数是台阶状 的,台阶的位置由请求间隔决定:

scss 复制代码
命中率 ≈ 1 - 1 / ceil(TTL ÷ 请求间隔)

实测(单边缘节点,内容每 3600s 变一次,请求固定间隔到达):

TTL 请求间隔 命中率 回源次数/天 说明
0 60s 0.0% 1440 不缓存
30s 60s 0.0% 1440 TTL < 间隔 → 每次都是 miss
60s 60s 0.0% 1440 TTL 等于间隔 → 仍然是 0%
120s 60s 49.9% 721 TTL = 2×间隔 → 50%
600s 60s 89.9% 145 TTL = 10×间隔 → 90%
600s + s-w-r=3300 60s 98.3% 145 接近全命中,代价是陈旧
600s 10s 98.3% 145 同样的 TTL,请求更密 → 台阶更高

第一行和第三行是本文最想让你记住的两个数字。 TTL=30s 配上 60s 的请求间隔,命中率是 0%------你配了缓存,写进了响应头,加了 CDN,用户一次都没受益。而 TTL=60s 配上 60s 间隔也是 0% :因为响应到达时 age 已经等于 TTL,判定为过期。"TTL 要大于请求间隔",不是"TTL 要大于 0"。

还有一层:最后两行的命中率都是 98.3%,但它们不是同一件事 。TTL=600s/间隔10s 的 98.3% 是"60 个请求里有 59 个命中新鲜副本";TTL=600s+s-w-r/间隔60s 的 98.3% 里,有 8% 是陈旧命中(用户拿到的是旧内容,同时后台在刷新,平均旧了 600s)。

难点七:immutable 与 max-age 的唯一区别,在刷新路径上

Cache-Control: max-age=31536000, immutable 和 Cache-Control: max-age=31536000 在正常导航 下行为完全一致(实测 P3 与 P3b 的 30 天账本每一栏都相同:207ms/访问、17.67MB、630 次请求)。唯一的区别出现在用户按 F5 刷新 的时候:浏览器在 reload 时会重新校验子资源,除非它带了 immutable。

实测(每 20 次访问一次刷新,30 天共 9 次):

策略 刷新带来的额外阻塞 额外请求数
P3(带 immutable) 0ms 0
P3b(去掉 immutable) 2160ms 54

9 次刷新,54 个多余的 304,2160ms 的额外等待。 这个数字不大------所以 immutable 是一个"几乎免费的小优化",而不是什么架构决策。但它揭示了 immutable 的准确语义:

immutable 是在向浏览器承诺「这个 URL 的内容永不改变」。它是承诺,不是技巧。

既然是承诺,它就有一个硬前提:URL 必须真的随内容变化 (内容 hash、构建 ID)。如果把 immutable 加在一个不换 URL 的资源上,你不是在优化缓存,你是在发行一个不可撤销的版本------这正是 P5 那 715.4h 的来历。

难点八:stale-while-revalidate 把 RTT 换成陈旧,而上界由"访问间隔"决定

stale-while-revalidate=86400 很容易被读成"允许陈旧 24 小时"。这是误读,而且误读的方向对你不利。 实测 P4 的实际最坏陈旧窗口是 3.5h ,平均 194.8min(3.2h)------远小于那个 86400s 的窗口。

原因很简单:s-w-r 的窗口只是"允许陈旧"的上界,实际的陈旧时长由"你多久来访问一次"决定。 每次访问都会触发一次后台刷新(1 个 RTT 后完成并写回缓存),所以下一次访问拿到的是"上一次访问时的版本"。访问间隔 4 小时、内容每 1 小时变一次 → 用户最坏看到 4h+1h 之内的东西。而一个 24 小时才来一次的偶发用户,才会真的看到 24 小时前的内容。

于是有了这条纪律:s-w-r 的窗口应该按"你的最低回访频率"来设,不是按"你能容忍多旧"来设。 前者是你能观测的(回访分布),后者只是一个愿望。

同样的逻辑也解释了 P4 的 -50% 是怎么来的:max-age=0, stale-while-revalidate=86400 的意思是"我每次都立刻用缓存里的副本渲染,同时在后台校验 "。它把"用户等 1 个 RTT"改成了"用户等 0ms,1 个 RTT 后缓存更新"。这是把延迟换成了陈旧,不是把延迟消灭了。

难点九:边缘缓存与浏览器缓存是两层账,s-maxage 不是 max-age 的同义词

CDN 的 TTL 和浏览器的 max-age 是两个不同人的账:

  • 浏览器缓存(max-age)服务于同一个用户的下一次导航。它的命中率由用户的回访频率决定。
  • 边缘缓存(s-maxage)服务于所有用户的下一次请求 。它的命中率由站点的总请求频率决定------这是一个比你想象中大得多的数字。

所以"TTL 要大于请求间隔"这条规则在两层里的含义完全不同 :浏览器层看的是单个用户 4 小时一次,边缘层看的是全站每秒几十次。同一个 TTL=600s,在你自己的浏览器上可能命中率很低,在边缘节点上可能是 89.9%(实测:间隔 60s → 89.9%,间隔 10s → 98.3%,间隔 1s 就逼近 100%)。把这两层混在一起配,是"CDN 配了但没效果"的最常见原因。

配套的还有三件事,都很容易错:

  • s-maxage 会覆盖 max-age ,且不带 private 的响应才会进共享缓存 。一个带 Set-Cookie 或 Authorization 的响应,很多 CDN 默认不缓存。
  • stale-while-revalidate 在边缘层是最值钱的:它把"回源的 40~200ms"整个从用户路径上删掉,而边缘的请求频率高,实际陈旧窗口很小(远小于浏览器层的 3.5h)。
  • Vary 是命中率的隐形杀手 。Vary: Cookie 基本等于"永不命中";Vary: Accept-Encoding 是必须的(否则会把 gzip 的响应发给不支持的用户);Vary: User-Agent 需要 CDN 做归一化,否则命中率会被 UA 字符串的多样性打散。

二、完整解法

三件工具,对应三个必须同时记的账:传输层(协议怎么选)、缓存层(四笔账怎么算)、连接层(握手和慢启动怎么省)。三份脚本都是零依赖、确定性、自带不变量断言,可以直接跑。

2.1 工具一:把「换协议」变成一个可算的问题

transport-hol.mjs 把 HTTP/1.1 / HTTP/2 / HTTP/3 放进同一个包级模型里,量化三件事:多路复用的收益、丢包作用域的大小、慢启动在第一口的代价。核心建模选择只有两条:

  1. 丢包的作用域:H1.1/H2 停整条连接(TCP 按序交付),H3 只停自己那条流(QUIC 流级重传)。
  2. 带宽分配 :只有"此刻真的能发"的连接参与分配;卡住的连接不让出份额(链路就空转)------这正是难点二要量化的成本。
js 复制代码
#!/usr/bin/env node
'use strict';
/*
 * transport-hol.mjs ------ HTTP/1.1 vs HTTP/2 vs HTTP/3 的传输层账本
 *
 * 零依赖、确定性(固定种子)。回答三个问题:
 *   1. 同一份资源清单,三种协议各自什么时候下完?
 *   2. 丢包时,停顿的「作用域」分别有多大(连接级 vs 流级)?
 *   3. 慢启动(cwnd)在单连接与多连接下的表现差多少?
 *
 * 断言失败即 exit 1。
 */

// ============ 模型参数(与正文「运行环境」逐条对齐)============
const CFG = {
  BANDWIDTH: 1.5,        // KB/ms,链路带宽 ≈ 12 Mbps
  RTT: 40,               // ms
  DNS: 20,               // ms,首次解析(按 host 计)
  TCP_TLS_RTT: 2,        // TCP 握手 1 RTT + TLS 1.3 握手 1 RTT
  QUIC_RTT: 1,           // QUIC 把 TLS 1.3 融进握手,共 1 RTT
  INIT_CWND: 14,         // KB,RFC 6928 的 10*MSS ≈ 14.6KB
  CWND_MAX: 4096,        // KB,拥塞窗口上限(避免无限翻倍)
  PKT: 8,                // KB,传输/重传单元
  RETX: 40,              // ms,丢包恢复到重传 = 1 RTT
  TICK: 0.25,            // ms,仿真步长
  HDR_H1: 0.7,           // KB/请求,H1.1 明文头部(含 Cookie)
  HDR_H2: 0.05,          // KB/请求,HPACK/QPACK 压缩后的头部
  H1_PER_HOST: 6,        // 浏览器对同源并发连接数上限
};

// ============ 确定性 PRNG ============
function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function splitPackets(kb) {
  const out = [];
  let left = kb;
  while (left > 1e-9) {
    const take = Math.min(CFG.PKT, left);
    out.push(take);
    left -= take;
  }
  return out.length ? out : [kb];
}

/**
 * 建一次连接。
 * scope: 'conn'(H1.1/H2:丢包停顿整条连接)| 'stream'(H3:只停自己这条流)
 * serial: H1.1 一条连接同一时刻只有一个请求;H2/H3 多路复用
 */
function makeSender(mode, readyAt) {
  return {
    mode,
    readyAt,
    serial: mode === 'h1',
    streams: [],
    nextStream: 0,
    cur: null,          // 正在传输的 {stream, idx, remaining}
    rr: 0,              // 流级轮转指针
    stalledUntil: 0,    // 连接级停顿(H1.1/H2)
    cwnd: CFG.INIT_CWND,
    winStart: 0,
    sentInWin: 0,
  };
}

function simulate(scenario) {
  const { origins, mode, lossP, seed } = scenario;
  const C = CFG;
  const rand = mulberry32(seed);

  // ---- 每 host 一次 DNS;H1.1 每 host 开 6 条连接,H2/H3 每 host 1 条 ----
  const senders = [];
  origins.forEach((host, hi) => {
    const n = mode === 'h1' ? Math.min(C.H1_PER_HOST, host.resources.length) : 1;
    const setup = C.DNS + (mode === 'h3' ? C.QUIC_RTT : C.TCP_TLS_RTT) * C.RTT;
    for (let i = 0; i < n; i++) senders.push(makeSender(mode, setup));
    // 资源按发现顺序轮转分配给连接(浏览器行为:新请求给第一个空闲连接)
    host.resources.forEach((size, ri) => {
      const s = senders[senders.length - n + (ri % n)];
      const hdr = mode === 'h1' ? C.HDR_H1 : C.HDR_H2;
      s.streams.push({
        host: host.name, idx: ri, kb: size,
        packets: [hdr].concat(splitPackets(size)),
        pi: 0, startAt: null, stalledUntil: 0,
        firstByteAt: null, doneAt: null,
        bytes: 0, stalls: 0, stallMs: 0,
      });
    });
  });

  // 第一条流的请求要等连接就绪 + 1 RTT(请求走到服务端 / 响应走回来)
  senders.forEach((s) => {
    if (s.serial) {
      if (s.streams.length) s.streams[0].startAt = s.readyAt + C.RTT;
    } else {
      s.streams.forEach((st) => { st.startAt = s.readyAt + C.RTT; });
    }
  });

  const hasPending = (s) => s.streams.some((st) => st.pi < st.packets.length);
  const streamReady = (st, t) =>
    st.pi < st.packets.length && st.startAt !== null && t >= st.startAt && t >= st.stalledUntil;

  let t = 0;
  let wireBytes = 0;
  let wastedKBms = 0;      // 链路空转(有带宽没人用)
  let totalStallMs = 0;
  let lossEvents = 0;
  let blockedStreamMs = 0;   // 「流·毫秒」:被一次丢包连累而停等的流 × 停等时长
  const stallLog = [];

  const MAXT = 600000;
  while (t < MAXT) {
    // 1) 谁现在真的能发?只有「能发」的连接参与带宽分配。
    const eligible = senders.filter((s) => {
      if (t < s.stalledUntil) return false;
      if (!hasPending(s)) return false;
      return s.streams.some((st) => streamReady(st, t));
    });

    if (eligible.length === 0) {
      // 没有任何连接能发:要么全下完了,要么全在等(等 RTT / 等重传)
      const allDone = senders.every((s) => !hasPending(s));
      if (allDone) break;
      wastedKBms += C.BANDWIDTH * C.TICK;
      t += C.TICK;
      continue;
    }

    const share = C.BANDWIDTH / eligible.length;
    let usedThisTick = 0;

    for (const s of eligible) {
      // ---- 拥塞窗口:按 RTT 时钟推进的慢启动 ----
      if (t - s.winStart >= C.RTT) {
        s.cwnd = Math.min(s.cwnd * 2, C.CWND_MAX);
        s.winStart = t;
        s.sentInWin = 0;
      }
      let budget = share * C.TICK;
      const cwndLeft = Math.max(0, s.cwnd - s.sentInWin);
      const allowed = Math.min(budget, cwndLeft);
      if (allowed <= 0) continue;
      budget = allowed;

      // ---- 轮转选包:一条连接内多个流公平轮转(H2/H3 的多路复用)----
      let guard = 0;
      while (budget > 1e-9 && guard++ < 4096) {
        if (!s.cur) {
          let picked = null;
          for (let k = 0; k < s.streams.length; k++) {
            const st = s.streams[(s.rr + k) % s.streams.length];
            if (streamReady(st, t)) { picked = st; s.rr = (s.rr + k + 1) % s.streams.length; break; }
          }
          if (!picked) break;                       // 这条连接此刻没有可发的流
          s.cur = { stream: picked, idx: picked.pi, remaining: picked.packets[picked.pi] };
        }
        const take = Math.min(budget, s.cur.remaining);
        s.cur.remaining -= take;
        s.sentInWin += take;
        budget -= take;
        wireBytes += take;
        usedThisTick += take;

        if (s.cur.remaining <= 1e-9) {
          const st = s.cur.stream;
          const idx = s.cur.idx;
          s.cur = null;
          // 丢包判定:每一次「传输尝试」独立丢包
          const lost = rand() < lossP;
          if (lost) {
            lossEvents++;
            const scopeConn = mode !== 'h3';
            const until = t + C.RETX;
            const pending = s.streams.filter((x) => x.pi < x.packets.length).length;
            if (scopeConn) {
              s.stalledUntil = Math.max(s.stalledUntil, until);
              s.streams.forEach((x) => {
                if (x.pi < x.packets.length) { x.stalls++; x.stallMs += C.RETX; }
              });
              blockedStreamMs += pending * C.RETX;
              stallLog.push({ t, host: st.host, scope: 'conn', ms: C.RETX, blocked: pending });
            } else {
              st.stalledUntil = Math.max(st.stalledUntil, until);
              st.stalls++; st.stallMs += C.RETX;
              blockedStreamMs += 1 * C.RETX;
              stallLog.push({ t, host: st.host, scope: 'stream', ms: C.RETX, blocked: 1 });
            }
            totalStallMs += C.RETX;
            s.cwnd = Math.max(CFG.INIT_CWND, s.cwnd / 2);
            s.winStart = t; s.sentInWin = 0;
            // 本包重传:pi 不前进(重传同一 idx)
            if (st.firstByteAt === null && idx > 0) st.firstByteAt = t;
            continue;
          }
          // 成功:字节落账
          st.bytes += st.packets[idx];
          st.pi = idx + 1;
          if (idx > 0 && st.firstByteAt === null) st.firstByteAt = t;   // 第 0 包是头部
          if (st.pi >= st.packets.length) {
            st.doneAt = t;
            // H1.1:这条连接上的下一个请求,现在才发得出去 → 再等一个 RTT
            if (s.serial) {
              const base = s.streams.indexOf(st);
              if (base + 1 < s.streams.length && s.streams[base + 1].startAt === null) {
                s.streams[base + 1].startAt = t + C.RTT;
              }
            }
          }
        }
      }
    }
    // 本 tick 没人用掉的带宽 = 链路空转(停等 RTT / 停等重传 / cwnd 封顶)
    wastedKBms += Math.max(0, C.BANDWIDTH * C.TICK - usedThisTick);
    t += C.TICK;
  }

  const streams = [];
  senders.forEach((s) => s.streams.forEach((st) => streams.push(st)));
  const done = streams.filter((st) => st.doneAt !== null);
  const payloadKB = streams.reduce((a, st) => a + st.kb, 0);
  const allDoneAt = Math.max.apply(null, done.map((st) => st.doneAt));
  const firstByteAt = Math.min.apply(null, streams.filter((st) => st.firstByteAt !== null).map((st) => st.firstByteAt));
  const sorted = done.map((st) => st.doneAt).sort((a, b) => a - b);
  const p50 = sorted[Math.floor(sorted.length / 2)];
  const util = wireBytes / (CFG.BANDWIDTH * allDoneAt);
  const stallByScope = stallLog.reduce((a, e) => { a[e.scope] = (a[e.scope] || 0) + 1; return a; }, {});
  return {
    mode, lossP, connections: senders.length, streams: streams.length,
    payloadKB, wireKB: wireBytes,
    allDoneAt, firstByteAt, p50, util, lossEvents, totalStallMs, blockedStreamMs, stallByScope,
    maxBlocked: stallLog.reduce((a, e) => Math.max(a, e.blocked), 0),
    wastedKBms, wastePct: wastedKBms / (CFG.BANDWIDTH * allDoneAt) * 100,
    stalls: stallLog,
  };
}

// ============ 场景定义 ============
function sizes(n, min, max, seed) {
  const r = mulberry32(seed);
  const out = [];
  for (let i = 0; i < n; i++) {
    const step = (max - min) / 4;
    out.push(Math.round(min + step * Math.floor(r() * 5)));
  }
  return out;
}

const MANY_SMALL = [{ name: 'cdn.a', resources: sizes(60, 4, 12, 11) }];            // 60 个 4~12KB
const FEW_LARGE = [{ name: 'cdn.a', resources: sizes(6, 150, 250, 22) }];            // 6 个 150~250KB
const SHARDED = [0, 1, 2, 3, 4].map((i) => ({ name: 'cdn' + i, resources: sizes(12, 4, 12, 33 + i) })); // 5 host × 12 小文件

const SCENARIOS = [
  { id: 'A', label: 'A · 多小资源(60 × 4~12KB,1 个源)', origins: MANY_SMALL, seed: 101 },
  { id: 'B', label: 'B · 少大资源(6 × 150~250KB,1 个源)', origins: FEW_LARGE, seed: 202 },
  { id: 'C', label: 'C · 域名分片(5 源 × 12 小文件)', origins: SHARDED, seed: 303 },
];
const MODES = ['h1', 'h2', 'h3'];
const NAMES = { h1: 'HTTP/1.1 (6 连接/源)', h2: 'HTTP/2 (1 连接/源)', h3: 'HTTP/3 (1 连接/源)' };
const PROFILES = [

  { id: 'clean', label: '干净网络(丢包率 0%)', lossP: 0 },
  { id: 'lossy', label: '一般移动网络(丢包率 2%)', lossP: 0.02 },
  { id: 'weak', label: '弱网(丢包率 5%)', lossP: 0.05 },
];

const results = {};
for (const pr of PROFILES) results[pr.id] = {};
for (const sc of SCENARIOS) {
  for (const pr of PROFILES) {
    results[pr.id][sc.id] = {};
    for (const mode of MODES) {
      results[pr.id][sc.id][mode] = simulate({
        origins: sc.origins, mode, lossP: pr.lossP, seed: sc.seed + Math.round(pr.lossP * 1000),
      });
    }
  }
}

// ============ 不变量断言 ============
let checks = 0;
function ok(cond, msg) {
  checks++;
  if (!cond) { console.error('✗ 断言失败: ' + msg); process.exit(1); }
}

for (const sc of SCENARIOS) {
  for (const pr of PROFILES) {
    const set = results[pr.id][sc.id];
    // 1) 三种协议下载的是同一份 payload 字节
    const p = MODES.map((m) => set[m].payloadKB);
    ok(p[0] === p[1] && p[1] === p[2], `${sc.id} payload 应三协议相等,实际 ${p.join('/')}`);
    // 2) 完成时间有限且为正
    MODES.forEach((m) => ok(Number.isFinite(set[m].allDoneAt) && set[m].allDoneAt > 0, `${sc.id}/${m} allDoneAt 非法`));
    // 3) 干净网络不应有丢包;有损网络必须有丢包
    MODES.forEach((m) => {
      if (pr.lossP === 0) ok(set[m].lossEvents === 0, `${sc.id}/${m} 干净网络不应丢包`);
      else ok(set[m].lossEvents > 0, `${sc.id}/${m} 有损网络应出现丢包`);
    });
    // 4) 连接数:H1.1 严格多于 H2/H3;H2 与 H3 相同
    ok(set.h1.connections > set.h2.connections, `${sc.id} H1.1 连接数应多于 H2`);
    ok(set.h2.connections === set.h3.connections, `${sc.id} H2/H3 连接数应相同`);
    // 5) 停顿作用域:H3 只有流级;H1.1/H2 只有连接级
    if (pr.lossP > 0) {
      ok(!set.h3.stallByScope.conn, `${sc.id}/h3 不应出现连接级停顿`);
      ok(set.h3.stallByScope.stream > 0, `${sc.id}/h3 应有流级停顿`);
      ok(set.h1.stallByScope.conn > 0, `${sc.id}/h1 应有连接级停顿`);
      ok(set.h2.stallByScope.conn > 0, `${sc.id}/h2 应有连接级停顿`);
      ok(!set.h2.stallByScope.stream, `${sc.id}/h2 不应出现流级停顿`);
      // 6) 同一次丢包,H2 连累的流数 ≥ H1.1(单连接 vs 六连接)
      ok(set.h2.maxBlocked >= set.h1.maxBlocked, `${sc.id} H2 单次丢包连累的流数应 ≥ H1.1`);
      // 7) H3 的单次丢包只连累 1 条流
      ok(set.h3.maxBlocked === 1, `${sc.id}/h3 单次丢包应只连累 1 条流,实际 ${set.h3.maxBlocked}`);
      // 8) 「流·毫秒」:H2 必须严格大于 H3(作用域差异的直接后果)
      ok(set.h2.blockedStreamMs > set.h3.blockedStreamMs, `${sc.id} H2 的流·毫秒应大于 H3`);
    }
    // 9) 利用率不可能超过物理带宽
    MODES.forEach((m) => ok(set[m].util <= 1.0001, `${sc.id}/${m} 利用率 > 1: ${set[m].util}`));
    // 10) H1.1 的线上字节必须多于 H2(明文头部 vs HPACK)
    ok(set.h1.wireKB > set.h2.wireKB, `${sc.id} H1.1 头部开销应大于 H2`);
    // 11) 干净网络下「已用带宽 + 空转带宽」必须等于链路总容量(互补关系)
    if (pr.lossP === 0) {
      MODES.forEach((m) => ok(Math.abs(set[m].wastePct + set[m].util * 100 - 100) < 1.5,
        `${sc.id}/${m} 利用率 ${(set[m].util * 100).toFixed(1)}% 与空转 ${set[m].wastePct.toFixed(1)}% 不互补`));
    }
  }
}

function PER_RTT_KB() { return CFG.BANDWIDTH * CFG.RTT; }
console.log('=== transport-hol.mjs · 传输层账本 ===');
console.log(`模型: 带宽 ${CFG.BANDWIDTH}KB/ms · RTT ${CFG.RTT}ms · 初始 cwnd ${CFG.INIT_CWND}KB · 传输单元 ${CFG.PKT}KB · 丢包恢复 ${CFG.RETX}ms\n`);

function table(pr) {
  console.log('■ ' + pr.label);
  console.log('场景 | 协议 | 连接数 | 首字节(ms) | 中位完成(ms) | 全部完成(ms) | 线上字节(KB) | 利用率 | 链路空转 | 丢包 | 停顿总计(ms) | 单次最多连累流');
  console.log('---|---|---|---|---|---|---|---|---|---|---|---');
  for (const sc of SCENARIOS) {
    for (const mode of MODES) {
      const r = results[pr.id][sc.id][mode];
      console.log([
        sc.id, NAMES[mode], r.connections,
        r.firstByteAt.toFixed(0), r.p50.toFixed(0), r.allDoneAt.toFixed(0),
        r.wireKB.toFixed(1), (r.util * 100).toFixed(1) + '%',
        r.wastePct.toFixed(1) + '%',
        r.lossEvents, r.totalStallMs.toFixed(0),
        r.lossEvents ? r.maxBlocked : '---',
      ].join(' | '));
    }
  }
  console.log('');
}
PROFILES.forEach(table);

console.log('■ 丢包的代价:同一场景同一协议,有损网络 vs 干净网络');
console.log('场景 | 协议 | 干净(ms) | 2%丢包(ms) | 2%恶化 | 5%丢包(ms) | 5%恶化');
console.log('---|---|---|---|---|---|---');
for (const sc of SCENARIOS) {
  for (const mode of MODES) {
    const c = results.clean[sc.id][mode].allDoneAt;
    const l = results.lossy[sc.id][mode].allDoneAt;
    const w = results.weak[sc.id][mode].allDoneAt;
    console.log([sc.id, NAMES[mode], c.toFixed(0), l.toFixed(0), (l / c).toFixed(2) + '×', w.toFixed(0), (w / c).toFixed(2) + '×'].join(' | '));
  }
}
console.log('');

console.log('■ 「流·毫秒」:一次丢包到底拖住了多少条流 × 多久(数值越小越抗丢包)');
console.log('场景 | 网络 | H1.1 | H2 | H3 | H2/H3 倍数');
console.log('---|---|---|---|---|---');
for (const sc of SCENARIOS) {
  for (const pr of PROFILES) {
    if (pr.lossP === 0) continue;
    const g = (m) => results[pr.id][sc.id][m].blockedStreamMs;
    console.log([sc.id, pr.lossP * 100 + '%', g('h1').toFixed(0), g('h2').toFixed(0), g('h3').toFixed(0),
      (g('h2') / g('h3')).toFixed(1) + '×'].join(' | '));
  }
}
console.log('');

console.log('■ 冷启动第一口:首字节(第一个资源的第一个数据帧到手)');
console.log('场景 | H1.1 | H2 | H3 | 差距(H1.1→H3)');
console.log('---|---|---|---|---');
for (const sc of SCENARIOS) {
  const v = MODES.map((m) => results.clean[sc.id][m].firstByteAt);
  console.log([sc.id, v[0].toFixed(0), v[1].toFixed(0), v[2].toFixed(0), (v[0] - v[2]).toFixed(0) + 'ms'].join(' | '));
}
console.log('');

console.log('■ 慢启动账本:第一个 RTT 里链路被用掉了多少(链路容量 ' + PER_RTT_KB() + 'KB/RTT)');
{
  const cap = CFG.BANDWIDTH * CFG.RTT;
  const h1 = results.clean.A.h1, h2 = results.clean.A.h2, h3 = results.clean.A.h3;
  const line = (tag, conns) => {
    const agg = conns * CFG.INIT_CWND;
    const used = Math.min(agg, cap);
    console.log(`${tag}: ${conns} 条连接 → 聚合初始 cwnd ${agg}KB → 首个 RTT 实际能用 ${used}KB(链路容量的 ${(used / cap * 100).toFixed(1)}%)`);
  };
  line('H1.1', h1.connections);
  line('H2  ', h2.connections);
  line('H3  ', h3.connections);
  console.log('H1.1 的 6 份独立 cwnd 把首个 RTT 直接顶到链路容量;H2/H3 的单份 cwnd 只用掉不到四分之一。');
  console.log('注意 H3 并不例外:QUIC 的拥塞控制也是每连接一份,不是每流一份。');
}
console.log('');

console.log('■ H3 的现实:Alt-Svc 发现成本(首次访问根本用不上 QUIC)');
{
  const tcp = CFG.DNS + CFG.TCP_TLS_RTT * CFG.RTT;
  const quic = CFG.DNS + CFG.QUIC_RTT * CFG.RTT;
  console.log(`首次访问:只能走 TCP,握手 ${tcp}ms(DNS ${CFG.DNS} + TCP 1 RTT + TLS1.3 1 RTT),响应里带回 Alt-Svc`);
  console.log(`第二次访问:走 QUIC,握手 ${quic}ms(DNS ${CFG.DNS} + QUIC/TLS1.3 合并握手 1 RTT)→ 省 ${tcp - quic}ms`);
  console.log(`第二次访问若用 0-RTT:省掉那 1 RTT,但只能用于幂等请求(否则可被重放)`);
}
console.log('');

console.log(`\n断言总数 ${checks},失败 0`);

实跑输出(三个场景 × 三种协议 × 三种网络):

text 复制代码
=== transport-hol.mjs · 传输层账本 ===
模型: 带宽 1.5KB/ms · RTT 40ms · 初始 cwnd 14KB · 传输单元 8KB · 丢包恢复 40ms

■ 干净网络(丢包率 0%)
场景 | 协议 | 连接数 | 首字节(ms) | 中位完成(ms) | 全部完成(ms) | 线上字节(KB) | 利用率 | 链路空转 | 丢包 | 停顿总计(ms) | 单次最多连累流
---|---|---|---|---|---|---|---|---|---|---|---
A | HTTP/1.1 (6 连接/源) | 6 | 175 | 433 | 675 | 522.0 | 51.6% | 48.5% | 0 | 0 | ---
A | HTTP/2 (1 连接/源) | 1 | 147 | 382 | 486 | 483.0 | 66.3% | 33.8% | 0 | 0 | ---
A | HTTP/3 (1 连接/源) | 1 | 107 | 342 | 446 | 483.0 | 72.2% | 27.8% | 0 | 0 | ---
B | HTTP/1.1 (6 连接/源) | 6 | 175 | 843 | 926 | 1179.2 | 84.9% | 15.2% | 0 | 0 | ---
B | HTTP/2 (1 连接/源) | 1 | 146 | 860 | 948 | 1175.3 | 82.7% | 17.3% | 0 | 0 | ---
B | HTTP/3 (1 连接/源) | 1 | 106 | 820 | 908 | 1175.3 | 86.3% | 13.7% | 0 | 0 | ---
C | HTTP/1.1 (6 连接/源) | 30 | 234 | 375 | 507 | 550.0 | 72.4% | 27.7% | 0 | 0 | ---
C | HTTP/2 (1 连接/源) | 5 | 155 | 409 | 481 | 511.0 | 70.9% | 29.2% | 0 | 0 | ---
C | HTTP/3 (1 连接/源) | 5 | 115 | 369 | 441 | 511.0 | 77.3% | 22.8% | 0 | 0 | ---

■ 一般移动网络(丢包率 2%)
场景 | 协议 | 连接数 | 首字节(ms) | 中位完成(ms) | 全部完成(ms) | 线上字节(KB) | 利用率 | 链路空转 | 丢包 | 停顿总计(ms) | 单次最多连累流
---|---|---|---|---|---|---|---|---|---|---|---
A | HTTP/1.1 (6 连接/源) | 6 | 169 | 429 | 700 | 531.4 | 50.6% | 49.4% | 3 | 120 | 10
A | HTTP/2 (1 连接/源) | 1 | 227 | 467 | 566 | 483.2 | 57.0% | 43.1% | 3 | 120 | 60
A | HTTP/3 (1 连接/源) | 1 | 107 | 378 | 477 | 483.1 | 67.6% | 32.5% | 3 | 120 | 1
B | HTTP/1.1 (6 连接/源) | 6 | 175 | 905 | 948 | 1211.2 | 85.2% | 14.8% | 4 | 160 | 1
B | HTTP/2 (1 连接/源) | 1 | 146 | 1051 | 1128 | 1207.3 | 71.4% | 28.7% | 4 | 160 | 6
B | HTTP/3 (1 连接/源) | 1 | 106 | 925 | 981 | 1207.3 | 82.1% | 17.9% | 4 | 160 | 1
C | HTTP/1.1 (6 连接/源) | 30 | 233 | 392 | 512 | 557.4 | 72.6% | 27.5% | 5 | 200 | 2
C | HTTP/2 (1 连接/源) | 5 | 152 | 418 | 496 | 533.0 | 71.7% | 28.3% | 5 | 200 | 12
C | HTTP/3 (1 连接/源) | 5 | 115 | 389 | 460 | 539.0 | 78.2% | 21.8% | 5 | 200 | 1

■ 弱网(丢包率 5%)
场景 | 协议 | 连接数 | 首字节(ms) | 中位完成(ms) | 全部完成(ms) | 线上字节(KB) | 利用率 | 链路空转 | 丢包 | 停顿总计(ms) | 单次最多连累流
---|---|---|---|---|---|---|---|---|---|---|---
A | HTTP/1.1 (6 连接/源) | 6 | 175 | 458 | 735 | 556.8 | 50.5% | 49.5% | 10 | 400 | 8
A | HTTP/2 (1 连接/源) | 1 | 267 | 598 | 863 | 511.2 | 39.5% | 60.5% | 10 | 400 | 60
A | HTTP/3 (1 连接/源) | 1 | 107 | 411 | 537 | 511.2 | 63.4% | 36.6% | 10 | 400 | 1
B | HTTP/1.1 (6 连接/源) | 6 | 175 | 893 | 953 | 1219.2 | 85.3% | 14.7% | 5 | 200 | 1
B | HTTP/2 (1 连接/源) | 1 | 146 | 1091 | 1173 | 1215.3 | 69.1% | 30.9% | 5 | 200 | 6
B | HTTP/3 (1 连接/源) | 1 | 106 | 858 | 934 | 1215.3 | 86.7% | 13.3% | 5 | 200 | 1
C | HTTP/1.1 (6 连接/源) | 30 | 231 | 368 | 542 | 563.4 | 69.4% | 30.7% | 4 | 160 | 2
C | HTTP/2 (1 连接/源) | 5 | 152 | 391 | 483 | 511.2 | 70.6% | 29.5% | 4 | 160 | 12
C | HTTP/3 (1 连接/源) | 5 | 115 | 355 | 441 | 511.2 | 77.3% | 22.7% | 4 | 160 | 1

■ 丢包的代价:同一场景同一协议,有损网络 vs 干净网络
场景 | 协议 | 干净(ms) | 2%丢包(ms) | 2%恶化 | 5%丢包(ms) | 5%恶化
---|---|---|---|---|---|---
A | HTTP/1.1 (6 连接/源) | 675 | 700 | 1.04× | 735 | 1.09×
A | HTTP/2 (1 连接/源) | 486 | 566 | 1.16× | 863 | 1.78×
A | HTTP/3 (1 连接/源) | 446 | 477 | 1.07× | 537 | 1.21×
B | HTTP/1.1 (6 连接/源) | 926 | 948 | 1.02× | 953 | 1.03×
B | HTTP/2 (1 连接/源) | 948 | 1128 | 1.19× | 1173 | 1.24×
B | HTTP/3 (1 连接/源) | 908 | 981 | 1.08× | 934 | 1.03×
C | HTTP/1.1 (6 连接/源) | 507 | 512 | 1.01× | 542 | 1.07×
C | HTTP/2 (1 连接/源) | 481 | 496 | 1.03× | 483 | 1.00×
C | HTTP/3 (1 连接/源) | 441 | 460 | 1.04× | 441 | 1.00×

■ 「流·毫秒」:一次丢包到底拖住了多少条流 × 多久(数值越小越抗丢包)
场景 | 网络 | H1.1 | H2 | H3 | H2/H3 倍数
---|---|---|---|---|---
A | 2% | 1000 | 7200 | 120 | 60.0×
A | 5% | 1920 | 16920 | 400 | 42.3×
B | 2% | 160 | 960 | 160 | 6.0×
B | 5% | 200 | 1200 | 200 | 6.0×
C | 2% | 320 | 1880 | 200 | 9.4×
C | 5% | 320 | 1920 | 160 | 12.0×

■ 冷启动第一口:首字节(第一个资源的第一个数据帧到手)
场景 | H1.1 | H2 | H3 | 差距(H1.1→H3)
---|---|---|---|---
A | 175 | 147 | 107 | 68ms
B | 175 | 146 | 106 | 69ms
C | 234 | 155 | 115 | 119ms

■ 慢启动账本:第一个 RTT 里链路被用掉了多少(链路容量 60KB/RTT)
H1.1: 6 条连接 → 聚合初始 cwnd 84KB → 首个 RTT 实际能用 60KB(链路容量的 100.0%)
H2  : 1 条连接 → 聚合初始 cwnd 14KB → 首个 RTT 实际能用 14KB(链路容量的 23.3%)
H3  : 1 条连接 → 聚合初始 cwnd 14KB → 首个 RTT 实际能用 14KB(链路容量的 23.3%)
H1.1 的 6 份独立 cwnd 把首个 RTT 直接顶到链路容量;H2/H3 的单份 cwnd 只用掉不到四分之一。
注意 H3 并不例外:QUIC 的拥塞控制也是每连接一份,不是每流一份。

■ H3 的现实:Alt-Svc 发现成本(首次访问根本用不上 QUIC)
首次访问:只能走 TCP,握手 100ms(DNS 20 + TCP 1 RTT + TLS1.3 1 RTT),响应里带回 Alt-Svc
第二次访问:走 QUIC,握手 60ms(DNS 20 + QUIC/TLS1.3 合并握手 1 RTT)→ 省 40ms
第二次访问若用 0-RTT:省掉那 1 RTT,但只能用于幂等请求(否则可被重放)


断言总数 174,失败 0

怎么读这份输出(这是本文最容易被读错的地方):

  • 先看「首字节」列,不要先看「全部完成」。 首字节决定用户看到东西的时间;全部完成决定 JS 什么时候能跑。A 场景首字节 175/147/107ms,B 场景 175/146/106ms------H3 的领先(约 69ms)几乎全部来自那一次握手 RTT 的节省,和场景内容无关。 如果你只想要这 69ms,preconnect 也能给你一部分,而且不用换协议栈。
  • 再看「单次最多连累流」列。 A 场景 H2 是 60,H1.1 是 10,H3 是 1------这一个数字比"全部完成"更能预测弱网体验,因为它描述的是抖动而不是均值。
  • 最后看「利用率」和「链路空转」。 A 场景三种协议的链路空转分别是 48.5% / 33.8% / 27.8%。一半的链路时间是空的,而用户在看白屏------空转的原因不是带宽不够,是"不知道该发什么"(Day26 的发现段)和"不敢发"(难点三的慢启动)。
  • 不要用这份输出做容量规划。 它是策略比较器(见文末模型边界),不是网络模拟器。

2.2 工具二:把缓存策略记成四笔账

cache-ledger.mjs 做两件事:用一个 30 天的回访轨迹把六种策略的「阻塞 / 字节 / 请求数 / 陈旧窗口」同时算出来;再用一个边缘节点仿真把"TTL 与请求频率"的台阶关系画出来。

关键的建模点是陈旧窗口的定义:某个时刻渲染出的资源版本,是被哪个时刻的内容变化作废的------差多久,就是用户看到多旧的东西。这一步是区分"看起来很快"和"其实很快"的唯一办法。

js 复制代码
#!/usr/bin/env node
'use strict';
/*
 * cache-ledger.mjs ------ 缓存策略账本
 *
 * 核心命题:缓存不是「命中率」一个数字,而是一组必须同时记账的量:
 *   阻塞毫秒(用户等多久)· 线上字节 · 请求数 · 陈旧窗口(用户看到多旧的东西)
 * 本次实测要证明:任何一次「省下的 RTT」,都会在陈旧窗口那一栏记上账。
 *
 * 零依赖、确定性。断言失败即 exit 1。
 */

const CFG = {
  RTT: 40,            // ms,一次往返
  BANDWIDTH: 1.5,     // KB/ms ≈ 12Mbps
  DAYS: 30,
  VIEWS_PER_DAY: 6,
  HTML_PERIOD: 3600,  // s,入口 HTML 的内容变化周期
  DEPLOY_PERIOD: 2 * 86400,   // s,发版周期(带 hash 的静态资源换 URL)
  IMAGE_PERIOD: 7 * 86400,    // s,图片内容变化周期(URL 不变)
};

function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// ============ 资源清单 ============
const ASSET_SET = [
  { name: 'app.[hash].js', kb: 260 },
  { name: 'vendor.[hash].js', kb: 180 },
  { name: 'chunk-a.[hash].js', kb: 90 },
  { name: 'chunk-b.[hash].js', kb: 90 },
  { name: 'chunk-c.[hash].js', kb: 90 },
  { name: 'main.[hash].css', kb: 48 },
];
const IMAGES = [{ name: 'hero.jpg', kb: 120 }];
const API = [{ name: 'profile.json', kb: 2 }];
const ASSET_KB = ASSET_SET.reduce((a, r) => a + r.kb, 0);

// ============ 缓存策略 ============
// mode: 'no-store' 完全不存 | 'no-cache' 每次强制校验 | 'max-age' 带新鲜期
// immutable: 刷新时也不重新校验(这才是 immutable 与 max-age 的唯一区别)
const POLICIES = [
  {
    id: 'P1', label: 'P1 · 全都 no-store(等于没有缓存)',
    doc: { mode: 'no-store' }, asset: { mode: 'no-store' },
    image: { mode: 'no-store' }, api: { mode: 'no-store' },
  },
  {
    id: 'P2', label: 'P2 · 全都 no-cache(每次 304 校验)',
    doc: { mode: 'no-cache' }, asset: { mode: 'no-cache' },
    image: { mode: 'no-cache' }, api: { mode: 'no-cache' },
  },
  {
    id: 'P3', label: 'P3 · 教科书基线(hash+immutable / HTML no-cache / 图 1h)',
    doc: { mode: 'no-cache' },
    asset: { mode: 'max-age', maxAge: 31536000, immutable: true },
    image: { mode: 'max-age', maxAge: 3600 },
    api: { mode: 'no-store' },
  },
  {
    id: 'P3b', label: 'P3b · 同上但**去掉 immutable**(只留 max-age=31536000)',
    doc: { mode: 'no-cache' },
    asset: { mode: 'max-age', maxAge: 31536000 },
    image: { mode: 'max-age', maxAge: 3600 },
    api: { mode: 'no-store' },
  },
  {
    id: 'P4', label: 'P4 · HTML 上 stale-while-revalidate;图片也上',
    doc: { mode: 'max-age', maxAge: 0, swr: 86400 },
    asset: { mode: 'max-age', maxAge: 31536000, immutable: true },
    image: { mode: 'max-age', maxAge: 3600, swr: 604800 },
    api: { mode: 'no-store' },
  },
  {
    id: 'P5', label: 'P5 · 全都 max-age=31536000, immutable(含 HTML,事故款)',
    doc: { mode: 'max-age', maxAge: 31536000, immutable: true },
    asset: { mode: 'max-age', maxAge: 31536000, immutable: true },
    image: { mode: 'max-age', maxAge: 31536000, immutable: true },
    api: { mode: 'no-store' },
  },
];

const kindOf = (r) => (r.name === 'index.html' ? 'doc' : r.name.endsWith('.json') ? 'api' : r.name.endsWith('.jpg') ? 'image' : 'asset');

// 内容版本:每个 kind 一条时间线,返回「某时刻的当前版本号」
function versionAt(kind, t) {
  if (kind === 'doc') return Math.floor(t / CFG.HTML_PERIOD);
  if (kind === 'asset') return Math.floor(t / CFG.DEPLOY_PERIOD);
  if (kind === 'image') return Math.floor(t / CFG.IMAGE_PERIOD);
  return Math.floor(t); // api 每次都是新内容
}
function obsoleteAt(kind, v) {   // 版本 v 在哪个时刻开始过期
  if (kind === 'doc') return (v + 1) * CFG.HTML_PERIOD;
  if (kind === 'asset') return (v + 1) * CFG.DEPLOY_PERIOD;
  if (kind === 'image') return (v + 1) * CFG.IMAGE_PERIOD;
  return v + 1;
}
// 某资源在某时刻的 URL(带 hash 的静态资源,URL 随版本变)
function urlOf(r, kind, t) {
  return kind === 'asset' ? r.name.replace('[hash]', versionAt('asset', t).toString(36)) : r.name;
}

// ============ 单次访问的缓存结算 ============
function serve(policy, kind, urlKey, now, entry, curVersion) {
  const pol = policy[kind];
  const kb = urlKey.kb;
  const dl = (bytes) => CFG.RTT + bytes / CFG.BANDWIDTH;   // 一次网络往返 + 传输
  const out = { blocking: 0, bytes: 0, requests: 0, origin: 0, served: null, next: null, revalidated: false, swr: false };

  if (pol.mode === 'no-store') {
    out.blocking = dl(kb); out.bytes = kb; out.requests = 1; out.origin = 1;
    out.served = { storedAt: null, version: curVersion };   // 不存
    out.next = null;
    return out;
  }
  if (!entry) {  // 缓存里没有 → 必下
    out.blocking = dl(kb); out.bytes = kb; out.requests = 1; out.origin = 1;
    out.served = { storedAt: now, version: curVersion };
    out.next = out.served;
    return out;
  }
  const cur = curVersion;   // 注意:带 hash 的 URL,其内容版本是常量(URL 变了才是新版本)
  const age = now - entry.storedAt;
  const fresh = pol.mode === 'max-age' && age < pol.maxAge;

  if (fresh) {
    out.served = entry;                       // 0 请求 0 字节,直接用
    out.next = entry;
    return out;
  }
  // 陈旧:能否 stale-while-revalidate 直接拿来用?
  const swrLeft = pol.swr ? pol.maxAge + pol.swr - age : -1;
  if (pol.swr && swrLeft > 0) {
    const changed = cur !== entry.version;
    out.blocking = 0;                          // 用户不等
    out.swr = true;
    out.requests = 1; out.origin = 1;          // 后台仍然发了一个请求
    out.bytes = changed ? kb : 0;              // 变了就整份重下,没变就是 304
    out.served = entry;                        // 本次仍然用旧副本(陈旧!)
    // 后台刷新在 1 RTT 后完成并写回缓存 ------ 这一步决定了下一次访问能拿到多新
    out.next = { storedAt: now + CFG.RTT / 1000, version: cur };
    return out;
  }
  // 必须阻塞校验
  const changed = cur !== entry.version;
  out.revalidated = true;
  out.blocking = dl(changed ? kb : 0);
  out.bytes = changed ? kb : 0;
  out.requests = 1; out.origin = 1;
  out.served = { storedAt: now, version: cur };
  out.next = out.served;
  return out;
}

function run(policy, opts) {
  const reloadEvery = (opts && opts.reloadEvery) || 0;   // 每 N 次访问是一次「刷新(F5)」
  const rand = mulberry32(7);
  const cache = new Map();          // url -> entry
  const fetchedTimes = new Map();   // 带 hash 的 url -> 被下载过几次(>0 字节)
  const acc = { blocking: 0, bytes: 0, requests: 0, origin: 0, views: 0, staleMax: 0, staleSum: 0, reloadBlocking: 0 };
  const totalViews = CFG.DAYS * CFG.VIEWS_PER_DAY;

  for (let vi = 0; vi < totalViews; vi++) {
    const day = Math.floor(vi / CFG.VIEWS_PER_DAY);
    const slot = vi % CFG.VIEWS_PER_DAY;
    const now = day * 86400 + slot * 14400 + Math.floor(rand() * 1800);   // 每 4 小时一次 ±30min
    const isReload = reloadEvery > 0 && vi % reloadEvery === 0;
    acc.views++;
    let viewBlocking = 0;

    // 1) 入口文档:它决定这一次访问用的是哪个「版本纪元」
    const docKey = { name: 'index.html', kb: 32 };
    const docOut = serve(policy, 'doc', docKey, now, cache.get('index.html'), versionAt('doc', now));
    if (docOut.next) cache.set('index.html', docOut.next); else cache.delete('index.html');
    acc.blocking += docOut.blocking; acc.bytes += docOut.bytes;
    acc.requests += docOut.requests; acc.origin += docOut.origin;
    viewBlocking += docOut.blocking;

    // 本次渲染用的 HTML 版本 → 它引用的那批 asset hash
    const htmlVersion = docOut.served ? docOut.served.version : versionAt('doc', now);
    const era = htmlVersion * CFG.HTML_PERIOD;
    const htmlStale = Math.max(0, now - obsoleteAt('doc', htmlVersion));
    acc.staleMax = Math.max(acc.staleMax, htmlStale);
    acc.staleSum += htmlStale;

    // 2) 静态资源:URL 里带 era 对应的 deploy 版本
    for (const r of ASSET_SET) {
      const url = r.name.replace('[hash]', versionAt('asset', era).toString(36));
      const key = { name: url, kb: r.kb };
      const out = serve(policy, 'asset', key, now, cache.get(url), versionAt('asset', era));
      if (out.next) cache.set(url, out.next); else cache.delete(url);
      if (out.bytes > 0) fetchedTimes.set(url, (fetchedTimes.get(url) || 0) + 1);
      acc.blocking += out.blocking; acc.bytes += out.bytes;
      acc.requests += out.requests; acc.origin += out.origin;
      viewBlocking += out.blocking;

      // 「刷新(F5)」:只有没写 immutable 的才会被重新校验
      if (isReload && policy.asset.mode === 'max-age' && !policy.asset.immutable) {
        acc.reloadBlocking += CFG.RTT;
        acc.requests += 1; acc.origin += 1; acc.blocking += CFG.RTT;
        viewBlocking += CFG.RTT;
      }
    }

    // 3) 图片(URL 不变,内容会变)
    for (const r of IMAGES) {
      const out = serve(policy, 'image', r, now, cache.get(r.name), versionAt('image', now));
      if (out.next) cache.set(r.name, out.next); else cache.delete(r.name);
      acc.blocking += out.blocking; acc.bytes += out.bytes;
      acc.requests += out.requests; acc.origin += out.origin;
      viewBlocking += out.blocking;
      if (out.served) {
        const st = Math.max(0, now - obsoleteAt('image', out.served.version));
        acc.staleMax = Math.max(acc.staleMax, Math.min(st, CFG.IMAGE_PERIOD));
      }
    }

    // 4) 接口
    for (const r of API) {
      const out = serve(policy, 'api', r, now, cache.get(r.name), versionAt('api', now));
      acc.blocking += out.blocking; acc.bytes += out.bytes;
      acc.requests += out.requests; acc.origin += out.origin;
      viewBlocking += out.blocking;
    }
  }
  acc.perViewBlocking = acc.blocking / acc.views;
  acc.perViewBytes = acc.bytes / acc.views;
  acc.perViewRequests = acc.requests / acc.views;
  acc.assetUrls = fetchedTimes.size;
  acc.assetRefetchMax = fetchedTimes.size ? Math.max.apply(null, Array.from(fetchedTimes.values())) : 0;
  return acc;
}

// ============ 断言 ============
let checks = 0;
function ok(cond, msg) {
  checks++;
  if (!cond) { console.error('✗ 断言失败: ' + msg); process.exit(1); }
}

const R = {};
POLICIES.forEach((p) => { R[p.id] = run(p); });
const R_RELOAD = {
  P3: run(POLICIES.find((p) => p.id === 'P3'), { reloadEvery: 20 }),
  P3b: run(POLICIES.find((p) => p.id === 'P3b'), { reloadEvery: 20 }),
};

// 1) no-store:字节必须是灾难级(每次访问把所有东西重下一遍)
ok(R.P1.bytes > R.P3.bytes * 5, `P1 字节应远多于 P3:${R.P1.bytes} vs ${R.P3.bytes}`);
// 2) 304 校验省的是字节......
ok(R.P2.bytes < R.P1.bytes, `P2(304) 字节应少于 P1(no-store):${R.P2.bytes} vs ${R.P1.bytes}`);
// 3) ......但不省往返:P2 的每访问阻塞必须显著高于 P3
ok(R.P2.perViewBlocking > R.P3.perViewBlocking * 1.5,
  `P2 每访问阻塞应显著高于 P3:${R.P2.perViewBlocking.toFixed(0)} vs ${R.P3.perViewBlocking.toFixed(0)}`);
// 4) stale-while-revalidate 把阻塞降到接近 0,但陈旧窗口必然 > 0 ------ 本篇的核心命题
ok(R.P4.perViewBlocking < R.P3.perViewBlocking, 'P4 的每访问阻塞时间应低于 P3');
ok(R.P4.staleMax > 0, 'P4 必须有陈旧窗口(天下没有免费的午餐)');
ok(R.P4.staleMax >= 3600, `P4 陈旧窗口应至少达到 HTML 的一个变化周期:${R.P4.staleMax}`);
// 4b) 关键:s-w-r 的 24h 窗口只是上界,实际陈旧被「访问间隔」卡住(这才是真账)
ok(R.P4.staleMax < 86400, `P4 实际陈旧窗口应远小于 swr 窗口 86400s,实际 ${R.P4.staleMax}`);
ok(R.P4.staleMax < 14400 + 3600, `P4 陈旧窗口应被访问间隔(4h)+内容周期(1h)卡住,实际 ${R.P4.staleMax}`);
// 5) 全都 immutable 的 P5:陈旧窗口必须灾难级(30 天内大概率 > 1 天)
ok(R.P5.staleMax > 86400, `P5 陈旧窗口应 > 1 天,实际 ${R.P5.staleMax}`);
ok(R.P5.staleMax >= R.P3.staleMax, 'P5 陈旧窗口应 ≥ P3');
// 5b) 资源类:带 hash 的 URL 一旦写入缓存,内容永不再变(immutable 的语义基础)
ok(R.P3.perViewBytes < R.P1.perViewBytes / 5, 'P3 每访问字节应远少于 P1');
// 5c) 直接锁死「带 hash 的 URL 只下载一次」------ 这条断言会抓住「把 hash URL 当成会变的资源」的建模错误
ok(R.P3.assetRefetchMax === 1,
  `P3 下每个带 hash 的 URL 最多只应下载 1 次,实际最多 ${R.P3.assetRefetchMax} 次`);
ok(R.P3b.assetRefetchMax === 1, 'P3b(去掉 immutable)同样不应重复下载同一个 hash URL');
ok(R.P2.assetRefetchMax === 1,
  `P2(no-cache)下 304 校验不应重新下载 body,实际最多 ${R.P2.assetRefetchMax} 次`);
ok(R.P1.assetRefetchMax > 1, 'P1(no-store)下同一个 hash URL 必然被重复下载');
// 6) immutable 与 max-age 的唯一区别体现在「刷新」上
ok(R_RELOAD.P3b.reloadBlocking > R_RELOAD.P3.reloadBlocking, 'P3b(无 immutable) 在刷新时应多付校验成本');
ok(R_RELOAD.P3.reloadBlocking === 0, `P3(immutable) 刷新时不应产生校验成本,实际 ${R_RELOAD.P3.reloadBlocking}`);
// 7) 阻塞时间与陈旧窗口必须同向记账:没有任何一个策略两者同时最优
{
  const best = POLICIES.map((p) => ({ id: p.id, b: R[p.id].perViewBlocking, s: R[p.id].staleMax }));
  const minB = best.reduce((a, x) => (x.b < a.b ? x : a));
  const minS = best.reduce((a, x) => (x.s < a.s ? x : a));
  ok(!(minB.id === minS.id && minB.b === minS.b), '不存在既最快又最新的策略(应被断言守住)');
  console.log(`  · 阻塞最低的是 ${minB.id}(${minB.b.toFixed(1)}ms/访问),陈旧窗口 ${minB.s.toFixed(0)}s`);
  console.log(`  · 最新鲜的是 ${minS.id}(陈旧 ${minS.s.toFixed(0)}s),阻塞 ${minS.b.toFixed(1)}ms/访问`);
}
// 8) 每个策略都必须真的服务了 180 次访问
POLICIES.forEach((p) => ok(R[p.id].views === CFG.DAYS * CFG.VIEWS_PER_DAY, `${p.id} 访问次数不对`));

console.log('=== cache-ledger.mjs · 缓存策略账本 ===');
console.log(`模型: 30 天 × 每天 6 次访问 = 180 次访问 · RTT ${CFG.RTT}ms · 带宽 ${CFG.BANDWIDTH}KB/ms`);
console.log(`内容变化: HTML 每 ${CFG.HTML_PERIOD}s · 发版每 ${CFG.DEPLOY_PERIOD / 86400} 天 · 图片每 ${CFG.IMAGE_PERIOD / 86400} 天\n`);

console.log('■ 六个策略的账本(180 次访问累计 / 以及每次访问摊到多少)');
console.log('策略 | 阻塞总计(s) | 每次访问(ms) | 线上字节(MB) | 每次访问(KB) | 请求数 | 最坏陈旧窗口 | 平均陈旧窗口');
console.log('---|---|---|---|---|---|---|---');
POLICIES.forEach((p) => {
  const r = R[p.id];
  const stale = r.staleMax === 0 ? '0s' : (r.staleMax / 3600).toFixed(1) + 'h';
  const avg = (r.staleSum / r.views / 60).toFixed(1) + 'min';
  console.log([p.label,
    (r.blocking / 1000).toFixed(2), r.perViewBlocking.toFixed(0),
    (r.bytes / 1024).toFixed(2), r.perViewBytes.toFixed(0),
    r.requests, stale, avg].join(' | '));
});
console.log('');

console.log('■ 相对 P3 基线的变化(阻塞 / 字节 / 陈旧窗口)');
console.log('策略 | 阻塞变化 | 字节变化 | 最坏陈旧窗口变化');
console.log('---|---|---|---');
POLICIES.forEach((p) => {
  const r = R[p.id], b = R.P3;
  const dp = (x, y) => (y === 0 ? '---' : ((x / y - 1) * 100).toFixed(0) + '%');
  const ds = b.staleMax === 0 ? (r.staleMax === 0 ? '持平(都是0)' : '从 0 变 ' + (r.staleMax / 3600).toFixed(1) + 'h')
    : ((r.staleMax / b.staleMax - 1) * 100).toFixed(0) + '%';
  console.log([p.label, dp(r.perViewBlocking, b.perViewBlocking), dp(r.perViewBytes, b.perViewBytes), ds].join(' | '));
});
console.log('');

console.log('■ immutable 的唯一区别:用户按 F5 刷新时(每 20 次访问一次,共 9 次)');
console.log('策略 | 刷新带来的额外阻塞(ms) | 额外请求数');
console.log('---|---|---');
console.log(['P3(带 immutable)', R_RELOAD.P3.reloadBlocking.toFixed(0), R_RELOAD.P3.requests - R.P3.requests].join(' | '));
console.log(['P3b(去掉 immutable)', R_RELOAD.P3b.reloadBlocking.toFixed(0), R_RELOAD.P3b.requests - R.P3b.requests].join(' | '));
console.log(`差额: 每 9 次刷新多付 ${(R_RELOAD.P3b.reloadBlocking - R_RELOAD.P3.reloadBlocking).toFixed(0)}ms 与 ${R_RELOAD.P3b.requests - R_RELOAD.P3.requests} 个 304 请求`);
console.log('');

// ============ 边缘(CDN)缓存命中率:台阶,不是曲线 ============
console.log('■ 边缘(CDN)命中率:TTL 与请求频率 ------ 这是一条台阶函数,不是一条曲线');
const EDGE_PERIOD = 3600;   // 内容每 3600s 变一次
console.log(`假设单个边缘节点;内容每 ${EDGE_PERIOD}s 变一次;请求以固定间隔到达\n`);
function edgeSim(ttl, interval, swr) {
  let t = 0, storedAt = -1e9, version = -1;
  let hits = 0, misses = 0, staleHits = 0, origin = 0, staleAgeSum = 0;
  const until = 86400;
  while (t < until) {
    t += interval;
    const cur = Math.floor(t / EDGE_PERIOD);
    const age = t - storedAt;
    const expired = ttl === 0 || age >= ttl;
    const changed = cur !== version;
    if (!expired && !changed) { hits++; continue; }               // 新鲜命中
    if (swr && expired && !changed && age < ttl + swr) {           // 陈旧命中 + 后台刷新
      staleHits++; hits++; origin++; staleAgeSum += age;
      storedAt = t; version = cur;                                 // 后台刷新立即完成(简化)
      continue;
    }
    misses++; origin++; storedAt = t; version = cur;
  }
  const total = hits + misses;
  return { hits, misses, staleHits, origin, hitRate: hits / total,
           avgStale: staleHits ? staleAgeSum / staleHits : 0 };
}
console.log('TTL | 请求间隔 | 命中率 | 陈旧命中占比 | 回源次数/天 | 陈旧命中时平均看到多旧(s)');
console.log('---|---|---|---|---|---');
const EDGE_CASES = [
  { ttl: 0, interval: 60, swr: 0, note: '不缓存' },
  { ttl: 30, interval: 60, swr: 0, note: 'TTL < 间隔 → 每次都是 miss' },
  { ttl: 60, interval: 60, swr: 0, note: 'TTL == 间隔 → 仍然是 0%' },
  { ttl: 120, interval: 60, swr: 0, note: 'TTL = 2×间隔 → 50%' },
  { ttl: 600, interval: 60, swr: 0, note: 'TTL = 10×间隔 → 90%' },
  { ttl: 600, interval: 60, swr: 3300, note: '加 s-w-r → 接近全命中,代价是陈旧' },
  { ttl: 600, interval: 10, swr: 0, note: '同样的 TTL,请求更密 → 台阶更高' },
];
const EDGE = EDGE_CASES.map((c) => {
  const r = edgeSim(c.ttl, c.interval, c.swr);
  console.log([
    c.ttl === 0 ? '0' : c.ttl + 's',
    c.interval + 's',
    (r.hitRate * 100).toFixed(1) + '%',
    r.staleHits ? ((r.staleHits / r.hits) * 100).toFixed(0) + '%' : '0%',
    r.origin,
    r.avgStale ? r.avgStale.toFixed(0) : '---',
  ].join(' | '));
  return Object.assign({}, c, r);
});
console.log('');
console.log('台阶的本质: 命中率 ≈ 1 - 1/ceil(TTL ÷ 请求间隔)');
EDGE_CASES.forEach((c, i) => {
  const r = EDGE[i];
  const steps = c.ttl === 0 ? 1 : Math.max(1, Math.ceil(c.ttl / c.interval));
  const predicted = 1 - 1 / steps;
  console.log(`  TTL ${c.ttl}s / 间隔 ${c.interval}s → 每次存入可服务 ${steps} 个请求 → 预测 ${(predicted * 100).toFixed(1)}%,实测 ${(r.hitRate * 100).toFixed(1)}%  ${c.note}`);
});
console.log('');

// 断言:台阶函数
{
  const [z, t30, t60, t120, t600, tswr, dense] = EDGE;
  ok(z.hitRate === 0, 'TTL=0 命中率必须是 0');
  ok(t30.hitRate === 0, 'TTL(30s) < 间隔(60s) 命中率必须是 0 ------ 这是最常被忽略的配置错误');
  ok(t60.hitRate === 0, 'TTL(60s) == 间隔(60s) 命中率仍是 0');
  ok(t120.hitRate > t60.hitRate, 'TTL 跨过一个间隔后命中率必须上一个台阶');
  ok(t600.hitRate > t120.hitRate, 'TTL 继续跨台阶应继续上升');
  ok(tswr.hitRate > t600.hitRate, 's-w-r 应把命中率推到接近 100%');
  ok(tswr.staleHits > 0, 's-w-r 的命中必须是「陈旧命中」');
  ok(tswr.avgStale > 0, 's-w-r 的陈旧命中必须付出陈旧代价(平均看到旧内容的秒数 > 0)');
  ok(dense.hitRate > t600.hitRate, '同样的 TTL,请求越密命中率越高(台阶越高)');
  ok(t600.origin < t120.origin && t120.origin < z.origin, 'TTL 越大回源越少');
  // 台阶公式预测值与实测必须吻合
  EDGE_CASES.forEach((c, i) => {
    if (c.ttl === 0) return;
    const steps = Math.max(1, Math.ceil(c.ttl / c.interval));
    const predicted = 1 - 1 / steps;
    if (!c.swr) ok(Math.abs(predicted - EDGE[i].hitRate) < 0.02,
      `台阶公式应吻合: TTL ${c.ttl}/间隔 ${c.interval} 预测 ${predicted} 实测 ${EDGE[i].hitRate}`);
  });
}

console.log(`断言总数 ${checks},失败 0`);

实跑输出:

text 复制代码
  · 阻塞最低的是 P5(46.5ms/访问),陈旧窗口 2575591s
  · 最新鲜的是 P1(陈旧 0s),阻塞 968.0ms/访问
=== cache-ledger.mjs · 缓存策略账本 ===
模型: 30 天 × 每天 6 次访问 = 180 次访问 · RTT 40ms · 带宽 1.5KB/ms
内容变化: HTML 每 3600s · 发版每 2 天 · 图片每 7 天

■ 六个策略的账本(180 次访问累计 / 以及每次访问摊到多少)
策略 | 阻塞总计(s) | 每次访问(ms) | 线上字节(MB) | 每次访问(KB) | 请求数 | 最坏陈旧窗口 | 平均陈旧窗口
---|---|---|---|---|---|---|---
P1 · 全都 no-store(等于没有缓存) | 174.24 | 968 | 160.31 | 912 | 1620 | 0s | 0.0min
P2 · 全都 no-cache(每次 304 校验) | 76.86 | 427 | 17.67 | 101 | 1620 | 0s | 0.0min
P3 · 教科书基线(hash+immutable / HTML no-cache / 图 1h) | 37.26 | 207 | 17.67 | 101 | 630 | 0s | 0.0min
P3b · 同上但**去掉 immutable**(只留 max-age=31536000) | 37.26 | 207 | 17.67 | 101 | 630 | 0s | 0.0min
P4 · HTML 上 stale-while-revalidate;图片也上 | 18.80 | 104 | 17.67 | 101 | 630 | 3.5h | 194.8min
P5 · 全都 max-age=31536000, immutable(含 HTML,事故款) | 8.37 | 46 | 1.24 | 7 | 188 | 715.4h | 21436.1min

■ 相对 P3 基线的变化(阻塞 / 字节 / 陈旧窗口)
策略 | 阻塞变化 | 字节变化 | 最坏陈旧窗口变化
---|---|---|---
P1 · 全都 no-store(等于没有缓存) | 368% | 807% | 持平(都是0)
P2 · 全都 no-cache(每次 304 校验) | 106% | 0% | 持平(都是0)
P3 · 教科书基线(hash+immutable / HTML no-cache / 图 1h) | 0% | 0% | 持平(都是0)
P3b · 同上但**去掉 immutable**(只留 max-age=31536000) | 0% | 0% | 持平(都是0)
P4 · HTML 上 stale-while-revalidate;图片也上 | -50% | 0% | 从 0 变 3.5h
P5 · 全都 max-age=31536000, immutable(含 HTML,事故款) | -78% | -93% | 从 0 变 715.4h

■ immutable 的唯一区别:用户按 F5 刷新时(每 20 次访问一次,共 9 次)
策略 | 刷新带来的额外阻塞(ms) | 额外请求数
---|---|---
P3(带 immutable) | 0 | 0
P3b(去掉 immutable) | 2160 | 54
差额: 每 9 次刷新多付 2160ms 与 54 个 304 请求

■ 边缘(CDN)命中率:TTL 与请求频率 ------ 这是一条台阶函数,不是一条曲线
假设单个边缘节点;内容每 3600s 变一次;请求以固定间隔到达

TTL | 请求间隔 | 命中率 | 陈旧命中占比 | 回源次数/天 | 陈旧命中时平均看到多旧(s)
---|---|---|---|---|---
0 | 60s | 0.0% | 0% | 1440 | ---
30s | 60s | 0.0% | 0% | 1440 | ---
60s | 60s | 0.0% | 0% | 1440 | ---
120s | 60s | 49.9% | 0% | 721 | ---
600s | 60s | 89.9% | 0% | 145 | ---
600s | 60s | 98.3% | 8% | 145 | 600
600s | 10s | 98.3% | 0% | 145 | ---

台阶的本质: 命中率 ≈ 1 - 1/ceil(TTL ÷ 请求间隔)
  TTL 0s / 间隔 60s → 每次存入可服务 1 个请求 → 预测 0.0%,实测 0.0%  不缓存
  TTL 30s / 间隔 60s → 每次存入可服务 1 个请求 → 预测 0.0%,实测 0.0%  TTL < 间隔 → 每次都是 miss
  TTL 60s / 间隔 60s → 每次存入可服务 1 个请求 → 预测 0.0%,实测 0.0%  TTL == 间隔 → 仍然是 0%
  TTL 120s / 间隔 60s → 每次存入可服务 2 个请求 → 预测 50.0%,实测 49.9%  TTL = 2×间隔 → 50%
  TTL 600s / 间隔 60s → 每次存入可服务 10 个请求 → 预测 90.0%,实测 89.9%  TTL = 10×间隔 → 90%
  TTL 600s / 间隔 60s → 每次存入可服务 10 个请求 → 预测 90.0%,实测 98.3%  加 s-w-r → 接近全命中,代价是陈旧
  TTL 600s / 间隔 10s → 每次存入可服务 60 个请求 → 预测 98.3%,实测 98.3%  同样的 TTL,请求更密 → 台阶更高

断言总数 39,失败 0

怎么读:

  • 横向比"字节"和"阻塞"两列的关系。 有四种组合,只有两种是真实的:字节少+阻塞低(P3/P4/P5,真的在省钱省时间)、字节多+阻塞高(P1,什么都没做)、字节少+阻塞高(P2,304 的典型形态)、字节多+阻塞低(一般不存在)。P2 那一栏就是"只看命中率会骗你"的证据。
  • P3 与 P3b 两行完全相同,这不是 bug。 它们的差别只在刷新路径上出现(见难点七的对照表),而 30 天的正常导航里一次刷新都没有。如果你只看正常导航的指标,immutable 的价值是 0------它的价值在别的路径上。
  • 把"最坏陈旧窗口"当成一等的验收指标。 P4 的 3.5h 和 P5 的 715.4h 之间的差,比它们阻塞时间的差(104ms vs 46ms)重要得多。阻塞时间用户只会抱怨慢,陈旧窗口会让用户提交一份基于旧价格、旧权限、旧接口的请求。

2.3 工具三:把连接建立与慢启动算成账

conn-budget.mjs 回答三个问题:握手到底要多少个 RTT、慢启动的前几个 RTT 浪费了多少链路、以及域名分片在这个 BDP 下值不值。

它有一条正确性要求 ,也是我在写这份脚本时被断言抓出来的第一个错:多条连接必须共享同一条链路的总带宽,不能各拿一份全带宽。 我最初给每个分片都配了完整的 60KB/RTT,于是"6 分片交付 1MB 只要 5 个 RTT"------低于链路的物理下限 18 个 RTT。断言当场失败,这个错误如果留在文章里,就会变成"分片能突破带宽限制"这种荒唐结论。

js 复制代码
#!/usr/bin/env node
'use strict';
/*
 * conn-budget.mjs ------ 连接建立与慢启动账本
 *
 * 命题:一次「新连接」的成本不止握手,还有慢启动------而慢启动常被完全忽略。
 *       域名分片在 HTTP/2/3 时代没有死,只是它的收益变了:不再是绕过 6 连接上限,
 *       而是「多买几份初始拥塞窗口」。
 *
 * 零依赖、确定性。断言失败即 exit 1。
 */

const CFG = {
  RTT: 40,             // ms
  DNS: 20,             // ms
  TCP_RTT: 1,
  TLS12_RTT: 2,
  TLS13_RTT: 1,
  QUIC_RTT: 1,         // TLS1.3 融进握手
  BANDWIDTH: 1.5,      // KB/ms ≈ 12Mbps
  INIT_CWND: 14,       // KB,RFC 6928
  INIT_CWND_OLD: 4,    // KB,老内核 / RFC 3390 之前
  CWND_MAX: 4096,      // KB
};
const PER_RTT_KB = CFG.BANDWIDTH * CFG.RTT;   // 一个 RTT 在带宽上能装多少字节 = 60KB

let checks = 0;
function ok(cond, msg) {
  checks++;
  if (!cond) { console.error('✗ 断言失败: ' + msg); process.exit(1); }
}

// ============ 1. 握手清单 ============
console.log('=== conn-budget.mjs · 连接建立与慢启动账本 ===');
console.log(`模型: RTT ${CFG.RTT}ms · 带宽 ${CFG.BANDWIDTH}KB/ms → 每个 RTT 的带宽容量 ${PER_RTT_KB}KB · 初始 cwnd ${CFG.INIT_CWND}KB\n`);

const HANDSHAKES = [
  { id: 'reuse', label: '复用已有连接(同源,连接热)', ms: 0, rtts: 0 },
  { id: 'dns', label: '仅 dns-prefetch 命中(连接仍需建)', ms: CFG.TCP_RTT * CFG.RTT + CFG.TLS13_RTT * CFG.RTT, rtts: 2 },
  { id: 'tls13', label: '新连接 · TCP + TLS 1.3', ms: CFG.DNS + (CFG.TCP_RTT + CFG.TLS13_RTT) * CFG.RTT, rtts: 3 },
  { id: 'tls12', label: '新连接 · TCP + TLS 1.2', ms: CFG.DNS + (CFG.TCP_RTT + CFG.TLS12_RTT) * CFG.RTT, rtts: 4 },
  { id: 'quic', label: '新连接 · QUIC(TLS1.3 合并握手)', ms: CFG.DNS + CFG.QUIC_RTT * CFG.RTT, rtts: 2 },
  { id: 'quic0', label: '新连接 · QUIC 0-RTT(会话恢复)', ms: CFG.DNS, rtts: 1 },
  { id: 'preconnect', label: 'preconnect 已建好(握手发生在过去)', ms: 0, rtts: 0 },
];

console.log('■ 握手清单:从「准备发请求」到「请求可以上线」要等多久');
console.log('方式 | 等到的握手延迟(ms) | 折合 RTT 数 | 相对 QUIC 基线');
console.log('---|---|---|---');
{
  const base = HANDSHAKES.find((h) => h.id === 'quic').ms;
  HANDSHAKES.forEach((h) => {
    console.log([h.label, h.ms, h.rtts, h.ms === 0 ? `省 ${base}ms` : ((h.ms / base - 1) * 100).toFixed(0) + '%'].join(' | '));
  });
}
console.log('');

// 断言:握手成本排序必须与 RTT 数一致
{
  const m = (id) => HANDSHAKES.find((h) => h.id === id).ms;
  ok(m('reuse') === 0 && m('preconnect') === 0, '复用/preconnect 的握手延迟必须是 0');
  ok(m('reuse') < m('dns'), '复用应优于仅 dns-prefetch');
  ok(m('quic') < m('tls13'), 'QUIC 握手应快于 TCP+TLS1.3');
  ok(m('quic0') < m('quic'), '0-RTT 应快于 1-RTT QUIC');
  ok(m('tls12') > m('tls13'), 'TLS1.2(2 RTT) 应慢于 TLS1.3(1 RTT)');
  ok(m('dns') === CFG.RTT * 2, 'dns-prefetch 只能省掉 DNS 那一段');
}

// ============ 2. 慢启动曲线 ============
// 关键正确性要求:多条连接共享同一条链路的总带宽,不能各拿一份全带宽。
function rttsFor(kb, initCwnd, sharePerRtt) {
  let cwnd = initCwnd, cum = 0, rtt = 0;
  while (cum < kb - 1e-9) {
    cum += Math.min(cwnd, sharePerRtt);
    cwnd = Math.min(cwnd * 2, CFG.CWND_MAX);
    rtt++;
    if (rtt > 200) return Infinity;
  }
  return rtt;
}
function rttsForShards(kbTotal, n, perRttCapacity, initCwnd) {
  return rttsFor(kbTotal / n, initCwnd, perRttCapacity / n);
}
function slowStart(initCwnd, sharePerRtt) {
  const rows = [];
  let cwnd = initCwnd, cum = 0, rtt = 1;
  while (rtt <= 8) {
    const sent = Math.min(cwnd, sharePerRtt);
    cum += sent;
    rows.push({ rtt, cwnd, sent, cum });
    cwnd = Math.min(cwnd * 2, CFG.CWND_MAX);
    rtt++;
  }
  return rows;
}

console.log('■ 慢启动:一条新连接的前几个 RTT 到底能收多少(初始 cwnd 14KB,链路容量 60KB/RTT)');
console.log('第 N 个 RTT | cwnd(KB) | 本 RTT 实收(KB) | 累计(KB) | 受谁限制');
console.log('---|---|---|---|---');
slowStart(CFG.INIT_CWND, PER_RTT_KB).forEach((r) => {
  console.log([r.rtt, r.cwnd, r.sent.toFixed(0), r.cum.toFixed(0), r.sent < PER_RTT_KB ? '拥塞窗口' : '带宽'].join(' | '));
});
console.log('');

{
  const rows = slowStart(CFG.INIT_CWND, PER_RTT_KB);
  ok(rows[0].sent === CFG.INIT_CWND, '第一个 RTT 只能发初始 cwnd');
  ok(rows[1].cwnd === CFG.INIT_CWND * 2, 'cwnd 每个 RTT 应翻倍');
  ok(rows.some((r) => r.sent === PER_RTT_KB), '慢启动最终必须被链路容量封顶');
  ok(rows.filter((r) => r.sent < PER_RTT_KB).length === 3, '本参数下应有 3 个 RTT 受拥塞窗口限制');
  ok(rttsFor(1000, CFG.INIT_CWND_OLD, PER_RTT_KB) > rttsFor(1000, CFG.INIT_CWND, PER_RTT_KB), '初始 cwnd 越小需要的 RTT 越多');
  // 共享带宽:分片不能凭空创造带宽,总时长不可能低于「总量 ÷ 链路容量」
  [1, 2, 4, 6].forEach((n) => {
    const floor = Math.ceil(1024 / PER_RTT_KB);
    ok(rttsForShards(1024, n, PER_RTT_KB, CFG.INIT_CWND) >= floor,
      `${n} 分片不可能突破带宽下限 ${floor} 个 RTT`);
  });
}

console.log('■ 交付 1MB 需要几个 RTT:分片的收益取决于 BDP(带宽 × 时延),不是「分片越多越好」');
console.log('链路容量/RTT | 折算带宽 | 1 连接 | 2 分片 | 4 分片 | 6 分片 | 带宽下限 | 分片最多省');
console.log('---|---|---|---|---|---|---|---');
const LINKS = [
  { perRtt: 60, mbps: 12 },
  { perRtt: 150, mbps: 30 },
  { perRtt: 500, mbps: 100 },
  { perRtt: 1250, mbps: 250 },
];
LINKS.forEach((L) => {
  const one = rttsFor(1024, CFG.INIT_CWND, L.perRtt);
  const two = rttsForShards(1024, 2, L.perRtt, CFG.INIT_CWND);
  const four = rttsForShards(1024, 4, L.perRtt, CFG.INIT_CWND);
  const six = rttsForShards(1024, 6, L.perRtt, CFG.INIT_CWND);
  const floor = Math.ceil(1024 / L.perRtt);
  console.log([L.perRtt + 'KB', '≈' + L.mbps + 'Mbps', one, two, four, six, floor,
    (one - six) + ' 个 RTT(' + ((one - six) * CFG.RTT) + 'ms)'].join(' | '));
});
console.log('');

{
  // 低 BDP 链路上分片几乎没有收益;高 BDP 链路上收益显著 ------ 这是本篇要证明的方向性结论
  const low1 = rttsFor(1024, CFG.INIT_CWND, 60), low6 = rttsForShards(1024, 6, 60, CFG.INIT_CWND);
  const hi1 = rttsFor(1024, CFG.INIT_CWND, 1250), hi6 = rttsForShards(1024, 6, 1250, CFG.INIT_CWND);
  ok(low6 <= low1, '低 BDP 下分片不应更慢');
  ok(low1 - low6 <= 2, `低 BDP(12Mbps) 下分片收益应很小,实际省 ${low1 - low6} 个 RTT`);
  ok(hi1 - hi6 > low1 - low6, '高 BDP(250Mbps) 下分片收益应显著大于低 BDP');
  ok(hi1 - hi6 >= 3, `高 BDP 下分片应至少省 3 个 RTT,实际 ${hi1 - hi6}`);
  // 边际递减:6 分片相对 4 分片的增量小于 2 分片相对 1 连接
  const g2 = rttsFor(1024, CFG.INIT_CWND, 500) - rttsForShards(1024, 2, 500, CFG.INIT_CWND);
  const g4 = rttsForShards(1024, 2, 500, CFG.INIT_CWND) - rttsForShards(1024, 4, 500, CFG.INIT_CWND);
  ok(g4 <= g2, `应先快后慢:1→2 省 ${g2},2→4 省 ${g4}`);
  // 而且分片是有代价的:握手次数线性增长
  [1, 2, 4, 6].forEach((n, i) => {
    if (i === 0) return;
    ok(n * (CFG.DNS + 2 * CFG.RTT) > (n - 1) * (CFG.DNS + 2 * CFG.RTT), '分片越多,握手总成本越高');
  });
}

console.log('■ 分片的价格:并行度是用「握手次数 + 慢启动重来」买的');
console.log('分片数 | 握手次数 | 握手总成本(ms) | 首个 RTT 聚合实收(KB,受总量封顶)');
console.log('---|---|---|---');
[1, 2, 4, 6].forEach((n) => {
  const agg = Math.min(n * CFG.INIT_CWND, PER_RTT_KB);
  console.log([n, n, n * (CFG.DNS + 2 * CFG.RTT), agg + 'KB' + (n * CFG.INIT_CWND > PER_RTT_KB ? '(已被链路封顶)' : '')].join(' | '));
});
console.log('注意最后一行:在 12Mbps 链路上 6 分片的聚合初始窗口 84KB 已经超过链路容量 60KB,');
console.log('多出来的那 24KB 一分钱都换不到 ------ 这就是「低 BDP 下分片无收益」的算术原因。');
console.log('');
{
  ok(Math.min(6 * CFG.INIT_CWND, PER_RTT_KB) === PER_RTT_KB, '6 分片聚合初始窗口应被链路封顶在 60KB');
}

// ============ 3. preconnect / dns-prefetch 省的与不省的 ============
console.log('■ preconnect / dns-prefetch:省的与不省的(用一个 250KB 资源的交付时间举例)');
const KB = 250;
function deliverCold(preconnect) {
  const hs = preconnect ? 0 : CFG.DNS + 2 * CFG.RTT;
  const dataRtts = rttsFor(KB, CFG.INIT_CWND, PER_RTT_KB);
  return { hs, dataRtts, total: hs + dataRtts * CFG.RTT };
}
function deliverWarm() {
  // 已热连接:cwnd 已经涨到带宽容量,直接按带宽算
  const dataRtts = Math.ceil(KB / PER_RTT_KB);
  return { hs: 0, dataRtts, total: dataRtts * CFG.RTT };
}
const cold = deliverCold(false), pre = deliverCold(true), warm = deliverWarm();
console.log('前提 | 握手(ms) | 数据阶段(RTT 数) | 交付总时长(ms)');
console.log('---|---|---|---');
console.log(['冷连接(无任何提示)', cold.hs, cold.dataRtts, cold.total].join(' | '));
console.log(['preconnect 已建连(但连接是冷的)', pre.hs, pre.dataRtts, pre.total].join(' | '));
console.log(['复用一条已经热起来的连接', warm.hs, warm.dataRtts, warm.total].join(' | '));
console.log(`preconnect 省下 ${cold.total - pre.total}ms(${((1 - pre.total / cold.total) * 100).toFixed(0)}%),但没有减少数据阶段需要的 ${pre.dataRtts} 个 RTT`);
console.log(`真正的热连接再省 ${pre.total - warm.total}ms ------ 这一段是 slow start 的账,preconnect 一点都省不到`);
console.log('');
{
  ok(pre.hs === 0 && cold.hs > 0, 'preconnect 应把握手降为 0');
  ok(pre.dataRtts === cold.dataRtts, 'preconnect 不应改变数据阶段所需的 RTT 数(它不预热拥塞窗口)');
  ok(warm.dataRtts < cold.dataRtts, '热连接的数据阶段 RTT 数应严格少于冷连接');
  ok(warm.total < pre.total && pre.total < cold.total, '总时长排序应为 热 < preconnect < 冷');
}

// ============ 4. 新连接的重复成本:慢启动重来一遍 ============
console.log('■ 换一个源站(或让连接过期)的隐藏成本:慢启动从 14KB 重新开始');
console.log('资源大小 | 热连接(RTT) | 冷连接(RTT) | 冷连接额外付出 | 冷连接额外毫秒');
console.log('---|---|---|---|---');
[60, 250, 1024].forEach((kb) => {
  const hot = Math.ceil(kb / PER_RTT_KB);
  const c = rttsFor(kb, CFG.INIT_CWND, PER_RTT_KB);
  console.log([kb + 'KB', hot, c, (c - hot) + ' 个 RTT', ((c - hot) * CFG.RTT) + 'ms'].join(' | '));
});
console.log('');
{
  const hot = Math.ceil(250 / PER_RTT_KB), c = rttsFor(250, CFG.INIT_CWND, PER_RTT_KB);
  ok(c > hot, '冷连接交付 250KB 需要更多 RTT');
  ok((c - hot) * CFG.RTT === (c - hot) * 40, '额外毫秒应按 RTT 折算');
}

// ============ 5. 保持连接存活:keep-alive 超时的账 ============
console.log('■ 连接闲置多久就会被回收(各浏览器/服务端的实际阈值,用于判断"复用得上吗")');
const KEEPALIVE = [
  { who: 'Chrome 空闲套接字', s: 300 },
  { who: 'Firefox 空闲套接字', s: 115 },
  { who: 'Safari 空闲套接字', s: 60 },
  { who: 'nginx keepalive_timeout 默认', s: 75 },
  { who: 'AWS ALB 默认空闲超时', s: 60 },
];
console.log('对象 | 空闲阈值(s) | 结论');
console.log('---|---|---');
KEEPALIVE.forEach((k) => {
  const eff = Math.min(k.s, 300);
  console.log([k.who, k.s, eff >= 115 ? '跨页导航(几分钟内)大概率复用得上' : '只要用户停顿超过 ' + k.s + 's,下次导航就得从冷连接 + 慢启动重来'].join(' | '));
});
console.log('');
{
  const min = Math.min.apply(null, KEEPALIVE.map((k) => k.s));
  ok(min >= 60, '连接空闲阈值不应小于 60s');
  ok(KEEPALIVE.every((k) => k.s > 0), '阈值必须为正');
}

console.log(`断言总数 ${checks},失败 0`);

实跑输出:

text 复制代码
=== conn-budget.mjs · 连接建立与慢启动账本 ===
模型: RTT 40ms · 带宽 1.5KB/ms → 每个 RTT 的带宽容量 60KB · 初始 cwnd 14KB

■ 握手清单:从「准备发请求」到「请求可以上线」要等多久
方式 | 等到的握手延迟(ms) | 折合 RTT 数 | 相对 QUIC 基线
---|---|---|---
复用已有连接(同源,连接热) | 0 | 0 | 省 60ms
仅 dns-prefetch 命中(连接仍需建) | 80 | 2 | 33%
新连接 · TCP + TLS 1.3 | 100 | 3 | 67%
新连接 · TCP + TLS 1.2 | 140 | 4 | 133%
新连接 · QUIC(TLS1.3 合并握手) | 60 | 2 | 0%
新连接 · QUIC 0-RTT(会话恢复) | 20 | 1 | -67%
preconnect 已建好(握手发生在过去) | 0 | 0 | 省 60ms

■ 慢启动:一条新连接的前几个 RTT 到底能收多少(初始 cwnd 14KB,链路容量 60KB/RTT)
第 N 个 RTT | cwnd(KB) | 本 RTT 实收(KB) | 累计(KB) | 受谁限制
---|---|---|---|---
1 | 14 | 14 | 14 | 拥塞窗口
2 | 28 | 28 | 42 | 拥塞窗口
3 | 56 | 56 | 98 | 拥塞窗口
4 | 112 | 60 | 158 | 带宽
5 | 224 | 60 | 218 | 带宽
6 | 448 | 60 | 278 | 带宽
7 | 896 | 60 | 338 | 带宽
8 | 1792 | 60 | 398 | 带宽

■ 交付 1MB 需要几个 RTT:分片的收益取决于 BDP(带宽 × 时延),不是「分片越多越好」
链路容量/RTT | 折算带宽 | 1 连接 | 2 分片 | 4 分片 | 6 分片 | 带宽下限 | 分片最多省
---|---|---|---|---|---|---|---
60KB | ≈12Mbps | 19 | 18 | 18 | 18 | 18 | 1 个 RTT(40ms)
150KB | ≈30Mbps | 10 | 9 | 8 | 8 | 7 | 2 个 RTT(80ms)
500KB | ≈100Mbps | 7 | 6 | 5 | 4 | 3 | 3 个 RTT(120ms)
1250KB | ≈250Mbps | 7 | 6 | 5 | 4 | 1 | 3 个 RTT(120ms)

■ 分片的价格:并行度是用「握手次数 + 慢启动重来」买的
分片数 | 握手次数 | 握手总成本(ms) | 首个 RTT 聚合实收(KB,受总量封顶)
---|---|---|---
1 | 1 | 100 | 14KB
2 | 2 | 200 | 28KB
4 | 4 | 400 | 56KB
6 | 6 | 600 | 60KB(已被链路封顶)
注意最后一行:在 12Mbps 链路上 6 分片的聚合初始窗口 84KB 已经超过链路容量 60KB,
多出来的那 24KB 一分钱都换不到 ------ 这就是「低 BDP 下分片无收益」的算术原因。

■ preconnect / dns-prefetch:省的与不省的(用一个 250KB 资源的交付时间举例)
前提 | 握手(ms) | 数据阶段(RTT 数) | 交付总时长(ms)
---|---|---|---
冷连接(无任何提示) | 100 | 6 | 340
preconnect 已建连(但连接是冷的) | 0 | 6 | 240
复用一条已经热起来的连接 | 0 | 5 | 200
preconnect 省下 100ms(29%),但没有减少数据阶段需要的 6 个 RTT
真正的热连接再省 40ms ------ 这一段是 slow start 的账,preconnect 一点都省不到

■ 换一个源站(或让连接过期)的隐藏成本:慢启动从 14KB 重新开始
资源大小 | 热连接(RTT) | 冷连接(RTT) | 冷连接额外付出 | 冷连接额外毫秒
---|---|---|---|---
60KB | 1 | 3 | 2 个 RTT | 80ms
250KB | 5 | 6 | 1 个 RTT | 40ms
1024KB | 18 | 19 | 1 个 RTT | 40ms

■ 连接闲置多久就会被回收(各浏览器/服务端的实际阈值,用于判断"复用得上吗")
对象 | 空闲阈值(s) | 结论
---|---|---
Chrome 空闲套接字 | 300 | 跨页导航(几分钟内)大概率复用得上
Firefox 空闲套接字 | 115 | 跨页导航(几分钟内)大概率复用得上
Safari 空闲套接字 | 60 | 只要用户停顿超过 60s,下次导航就得从冷连接 + 慢启动重来
nginx keepalive_timeout 默认 | 75 | 只要用户停顿超过 75s,下次导航就得从冷连接 + 慢启动重来
AWS ALB 默认空闲超时 | 60 | 只要用户停顿超过 60s,下次导航就得从冷连接 + 慢启动重来

断言总数 32,失败 0

怎么读:

  • 握手清单里的 0ms 只有两种来源:复用已有连接,和 preconnect。 前者免费且最彻底(拥塞窗口已经热了),后者要你在空闲期提前花掉一个连接的成本。所以 preconnect 该用在哪里是有判据的:这个源站出现在"用户下一步大概率要走的路径"上,且它的握手成本(跨域、TLS 冷启、跨国)明显高于平均。
  • "preconnect 省 100ms,但数据阶段的 6 个 RTT 一个都没少"------这是最容易被忽略的一行。如果你的页面瓶颈是"1MB 的 JS 要下很久",preconnect 帮不上忙;如果瓶颈是"点下去要等 100ms 握手才开始下",preconnect 正好。
  • 换源站那一行的 80ms 是"把资源挪到另一个域名"的真实成本。60KB 的资源放新域名 = 多 2 个 RTT;1MB 的资源 = 多 1 个。这就是"要不要为静态资源单独开一个域名"的正确答案应该长什么样。

2.4 改动清单:按四栏归位

把上面的账放到一张可执行的清单里。每一栏的问题不同,工具不同,验收指标也不同------混在一起改,就会出现"改了缓存,结果 LCP 没动"。

栏 典型动作 收益 代价/前提 验收指标
传输 H1.1 → H2 消除连接级串行(小资源多时 -28%) 单连接慢启动(少大资源时 +2%);TCP 丢包作用域变大 小资源数、丢包率
传输 H2 → H3 丢包作用域从连接级降到流级(流·毫秒 -98%) 首次访问用不上(Alt-Svc);0-RTT 只能幂等 弱网下的 P95、流·毫秒
连接 preconnect 关键第三方 省 13 个握手 RTT(60140ms) 提前消耗连接;错误预连会抢占带宽 首字节时间
连接 合并域名 / 减少源站 省每源 1~2 个 RTT + 一份慢启动 失去分片带来的早期 cwnd(大 BDP 下才有价值) 连接数、每源 RTT 数
缓存 hash + immutable 刷新路径省 54 个 304 / 2160ms URL 必须真的随内容变 静态资源请求数
缓存 HTML 上 s-w-r 阻塞 -50%(207→104ms) 陈旧窗口 0 → 3.5h 陈旧窗口分布,不只是均值
缓存 干掉 no-cache 请求数 -61%(1620→630) 需要正确的失效设计 每次访问的阻塞毫秒
缓存 别把 immutable 给入口页 避免 715.4h 的不可撤销版本 --- 最坏陈旧窗口
边缘 TTL 调到 > 请求间隔的 2~10 倍 命中率从 0% 台阶跳到 50%~90% 陈旧暴露;Vary 会打散命中 边缘命中率 + 回源 QPS
边缘 边缘层开 s-w-r 把回源延迟从用户路径删除 边缘陈旧窗口(通常远小于浏览器层) 边缘命中率 + 陈旧时长

2.5 十二条纪律

  1. 先问作用域,再问协议。 "卡住的时候,有多少条流陪着一起等?"------这一个问题比"你用的是 H2 还是 H3"信息量大。
  2. 不要用平均延迟验证协议升级。 用丢包率分桶后的 P95,以及流·毫秒这类"牵连范围"指标。
  3. 冷启动要单独算。 首字节、第一个 RTT 的链路利用率、每条新连接的握手 RTT------这三个数字决定了"用户点下去到看见东西"。
  4. preconnect 治握手,不治慢启动。 想治慢启动只有两条路:复用热连接,或者让资源更早被发现(Day26)。
  5. 域名分片先算 BDP。 12Mbps 上分片省 1 个 RTT,250Mbps 上省 3 个------如果你的用户主要在中低带宽,分片基本只增加成本。
  6. 缓存要记四笔账。 阻塞、字节、请求数、陈旧窗口。第四笔没有人看,而它是前三笔的定价。
  7. 304 不是免费的。 它省 body,不省 RTT。"命中率 100%"和"零请求"是两件事。
  8. immutable 是承诺。 承诺 URL 永不变。加在会变的 URL 上,就是发行一个不可撤销的版本。
  9. s-w-r 的窗口按回访频率设,不按容忍度设。 你写 24h,用户实际看到的是"访问间隔 + 内容周期"。
  10. TTL 必须大于请求间隔。 相等也是 0% 命中。这是个台阶,不是斜坡。
  11. Vary 会偷偷吃掉你的命中率。 每加一个 Vary 维度,就把缓存切成更多份,每份的请求频率都下降------回到第 10 条。
  12. 网络层的每一项收益都要指明它换的是什么。 换不到东西的"优化"就是噪声;说不清换了什么的"优化"就是风险。

三、应用场景

场景一:上了 HTTP/2 之后,弱网用户反而更慢了

这是最典型的"协议升级反效果"。症状是:桌面端指标全线变好,移动端 P95 变差,归因到"机型/网络环境复杂"就结束了。

诊断顺序(用 transport-hol.mjs 的思路):

  1. 先量丢包作用域 :把移动端的丢包率分桶(01% / 1 3% / >3%),看每桶的 P95。如果只有 >1% 的桶变差,就是 TCP 队头阻塞。
  2. 再量资源形态:如果首屏是"少量大资源"(几个大 bundle、几张主图),H2 的单连接慢启动会让第一个 RTT 的链路利用率掉到 23.3%。实测这种形态下 H2 比 H1.1 慢 2%(干净网络)到 19%(2% 丢包)。
  3. 对策按优先级 :① 上 H3(真正的流级重传,实测流·毫秒从 7200 降到 120);② 如果暂时上不了 H3,把首屏拆成 2 个源站------这不是回到 H1.1 时代,而是"在丢包网络上给自己买第二个失败域";③ 把大 bundle 拆小(回到 Day30 的拆包话题)。

注意:②是"用配置绕过协议缺陷",它有代价(多一次握手 + 多一份慢启动,见 conn-budget)。用之前先算 BDP。

场景二:为了"怕用户拿到旧代码",全站配了 no-cache

这是国内团队最常见的缓存配置。它的账(实测 P2):字节只用了 17.67MB(很省),但每次访问阻塞 427ms,是教科书基线 P3 的 2.06 倍,请求数是 P3 的 2.57 倍(1620 vs 630)。

关键洞察 :no-cache 的字节优化是真的 (比 no-store 省 89%),所以它看起来"有效"。它的问题全在请求数上------1620 个请求,每一个都要付一个 RTT,而且是串在关键路径上的。

正确做法:把"怕拿到旧代码"翻译成具体需求。"怕旧代码"实际上是三件事:

  • 怕应用版本不一致 (新 HTML + 旧 JS)→ 用 hash URL + immutable 解决,这是 P3。
  • 怕入口页被人缓存住 (发布后用户还看到旧入口)→ 用 HTML no-cache 或 s-w-r 解决,这是 P4。
  • 怕接口数据陈旧→ 这是数据层的事,用版本号/ETag/短 TTL,不是"全站 no-cache"。

三件事三个解法,合成一个"全站 no-cache"之后,代价是 427ms/访问。

场景三:把 immutable 也给了入口 HTML

实测后果(P5):阻塞 46ms/访问、字节 1.24MB、请求 188 次------每一项指标都是六种策略里最好的 ,而最坏陈旧窗口是 715.4h ≈ 29.8 天。

这个场景的可怕之处在于它不会失败 。构建成功、部署成功、监控上的所有性能指标都变好了。唯一的问题在 30 天后才会有人报:"我这边一直看不到新功能。"而那时候缓存已经散落在无数个用户的浏览器里,你没有任何办法撤销它 ------只能换 URL(index.html?v=2)或换域名。

纪律:入口文档(HTML)永远不要 immutable。 它可以 no-cache(每次校验)、可以 max-age=0, s-w-r=...(立刻用旧的 + 后台更新)、可以很短 max-age,但不能是"永不验证"。

场景四:CDN 的 TTL 设成 60s,因为"内容更新快"

实测:请求间隔 60s、TTL 60s → 命中率 0.0% ,回源 1440 次/天,与"完全不缓存"完全相同。你配了 CDN,但一次都没命中。

正确做法(按顺序):

  1. 先量请求间隔 (边缘节点同一个 URL 的到达间隔中位数),再设 TTL。TTL 应当是间隔的 2~10 倍(台阶:2× → 50%,10× → 90%)。
  2. 如果内容是"秒级变化"的 (价格、库存、余额),那它本来就不该走共享缓存------用 no-store 或 private,别用 TTL 糊。
  3. 如果内容是"分钟级变化"但可以接受短暂陈旧 ,用 s-maxage + stale-while-revalidate:实测命中率从 89.9% 抬到 98.3%,且回源次数不变 (145 次/天)------因为 s-w-r 是把回源挪到后台,不是取消回源。
  4. 检查 Vary 。如果响应里有 Vary: Cookie 或未归一化的 Vary: User-Agent,你的有效请求频率会被除以一个很大的数------命中率又掉回台阶下面去了。

场景五:静态资源域名分片到 4 个域名(H2 时代的遗留)

实测(conn-budget):4 分片的握手总成本 400ms(vs 单域名 100ms),而收益取决于 BDP------12Mbps 链路上省 1 个 RTT(40ms),100Mbps 上省 3 个 RTT(120ms)。也就是说对中低带宽用户,这个配置每年只买到 40ms,却付出了 4 倍的连接数与握手成本。

判断方法:先看你的用户带宽分布。 如果 P50 在 1230Mbps(大多数国内移动/家用场景),分片的收益是 1 2 个 RTT,低于"合并域名 + preconnect"能拿到的收益 (后者省的是握手 RTT,且没有慢启动重来的代价)。分片应该在"高 BDP + 大资源"的场景里保留(例如跨国访问 CDN 的大视频/大包),而不是作为默认配置。

场景六:TTFB 高,但没人知道是 DNS、TLS、还是后端

这是网络层最常见的"归因真空"。用 conn-budget 的握手清单拆:

环节 成本 怎么确认
连接复用 0ms PerformanceResourceTiming 里 nextHopProtocol 有值、connectStart/connectEnd 为 0
DNS 20~120ms domainLookupEnd - domainLookupStart > 0
TCP 1 RTT connectEnd - connectStart
TLS 1~2 RTT connectEnd - secureConnectionStart(>0 说明是新 TLS 握手)
请求排队 不确定 requestStart - connectEnd(H1.1 上这条常常很大)
服务端 你的后端 responseStart - requestStart
慢启动 前 3 个 RTT 不是一段区间,而是"下载段前几段的斜率"

如果是"每次导航都要重新握手" (DNS/TCP/TLS 三段都 > 0),那不是后端慢,是 keep-alive 被回收了(Chrome 300s / Safari 60s / nginx 75s / ALB 60s)或者用户在多个源站之间跳。对策是 preconnect 关键第三方 + 减少源站数,不是优化后端。

场景七:SPA 路由切换"感觉慢",但接口明明很快

SPA 里点一个路由,前端没发任何导航请求,API 也可能只用了 20ms,但用户感觉等了 300ms。常见原因是首屏 chunk 的加载走了冷路径:

  1. 这些 chunk 在哪个域名上? 如果和别的资源不同源,用户切路由时可能触发新连接 (100ms 握手)+ 慢启动(前 3 个 RTT 只发 14/28/56KB)。实测 250KB 的 chunk:热连接 5 个 RTT,冷连接 6 个 RTT。
  2. 这些 chunk 有多大? 实测 60KB 的资源走冷连接要多付 2 个 RTT(80ms);1MB 的多付 1 个(40ms)。小 chunk 走冷路径的惩罚比例最大。
  3. 对策 :<link rel="preconnect"> 关键源站(治握手);modulepreload 或路由级 prefetch(治"发现太晚",Day26 的话题);把路由 chunk 放在同一源站(治慢启动重来)。

四、总结

  1. 协议升级不是能力,是分配策略。 它优化的是"串行",不是"带宽"。实测同一份 1.2MB:多小资源时 H2 比 H1.1 快 28%,少大资源时 H2 比 H1.1 慢 2% (干净网络)到 19%(2% 丢包)。
  2. 丢包的停顿作用域,是 H2 与 H3 真正的分水岭。 H2 一次丢包让 60 条流一起停 40ms(7200 流·毫秒),H3 只让 1 条停(120 流·毫秒)------差 60 倍。所以"我们上了 H2"这句话没有信息量,要问用户在哪张网络里。
  3. 单连接的慢启动是一笔集体忽略的税。 初始 cwnd 14KB 意味着第一个 RTT 只用掉 12Mbps 链路的 23.3% ,而 H1.1 的 6 条连接用满 100%。preconnect 省握手但不省慢启动(250KB 资源的数据阶段仍然是 6 个 RTT)。
  4. 域名分片的收益取决于 BDP。 12Mbps 上分片省 1 个 RTT,100Mbps 上省 3 个。"分片死了"是错的,"分片永远有用"也是错的------要算 BDP。
  5. 缓存是四笔账,不是命中率。 阻塞毫秒、线上字节、请求数、陈旧窗口 。实测 no-cache 全站方案比教科书基线字节相同(17.67MB)而阻塞高一倍 (427ms vs 207ms)------304 省字节,不省往返。
  6. 命中率是台阶,TTL 必须大于请求间隔。 TTL 60s 配 60s 请求间隔 = 0.0% 命中 ,与不缓存完全相同。2× 间隔 → 50%,10× → 90%。而 s-w-r 能把 89.9% 抬到 98.3%,代价是 8% 的命中是"陈旧命中"。
  7. s-w-r 的实际陈旧窗口由"访问间隔"决定,不是那个 24h 的窗口。 实测写 swr=86400、访问间隔 4h、内容周期 1h,实际最坏陈旧是 3.5h。窗口要按回访频率设,不是按容忍度设。
  8. 每一项收益都要指明它换了什么。 P4 用 3.5h 的陈旧换了 103ms/访问;P5 用 715.4h 的陈旧换了 161ms/访问;分片用 4 倍握手换了 1~3 个 RTT。说不出换了什么的优化,是风险;说得出换了什么的优化,才是决策。
  9. 三件工具 + 一条纪律。 设计期用 conn-budget.mjs 算握手与慢启动、用 transport-hol.mjs 比协议与作用域;上线后用 cache-ledger.mjs 记缓存四笔账;纪律是那句每天都要问的话------这次改动,我省下的是哪一个 RTT,又把它记到了哪一栏?

下一篇 Day28 讲 SSR / SSG / ISR:把渲染从客户端搬到服务端之后,TTFB 与"可交互时间"的关系会整个换一套------那是把本篇里"服务端那一格"和 Day26 的"发现段"合起来的一篇。


运行环境

  • Node.js 16+,零依赖,三个文件各自独立可跑:node transport-hol.mjs、node cache-ledger.mjs、node conn-budget.mjs。每份脚本自带不变量断言(分别 174 / 39 / 32 条,合计 245 条),失败即 exit 1;本文引用的所有数字均为上述脚本的实跑输出,并与文中表格逐条核对过。
  • 模型边界(重要) :三份脚本都是确定性简化模型,不是网络模拟器,也不是浏览器。共同参数:链路带宽 1.5KB/ms(≈12Mbps)、RTT 40ms、DNS 20ms、TCP 握手 1 RTT、TLS 1.3 握手 1 RTT、QUIC 握手 1 RTT、初始拥塞窗口 14KB(RFC 6928 的 10×MSS)、传输/重传单元 8KB、丢包恢复(检测+重传)1 RTT、HTTP/1.1 每源 6 连接、H1.1 明文头部 0.7KB/请求、H2/H3 压缩头部 0.05KB/请求。
  • transport-hol.mjs 的额外简化:① 丢包在"一次传输尝试完成时"即被发现并立刻停顿作用域、1 RTT 后重传------没有模拟快速重传、SACK、拥塞窗口收缩的细节 (这些对三种协议大体中性,不改变作用域的差异方向);② 流内按 8KB 包轮转调度,近似 H2 的帧交织;③ 拥塞窗口按 RTT 时钟翻倍并在带宽容量处封顶;④ 慢启动在丢包时减半但不再回到初始值。两处已知的乐观之处:H3 的丢包恢复延迟与 TCP 一样按 1 RTT 计(真实 QUIC 通常略优),以及 H2 的头部压缩收益按固定 0.05KB/请求计(真实 HPACK 首请求更大、后续更小)。
  • cache-ledger.mjs 的额外简化:① 只模拟单个用户 的 30 天回访轨迹(每天 6 次、间隔 4h±30min),不含多用户与边缘层(边缘层单独用台阶公式仿真);② "内容变化"按固定周期(HTML 3600s / 发版 2 天 / 图片 7 天),真实发布节奏是不规则的;③ 304 一律假设"未变化 → 0 字节",未模拟弱 ETag/Last-Modified 的秒级误差;④ 阻塞时间只计"网络往返 + 传输",未计浏览器解析与 JS 执行(那属于 Day26/Day29);⑤ 边缘仿真假设请求以固定间隔到达、后台刷新在 1 RTT 后完成,真实到达是泊松过程(台阶位置会平移,但台阶的存在与公式不变)。
  • conn-budget.mjs 的额外简化:① 假设单条瓶颈链路、多条连接共享总带宽 (这一点是脚本里被断言守住的关键正确性要求);② 忽略 ACK 开销、TLS 记录开销与队头阻塞;③ keep-alive 阈值(Chrome 300s / Firefox 115s / Safari 60s / nginx 75s / ALB 60s)是当前实现约定,会随版本变化,落地前需按目标环境确认。
  • 三个场景的资源清单(60×412KB / 6×150 250KB / 5 源×12 小文件)来自一个中等规模真实页面的形态抽象,不是某个具体站点的抓包数据。数字只用于策略之间的相对比较,不用于绝对预测------它们的价值在于把"H2 vs H1.1""TTL 60s vs 120s""分片 vs 单域名"这几组差异放进同一个坐标系,得到的结论(方向与量级)比具体毫秒数更重要。
  • 浏览器侧的行为约定(Alt-Svc 发现与首次访问走 TCP、0-RTT 仅限幂等请求、immutable 在 reload 时跳过校验、s-maxage 覆盖 max-age、Vary 对命中率的影响、stale-while-revalidate 的语义、Chrome 移除服务端推送)均为 Chromium/主流浏览器与主流 CDN 的当前行为;Safari/Firefox 对部分指令与 QUIC 的支持进度不同,落地前需按目标浏览器与 CDN 厂商逐条确认------这属于"兼容性核对",不属于"模型不确定性"。
  • 自测暴露并修复的真实问题(保留在此,因为它们都属于"模型写错会让结论反向"的类型,且现在每一条都有对应断言守着):
    1. 空转带宽记账公式 (transport-hol.mjs):初版把每个 tick 的带宽份额重复扣减,使"已用带宽 + 空转带宽"不再等于链路总容量。现在这条互补关系由断言直接守住(干净网络下两者之和必须为 100%±1.5%)。
    2. 带 hash 的 URL 被当成"内容会变"的资源 (cache-ledger.mjs):初版对 app.a1b2c3.js 这样的 URL 仍按"当前内容版本"比对,于是每次校验都判定"变了"并整份重下 260KB------这会让 no-cache 看起来比 immutable 还省字节 ,结论完全反向。(带 hash 的 URL 其内容版本是常量,这正是 immutable 语义成立的基础。)现在由"每个 hash URL 最多下载 1 次"这条断言锁死,no-store / no-cache / immutable 三种策略都必须通过它。
    3. stale-while-revalidate 的后台刷新没有写回缓存 (cache-ledger.mjs):初版只把旧副本交给用户、把后台请求记在账上,却没有更新缓存条目,于是 P4 的陈旧窗口被算成 23.4h(几乎等于 swr 的上界)。这一个 bug 会让本文的核心结论------"实际陈旧窗口由访问间隔决定,而不是由 swr 的值决定"------完全站不住。现在由"实际陈旧必须远小于 swr 窗口"断言守住。
    4. 分片被算成各拿一份完整带宽 (conn-budget.mjs):初版给每个分片都配了 60KB/RTT,算出"6 分片交付 1MB 只要 5 个 RTT"------低于链路物理下限 18 个 RTT。被"分片不可能突破带宽下限"断言抓出后,结论也从"多买几份 cwnd 所以分片还是有用"改写成"收益取决于 BDP",也就是现在文中难点四的样子。
相关推荐
代码猎人1 小时前
前端怎么跟进生态新技术
前端
海阳皓辰网络科技有限公司1 小时前
Vue3 冷库货位选择器:三级联动、扫码带出与批次混放校验
前端·vue.js
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY81
前端
女生也可以敲代码1 小时前
div上有红色下划线报错:JSX 元素隐式具有类型 “any“,因为不存在接口 “JSX.IntrinsicElements“。ts(7026)
前端
申君健24864182772021 小时前
ArkTS 速成指南(写给有 TypeScript / 前端经验的开发者)
前端
代码猎人1 小时前
前端怎么落地工程化规范
前端
阶进遐1 小时前
浏览器工具自制指南·对话导出(1):需求与选型
前端
朱涛的自习室1 小时前
预算 1000 美金的开源软件,DeepSeek 只花 1 美金就做完了(Day 6)
android·前端·人工智能
excel2 小时前
JavaScript 中 substr、substring 和 slice 的区别
前端