t = 0.000 000 s time stopped ×µs 时间尺度
clickdnstcptls kernellooppgpaint
☕ 一口咖啡 ≈ 九个这样的请求周期*
一次请求的微观史诗

200. 毫秒

周二早晨 9:41,旧金山一家咖啡馆里,有人点下了「购买」。 211.4 毫秒*之后,屏幕显示 Order confirmed。 这个页面,讲的就是这中间发生的一切。

点下按钮的人,没有察觉任何延迟。

向下滚动 · 滚动距离等于时间,时间只在你移动时流逝

go beneath this request

从线材之上,构建一个后端框架。

node:net 开始,到一个生产级、经过基准测试、可逐字节解释的框架结束。

the rules of this page

本页的规则

  • 向下滚动。故事与时钟一同前进。
  • 顶部的时钟是精确的。它结束于 t = 0.211 400 s
  • 当故事为讲解而暂停时,顶部的时钟会冻结并转为白色。继续滚动,它会接上故事。
  • 紫色是页面唯一使用的颜色,标记正在移动的数据,永远自上而下。页面上没有其他元素使用这个颜色。
the setup

场景

客户端是旧金山一家咖啡馆 Wi-Fi 上的笔记本电脑。服务器是弗吉尼亚州阿什本(Ashburn)的一个 Node.js API——一台租来的亚马逊机器(一个 EC2 实例)上四个 node 进程之一,前面有一台负载均衡器,把每个进来的连接转交给其中一个。Postgres 数据库跑在隔壁那栋楼,是同一个亚马逊区域里的另一座数据中心建筑,0.35 ms 之外。

这条连接还是冷的。这台笔记本最近没有连过这台服务器,所以它没有可复用的现成连接,也没有可恢复的加密设置。TCP 连接与加密握手都要完整跑一遍。一条热连接会跳过这些握手——而它们占了 211 毫秒里的一半以上。本页故意从冷连接开始,让这段时间出现在总数里。

到确认信息渲染出来时,这个请求与它的回复已经六次*横跨北美,途经约四十台机器*,运行了约一亿行*别人写的代码。
the cast

出场角色 · 七站

这个请求要停七站,自上而下。笔记本在店里的 Wi-Fi 上。天花板上的接入点把信号从无线电搬到店里的有线网络。ISP——就是这家店买网络的那家公司——把流量带到更广阔的互联网,送上这里到弗吉尼亚之间那4,700 公里*玻璃光纤。在阿什本,负载均衡器接住连接,把它交给我们的服务器 PID 1447*(就是 会打印的那个数字),部署于 23 天前;Postgres 数据库在隔壁楼。屏幕变化之前,这七站处理的都是同样的 22 字节

  • 01笔记本电脑咖啡馆 Wi-Fi
  • 02接入点无线电 → 有线网络· 十米之外
  • 03ISP店里买网络的那家公司
  • 04互联网4,700 km 玻璃光纤
  • 05负载均衡器背后 4 个 node 进程 · 阿什本
  • 06服务器 · PID 1447node · i-0f3… · 4 个之一 · 23 天前部署
  • 07postgres隔壁楼 · 0.35 ms 之外

旧金山 → 阿什本 · 跟着那个紫色点

act 1

点击

t = 0 → 4 ms · 全部发生在这台笔记本里

01触控板里的那一下

你的指尖从玻璃下方的一组极板上吸走了电荷。扫描那张网格的控制器发现了这处电容凹陷,于是拉起一个中断

电容式触控板网格 · 第 9 列第 2 行的单元记录到触摸
col 9, row 2:阈值被跨过 →电容凹陷 → 拉起中断

中断是一个硬件信号,它让 CPU 挂起手上正在跑的任何东西,跳进驱动代码。驱动跑完,CPU 再恢复它刚才暂停的工作。

HID(人机接口设备)是键盘、鼠标、触控板共用的通用协议,所以一个驱动就能读全部这些设备。

02投递链:触控板到 Chrome

Chrome 不是一个进程,而是好几个:浏览器进程管窗口、网络和输入;标签页里的页面跑在它自己的沙箱渲染器里。鼠标事件被投递给 tab 4 的渲染器。

03命中测试:从根走到按钮

浏览器套用 CSS 之后,它已经知道每个可见元素占据的确切矩形。这些元素和它们的矩形构成了布局树。渲染器拿点击坐标去比对这些矩形,从最外层往里走,直到找到仍然包含这个点的最小那个元素。

结账页线框 · 命中测试从根下降到购买按钮
<html>
<body>
<main class="checkout">
<form id="order">
<button class="buy"> ← 命中
这次遍历跑在渲染器主线程上,也就是你的 JavaScript 跑的那条线程——这正是为什么一个同步循环占住那条线程时,点击会「像死了一样」。

按钮的 click 监听器终于跑起来了。它只做一次调用——那个 fetch——然后拿回一个 promise。

三道检查 在这个 fetch 能走之前,浏览器先跑三道检查,全在微秒级:
被编译进 Chrome 本身——名单上的主机绝不允许通过明文 HTTP 访问。api.thenodebook.com 就在名单上,所以哪怕是第一次访问也强制 https。
混合内容:一个 https 页面被禁止加载 http 资源。页面与目标都是 https,所以没什么要拦的。
是页面自己规定 fetch 可以往哪儿打的规则。'self'——页面来自的那个站点——覆盖了这个目标。
这一刻,时钟停在 t = 0.004 000 s

04URL 被拆开,然后被检查

这个字符串有三个部分。scheme——那个 https://——告诉浏览器要在一条加密连接上跑 HTTP。host 指名了机器,而path 被原样传下去,由服务器自行解释。

05请求本身 · 203 字节

请求被起草出来:一行请求行、几个头部、一个空行,然后是 22 字节的 JSON 主体。总共 203 字节纯文本。

POST /v1/checkout HTTP/1.1
Host: api.thenodebook.com
Content-Type: application/json
Content-Length: 22
Accept: application/json
Origin: https://thenodebook.com

{"sku":"NB-01","qty":1}

每一行都以 \r\n 结尾——HTTP 从电传打字机那里继承来的两字节换行。头部结束后那个空行,是主体开始的唯一标志。

Content-Length 主体不会被扫描去找结束标记。Content-Length: 22 说了 22,解析器就数 22 个字节然后停下
把这个数字设错,请求就会挂死——服务器会一直等一个永远不会来的字节,或者把下一个请求的开头当成这个请求主体的一部分。这一刻,时钟停在 t = 0.008 000 s
act 2

名字

t = 8 → 11 ms · DNS 往返约 2 ms

URL 指名了一台主机:api.thenodebook.com。咖啡馆和弗吉尼亚之间坐着几十台路由器,而它们每一台转发流量的方式,都是读一个目的地址——一个 IP 地址,四个 0 到 255 的数字,合起来占 32 位。

主机名是给人读、给人记的东西,路径上的路由器从不使用它。所以在请求能动之前,这个名字必须先变成一个数字。做这个转换的系统就是 ——域名系统,它存着名字到地址的映射。没有任何一家公司经营它的全部:它的记录散落在数百万台服务器上,缓存副本又在数百万台之上。

01那个问题 · 48 字节

DNS 问题画成一个写好地址的信封
12 B头部
24 B问题名
2 B类型
2 B
发往 203.0.113.53端口 53,来自 10.0.0.23 端口 51334,里面装着这48 字节的问题。

一个 HTTP 头部是你能用手敲出来的文本。一个 DNS 查询是二进制的——定宽字段、字节偏移,全部由 RFC 1035 钉死。

12 字节 DNS 头部 · RFC 1035 方框图
字段含义
transaction id0x3f2a随机选出的事务号
flags仅 RD 置位其余七个标志全为 0
qdcount1问题数
ancount / nscount / arcount0 / 0 / 0新问题上全部为零
十二个字节的头部,每一个比特都有定义。

UDP 没有连接,所以那个事务号就是操作系统把应答匹配回问题的方式。应答必须原样回显同一个数。一个看不到流量的伪造者,必须把十六个比特全盲猜对——1/65,536 的机会。

类型是 A——请求 IPv4 地址(AAAA 则请求 IPv6)。类是 IN,代表 internet,也是至今唯一常用的类。

02任播:一个地址,同时在几十座城市

28 个解析器节点各自宣告 203.0.113.53 · 圣何塞节点是最近的那个
圣何塞洛杉矶西雅图丹佛达拉斯芝加哥纽约 阿什本亚特兰大迈阿密多伦多伦敦巴黎法兰克福 阿姆斯特丹斯德哥尔摩马德里米兰华沙迪拜孟买 新加坡东京大阪首尔悉尼圣保罗约翰内斯堡
203.0.113.53 同时存在于数十座城市。每个分组被投递到网络跳数最少的那份副本——通常(但不总是)是地图上最近的那个。从旧金山的咖啡馆出发,那份副本在圣何塞,往返约2 ms

互联网没有中央路由表。每个网络向邻居宣告自己能到达哪些地址块;邻居把这些宣告加上自己的网络前缀再转发出去,而路由器为每个块保留它收到过的最佳路径。承载这些宣告的协议是

而 BGP 没有任何字段去记录同一个块的两份宣告,是来自一台机器还是四十台不同的机器。

任播靠的正是这个缺失的字段 解析器的运营方故意从几十座城市宣告同一个地址块,而各地区的路由器沿用它们一直在用的最短路径选择,就把流量投递到最近的那份宣告。路由器不需要任何中央协调,因为每一台本来就在沿自己的最短路径转发。
DNS 特别适合这么做,因为一次查找就是一次 UDP 往返、不保留任何连接状态——所以明天落到另一个站点的查询,依然会得到同样的答案。

03:这个答案还能活多久

一条 300 秒长的 TTL 条
已过 88 s
剩余 212 s
TTL 是一个以秒为单位的倒计时,由发布这条记录的人设定。这一条被设成 300 秒,而每个缓存都从它存下答案的那一刻开始倒数。这次查询继承了剩下的 212 秒

根用一次转介(referral)回复,指名了下一批该问的服务器——.com 的那些。Verisign 的服务器又转介一次,到 ns-482.awsdns-60.com。那个域名服务器本身也是个名字,所以转介里附带了它的 IP 地址作为粘合记录(glue record)。

名字有了数字。下一步是连接——它花的时间,将是这次名字查找的好几倍

act 3

握手

t = 11 → 134 ms · 建立连接花掉 123 ms

01套接字:四个数字定义一条连接

往下看,一条连接就是两台机器之间「交换字节」的一份约定,由四个数字标识:本地地址、本地端口、远端地址、远端端口。

字段
本地地址10.0.0.23
本地端口49732(内核分配)
远端地址3.213.44.7
远端端口443 → https 服务器

端口让一台机器在同一个地址上跑很多监听者——很像一个 Express 应用把 /api/admin 路由到不同处理函数;机器本身则把 443 端口和 22 端口路由到不同的程序。端口只是一个数字,0 到 65,535*

02封装:SYN 出门前要套三层头

在这个 能离开机器之前,给它套上三层头部,每一层都把上一层包在里面。下面这些层由内到外排列,每一层都会被依次检查。

封装栈 · 由内到外
tcp · 20 B程序到程序 · 端口 49732 → 443 · seq 2,921,748,316· window 64,240 · 1460 / SACK / 窗口缩放 ×128 / 时间戳
ipv4 · 20 B跨越互联网 · 总长 60 · 不分片 · TTL 64 · 10.0.0.23 → 3.213.44.7
802.11 + FCS · 66 B只管这一跳· 发往十米外的接入点 · 硬件地址出厂即定,绝不离开这个房间
合计 106 字节全是头部。一个 SYN 不携带任何应用数据——请求还老老实实待在用户空间里。

最内层是 TCP 头。,而序列号说明这个包的字节从哪儿开始,好让远端把它们按序放回去、并发现任何丢失的。起始值是随机抽取的,这样一个看不到线路的陌生人就没法伪造出一个像样的下一个包。

window(64,240 字节)是接收方在发送方必须暂停之前,准备缓冲多少。TTL 64* 允许包在被丢弃前经过六十四跳路由器——而这条路线用掉约十七跳

最外层是一个发往十米之外接入点的 802.11 帧——不是发往弗吉尼亚。那些十六进制值是出厂时写在每个无线电上的硬件地址,它们走不出这个房间。弗吉尼亚的地址坐在包在里面的 IP 头里,这一层从不去读它。

03第一跳:十米空气,也是全程最不可靠的一段

整条路线上最不可靠的一段,就是第一跳。一跳是旅程里每一段机器到机器的腿,而这第一跳是十米空气。请求本身并不上路——那 203 字节留在笔记本上,走的只有 SYN。

空气是一条共享信道,房间里每台设备都在上面。此刻这家咖啡馆里还有十一台其他设备在这个信道上。在 CSMA/CA 之下,无线电先等安静再发送,但无法保证另一个无线电不打算同时发。

我们这一帧离开天线的同一个微秒里,信道上另一台设备也发了——两个信号互相搅乱,没有确认回来。这里的确认是 Wi-Fi 自己的逐跳收据,比后面要从弗吉尼亚回来的那个 TCP ACK 更小、更本地。

无线电挑了一个随机退避——十二个固定的微秒级时隙——等完,再发一次。这在时钟上表现为 +1.2 ms。第二次尝试通过了,接入点在几微秒内确认它、剥掉 802.11 头,把这个分组顺着铜线往墙里送——有线部分的路程从这里开始。

04线缆调制解调器与头端

从路由器出来,分组进入一台线缆调制解调器和一条整个街区共享的同轴线。上行传输是的:调制解调器要向头端(headend)——就是有线公司那栋本地建筑,整个社区的同轴在那里汇聚到一台叫 的机器上——申请发送时隙

在这里,分组第一次被转换成光,沿城域光纤走到 ISP 的区域 (point of presence,接入点)——一栋满是路由器、各家网络在此互联的建筑。长途路由从那里开始,剩下到弗吉尼亚的整条路都是光纤。

05:那个四元组只在路由器这一侧有效

路由器把源地址改写成自己的公网地址,并把这个映射记在一张表里。

内部外部状态
10.0.0.23:49732203.0.113.9:28114SYN_SENT
10.0.0.41:52210203.0.113.9:28115ESTABLISHED
10.0.0.18:44903203.0.113.9:28116TIME_WAIT

state 那一列是每条连接的阶段:SYN_SENT 意味着握手在飞,ESTABLISHED 意味着它已开放传数据,TIME_WAIT 意味着它刚被关闭、被短暂保留以防迟到的包还会来。

对弗吉尼亚来说,这条连接就是 203.0.113.9:28114。应答到达时,路由器反查这个映射,把它转发给一台其地址从未出现在公网上的笔记本。前面那个套接字场景里的四元组,只在路由器的这一侧有效

06BGP:路由器怎么挑一根出线

ISP 的核心路由器做的事和咖啡馆里那台一样,只是规模大得多。它必须为3.213.44.7 挑一根出线。它的表以前缀为键——检查时,前 16 位匹配上被选中的前缀。

每个网络都有一个编号,叫 。Lumen(一家长途运营商)是 3356,亚马逊是 16509。每个网络持续告诉邻居自己能到达哪些前缀,用的协议就是 BGP3.213.0.0/16AS16509 宣告,而那份宣告正是沿着这条链抵达了这台路由器。

07长途:玻璃里的光

这一段上,时钟跑得比这一幕里任何地方都快。在玻璃里,光以三分之二的光速 前进。

每隔约 80 公里,就有一个 EDFA(掺铒光纤放大器)——它其实就是光纤本体上一截被掺了铒的纤芯——直接以光的形式把信号放大,不需要先把光转成电。整个分组就这样被放大约六十次*,一路上从未变成过电信号

量级上有1015 比特每秒*穿过这根光纤,分散在几十个波长上——同一根纤芯里,每个波长承载一条独立的信道。SYN 的 106 字节只是其中极小的一份。

SYN 跨过那条虚线边界,进入 AS16509——亚马逊的网络。最后一台公网路由器把 SYN 交接出去。从这里起,光纤和它经过的机器都属于 AS16509。

冷连接 紫色点自你点击起就一直留在旧金山——真正横跨全国又回来的,是连接的建立过程,不带任何载荷。
当这一段落地,时钟会读到 72 ms,整整占去预算的三分之一。这就是冷连接的代价。这一刻,时钟停在 t = 0.072 000 s

08TLS 握手:两条对角线

序列图 · ClientHello 从客户端泳道斜穿而下,历时 31 毫秒
client ·旧金山
server · 阿什本
ClientHello + key_share →
客户端在第一轮就投机地发出 key_share。
序列图 · ServerHello、证书、Finished 从服务端泳道斜穿而上
client · 旧金山
server · 阿什本
← ServerHello · 证书 · Finished
TLS 1.2 需要两轮往返才能开始传应用数据; 1.3 把握手压到一轮——在这条线路上省下约 62 ms*

09X25519:密钥从未被送出

曲线点示意 · 双方各用自己的秘密去乘基点,交换结果,抵达同一个共享点 K
客户端秘密 a
发出 a·G
×
服务端秘密 b
发出 b·G
K = a·b·G双方各自算出
从未上线
线上传的只有 a·Gb·G;把它们逆算回 a 或 b 在计算上不可行。
密钥分发 对称加密的弱点在于密钥分发:双方需要同一把密钥,而唯一可用的信道,就是那条不可信的线本身
TLS 1.3 用 X25519 配合 Let's Encrypt E5 证书(90 天有效期*,部署时随系统装入)解决它。这一刻,时钟停在 t = 0.110 000 s
随着握手推进,挂锁合拢,紫色载荷被加密。

每回来一个 ,就是路径「还能吞更多」的证据,额度随之增长。这种循序渐进的增长叫慢启动,正是大文件下载在头几秒会加速的原因。是 RFC 6928* 建议给内核的起始值——本次请求用掉这十个里的一个

请求共六次横跨北美。前四次只携带握手消息,把连接搭起来;这一次,包里装的才是请求本身。

握手完成 所有握手消息都已往返,连接正式建立。从点击到现在,134 ms 已经过去——几乎全部花在建立这条信道上。这一刻,时钟停在 t = 0.134 000 s
act 4

请求启程

t = 134 → 165 ms · 请求飞行 31 ms

t = 134 ms。握手完成了。TLS 1.3——那个花掉刚才 127 毫秒去协商的加密层——在你这一侧已经结束,所以你的笔记本把最后一条握手消息和请求一起发出,而不是先发握手消息、再等一整个往返才让请求跟上。

你的 Finished 消息——那条证明你导出了正确密钥的消息——和你的应用数据同时出去。application data 是 TLS 给「你真正的字节」起的名字,以区别于那些商定密钥的设置消息。

01线上的请求:203 → 225 → 不到300 字节

TLS 记录头逐字节走查
字节含义
type17application data · 请求的字节
version03 03声称是 TLS 1.2(伪装以兼容中间设备)
length00 dc220 · 加上头部自己那 5 字节 =225
http · 203 B第一幕起草的那个请求
tls · 225 B一条记录 · AES-256-GCM 密封
tcp · 20 B哪一场对话,流里的哪个位置
ip · 20 B哪两台机器
203 字节 HTTP 长成一条 225 字节 TLS 记录,在线上以不到 300 字节行进。每一层只读自己的头,把里面的一切当作不透明的载荷。

02:一遍完成加密与认证

AEAD 示意 · 明文、握手导出的 32 字节密钥、12 字节 nonce、可读的记录头进入 AES-256-GCM
明文 203 B 密钥 32 B nonce 12 B 记录头(可读)
AES-256-GCM
一遍完成
密文 tag 16 B
那把32 字节密钥是双方在握手末尾各自导出的,它从未在线上传输 是一个只用一次的数字,所以没有两条记录会加密得一模一样。

记录头被作为关联数据喂进去,因为它必须保持可读(接收方需要用长度把这条记录从流里切出来),所以它不能被加密——但 tag 依然覆盖它,于是即便是明文发送的字节,也无法在不被发现的情况下被篡改。

如果传输中任何一个比特发生变化——密文里、头里、还是 tag 里——接收端的验证就会失败。TLS 不试图修复损坏:这条记录被丢弃,连接关闭。所以咖啡馆到弗吉尼亚之间的任何机器都可以搬运这条记录,而它们谁都无法读它、也无法在不被发现的情况下改它

几十台路由器必须转发这个包,而它们每一台决定下一步往哪送的方式,就是读那个目的地址——所以那个地址必须全程保持可读。路由信息保持公开;载荷只能在弗吉尼亚被解密。

03CDN 没参与 · 第五次穿越

一个 CDN 边缘节点就坐在离咖啡馆几毫秒的地方,保存着页面和图片的副本,好让没人为它们横跨一整块大陆。它在这次请求里没起任何作用。

光在玻璃里传播会变暗,而每一个放大器内部都有一截掺杂纤芯,在信号还是光的时候把它重新放大。这在两岸之间大约发生六十次,而信号直到弗吉尼亚才重新变成电。整条走廊整体上以1015 比特每秒的量级运转。

这些握手已经四次走过这同一条路,两次出去两次回来,携带的都只是设置消息。现在,这个包携带的是请求本身。

04亚马逊的网络接管了

负载均衡器的全部决策:加密的段抵达,定义连接的四个数字被哈希,哈希挑中四个 node 进程之一;载荷从不被读取
边界路由器AS16509 边缘
+ 叠加头VPC 内部坐标
NLB第四层 · 只哈希四元组
实例 1/4 实例 2/4 实例 3/4← 本连接及此后每一个包 实例 4/4
租户共用同一根物理线;叠加网络让彼此的流量互不看见。哈希结果是实例 i-0f3…

横跨大陆的长途在AS16509 边缘的一台边界路由器上终止。进入内网后,分组又捡起一个头——一个用亚马逊内部坐标书写的叠加地址,指明它所属的 。租户们共用同一根物理线,而叠加网络让每一家的流量彼此隔离。

然后是 :它工作在第四层、绝不再往上,对四元组(你的地址和端口,加上服务器的地址和端口)做哈希,把这条流——本次连接及其此后的每一个包——分给四个实例中的第三个:一台租来的虚拟机,里面跑着一个 Node 进程。

连接的每个包都携带同样那四个数字,所以哈希每次都落在同一处,整条连接就留在同一台机器上。它必须留在那儿,因为只有正在处理这条连接的机器保留着它的状态——哪些字节已经到了、它们在流里属于哪个位置的运行记录。把这些包分散到四台机器上,没有一台会持有足够的信息来重建这条连接。

均衡器从不读请求本身 TLS 密钥只存在于你的笔记本PID 1447 的内存里,所以它转发的是 204 字节它无从解密的密文
一台更强的均衡器——第三幕提过的第七层那种——会持有密钥并按请求里的 URL 做路由;但这一台为了速度停在 TCP 层,转发前只做一次表查询和一次地址改写,全程微秒级

这个包正朝着一张网卡、然后内核、然后一个叫 node 的进程而去。接下来的五十微秒,讲起来比刚才整个横跨大陆还要久。

act 5

抵达:内核

t = 165 → 165.05 ms · 时间尺度骤扩 400 倍
尺度改变t = 165 ms,请求抵达服务器,本页的尺度急剧改变。此前四幕里,一段滚动覆盖的是毫秒。从这里起,同样一段滚动覆盖的是微秒——而一微秒是一毫秒的千分之一,放大四百倍
此前每一个场景都跨越了数千公里光纤。而接下来的五十微秒将占掉这一幕剩下的全部,且全部发生在这一台计算机内部

01光子变电子

离开咖啡馆三十一毫秒*之后,请求抵达阿什本一台真实的计算机。NLB 转发了这条 TCP 流,一个字节都没读。第一个处理它的硬件是 NIC——把这台计算机接到网络上的网卡,Linux 把这一张叫 ens5

它的收发芯片 PHY 把进来的光转成电比特,卡找出帧的起止位置,并在比特到达时就用自己的硬件校验帧的校验和。工程师把这叫卸载(offloading),而CPU 从不运行它

02剥壳:从帧开始,逆序拆头

在第三幕,请求被打包进一串头部:HTTP 在 TLS 里,TLS 在 TCP 里,TCP 在 IP 里,最外面套一个以太网帧。现在这些头按相反顺序被摘下来,从帧开始。

一个帧只把分组带过一条物理链路,再远就不行了,它的地址是 MAC 地址——出厂时烧进每张卡里的序列号。路线上的每一台路由器都剥掉旧帧、加上一个新的,所以这里的 dst mac 指的是这台服务器的卡,而那台笔记本的 MAC 从未离开咖啡馆

type 字段记录里面是什么,0x0800 意味着一个 IPv4 分组。尾部那四个字节是 FCS——一个由帧的每一个字节算出来的数,抵达时重算并比对。不匹配就丢弃,TCP 察觉到缺失的字节并重发。就是这同一个检查,在上一幕堪萨斯上空丢掉了一帧。

03进内存:DMA 与环形缓冲区

接收描述符环 · 一个槽位正被 DMA 以紫色填入
01234567
驱动在点击发生之前很久就建好了这个描述符环。每个描述符持有一个内存地址,告诉卡该把下一帧写到哪里。

卡取下一个描述符,把帧直接写进内核 RAM 的那个地址——内核拥有、任何进程都看不见的内存。这次写入就是 ,而卡之所以被允许这么做,是因为它是总线主控(bus master),能自行写入 RAM,不需要 CPU 参与、也没什么要等

这个环刻意做成一个圆。如果内核跟不上、卡绕回到内核还没读的描述符上,那些帧就会被丢掉——这是一个真实的故障,你可以在服务器的 rx_dropped 计数器上看着它增长。

进来八微秒了,只有卡做过任何工作

04中断

一个 CPU 核心不会轮询新工作,是硬件去打断它。卡拉起一个中断——一个强迫核心停下手上东西的电信号——于是 core 1 停住。

接收活锁 Mogul 与 Ramakrishnan 测量过「每包一次中断」的机器:超过某一负载,它把所有周期都花在处理中断上,没有一个用在中断本要服务的工作上。吞吐量不是触顶,而是崩溃到零——恰好在流量峰值的时候。他们把这叫 receive livelock(接收活锁)
在中断里做那些活会耽住其他所有中断,所以内核把慢的那部分推迟一小会儿再跑——这就是 轮询。这一刻,时钟停在 t = 0.165 050 s

05rcv_nxt:内核期望的下一个字节

内核把「期望的下一个字节序号」存在一个叫 rcv_nxt 的计数器里。

TCP 流带 · rcv_nxt 书签
这225 字节的记录,正好落在 rcv_nxt 期望的字节位置上 ▲ rcv_nxt → 前进 225
两个数字相符,说明没丢包也没乱序,本该容纳乱序片段的重组队列空着

225 字节的 TLS 密文——加密的字节,依然密封着——被追加到接收队列,也就是字节等着进程来读的地方。rcv_nxt 前进 225。而能解密这条记录的密钥握在 Node 手里,Node 还在睡着。

06:那个睡着的进程被叫醒

内核把 391——你的进程用来称呼这个套接字的那个小数字——标为可读,然后遍历这个套接字的等待队列。

epoll 实例归属
interest list这个进程服务的每一个套接字
fd 391 → EPOLLIN有数据可读
ownernode, pid 1447

一个没事干的进程不会空转。它告诉内核「这个套接字有数据时叫我」,然后彻底让开 CPU。你运行过的每一个空闲 Node 服务器——那些在 top 里显示 0.0% CPU 的——一生都恰恰花在这里:睡在 epoll_wait 里面

那个 epoll 实例正看着它的套接字,它的 ready list 多出一条。睡着的进程之一醒了:node,PID 1447,四个之一。

水平触发 vs 边缘触发 水平触发:只要还有数据,就每次轮询都提醒我。边缘触发:只在数据到达的那个跃变上告诉我一次。
对比更早的 select(2)(那个 (2) 是 Unix 手册第2 章、即的说法):每次调用都要把你监视的整份套接字清单再交给内核一遍,内核再把 n 个全扫一遍。而 epoll 下,一条线程能同时处理一万个套接字,每次等待的开销只与就绪的套接字数量有关,而不是被监视的总数。
而这块面板你自己早就写过了:你调用 server.listen(3000),然后看起来什么都没发生。那个「什么都没发生」,就是一个进程睡在 epoll_wait 里——而 O(ready) 的唤醒,正是这一切「什么都没发生」之所以免费的原因。

内核的活儿到这里结束。从这次唤醒到响应之间的一切,都发生在 Node.js 内部。

act 6

走进 Node.js

t = 165.3 → 165.7 ms

01先是那次读取

查出它为你的套接字注册的回调——它的 watcher,也就是 .on('readable') 的 C 侧版本——并调用它。你的字节还在内核内存里,而你的程序不能直接读内核内存,所以 Node 必须要一份拷贝。

read(fd, base, 65536) 去要这份拷贝。这个系统调用把 225 字节仍然加密的数据——一条 TLS 记录——从套接字的接收队列拷进用户空间,也就是你的程序(包括 Node)运行的地方。

它进入 slab*:Node 在启动时就留出的一块 64 KB 缓冲区,按需切片分发出去,所以一个繁忙的套接字不会每次读取都现分配内存一次拷贝,也是这个请求需要的最后一次。从这里到你的处理函数,这些字节只被指向,再也不被搬动。

02解密:你的 JavaScript 从未见过密钥

然后 TLSWrap——套接字与加密之间的那个 C++ 层——把这条记录交给 ,也就是编译进 Node 的加密库,用第三幕的密钥解密它。出来的是 203 字节明文 HTTP自旧金山以来第一次可读。你的 JavaScript 从未见过任何密钥

03llhttp:一个从不回头的状态机

整套设计都源自一个事实:网络被允许把那 203 字节切在任何地方。一次读取可以停在词的中间——停在 Content-Le 里——剩下的落在下一次读取里。

所以:不能用 split('\n'),不能用正则,不能用任何需要一次拿到整个字符串的东西。

llhttp——Node 的解析器——是一个状态机。它只保留一块内存:它所处状态的名字;读恰好一个字节,更新那个状态,然后继续。它从不回头,所以字节被怎么切分完全不影响它。它的规则用 TypeScript 写,编译成 C——读你请求的那个解析器,是从你自己也能读的源码生成的

状态触发转移
METHOD遇到空格 → 方法结束,URL 开始
URL → HTTP_VERSION再一个空格
头部循环 ×6HEADER_FIELD → COLON_SP → HEADER_VALUE → CRLF
on_headers_complete一个前面什么都没有的 CRLF
BODYContent-Length 说 22 → 数22 个字节然后停

头部名字被逐字符拿去比对它认识的字段,而在真有东西需要之前,一个字符串都不分配203 字节,一遍扫完,零回溯。

于是生成 IncomingMessage / ServerResponse——就是你在处理函数里用的 reqresreq.method'POST',因为请求的头四个字节拼出了 POST。res 同时被创建,已经持有第三幕那个套接字,所以它能在请求进来的同一条连接上把响应发回去。

04那十一行

路由到 checkout(req, res)。那段处理函数只有十一行

async function checkout(req, res) {
  const { sku, qty } = await readBody(req);
  if (!sku || qty < 1) return res.writeHead(400).end();
  const order = await db.query(
    'INSERT INTO orders (sku, qty) VALUES ($1, $2) RETURNING id',
    [sku, qty]
  );
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ ok: true, id: order.rows[0].id }));
}

05:六个阶段

事件循环画成一个六阶段闭环 · 幽灵请求绕着它流动,而紫色载荷停在 poll 阶段
timerssetTimeout / setInterval pending延后的 I/O 回调 idle / prepare内部用 poll← 紫色载荷停在这儿 checksetImmediate close关闭回调
查询已经发出去了,走的是通往 Postgres 的十条池化连接之一——每一条都在启动时打开、按请求出借。答案还不存在。

队列是空的。Promise 回调从这里跑,但只在有东西 settle 时——也就是它 resolve 或 reject——而接下来两毫秒里什么都不会 settle。

同时,在同一条线程上:你的处理函数被挂起期间,循环照旧在跑。接下来这两毫秒里,这一条线程处理了大约三十个其他请求的读、写和回调。

一条线程能同时服务很多请求,只要它从不在任何单个请求上阻塞。

那个从没被用到的线程池 Node 确实有一个线程池*,而你这个请求从没用过它套接字不走线程池。这一幕里的一切都跑在那一条循环线程上,因为 Linux 能同时看着几千个套接字而不在任何一个上阻塞
线程池是给那些阻塞调用的工作线程用的:它们在循环线程之外跑那个阻塞调用,然后把结果交回 poll 阶段。所以这个被人们称作单线程的进程,实际跑着五条线程,外加 V8 用于编译和垃圾回收的那些。你的 JavaScript 跑在其中一条上,而阻塞的活儿跑在池子里。
套接字去循环;文件、dns.lookuppbkdf2 去池子。如果 checkout() 需要的是一次密码哈希而不是一次 INSERT,那份工作就会跑在池子里。

一个循环,一条线程,以及其他一切之所以存在、就为了抵达的那十一行。下一站是数据库,0.35 ms 之外。

act 7

数据库

t = 166.05 → 167.5ms · 隔壁楼往返 0.35 ms*

01连接池:开池子的代价

十条池化连接从 node 进程垂下到 Postgres · 第四条被借出时亮起,载荷点沿它下降
1234 ↓5 678910
部署时,这个进程在接收任何请求之前就先开好了十条连接。每一条都有代价。

Postgres 有自己的传输协议,与 HTTP 无关。pg 驱动*用它:Parse / Bind / Execute。参数与 SQL 分离,所以天然挡住 SQL 注入。查询以5 帧、218 字节、一次 write() 系统调用出去。

02B 树:主键索引的下降路径

orders 主键索引的三层 B 树 · 从根到最右叶子的下降路径被高亮
root
internalinternalinternal
leafleafleafleaf(最右)
三次页面读取,全在 Postgres 的 RAM 缓存(shared_buffers)里——因为过去几千次插入读过同样的页。自增主键总是落在最右叶子,所以这条路径一直是热的。

出去的查询与回来的行,三遍都装得进那一次重传的额度里。

:那块通过网络回答的磁盘 「云上的磁盘」其实是另一台机器。Postgres 提交事务前的最后一步 fsync 说的是「把 的这段字节推到底层硬件,直到硬件回话已经落盘为止」——放在物理机上,硬件确认就是 SSD 控制器的一条回信;放在 这样的网络块存储上,「硬件确认」其实是一次跨过 AWS 存储网络的小往返:字节送到存储节点、在同一个可用区里做副本、然后回执才返回。全程通常在一毫秒以内,但它确实是一次网络往返——你「刷盘」的动作从没真的碰到本地物理盘。

SCRAM 认证不在请求路径上。Postgres 要客户端在开新连接时跑一整套 SCRAM 挑战/响应认证——那是几次交互、几次哈希计算,加起来上毫秒。如果每个请求都做一次,它会稳稳地夹在点击与每一个订单之间。所以连接池的做法是:部署时就一次性开完 10 条连接(本机 4 个 node 进程共 40 条),SCRAM 在没有用户在等的那一刻跑完,之后每个请求只借用一条已经认证过的连接。你在本页看到的每一次 db.query(),都没有认证成本。

刷盘完成——这一行现在落在持久存储上了。循环先排空微任务,然后处理函数剩下的部分才继续跑。

act 8

响应

t = 167.1 → 168 ms · 24 字节,向西发出

01132 字节头部 + 24 字节主体

处理函数最后两行现在跑了。res.end(body) 调进 ServerResponse——实现你一直在用的那个 res 对象的类。状态行 HTTP/1.1 200 OK 与四个头部被一次性序列化成一个132 字节的纯文本字符串。

那第一行占据的位置,和第一幕里请求行占据的位置是同一个。每一行都以 \r\n 结尾——HTTP 的两字节换行——而这些字节被算在那 132 里

02cork:把两次写变成一个包

cork 解决一个具体的问题。套接字上每一次 write() 都是一次独立的交给内核,而每一次交接都可能作为自己的一个包离开机器。每个包在你数据的第一个字节前面要背约 40 字节的 TCP 与 IP 头,而且每一个都要付一次从用户空间进内核的穿越。

没有 cork,这个响应会作为两个包出去——头部先走,主体第二个——而笔记本得接收两个。所以 Node 在写之前给套接字打上 cork:头部那次写被扣住,主体那次写排在它后面,然后 uncork 把一个 156 字节的缓冲区一次交给内核。两次写作为一个包离开——一次穿越、一次抵达,而不是两次*Node 在 res.end() 内部自己就做了这件事。

Nagle ×延迟 ACK:那个没有发生的 40 毫秒 如果没有 cork、也没有 TCP_NODELAY,这个回复会表现得完全不同
132 字节头部作为第一个包离开。24 字节主体紧接着就绪,但 Nagle 算法把它扣住了——Nagle 的规则是:一个套接字在先前那个小包还没被确认时,不得发送第二个小包。所以主体在等笔记本确认头部。
而笔记本遵循它自己的规则——延迟 ACK:一个光秃秃的确认不值得为它单发一个包,所以笔记本会把这个确认扣住最多 40 ms*,等着看有没有回复数据可以捎带。
现在两条规则互相僵住了:主体不走,除非头部被确认;确认不走,除非笔记本有回复数据来捎带它;而唯一的回复数据,正是被 Nagle 扣住的那个主体。
每条规则本身都合理,合在一起却能把一个小响应整整拖 40 毫秒,直到笔记本的延迟 ACK 定时器自己触发。Node 通过在套接字创建时就设上 TCP_NODELAY 来避开这个坑,把 Nagle 关掉,让小的写立即出去。Node 在它的 net 模块里把这个设成默认,所以这 40 毫秒的僵持在这条连接上没有发生

03加密并离开机器

t = 167.9 ms。TLS 加密这个响应。 用第三幕建立的会话密钥加密那 156 字节明文,结果是一条 178 字节的 TLS 记录密文。服务器与笔记本之间没有任何路由器或负载均衡器读得懂其中任何一部分

离开这台机器 · 自上而下
用户空间一个加密缓冲区 · 178 B
writev()多个缓冲区,一次系统调用
内核一个段 · 178 B · 发送缓冲区留着一份拷贝直到 ACK
网卡硬件算校验和 · 第五幕接收环的对偶
发送对内核的开销远小于接收时的开销——约十分之一的工作量。

t = 168.0 ms,响应离开这台机器,向西而去。这是第六次横跨大陆*,在光纤上要花31 毫秒行的结果回来之后,服务器做完所有事只用了半毫秒。

act 9

归途

t = 168 → 199 ms · 向西31 ms · 便宜的那个方向

穿过玻璃光纤,向西,经过同样那六十个放大器——每约 80 公里一次光的提升,同样的放大器,反方向。这是一个,TCP 流里一个包大小的片。

前五次穿越只携带了握手消息,用来搭建连接。这一次携带的是答案本身——四千六百公里光纤,单向。

01回到咖啡馆

这次信道是安静的,所以接入点立即发送,没有退避、没有重试

客户端内核的接收路径 · 五个小步骤
NICWi-Fi 芯片 接收环DMA 写入,CPU 不拷一个字节 softirq慢的部分推迟一刻再跑 套接字缓冲区这条连接的暂存区 唤醒Chrome 的套接字线程
这就是第五幕那条接收路径,现在跑在笔记本上而不是服务器上

TCP 做它的簿记。流里的每一个字节在发送前都被编了号,所以接收方能证明没有东西缺失、也没有东西到达两次。这些字节也被做了校验和:接收方从字节重算出一个数,与发来的那个数比对,从而抓住这 4,700 公里上的任何损伤。你的答案两项检查都通过,依然加密着,在这一条连接的套接字缓冲区里排队。

fd——Chrome 用来称呼这个套接字的那个小整数——变为可读。一条睡在kqueue(macOS 版的、你在阿什本见过的那个 epoll)里的线程醒来,手里恰好一个就绪的描述符,就是你这条连接的。它是 Chrome 的一个辅助线程,唯一的工作就是等在网络套接字上,好让页面的主线程不必去等。

这次唤醒比阿什本那次慢一点,因为笔记本更忙——它跑着六十个都想要 CPU 时间的标签页。

02那个不算一次穿越的 ACK

笔记本扣着已到达的答案,同时一个不带数据的 ACK 向东回去 · 服务器内核仍在发送缓冲区里握着响应的拷贝
笔记本答案已到
ACK · 0 字节数据不计为一次穿越
阿什本发送缓冲区仍握着拷贝
一个携带零字节数据的包,只说一句「我拿到了直到第某某号的每一个字节」。

现在有一个这样的包出发去阿什本,往东再三十一毫秒。这个故事不把它算作一次穿越,因为它不携带自己的数据,只携带那个确认。

回到阿什本,服务器的内核仍然在它的发送缓冲区里握着你响应的一份拷贝——就是第八幕你看着答案进去的那一个——而它会一直握着,直到这张收据抵达。如果收据永远不来,一个定时器会触发,内核把答案再发一遍,而你和那个 Node 进程都永远不会知道这事发生过

TCP 可靠性的全部机制 这就是让 TCP 可靠的那个机制,而这是本页唯一一处你直接看到它的地方每一个字节都被编号,每一次抵达都被确认,而任何未被确认的都会被重发。
你在这两百毫秒里一直依赖的那份可靠性,完全是由这些朝反方向旅行的确认构成的

03解密、解析、变成文本

用第三幕商定的 AES-256-GCM 解密。tag 匹配——这证明那 4,700 公里的玻璃没有改动过任何一个字节

那些仍然加密的字节被交给 Chrome 的网络进程——拥有每一个套接字和每一把 TLS 密钥的那个独立进程,好让网页永远碰不到其中任何一个。这正是第一幕最先从标签页里拆分出来的那个进程。

promise 被 resolve。Order confirmed 这几个字现在作为文本存在了,还不是像素

04绘制

渲染器把文本变成绘制指令——诸如「填充这个矩形」、「把Order confirmed 的字形放在这些坐标上」。合成器把这些变成一帧*

冷连接 vs 暖连接 · 同一个请求
冷连接 211.4 ms
暖连接 ~95 ms
握手占去 123 ms。第二次点击省掉全部握手,剩下的大部分纯粹是光速
the clock stops

Order confirmed

t = 211.4 ms。刷新到来。显示器把合成器那一帧逐行读出,自上而下,按钮下方的像素变成 Order confirmed时钟停止。

两百一十一毫秒,六次穿越三次握手一次 fsync你写的十一行代码
它感觉像是瞬间发生的。

go beneath this request

从线材之上,构建一个后端框架。

node:net 开始,到一个生产级、经过基准测试、可逐字节解释的框架结束。

这本书更往下走

运行时那一卷讲事件循环,以及 Node 调进内核的那个点——对应第 5、6、8 幕。
网络那一卷讲 DNS、TCP、TLS 和 HTTP——横跨第 1 到第 4 幕以及第 9 幕。

灵感与结构改编自 Ishtmeet Singh 的 《The Node Book》 及其可视化 200. milliseconds, 仅用于学习创意,版权归原作者所有。

顺带一提:这个页面本身也走同一根线。它是一份约 160 KB 的 gzip 后 JS,没有一张内容图片,只有一个紫色的点