一、技术难点
Day26 讲的是发现段 :一个资源什么时候被浏览器知道、按什么优先级排队。这一篇讲它后面那一段:字节已经上路了,从服务器的网卡到用户的屏幕,这中间发生了什么。
先给结论:网络层的每一项"优化"都不是免费的,它要么拿另一个指标去换,要么依赖一个你从未验证过的前提。 本文要拆掉的三个前提,都是在团队里被当作常识的口头禅:
- 「换成 HTTP/2 就更快了 」------实测(transport-hol.mjs)在 2% 丢包、少量大资源的场景下,HTTP/2 比 HTTP/1.1 的六连接慢 19%(1128ms vs 948ms);把丢包提到 5%,差距还在扩大。
- 「缓存就是命中率 」------实测(cache-ledger.mjs)全站 304 校验把字节从 160.31MB 压到 17.67MB(省 89% ),可阻塞时间只从 968ms/访问降到 427ms/访问(省 56%),而且比教科书基线还慢 106%。304 省的是字节,不省往返。
- 「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 放进同一个包级模型里,量化三件事:多路复用的收益、丢包作用域的大小、慢启动在第一口的代价。核心建模选择只有两条:
- 丢包的作用域:H1.1/H2 停整条连接(TCP 按序交付),H3 只停自己那条流(QUIC 流级重传)。
- 带宽分配 :只有"此刻真的能发"的连接参与分配;卡住的连接不让出份额(链路就空转)------这正是难点二要量化的成本。
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 关键第三方 | 省 1 |
提前消耗连接;错误预连会抢占带宽 | 首字节时间 |
| 连接 | 合并域名 / 减少源站 | 省每源 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 十二条纪律
- 先问作用域,再问协议。 "卡住的时候,有多少条流陪着一起等?"------这一个问题比"你用的是 H2 还是 H3"信息量大。
- 不要用平均延迟验证协议升级。 用丢包率分桶后的 P95,以及流·毫秒这类"牵连范围"指标。
- 冷启动要单独算。 首字节、第一个 RTT 的链路利用率、每条新连接的握手 RTT------这三个数字决定了"用户点下去到看见东西"。
- preconnect 治握手,不治慢启动。 想治慢启动只有两条路:复用热连接,或者让资源更早被发现(Day26)。
- 域名分片先算 BDP。 12Mbps 上分片省 1 个 RTT,250Mbps 上省 3 个------如果你的用户主要在中低带宽,分片基本只增加成本。
- 缓存要记四笔账。 阻塞、字节、请求数、陈旧窗口。第四笔没有人看,而它是前三笔的定价。
- 304 不是免费的。 它省 body,不省 RTT。"命中率 100%"和"零请求"是两件事。
immutable是承诺。 承诺 URL 永不变。加在会变的 URL 上,就是发行一个不可撤销的版本。s-w-r的窗口按回访频率设,不按容忍度设。 你写 24h,用户实际看到的是"访问间隔 + 内容周期"。- TTL 必须大于请求间隔。 相等也是 0% 命中。这是个台阶,不是斜坡。
Vary会偷偷吃掉你的命中率。 每加一个Vary维度,就把缓存切成更多份,每份的请求频率都下降------回到第 10 条。- 网络层的每一项收益都要指明它换的是什么。 换不到东西的"优化"就是噪声;说不清换了什么的"优化"就是风险。
三、应用场景
场景一:上了 HTTP/2 之后,弱网用户反而更慢了
这是最典型的"协议升级反效果"。症状是:桌面端指标全线变好,移动端 P95 变差,归因到"机型/网络环境复杂"就结束了。
诊断顺序(用 transport-hol.mjs 的思路):
- 先量丢包作用域 :把移动端的丢包率分桶(0
1% / 13% / >3%),看每桶的 P95。如果只有 >1% 的桶变差,就是 TCP 队头阻塞。 - 再量资源形态:如果首屏是"少量大资源"(几个大 bundle、几张主图),H2 的单连接慢启动会让第一个 RTT 的链路利用率掉到 23.3%。实测这种形态下 H2 比 H1.1 慢 2%(干净网络)到 19%(2% 丢包)。
- 对策按优先级 :① 上 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,但一次都没命中。
正确做法(按顺序):
- 先量请求间隔 (边缘节点同一个 URL 的到达间隔中位数),再设 TTL。TTL 应当是间隔的 2~10 倍(台阶:2× → 50%,10× → 90%)。
- 如果内容是"秒级变化"的 (价格、库存、余额),那它本来就不该走共享缓存------用
no-store或private,别用 TTL 糊。 - 如果内容是"分钟级变化"但可以接受短暂陈旧 ,用
s-maxage+stale-while-revalidate:实测命中率从 89.9% 抬到 98.3%,且回源次数不变 (145 次/天)------因为s-w-r是把回源挪到后台,不是取消回源。 - 检查
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 的加载走了冷路径:
- 这些 chunk 在哪个域名上? 如果和别的资源不同源,用户切路由时可能触发新连接 (100ms 握手)+ 慢启动(前 3 个 RTT 只发 14/28/56KB)。实测 250KB 的 chunk:热连接 5 个 RTT,冷连接 6 个 RTT。
- 这些 chunk 有多大? 实测 60KB 的资源走冷连接要多付 2 个 RTT(80ms);1MB 的多付 1 个(40ms)。小 chunk 走冷路径的惩罚比例最大。
- 对策 :
<link rel="preconnect">关键源站(治握手);modulepreload或路由级 prefetch(治"发现太晚",Day26 的话题);把路由 chunk 放在同一源站(治慢启动重来)。
四、总结
- 协议升级不是能力,是分配策略。 它优化的是"串行",不是"带宽"。实测同一份 1.2MB:多小资源时 H2 比 H1.1 快 28%,少大资源时 H2 比 H1.1 慢 2% (干净网络)到 19%(2% 丢包)。
- 丢包的停顿作用域,是 H2 与 H3 真正的分水岭。 H2 一次丢包让 60 条流一起停 40ms(7200 流·毫秒),H3 只让 1 条停(120 流·毫秒)------差 60 倍。所以"我们上了 H2"这句话没有信息量,要问用户在哪张网络里。
- 单连接的慢启动是一笔集体忽略的税。 初始 cwnd 14KB 意味着第一个 RTT 只用掉 12Mbps 链路的 23.3% ,而 H1.1 的 6 条连接用满 100%。preconnect 省握手但不省慢启动(250KB 资源的数据阶段仍然是 6 个 RTT)。
- 域名分片的收益取决于 BDP。 12Mbps 上分片省 1 个 RTT,100Mbps 上省 3 个。"分片死了"是错的,"分片永远有用"也是错的------要算 BDP。
- 缓存是四笔账,不是命中率。 阻塞毫秒、线上字节、请求数、陈旧窗口 。实测
no-cache全站方案比教科书基线字节相同(17.67MB)而阻塞高一倍 (427ms vs 207ms)------304 省字节,不省往返。 - 命中率是台阶,TTL 必须大于请求间隔。 TTL 60s 配 60s 请求间隔 = 0.0% 命中 ,与不缓存完全相同。2× 间隔 → 50%,10× → 90%。而
s-w-r能把 89.9% 抬到 98.3%,代价是 8% 的命中是"陈旧命中"。 s-w-r的实际陈旧窗口由"访问间隔"决定,不是那个 24h 的窗口。 实测写swr=86400、访问间隔 4h、内容周期 1h,实际最坏陈旧是 3.5h。窗口要按回访频率设,不是按容忍度设。- 每一项收益都要指明它换了什么。 P4 用 3.5h 的陈旧换了 103ms/访问;P5 用 715.4h 的陈旧换了 161ms/访问;分片用 4 倍握手换了 1~3 个 RTT。说不出换了什么的优化,是风险;说得出换了什么的优化,才是决策。
- 三件工具 + 一条纪律。 设计期用
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×4
12KB / 6×150250KB / 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 厂商逐条确认------这属于"兼容性核对",不属于"模型不确定性"。 - 自测暴露并修复的真实问题(保留在此,因为它们都属于"模型写错会让结论反向"的类型,且现在每一条都有对应断言守着):
- 空转带宽记账公式 (
transport-hol.mjs):初版把每个 tick 的带宽份额重复扣减,使"已用带宽 + 空转带宽"不再等于链路总容量。现在这条互补关系由断言直接守住(干净网络下两者之和必须为 100%±1.5%)。 - 带 hash 的 URL 被当成"内容会变"的资源 (
cache-ledger.mjs):初版对app.a1b2c3.js这样的 URL 仍按"当前内容版本"比对,于是每次校验都判定"变了"并整份重下 260KB------这会让no-cache看起来比immutable还省字节 ,结论完全反向。(带 hash 的 URL 其内容版本是常量,这正是immutable语义成立的基础。)现在由"每个 hash URL 最多下载 1 次"这条断言锁死,no-store/no-cache/immutable三种策略都必须通过它。 stale-while-revalidate的后台刷新没有写回缓存 (cache-ledger.mjs):初版只把旧副本交给用户、把后台请求记在账上,却没有更新缓存条目,于是 P4 的陈旧窗口被算成 23.4h(几乎等于swr的上界)。这一个 bug 会让本文的核心结论------"实际陈旧窗口由访问间隔决定,而不是由swr的值决定"------完全站不住。现在由"实际陈旧必须远小于swr窗口"断言守住。- 分片被算成各拿一份完整带宽 (
conn-budget.mjs):初版给每个分片都配了 60KB/RTT,算出"6 分片交付 1MB 只要 5 个 RTT"------低于链路物理下限 18 个 RTT。被"分片不可能突破带宽下限"断言抓出后,结论也从"多买几份 cwnd 所以分片还是有用"改写成"收益取决于 BDP",也就是现在文中难点四的样子。
- 空转带宽记账公式 (