DNS 与 HTTP 入门

跑 curl https://api.example.com/user/42。3 个论断,每个都能在一张可以拖的图上验证:冷 DNS 查询是 4 个往返,改完记录之后世界还会把旧答案再发一整个 TTL; HTTP/1.1 一条连接只扛一个请求,6 条连接从这来; HTTP/2 修好了 framing 却仍是 TCP 的囚徒 —— 这就是 QUIC 的理由。

01

名字得先变成地址

curl 里没有任何东西能对着一个名字开 socket。第一个字节之前,api.example.com 必须变成 203.0.113.42, 而大多数「时灵时不灵」的故障就住在这段路上。

getaddrinfo 往 /etc/resolv.conf 里的地址发一个 UDP 数据报。那台是递归 resolver,缓存冷时由它替我们走:在路上的那次查询要出去 4 次,已经回答过的每一跳留在画面上。按播放,或者一跳一跳拖滑块:

第 0 跳 / 共 4 跳 —— 已用 0 ms

注意哪一跳最贵。根和 .com 服务器被 anycast 到几百个站点,所以它们 12 ms 和 18 ms 就回;权威服务器在域名主人放它的地方,要 34 ms。 4 跳,72 ms —— 而且每一跳给的都是转介,不是答案。根永远不知道 api.example.com 住在哪儿,只知道谁管 .com。

几乎没有哪次查询真付这个价。程序和权威服务器之间的每一层都留了副本,同一趟走读会在还握着答案的那一层被截断。把滑块往缓存栈下面拖,看没有发生的那段路缩短:

浏览器缓存 — 0.05 ms

差距是 3 个数量级:浏览器自己的表 0.05 ms, 已经有答案的 resolver 8.0 ms,谁都没有的时候 。这就是为什么对新主机的第一次请求慢、第二次瞬间完成 —— 也是为什么你服务的延迟图有一条跟你服务毫无关系的长尾。

地址只是一个名字能带的东西之一。问句里的记录类型决定answer section 里回来什么。把滑块拖过值得知道的这 8 种:

A —— 203.0.113.42

停在 SOA 上。它最后一个字段正是接下来两张图的主题:负 TTL —— 世界可以记住一个名字不存在多久。另外注意 CAA 是 CA 签发前会检查的,所以它过期坏的是续签而不是流量 —— 故障 60 天后才到。

缓存的代价是:世界什么时候不再相信旧答案,不归你管。每份副本带着 TTL, 而每个 resolver 是在它自己碰巧去问的那一刻起的表。把滑块拖过改动之后的秒数,数一数还在发旧地址的 resolver:

16 / 16 — 改完后 0 s

看最后一份旧副本什么时候翻过来。收敛要花改动之后一整个 TTL,而不是你碰巧看到的最后一次续期之后 —— 因为在你改 zone 前 1 秒才问过的那个 resolver,会把副本抱满整个窗口。这就是不变式:每个 resolver 都可能在你改完之后再发最多一个 TTL 的旧记录,而且收不回来。所以要在改动之前把 TTL 降下来,不是跟改动一起降。

失败走的是自己那个时钟,而且通常更长。改掉一个拼写错误,拖后面的秒数,数还在回 NXDOMAIN 的 resolver, 对着 SOA minimum 看:

16 / 16 — 改对后 0 s

因为 SOA minimum 是 zone 的属性而不是记录的属性,一个从来不存在的名字,是拿一个没人为它设过的数在缓存 —— 常常是 1 h。 10 秒改好拼写,一小时之后每个 resolver 才忘掉它。

别名会加自己的往返。CNAME 的意思是「换这个名字再问一遍」, 所以每多一层就是拿到地址之前多一次权威查询。拖滑块把链拉长,从底下那行读冷解析的代价:

0 层别名 —— 冷解析 42 ms

3 层别名把 42 ms 的查询变成 , 而每一跳都是一次新的权威查询,带着自己的 TTL。虚线那行是常年坑人的地方:zone 顶点上已经住着 SOA 和 NS 记录,而 RFC 1034 禁止 CNAME 和同名的任何东西共存。所以 example.com 本身不能是 CNAME; 你得用 DNS 服务商的 ALIAS、ANAME 或者 flattening —— 它们在服务端把链解开,直接发给你一条 A 记录。

大小是协议一直没松手的另一个约束。经典 DNS 响应是一个 UDP 数据报,原始上限 512 B。用滑块加 A 记录,把答案推过预算:

60 B — 一个数据报

过了 512 B —— ——服务器置上 TC 位, 客户端整条查询改走 TCP 重来一遍,再多一个往返。EDNS(0) 让客户端宣告一个更大的缓冲区,所以今天几乎没人再退回 TCP。但更大不等于无限:过了 1.2 KB —— 也就是 IPv6 最小 MTU 减掉头部 —— 数据报会分片,而丢 IP 分片的中间盒足够多,以至于 DNS Flag Day 2020 让整个行业统一到这个数。才是真的天花板。

「resolver 很快」后面还藏着一件事。1.1.1.1 不是一台服务器,是被几百个站点同时用 BGP 广播的一个地址,你的包停在最近的那个。沿着路线拖动客户端,比较最近的站点和单台服务器:

到最近站点 12 ms. 拖动客户端;方向键每次移动 400 公里,Home 归位。
东京 — 12 ms

光在光纤里大约跑 c 的三分之二,所以一个往返大致是每 100 公里 1 毫秒,没有协议能绕过去。 Anycast 不让包变快,它把目的地搬近。根区只有 13 个命名服务器却能在世界各地毫秒级回答,靠的是同一招。

接着就是真正会把人叫醒的那种故障。记录搬了,尊重 TTL 的东西都跟着搬,而某个把地址缓存到天荒地老的进程, 还在跟一台没人服务的机器说话。把滑块拖过搬家之后的 10 分钟:

0 s —— 0 次请求失败

会重新解析的客户端过一个 TTL 就恢复。被钉住的那个到第 10 分钟还在失败,已经 24,000 次 —— 而且是安静失败:没有 DNS 错误可记,只有对着一台已经搬走的机器发起的连接。 JVM 是经典惯犯 —— 装了 security manager 时networkaddress.cache.ttl 默认 −1,永远缓存。把它设成记录的 TTL。

02

一次一个请求,而且是文本

HTTP/1.1 是 TCP 上的面向行的文本协议,每条连接同时只能有一个未完成请求。 web 二十年长出来的每一个变通,都是这一句话的推论。

拿到地址,curl 开一条 TCP 连接,做完 TLS 握手,然后开始说话。它说的是 ASCII,以一个空行结束 —— 没有长度前缀,没有 framing 头。

按播放,把请求一行一行发出去,看下面那条 bar 里已经上线的字节累加:

第 0 行 / 共 7 行 —— 已发 0 B

注意最后一行。那个空的 \r\n是唯一告诉服务器「头结束了」的东西 —— 所以解析 HTTP/1.1 是扫描分隔符,而不是读一个长度。大小已知的 body 用 Content-Length: N; 大小还没人知道的 body,一块一块地 framing。逐块走一遍:

第 0 / 3 块

每一块前面带着自己的十六进制长度, 而一个 0 长度的块就是 body 的结束。 server-sent-events 流、long poll、还没数完的查询结果,就是这样在知道大小之前先出门的 —— 也正因如此,一个会把整个响应缓冲下来的代理,能在没人改一行代码的情况下,把一个流式端点变成批处理端点。

那 149 B 很便宜。不便宜的是走到「可以发它」这一步。拖往返时延滑块,再切换握手方式, 看第一个响应字节什么时候落地:

首字节 150 ms

在 50 ms 下,全新的 TLS 1.3 连接第一个字节 150 ms 到, TLS 1.2 是 200 ms,已经开着的连接是 50 ms。这就是 keep-alive 的全部理由:3 个往返里有 2 个是建连,而建连可以复用。

HTTP/1.0 每个响应之后就关连接。HTTP/1.1 把「留着」变成默认。拖请求数滑块,比较每次都新开一条和一条一直留着:

1 次请求 —— 留着 170 ms,每次新开 170 ms

6 次请求,老办法 1.02 s,新办法 520 ms。服务器会给复用设上限:一个空闲超时(通常 60–75 秒),外加每条连接的最大请求数。这也是 curl https://a/x https://a/y 比跑两次 curl 快的原因 —— 一个进程,一条连接。

keep-alive 摊薄了握手,没摊薄往返。pipelining 本来是要修这个的:响应 1 还没回来就发请求 2。问题在于响应必须按序回来。拖第一个响应的右边缘,看它后面那两个:

第一个响应 60 ms. 拖第一个响应的右边缘;方向键每次 10 ms,Home 复位。
第一个响应 60 ms

盯住响应 3。它几乎立刻就绪,却要等前面那个慢的走了才能走。这就是队头阻塞,也是这一整页后面都在讲的那个词。 pipelining 还撞上第二堵墙:会缓冲、乱序、丢弃流水线响应的透明代理。浏览器在 2000 年代中期开过它,然后全都关了回去。

于是剩下的并发只有「多开连接」。拖动并行连接数,找到所有浏览器都停下的那个数:

1 条连接 —— 2.20 s

因为曲线对连接数是双曲线,前 6 条买到 , 后 6 条只买到 。6 是那个拐点,而且是个折中:并行到足够有用,又少到一个上百资源的页面不像一次攻击。以前绕过上限的招数叫域名分片 —— 把资源放到 img1、img2 …… 于是每个名字都能开 6 条。到了 HTTP/2 这一招反而有害,而这条建议活得比写它的那个协议还久。

最后一笔代价没人看见。那 30 个请求每一个都带着同样的头,而cookie 通常是里面最大的。把它拖到某个热心的 session 库会痛快给你的 4 KB:

3.6 KB 每次整页加载

一个页面 的请求头, 是上行,而且在任何一个内容字节之前。 HTTP/1.1 帮不上忙:它没有办法说「跟上次一样的头」。修这件事,是 HTTP/2 做的第一件事。

03

一条连接,很多流

HTTP/2 保留了 HTTP 的全部语义,只换掉线格式:带 stream id 的二进制帧、共享的头部表、以及一条连接而不是 6 条。它仍然是 TCP 的囚徒。

逼出「每条连接一个请求」的正是文本 framing:消息前面没有长度,就没法把两条消息交错着发。所以 HTTP/2 在所有东西前面都放上长度。每条消息变成一串帧,而每一帧都以同样的 9 个字节开头。

逐个走过帧头字段,从上面那行读出各自的位宽:

length —— 24 位

72 位,而最后 31 位就是全部的点子:每一帧都带stream id。length 说这帧到哪儿结束,type 说它是 HEADERS、DATA、 SETTINGS、WINDOW_UPDATE、RST_STREAM、PING 还是 GOAWAY, id 说它属于哪一场对话。客户端发起的流是奇数,服务器发起的是偶数,id 0 是连接本身。

有了这个标签,发送方可以按任意顺序把不同请求的帧放上线。一帧一帧播放这条连接,看正在发出的那一帧落进拥有它的那条流:

第 0 帧 / 共 9 帧

注意没有哪条流要等另一条结束。这就废掉了 6 连接那套变通:现在一条连接扛全部,于是在探测的是一个拥塞窗口而不是 6 个互相抢的窗口,而域名分片 —— HTTP/1.1 时代的美德 —— 变成了把好处扔掉的办法。

framing 层还让一个后来被证明是错误的特性成为可能。知道页面需要 app.css 的服务器,可以在浏览器开口之前用另一条流把它推过去。拖动浏览器本来就有多少:

已缓存 0% —— 浪费 0 B

服务器看不见浏览器的缓存,所以回访时 120 KB 里绝大部分是白发的 —— 而且是抢在浏览器真正在等的 HTML前面发的。Chrome 2022 年把 server push 删了。响应头里的 Link: rel=preload 做了有用的那一半:告诉浏览器该取什么,再让浏览器自己决定要不要。

framing 也让头部问题变得可解。HPACK 给两端一张同步的表:一个头发一次,之后就发它的索引。拖请求数滑块,比较HTTP/1.1 本来要发的量和现在线上真正走的量:

1 次请求 —— 121 B 对 149 B,省 19%

第一个请求几乎没便宜多少 —— 它仍然带着每一个头,以 Huffman 编码的字面量形式,并把它们装进表里。到第二个就小了 55%, 到第八个是 对 1.2 KB,省下 82%。静态表覆盖了常见的那些,所以连第一个请求也有索引可用。锋口在于:压缩让密文长度依赖明文,能在秘密头旁边注入自己头的攻击者可以从长度反推出秘密 —— CRIME 攻击。答案是永不索引的字面量:打上这个标志,任何中间人都不许把它放进表。

多路复用需要自己的反压,否则一条贪心的流会饿死共用这个 socket 的另外 5 条。每条流都有一个接收窗口。沿着字节轴拖动窗口, 看发送方在往返之间停下来:

窗口 64 KB. 拖窗口右边缘;方向键每次一档,Home 回到默认。
64 KB — 1.25 MB/s

默认值是 64 KB —— 出自 RFC 9113 §6.9.2 —— 而每个往返只能有一个窗口在飞,所以在 50 ms 下,单条流被卡在 1.25 MB/s,管子再粗也没用。把它提到 ,同一条流能跑到 320 MB/s。这就是「HTTP/2 下大文件慢」背后的那个数:几乎总是没调SETTINGS_INITIAL_WINDOW_SIZE。

并发也有天花板,而且不是大家以为的那个。把一次性发出的请求数滑块拖过服务器宣告的上限:

发出 1 —— 排队 0

过了 128 —— nginx 的http2_max_concurrent_streams 默认值 —— 多出来的请求不会被拒绝,而是在客户端排队, 直到有 stream id 空出来。发 个,其中 172 个在碰到网络之前就在等。「HTTP/2 让并发免费」是假的;它让并发变便宜,便宜到服务器挑的那个数为止。

接下来就是逼出 HTTP/3 的那个缺陷。TCP 交付的是一条有序字节流,所以丢一个段会把后面所有东西都摁住 —— 包括那些已经到了的流的字节。提高丢包率滑块,看所有泳道同时出现缺口:

丢包 0.0% —— 170 ms

因为这些流共享一个顺序,每一条都在等同一次重传: 干净链路 170 ms,1% 丢包 。 HTTP/2 修好了应用层队头阻塞,继承了传输层那一种,而且把 6 条连接并成 1 条之后,一次丢包比以前更贵。

04

把流搬到 HTTP 底下

HTTP/2 剩下的每个问题都是 TCP 的问题,而 TCP 住在内核里。 QUIC 在 UDP 之上重建传输层。

一个全新的传输层协议号会被一半的中间盒丢掉,所以 QUIC 骑在 UDP 上,并在它之上重建传输层。切一下分段控件,看这 5 件事从内核搬进进程:

TCP + TLS — 进程里

5 件全放用户态,就是这笔交易。它买到可部署性 —— QUIC 的修复跟着应用发,而不是跟着内核升级发 —— 代价是 CPU,因为每个包都由用不上内核分段卸载的代码来加密和确认。它还意味着连接属于那个库,而不属于 socket —— 接下来 3 张图才因此成为可能。

这首先买到的是握手。QUIC 把 TLS 1.3 的密码学交换和自己的传输参数放在同一批包里。拖往返时延滑块,切换传输层, 看第一个字节什么时候到:

首字节 100 ms

在 50 ms 下,TCP 加 TLS 1.3 要 150 ms,QUIC 要 100 ms —— 省了一个往返,因为不用先做完一个单独的 TCP 握手。对聊过的服务器,0-RTT 把请求放进第一个包里:50 ms, 光速定下的地板。

在这一切之前,服务器得先知道客户端地址是真的,否则 QUIC 就是一台对着源地址所指的人开火的反射放大器。所以在地址被验证之前,服务器最多只能回它收到的 3 倍。把客户端的第一个数据报往下拖,直到服务器可发的那条够不着虚线:

进 1,200 B —— 出 3,600 B

注意预算真正不够用的位置:进来 1,000 字节,而不是 1,200。1,200 字节的最小数据报是另一条规则 —— RFC 9000 用它保证一条 QUIC 路径不会比这更窄 —— 而 3 倍的天花板把它变成 3,600 字节的预算,比3,000 字节的那批证书多出 600 字节。拖到 1,000 以下,下面那条就够不着虚线了:服务器发到能发的为止,停在半路,等客户端再来一个数据报才能接着发 —— 多一个往返。填充给的是余量,不是门槛。

0-RTT 的地板上也有个洞。那批包用上一次会话推出来的密钥加密,而且不带任何新鲜性证明 —— 所以谁抓到它都能再发一遍。拖动重放次数,读下面那本账:

重放 0 次 —— 扣款 1 次

看着扣款每份复制执行一次。这就是 0-RTT 只对幂等请求安全的原因:重放一个 GET 只是浪费一个响应,重放一个POST /charge 就是第二次扣款。服务器要么把 early data 限制在安全方法上,要么自带防重放 —— 一次性 ticket,或者应用自己校验的幂等键。

QUIC 买到的第二样,正是 HTTP/2 修不了的那个。流有各自的序号,所以丢一个包只摁住它自己那条流。提高丢包率滑块,比较这一页在哪儿结束和HTTP/2 在哪儿结束:

丢包 0.0% —— 120 ms

1% 丢包时这一页落在 124 ms,HTTP/2 是 290 ms; 3% 时是 对 530 ms。机制就是全部差别:6 条泳道里有 5 条根本没注意到那次重传,因为它们的字节不依赖那个缺席的包先到。在干净的有线链路上,收益四舍五入等于零 —— 赢的是丢包,不是带宽。

不过独立的流会弄坏 HPACK。HPACK 假设两端按发送顺序处理表更新,而 QUIC 故意不保证这件事。QPACK 的答案是一个预算。拖动允许多少条流为一个还没落地的表项阻塞:

0 / 6 —— 请求头 726 B

为 0 时 —— SETTINGS_QPACK_BLOCKED_STREAMS 的默认值 —— 编码器绝不能引用一个无法证明已经到达的表项,于是退回字面量:6 个请求 726 B,而不是 84 B。把预算提上去,买到压缩,付出的是队头阻塞,一条流一条流地付。几乎没人调它,所以现实里 QPACK 省下的比 HPACK 少。

QUIC 改掉的最后一样,是「连接」这个词的含义。 TCP 用四元组标识一条连接,所以换网就死。QUIC 在每个包里放一个 connection ID。把客户端从 Wi-Fi 拖到 LTE,看四元组对不上了,而connection ID 还对得上:

在 Wi-Fi 上。把客户端从 Wi-Fi 拖到 LTE;方向键也能移动,Home 拖回 Wi-Fi。
在 Wi-Fi 上

因为服务器按 ID 而不是地址索引会话,视频在切换时继续播,而不是停下来重建一条连接和一次 TLS 会话。这些 ID 按设计会轮换且互不可关联,所以迁移不会顺手给被动观察者一个跨网跟踪设备的把柄。

剩下的问题是:一个会 3 种协议的客户端怎么落到这一种上。 TLS 的 ALPN 扩展在握手里就把 HTTP/1.1 和 HTTP/2 定下来了; HTTP/3 没法这么选,因为选它意味着压根不建 TCP 连接。逐次走过这几次访问:

第 1 次 —— TCP 上的 HTTP/2

第一次访问是普通的 TCP 上 HTTP/2,它的响应带着Alt-Svc: h3=":443"; ma=86400 —— 「这儿有个 QUIC 端点,记住一天」。第二次访问让 QUIC 握手和 TCP 握手赛跑,留下先完成的那个。到第三次,客户端直奔 QUIC。 Cloudflare 2024 年服务的 HTTPS 请求里大约 30% 走 HTTP/3 —— 基本上全在 CDN 边缘。在数据中心内部,丢包可以忽略,而每个代理、sidecar 和 tracing 库都假设 TCP,HTTP/2 仍然是明智的默认。

05

整个请求到底花多少

4 个值得冷启动答出来的问题,用上面那些图同一套模型定价,外加 5 个 code review 里该抓住的东西。

第一个问题是面试官真会问的那个:从敲下 URL 到第一个 JSON 字节,时间都去哪儿了?几乎全是往返,而你付哪几个,完全取决于传输层。切换它,再拖往返时延滑块:

合计 158 ms

50 ms 加一个热 resolver:TCP 要 158 ms,HTTP/3 要 108 ms, 0-RTT 要 58 ms;缓存是冷的就三者各加 72 ms。没有一样是带宽,全是时延, 杠杆只有两个:少几个往返,和短一点的往返。

第二个是队头阻塞,它每一层意思都不同:HTTP/1.1 的 pipelining 是应用层,HTTP/2 是传输层(TCP 是一条有序字节流),HTTP/3 在传输层没有 —— 但把可阻塞流的预算调高, QPACK 会在头部层把它请回来。浏览器为前者开 6 条连接,为后者开一条,而那一条默认扛 128 条流。

第三个是 HTTP/3 什么时候值得上。诚实的答案是一个交叉点,不是一句判决 —— 拖丢包率滑块,看HTTP/2先输给HTTP/3,再输给6 条 HTTP/1.1 连接:

丢包 0.0% —— HTTP/1.1 450 ms,HTTP/2 170 ms,HTTP/3 120 ms

模型只有一句话:每丢一个包,就让共享那份顺序的东西停一个往返。 1% 时 124 ms / 290 ms / 470 ms;过了 , 一条连接就比它取代的 6 条更差。

  • networkaddress.cache.ttl=-1 —— 整个 JVM 生命周期钉死在一个地址上。
  • 对 HTTP/2 源站做域名分片 —— 1 个拥塞窗口够用的地方开了 6 个。
  • 依赖 HTTP/2 server push —— Chrome 2022 年删了;改用 Link: rel=preload。
  • 非幂等 handler 能从 0-RTT 走到 —— early data 按设计可重放。