200. 毫秒
周二早晨 9:41,旧金山一家咖啡馆里,有人点下了「购买」。 211.4 毫秒*之后,屏幕显示 Order confirmed。 这个页面,讲的就是这中间发生的一切。
点下按钮的人,没有察觉任何延迟。
向下滚动 · 滚动距离等于时间,时间只在你移动时流逝
本页的规则
- 向下滚动。故事与时钟一同前进。
- 顶部的时钟是精确的。它结束于
t = 0.211 400 s。 - 当故事为讲解而暂停时,顶部的时钟会冻结并转为白色。继续滚动,它会接上故事。
- 紫色是页面唯一使用的颜色,标记正在移动的数据,永远自上而下。页面上没有其他元素使用这个颜色。
场景
客户端是旧金山一家咖啡馆 Wi-Fi 上的笔记本电脑。服务器是弗吉尼亚州阿什本(Ashburn)的一个 Node.js API——一台租来的亚马逊机器(一个 EC2 实例)上四个 node 进程之一,前面有一台负载均衡器,把每个进来的连接转交给其中一个。Postgres 数据库跑在隔壁那栋楼,是同一个亚马逊区域里的另一座数据中心建筑,0.35 ms 之外。
这条连接还是冷的。这台笔记本最近没有连过这台服务器,所以它没有可复用的现成连接,也没有可恢复的加密设置。TCP 连接与加密握手都要完整跑一遍。一条热连接会跳过这些握手——而它们占了 211 毫秒里的一半以上。本页故意从冷连接开始,让这段时间出现在总数里。
出场角色 · 七站
这个请求要停七站,自上而下。笔记本在店里的 Wi-Fi 上。天花板上的接入点把信号从无线电搬到店里的有线网络。ISP——就是这家店买网络的那家公司——把流量带到更广阔的互联网,送上这里到弗吉尼亚之间那4,700 公里*玻璃光纤。在阿什本,负载均衡器接住连接,把它交给我们的服务器 PID 1447*(就是 会打印的那个数字),部署于 23 天前;Postgres 数据库在隔壁楼。屏幕变化之前,这七站处理的都是同样的 22 字节。
- 01笔记本电脑
- 02接入点
- 03ISP
- 04互联网
- 05负载均衡器
- 06服务器 · PID 1447
- 07postgres
旧金山 → 阿什本 · 跟着那个紫色点
点击
01触控板里的那一下
你的指尖从玻璃下方的一组极板上吸走了电荷。扫描那张网格的控制器发现了这处电容凹陷,于是拉起一个中断。
中断是一个硬件信号,它让 CPU 挂起手上正在跑的任何东西,跳进驱动代码。驱动跑完,CPU 再恢复它刚才暂停的工作。
HID(人机接口设备)是键盘、鼠标、触控板共用的通用协议,所以一个驱动就能读全部这些设备。
02投递链:触控板到 Chrome
Chrome 不是一个进程,而是好几个:浏览器进程管窗口、网络和输入;标签页里的页面跑在它自己的沙箱渲染器里。鼠标事件被投递给 tab 4 的渲染器。
03命中测试:从根走到按钮
浏览器套用 CSS 之后,它已经知道每个可见元素占据的确切矩形。这些元素和它们的矩形构成了布局树。渲染器拿点击坐标去比对这些矩形,从最外层往里走,直到找到仍然包含这个点的最小那个元素。
按钮的 click 监听器终于跑起来了。它只做一次调用——那个 fetch——然后拿回一个 promise。
① 被编译进 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: 22 说了 22,解析器就数 22 个字节然后停下。把这个数字设错,请求就会挂死——服务器会一直等一个永远不会来的字节,或者把下一个请求的开头当成这个请求主体的一部分。这一刻,时钟停在
t = 0.008 000 s。
名字
URL 指名了一台主机:api.thenodebook.com。咖啡馆和弗吉尼亚之间坐着几十台路由器,而它们每一台转发流量的方式,都是读一个目的地址——一个 IP 地址,四个 0 到 255 的数字,合起来占 32 位。
主机名是给人读、给人记的东西,路径上的路由器从不使用它。所以在请求能动之前,这个名字必须先变成一个数字。做这个转换的系统就是 ——域名系统,它存着名字到地址的映射。没有任何一家公司经营它的全部:它的记录散落在数百万台服务器上,缓存副本又在数百万台之上。
01那个问题 · 48 字节
一个 HTTP 头部是你能用手敲出来的文本。一个 DNS 查询是二进制的——定宽字段、字节偏移,全部由 RFC 1035 钉死。
| 字段 | 值 | 含义 |
|---|---|---|
| transaction id | 0x3f2a | 随机选出的事务号 |
| flags | 仅 RD 置位 | 其余七个标志全为 0 |
| qdcount | 1 | 问题数 |
| ancount / nscount / arcount | 0 / 0 / 0 | 新问题上全部为零 |
UDP 没有连接,所以那个事务号就是操作系统把应答匹配回问题的方式。应答必须原样回显同一个数。一个看不到流量的伪造者,必须把十六个比特全盲猜对——1/65,536 的机会。
类型是 A——请求 IPv4 地址(AAAA 则请求 IPv6)。类是 IN,代表 internet,也是至今唯一常用的类。
02任播:一个地址,同时在几十座城市
互联网没有中央路由表。每个网络向邻居宣告自己能到达哪些地址块;邻居把这些宣告加上自己的网络前缀再转发出去,而路由器为每个块保留它收到过的最佳路径。承载这些宣告的协议是 。
而 BGP 没有任何字段去记录同一个块的两份宣告,是来自一台机器还是四十台不同的机器。
DNS 特别适合这么做,因为一次查找就是一次 UDP 往返、不保留任何连接状态——所以明天落到另一个站点的查询,依然会得到同样的答案。
03:这个答案还能活多久
根用一次转介(referral)回复,指名了下一批该问的服务器——.com 的那些。Verisign 的服务器又转介一次,到 ns-482.awsdns-60.com。那个域名服务器本身也是个名字,所以转介里附带了它的 IP 地址作为粘合记录(glue record)。
名字有了数字。下一步是连接——它花的时间,将是这次名字查找的好几倍。
握手
01套接字:四个数字定义一条连接
往下看,一条连接就是两台机器之间「交换字节」的一份约定,由四个数字标识:本地地址、本地端口、远端地址、远端端口。
| 字段 | 值 |
|---|---|
| 本地地址 | 10.0.0.23 |
| 本地端口 | 49732(内核分配) |
| 远端地址 | 3.213.44.7 |
| 远端端口 | 443 → https 服务器 |
端口让一台机器在同一个地址上跑很多监听者——很像一个 Express 应用把 /api 和 /admin 路由到不同处理函数;机器本身则把 443 端口和 22 端口路由到不同的程序。端口只是一个数字,0 到 65,535*。
02封装: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:49732 | 203.0.113.9:28114 | SYN_SENT |
| 10.0.0.41:52210 | 203.0.113.9:28115 | ESTABLISHED |
| 10.0.0.18:44903 | 203.0.113.9:28116 | TIME_WAIT |
state 那一列是每条连接的阶段:SYN_SENT 意味着握手在飞,ESTABLISHED 意味着它已开放传数据,TIME_WAIT 意味着它刚被关闭、被短暂保留以防迟到的包还会来。
对弗吉尼亚来说,这条连接就是 203.0.113.9:28114。应答到达时,路由器反查这个映射,把它转发给一台其地址从未出现在公网上的笔记本。前面那个套接字场景里的四元组,只在路由器的这一侧有效。
06BGP:路由器怎么挑一根出线
ISP 的核心路由器做的事和咖啡馆里那台一样,只是规模大得多。它必须为3.213.44.7 挑一根出线。它的表以前缀为键——检查时,前 16 位匹配上被选中的前缀。
每个网络都有一个编号,叫 。Lumen(一家长途运营商)是 3356,亚马逊是 16509。每个网络持续告诉邻居自己能到达哪些前缀,用的协议就是 BGP。3.213.0.0/16 由 AS16509 宣告,而那份宣告正是沿着这条链抵达了这台路由器。
07长途:玻璃里的光
这一段上,时钟跑得比这一幕里任何地方都快。在玻璃里,光以三分之二的光速 前进。
每隔约 80 公里,就有一个 EDFA(掺铒光纤放大器)——它其实就是光纤本体上一截被掺了铒的纤芯——直接以光的形式把信号放大,不需要先把光转成电。整个分组就这样被放大约六十次*,一路上从未变成过电信号。
量级上有1015 比特每秒*穿过这根光纤,分散在几十个波长上——同一根纤芯里,每个波长承载一条独立的信道。SYN 的 106 字节只是其中极小的一份。
SYN 跨过那条虚线边界,进入 AS16509——亚马逊的网络。最后一台公网路由器把 SYN 交接出去。从这里起,光纤和它经过的机器都属于 AS16509。
当这一段落地,时钟会读到 72 ms,整整占去预算的三分之一。这就是冷连接的代价。这一刻,时钟停在
t = 0.072 000 s。
08TLS 握手:两条对角线
09X25519:密钥从未被送出
发出 a·G
发出 b·G
a·G 和 b·G;把它们逆算回 a 或 b 在计算上不可行。TLS 1.3 用 X25519 配合 Let's Encrypt E5 证书(90 天有效期*,部署时随系统装入)解决它。这一刻,时钟停在
t = 0.110 000 s。
每回来一个 ,就是路径「还能吞更多」的证据,额度随之增长。这种循序渐进的增长叫慢启动,正是大文件下载在头几秒会加速的原因。十是 RFC 6928* 建议给内核的起始值——本次请求用掉这十个里的一个。
请求共六次横跨北美。前四次只携带握手消息,把连接搭起来;这一次,包里装的才是请求本身。
t = 0.134 000 s。
请求启程
t = 134 ms。握手完成了。TLS 1.3——那个花掉刚才 127 毫秒去协商的加密层——在你这一侧已经结束,所以你的笔记本把最后一条握手消息和请求一起发出,而不是先发握手消息、再等一整个往返才让请求跟上。
你的 Finished 消息——那条证明你导出了正确密钥的消息——和你的应用数据同时出去。application data 是 TLS 给「你真正的字节」起的名字,以区别于那些商定密钥的设置消息。
01线上的请求:203 → 225 → 不到300 字节
| 字节 | 值 | 含义 |
|---|---|---|
| type | 17 | application data · 请求的字节 |
| version | 03 03 | 声称是 TLS 1.2(伪装以兼容中间设备) |
| length | 00 dc | 220 · 加上头部自己那 5 字节 =225 |
02:一遍完成加密与认证
一遍完成
记录头被作为关联数据喂进去,因为它必须保持可读(接收方需要用长度把这条记录从流里切出来),所以它不能被加密——但 tag 依然覆盖它,于是即便是明文发送的字节,也无法在不被发现的情况下被篡改。
如果传输中任何一个比特发生变化——密文里、头里、还是 tag 里——接收端的验证就会失败。TLS 不试图修复损坏:这条记录被丢弃,连接关闭。所以咖啡馆到弗吉尼亚之间的任何机器都可以搬运这条记录,而它们谁都无法读它、也无法在不被发现的情况下改它。
几十台路由器必须转发这个包,而它们每一台决定下一步往哪送的方式,就是读那个目的地址——所以那个地址必须全程保持可读。路由信息保持公开;载荷只能在弗吉尼亚被解密。
03CDN 没参与 · 第五次穿越
一个 CDN 边缘节点就坐在离咖啡馆几毫秒的地方,保存着页面和图片的副本,好让没人为它们横跨一整块大陆。它在这次请求里没起任何作用。
光在玻璃里传播会变暗,而每一个放大器内部都有一截掺杂纤芯,在信号还是光的时候把它重新放大。这在两岸之间大约发生六十次,而信号直到弗吉尼亚才重新变成电。整条走廊整体上以1015 比特每秒的量级运转。
这些握手已经四次走过这同一条路,两次出去两次回来,携带的都只是设置消息。现在,这个包携带的是请求本身。
04亚马逊的网络接管了
i-0f3…。横跨大陆的长途在AS16509 边缘的一台边界路由器上终止。进入内网后,分组又捡起一个头——一个用亚马逊内部坐标书写的叠加地址,指明它所属的 。租户们共用同一根物理线,而叠加网络让每一家的流量彼此隔离。
然后是 :它工作在第四层、绝不再往上,对四元组(你的地址和端口,加上服务器的地址和端口)做哈希,把这条流——本次连接及其此后的每一个包——分给四个实例中的第三个:一台租来的虚拟机,里面跑着一个 Node 进程。
连接的每个包都携带同样那四个数字,所以哈希每次都落在同一处,整条连接就留在同一台机器上。它必须留在那儿,因为只有正在处理这条连接的机器保留着它的状态——哪些字节已经到了、它们在流里属于哪个位置的运行记录。把这些包分散到四台机器上,没有一台会持有足够的信息来重建这条连接。
一台更强的均衡器——第三幕提过的第七层那种——会持有密钥并按请求里的 URL 做路由;但这一台为了速度停在 TCP 层,转发前只做一次表查询和一次地址改写,全程微秒级。
这个包正朝着一张网卡、然后内核、然后一个叫 node 的进程而去。接下来的五十微秒,讲起来比刚才整个横跨大陆还要久。
抵达:内核
此前每一个场景都跨越了数千公里光纤。而接下来的五十微秒将占掉这一幕剩下的全部,且全部发生在这一台计算机内部。
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 与环形缓冲区
卡取下一个描述符,把帧直接写进内核 RAM 的那个地址——内核拥有、任何进程都看不见的内存。这次写入就是 ,而卡之所以被允许这么做,是因为它是总线主控(bus master),能自行写入 RAM,不需要 CPU 参与、也没什么要等。
这个环刻意做成一个圆。如果内核跟不上、卡绕回到内核还没读的描述符上,那些帧就会被丢掉——这是一个真实的故障,你可以在服务器的 rx_dropped 计数器上看着它增长。
进来八微秒了,只有卡做过任何工作。
04中断
一个 CPU 核心不会轮询新工作,是硬件去打断它。卡拉起一个中断——一个强迫核心停下手上东西的电信号——于是 core 1 停住。
在中断里做那些活会耽住其他所有中断,所以内核把慢的那部分推迟一小会儿再跑——这就是 与 轮询。这一刻,时钟停在
t = 0.165 050 s。
05rcv_nxt:内核期望的下一个字节
内核把「期望的下一个字节序号」存在一个叫 rcv_nxt 的计数器里。
225 字节的 TLS 密文——加密的字节,依然密封着——被追加到接收队列,也就是字节等着进程来读的地方。rcv_nxt 前进 225。而能解密这条记录的密钥握在 Node 手里,Node 还在睡着。
06:那个睡着的进程被叫醒
内核把 391——你的进程用来称呼这个套接字的那个小数字——标为可读,然后遍历这个套接字的等待队列。
| epoll 实例 | 归属 |
|---|---|
| interest list | 这个进程服务的每一个套接字 |
| fd 391 → EPOLLIN | 有数据可读 |
| owner | node, pid 1447 |
一个没事干的进程不会空转。它告诉内核「这个套接字有数据时叫我」,然后彻底让开 CPU。你运行过的每一个空闲 Node 服务器——那些在 top 里显示 0.0% CPU 的——一生都恰恰花在这里:睡在 epoll_wait 里面。
那个 epoll 实例正看着它的套接字,它的 ready list 多出一条。睡着的进程之一醒了:node,PID 1447,四个之一。
对比更早的
select(2)(那个 (2) 是 Unix 手册第2 章、即的说法):每次调用都要把你监视的整份套接字清单再交给内核一遍,内核再把 n 个全扫一遍。而 epoll 下,一条线程能同时处理一万个套接字,每次等待的开销只与就绪的套接字数量有关,而不是被监视的总数。而这块面板你自己早就写过了:你调用
server.listen(3000),然后看起来什么都没发生。那个「什么都没发生」,就是一个进程睡在 epoll_wait 里——而 O(ready) 的唤醒,正是这一切「什么都没发生」之所以免费的原因。
内核的活儿到这里结束。从这次唤醒到响应之间的一切,都发生在 Node.js 内部。
走进 Node.js
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 | 再一个空格 |
| 头部循环 ×6 | HEADER_FIELD → COLON_SP → HEADER_VALUE → CRLF |
| on_headers_complete | 一个前面什么都没有的 CRLF |
| BODY | Content-Length 说 22 → 数22 个字节然后停 |
头部名字被逐字符拿去比对它认识的字段,而在真有东西需要之前,一个字符串都不分配。203 字节,一遍扫完,零回溯。
于是生成 IncomingMessage / ServerResponse——就是你在处理函数里用的 req 和 res。req.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:六个阶段
队列是空的。Promise 回调从这里跑,但只在有东西 settle 时——也就是它 resolve 或 reject——而接下来两毫秒里什么都不会 settle。
一条线程能同时服务很多请求,只要它从不在任何单个请求上阻塞。
线程池是给那些阻塞调用的工作线程用的:它们在循环线程之外跑那个阻塞调用,然后把结果交回 poll 阶段。所以这个被人们称作单线程的进程,实际跑着五条线程,外加 V8 用于编译和垃圾回收的那些。你的 JavaScript 跑在其中一条上,而阻塞的活儿跑在池子里。
套接字去循环;文件、
dns.lookup、pbkdf2 去池子。如果 checkout() 需要的是一次密码哈希而不是一次 INSERT,那份工作就会跑在池子里。
一个循环,一条线程,以及其他一切之所以存在、就为了抵达的那十一行。下一站是数据库,0.35 ms 之外。
数据库
01连接池:开池子的代价
Postgres 有自己的传输协议,与 HTTP 无关。pg 驱动*用它:Parse / Bind / Execute。参数与 SQL 分离,所以天然挡住 SQL 注入。查询以5 帧、218 字节、一次 write() 系统调用出去。
02B 树:主键索引的下降路径
出去的查询与回来的行,三遍都装得进那一次重传的额度里。
fsync 说的是「把 的这段字节推到底层硬件,直到硬件回话已经落盘为止」——放在物理机上,硬件确认就是 SSD 控制器的一条回信;放在 这样的网络块存储上,「硬件确认」其实是一次跨过 AWS 存储网络的小往返:字节送到存储节点、在同一个可用区里做副本、然后回执才返回。全程通常在一毫秒以内,但它确实是一次网络往返——你「刷盘」的动作从没真的碰到本地物理盘。SCRAM 认证不在请求路径上。Postgres 要客户端在开新连接时跑一整套 SCRAM 挑战/响应认证——那是几次交互、几次哈希计算,加起来上毫秒。如果每个请求都做一次,它会稳稳地夹在点击与每一个订单之间。所以连接池的做法是:部署时就一次性开完 10 条连接(本机 4 个 node 进程共 40 条),SCRAM 在没有用户在等的那一刻跑完,之后每个请求只借用一条已经认证过的连接。你在本页看到的每一次
db.query(),都没有认证成本。刷盘完成——这一行现在落在持久存储上了。循环先排空微任务,然后处理函数剩下的部分才继续跑。
响应
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() 内部自己就做了这件事。
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 记录密文。服务器与笔记本之间没有任何路由器或负载均衡器读得懂其中任何一部分。
在 t = 168.0 ms,响应离开这台机器,向西而去。这是第六次横跨大陆*,在光纤上要花31 毫秒。行的结果回来之后,服务器做完所有事只用了半毫秒。
归途
穿过玻璃光纤,向西,经过同样那六十个放大器——每约 80 公里一次光的提升,同样的放大器,反方向。这是一个段,TCP 流里一个包大小的片。
前五次穿越只携带了握手消息,用来搭建连接。这一次携带的是答案本身——四千六百公里光纤,单向。
01回到咖啡馆
这次信道是安静的,所以接入点立即发送,没有退避、没有重试。
TCP 做它的簿记。流里的每一个字节在发送前都被编了号,所以接收方能证明没有东西缺失、也没有东西到达两次。这些字节也被做了校验和:接收方从字节重算出一个数,与发来的那个数比对,从而抓住这 4,700 公里上的任何损伤。你的答案两项检查都通过,依然加密着,在这一条连接的套接字缓冲区里排队。
fd——Chrome 用来称呼这个套接字的那个小整数——变为可读。一条睡在kqueue(macOS 版的、你在阿什本见过的那个 epoll)里的线程醒来,手里恰好一个就绪的描述符,就是你这条连接的。它是 Chrome 的一个辅助线程,唯一的工作就是等在网络套接字上,好让页面的主线程不必去等。
这次唤醒比阿什本那次慢一点,因为笔记本更忙——它跑着六十个都想要 CPU 时间的标签页。
02那个不算一次穿越的 ACK
现在有一个这样的包出发去阿什本,往东再三十一毫秒。这个故事不把它算作一次穿越,因为它不携带自己的数据,只携带那个确认。
回到阿什本,服务器的内核仍然在它的发送缓冲区里握着你响应的一份拷贝——就是第八幕你看着答案进去的那一个——而它会一直握着,直到这张收据抵达。如果收据永远不来,一个定时器会触发,内核把答案再发一遍,而你和那个 Node 进程都永远不会知道这事发生过。
你在这两百毫秒里一直依赖的那份可靠性,完全是由这些朝反方向旅行的确认构成的。
03解密、解析、变成文本
用第三幕商定的 AES-256-GCM 解密。tag 匹配——这证明那 4,700 公里的玻璃没有改动过任何一个字节。
那些仍然加密的字节被交给 Chrome 的网络进程——拥有每一个套接字和每一把 TLS 密钥的那个独立进程,好让网页永远碰不到其中任何一个。这正是第一幕最先从标签页里拆分出来的那个进程。
promise 被 resolve。Order confirmed 这几个字现在作为文本存在了,还不是像素。
04绘制
渲染器把文本变成绘制指令——诸如「填充这个矩形」、「把Order confirmed 的字形放在这些坐标上」。合成器把这些变成一帧*。
Order confirmed
t = 211.4 ms。刷新到来。显示器把合成器那一帧逐行读出,自上而下,按钮下方的像素变成 Order confirmed。时钟停止。
两百一十一毫秒,六次穿越,三次握手,一次 fsync,你写的十一行代码。
它感觉像是瞬间发生的。
这本书更往下走
运行时那一卷讲事件循环,以及 Node 调进内核的那个点——对应第 5、6、8 幕。
网络那一卷讲 DNS、TCP、TLS 和 HTTP——横跨第 1 到第 4 幕以及第 9 幕。
灵感与结构改编自 Ishtmeet Singh 的 《The Node Book》 及其可视化 200. milliseconds, 仅用于学习创意,版权归原作者所有。