Web 请求全链路 入门
curl https://api.example.com/user/42 要花 137 毫秒。可以上手拖的图证明 3 件事:12 个阶段严格串行,总数是个和;服务端只占 137 ms 里的 1.03 ms; 其中 101 ms 是复用连接就能直接删掉的建连开销。 20 篇 primer 都链在它的阶段上。
一条命令、12 个阶段、137 毫秒
大约七分之一秒后,一份 JSON 落到 stdout。这段时间里几乎没有一点属于服务端。
命令是 curl https://api.example.com/user/42,走一条 30 ms 的路径, DNS 缓存是冷的,也没有已经打开的连接。对端是 nginx 后面的一个进程,在 PostgreSQL 上做一次主键查找,返回 2 KiB 的 JSON。没有任何稀奇的东西。
12 个阶段严格一个接一个地跑,因为每一个都在消费上一个的产出:你没法给一个还没解析出来的地址开 socket。时钟底下的那个阶段是琥珀色,它后头的全部是青色。按播放,或者把时钟横着拖过整张图:
注意已经付过账的那些阶段都堆在同一行里。12 根条里有 5 根坐在线路那条泳道上,扛着 137 ms 里的 131 ms; 服务端那 4 个阶段加起来薄到画不出宽度。总数是个和而不是最大值,恰恰因为时钟底下那个阶段永远是唯一在跑的那个。
这值得一条泳道一条泳道地读,而不是听我说。把滑块走过 5 条泳道,你选中的那条会报出它拥有几个阶段、占了这次请求的多少:
服务端应用—— 解析、查询、序列化 —— 一共 960 µs。把两个内核也加上,整个机器侧的开销是 1.03 ms: 占调用方所等时间的百分之零点七五。不管你在那个 handler 里怎么折腾,你调的是用户感受到的那个数字的 0.7%。
原因是算术,不是工程。一次 30 ms 的往返是一个大到让机器内部的一切都消失的单位 —— 把标记拖到每一个更小的延迟上,读出一次往返能装下多少个:
375,000 次主存访问装得进一次往返里,30,000,000 次 L1 命中也装得进。一次让 CPU 工程师觉得灾难性的 cache miss, 还不到网络能做的最便宜那件事的三百万分之一。这个比值就是这一页后面基本都在讲往返的原因。
而且它在任何距离上都成立,这才是让人意外的部分。把往返从同机架的 1 ms 一路拖到跨大陆的 61 ms, 看线路和服务端怎么各走各的:
服务端那根条从来不动。它在任何设置下都是 1.03 ms, 因为服务端做的事没有一件跟距离有关;而线路那根条是唯一会长的东西。于是整页塌缩成一个开销模型:总时长 ≈ 5 ms 进程启动 + DNS + 3 × RTT + 2.4 ms。往返 1 ms 时是 50.4 ms,61 ms 时是 230 ms。这一页后面都在论证:这 4 项里你能删掉哪几项。
还没有包的时候
137 ms 里有 45 ms 花在客户端还没拿到可连的地址之前,而两个罪魁祸首都不是网络。
按下回车让 shell fork,再 execve一个此刻还不在内存里的二进制。内核建一个地址空间,把 ELF 和它的 6 个共享对象映射进去,再把控制权交给动态链接器 —— 见进程与线程和虚拟内存。
这 5 ms 里几乎没有一点是 fork 的。把滑块走过 5 个阶段,看时钟堆在那两个没人料到的阶段上,后头跟着已经做完的部分:
注意质量落在哪儿。重定位—— 链接器把 6 个库里每一处符号引用都补上 —— 要 2.1 ms;把代码段按需调进来又要 1.6 ms,约 1,340 次次缺页。这两样都是启动的价钱,不是干活的价钱,这就是「长驻客户端胜过每请求起一个」的全部论据。底下那个分配器见内存分配。
接着是 getaddrinfo,可能 20 µs,也可能 40 ms ——2,000× 的摆幅,完全取决于谁已经知道答案。把滑块沿委派往上拖,看给出答案的那一跳离你越来越远,而它前面的每一跳都得走一遍:
这条链是从远端往回空的,顺序很重要:权威答案过期得比 .com的委派早得多,后者的 NS 记录带两天 TTL。所以常见的冷查询不是从根开始那 40 ms 的走法 —— 而是从 .com 起步的那 25.1 ms,委派的其余部分还缓存着。记录类型见 DNS 与 HTTP。
故障模式比开销更糟,因为 DNS 骑在 UDP 上,除了调用方没人替它重传。拖动 resolv.conf 里的超时和 nameserver 数量,读出调用线程被没人应答的那几次尝试阻塞多久:
在 glibc 默认值下 ——timeout:5、attempts:2、两个 nameserver ——一个被黑洞吞掉的查询会把调用方阻塞20 秒, 然后返回 EAI_AGAIN。它响亮地失败,这算好情况,但它失败的时刻在它上面每一层超时都早已触发之后。请求 handler 里的同步 DNS 是个 bug,不是个性能问题。
一个 HTTP 字节之前的两次握手
域名解析完了,可有用的话一句还没说。我们和请求之间隔着两场谈判。
socket() 花的是一次内核分配,connect()花的是一个 TX 描述符 —— 两个加起来 60 µs。之后的一切都是在等对端回话,而对端在 30 ms 之外。两次握手的深入版本在TCP 深入和TLS 与安全。
在 GET /user/42 能出门之前,有 6 条报文要来回跑。逐条走过去,看正在飞的那条每次占掉半个往返,而已经送达的那些停在原地:
注意每个往返里有哪半程是空的。客户端把ACK和 ClientHello 背靠背发出去,所以 TLS 1.3 只花一个往返而不是两个 —— key share 跟着第一条报文一起走,不等协商出密码套件。时钟走到 61.2 ms:2 个往返,加 1.2 ms 的证书验证。
对着一次 137 ms 的请求,这是个具体的、可以用手拖出来的比例。拖动往返时延,读出握手占了整体多少:
30 ms 时两次握手是请求的 45%。跨太平洋的 150 ms 时是 61%,因为那 3 个往返有 2 个是纯建连,只有 1 个带数据。这就是那个值得删掉的项,而且它删得掉:它里头没有任何一点取决于请求本身。
于是有 4 种开连接的方式,画在同一个舞台上,好让人比较而不是背诵。在它们之间切换,读出每一种已经预付掉了什么:
读那一列往返数,别读直觉。会话恢复重放上一条连接留下的预共享密钥,可它仍然要 2 个往返, 和完整握手一样多:TLS 1.3 本来就只要一个往返,没有第二个可以省。恢复省掉的是证书 —— 61.2 ms 变成 60 ms, 省下的就是那 1.2 ms 的链解析和两次签名验证。2 减到 1 是 TLS 1.2 的账。真正省掉一个往返的是 0-RTT(30 ms), 它把应用数据放在第一波报文里发出去 —— 这正是它对任何非幂等操作都不安全的原因:抓到那一波的攻击者可以重放它,而服务端此时还没有握手状态可以察觉。它也还是 1 个往返而不是 0,因为前面那个 TCP 握手没有消失;只有 QUIC 才把它折进去。而复用连接什么都不用付。
在这一切算数之前还有一件事必须成立:证书要能链到客户端已经信任的根。一次弄断一环,读出验证器到底报了什么:
坑在第三种情况。unable to get local issuer certificate读起来像根不受信,而它几乎从来不是 —— 它的意思是服务端没把中间证书发过来,于是客户端手里有一张合法的叶证书、一张合法的根,中间没有东西把它们接上。它还会在不同客户端之间时好时坏:某个浏览器如果从别的站点缓存过那张中间证书,它就会成功,而 curl 失败。
顺着栈下去,再爬回来
请求本身是 312 字节文本。把它送上线路、再从线路上接下来,正是各层挣饭吃的地方 —— 也是它们丢东西的地方。
每一层都把上一层交下来的东西包起来,再加上自己的头: 一个 TLS 记录、一个 TCP 段、一个 IP 包、一个以太网帧,以及 PHY 在这一切之前先打出去的前导码。这是网络栈的主题,字节布局见二进制与数制。
下面这根条永远是一个帧,不管里头装什么都画成同样宽。把载荷缩小,看那 112 字节的框怎么把画面吃掉:
载荷满到 1,426 字节时,框只占 7.28%,没人会去想它。只有 1 字节时 —— 一个 ACK、一个 keepalive、一个一次只发一个字段的话痨 RPC —— 有效载荷率是 0.9%, 你付113 字节搬 1 字节。这就是 Nagle 算法存在的全部理由,也是「关掉它」是个决定而不是次优化的全部理由。
超过一个段的量,TCP 就要切。最大段长是 1,500 字节的 MTU 减去 40 字节的 IP 和 TCP 头,再减去 12 字节的时间戳选项 —— 拖动响应正文,看段数一格格跳:
注意最后那个段的代价。我们这 2 KiB 的 JSON 是两个段:一个满的, 加一个 622 字节的余数,装在按 1,426 字节做的帧里。两个仍然在半个往返里跨过去,因为它们背靠背发出、而窗口远比两个段宽 —— 段数花的是带宽不是延迟,直到一次丢包让第二个段得等第一个。
在服务端,网卡用 DMA 把每个帧直接写进一个环形缓冲区并触发一次中断;随后 softirq 以 64 个一批把环掏空。把到达速率抬过一颗核能处理的上限:
越过处理速率,环就填满了,网卡没地方放下一个帧,于是它把 rx_missed_errors 加一,然后把帧扔掉。没有背压可施 —— 发送方不在听。这次丢弃在一个普通服务能看到的每一层上都是静默的:TCP 重传,请求最终会成功,只是慢。决定那个速率的 softirq 预算见系统调用与中断。
字节进了 socket 缓冲区之后,一个停在 epoll_wait 里的 worker 还得变成正在运行的线程。往那颗核上加几个已经可运行的线程, 看这 6 步里哪一步被拉长:
因为前 4 步只是在动链表,空闲核上的唤醒是 22 µs —— 而运行队列是唯一跟负载有关的那一步。前面排着 6 个可运行线程,就把 22 µs 的唤醒变成 9 ms, 这是一个任何指着你代码的 profiler 都解释不了的 p99。I/O 模型讲 epoll 和 io_uring,CPU 调度讲「可运行」的代价,并发原语讲底下那个 futex。
服务端到底在干什么
整个 handler 一共 1.03 毫秒。这点时间也值得知道花在哪 —— 因为它里头有两项是没有上限的。
解析请求行和 8 个头,是 60 µs 的指针算术,跑在一块从不离开 L1 的缓冲区上 —— 为什么这重要见存储层次, 连接表被多个核同时写之后会发生什么见缓存一致性。然后是 SELECT * FROM users WHERE id = 42。
索引是一棵 B+tree,它的高度决定这次查找要碰几页。把行数拖过 7 个数量级,看这棵树只在扇出用完的时候才长一层:
注意它长得多稀罕。一个 8 KiB 的页能放大约 367 条 bigint 索引项,所以一亿行需要4 层:367³ 才 4900 万,367⁴ 是 180 亿 —— 比一千万行的表多一层。给表多加一个零,大多数时候不给查找加任何东西 —— 这份平坦正是 B+tree 长成这样、而不是长成二叉树的全部理由。节点布局见数据库存储。
于是就是 4 个索引页加1 个堆页。这到底花 550 µs 还是 5.55 ms,只取决于这 5 页里buffer pool 已经握着几页。把页从池子里拿走,再换掉底下的设备:
一次全冷的下降在 NVMe 上是 1 ms ——5 次 4 KiB 读、每次约 90 µs, 这也是为什么底下那层 page cache 几乎无关紧要 (文件系统), 以及为什么磁盘存储把篇幅花在队列深度上。同一个查询计划,换到云块存储上,同样的下降是 5.55 ms,差一个数量级。
这些都还不是那个没有上限的项。池化连接是有限资源,需求一旦越过它,等待就根本不是服务时间了。把在途请求数抬过池大小,再把每个请求占住一个槽位的时长拉长:
需求一越过池大小,队列就成了全部延迟 —— 在那之下等待恰好是零,画面很无聊。200 个在途、池子 20 个、每个占 5 ms, 就是在一次 550 µs 的查询上加了 45 ms 的等待。上限就是 Little 定律,没别的 ——20 个槽位 ÷ 5 ms 是每秒 4,000 次,再压更多进来换不到更多吞吐,只换到一条队列。
对写而言,最后一项是持久性。COMMIT 在预写日志记录落到掉电还在的介质上之前不会返回,而唯一的杠杆是攒批。把共用一次刷盘的事务数抬上去:
一次提交一次刷盘时,设备就是吞吐本身:NVMe 每秒 1,538 次(每次 650 µs),云盘每秒 124 次。组提交把一次刷盘摊到 16 个事务上,拿回一个数量级,代价是每个事务多几毫秒延迟。把 fsync 关掉能再拿回两个数量级,并悄悄放弃 ACID 里的 D —— COMMIT 到底在承诺什么,见事务。
137 ms 去哪了
每个阶段现在都量过了。同样这 12 个数按开销排一次序,会说出 trace 说不出的话。
§01 那张瀑布图是按事情发生的顺序画的,那是理解机制的正确顺序,却是决定修什么的错误顺序。把同样这 12 个阶段按开销排一下,论证就换了个形状。
把滑块沿阶段往下走 —— 这次是按开销排的 —— 读出每一个占整体多少,前三名和其余的分开画:
这三个—— DNS、TLS、TCP —— 是 101 ms, 占这次请求的 74%,而且每一个都是建连: 没有一个取决于我们问的是哪个用户。最便宜的 6 个加起来是 1.09 ms。这是一份火焰图什么都显示不出来的 profile, 因为这些时间根本没有一点在 CPU 上。
既然阶段是串行的,删掉一个就必须正好减少它自己那么长、不多不少 —— 这是个可以验的说法,不用信我。挑一个要删的阶段,看它后面的全部怎么往左滑进那个空档:
每一个选择都把终点正好挪动那根消失的条的长度。这就是不变式写成算术的样子:没有哪个阶段和别的重叠, 所以总数就是和,而你能不花的每一毫秒都直接从末尾减掉。它也告诉你哪些删除值得追 —— 最大的三个全是建连。
建连正是你真能删掉的那部分。在 4 种连接策略之间切换,看哪些条消失了:
注意哪些条活了下来。光是缓存住 DNS 答案就把 137 ms 变成 97.5 ms。再把连接保持着开,变成 36.2 ms。一个连进程启动都省掉的长驻客户端 —— 任何服务间调用就是这个 —— 落在 31.2 ms, 砍掉 4.4×,而其中 30 ms 是那一个带着请求和回复的往返。没有第五种策略:那就是地板。
省下来的是每请求的,所以它会叠加。拖动这串跑几次请求, 把复用一条连接和每次都新建放在一起比:
50 次顺序调用,复用要 1.67 s,不复用要 6.87 s —— 同样的活,4.1× 的墙钟,差额全在那些一个字节数据都没带的握手上。这也是CPU 体系结构和汇编与 ISA不再是杠杆的地方:你在一个解析循环里能赢下的常数因子是微秒级的,而对手是一个以往返为单位的项。
什么会坏、坏在哪
症状沿着层往上跑。原因恰好只住在一层里,而 trace 就是从前者到后者的地图。
一个 500 几乎从来不是故事的全部,「这个 API 很慢」也不是。下面这 10 种故障每一种都会留下一个上层留不下的签名,这正是这张地图值得背下来的原因。
把滑块走过它们,读出每一种故障实际住在哪,以及它发作时调用方看到什么:
10 种里有 2 种坐在同一个阶段上,而它们正是最容易被搞混的一对:慢查询和耗尽的连接池, 表现出来都是「数据库慢」。分辨它们靠形状 —— 慢查询只让某些请求变长,而饿死的池子让每一个请求同等变长,包括那些根本不碰数据库的。
那张单子里的第二种值得单独给一张图,因为它的症状是个人人引用、却不知道出处的数字。逐步走过一个永远没人应答的 SYN 的各次重传:
因为重传定时器每次翻倍,已过的时间是 1、3、7、15 秒 —— 所以你日志里那个「卡了三秒」就是一个被丢掉的 SYN,没有别的。 Linux 在 tcp_syn_retries 次翻倍之后放弃,默认 127 秒:长到它上面每一层超时都先触发。状态机见 TCP 深入。
这就带出一个问题:那些超时该设成多少。把客户端的预算往下拖,拖到低于一次冷请求的开销,看重试策略此时对压过去的负载做了什么:
低于 137 ms 时,每一次首发调用都超时并被重试,于是服务收到 3 倍的负载,一件也没完成 —— 典型的重试风暴,起因是超时按热的那个数设的,而不是按冷的那个。预算必须覆盖调用方合法能走的最慢路径,否则它就把一个慢依赖变成一个死依赖。
最后一种故障模式其实什么都没坏。它是只在规模上才现身的算术。把一次用户请求扇出到几个后端抬上去:
注意它叠加得多快。10 次调用时,9.6% 的用户请求包含一次 p99 调用;100 次时是 63%。一个 p99 健康地停在 20 ms 的服务,组合出来的面向用户 p50却不健康,而没有哪个单独的服务有错:Dean 和 Barroso 的「规模上的长尾」 (CACM 56:2, 2013)。
所以:阶段串行,总数就是个和;故障在你盯着的那层静默、在下面两层响亮;而坑是去优化 profiler 能看见的那 0.7%。开销 3 行就装得下:
ms spawn 5.00 DNS 0.02-40.00
TCP 1xRTT TLS 1xRTT data 1xRTT
server 1.03 = 5 + DNS + 3xRTT + 2.4