Web 请求全链路 入门

20 篇 primer 把这摞栈搭起来。这一篇把它们串成一个故事:curl https://api.example.com/user/42 从敲键到 JSON 落 stdout。 5 个 section 把画面搭起来:布景 —— 一次 curl、~250 ms、20 层;trace —— 跨 5 个泳道的 12 个阶段、可交互的瀑布图、 每篇 primer 都在它的阶段交叉链接;延迟预算 —— 时间实际花到哪、复用能省掉什么;故障模式 —— 每层在哪坏;以及速查表。CS 基础系列的收官之作。

01

布景 —— 一次 curl、~250 ms、20 层

你敲了 curl https://api.example.com/user/42, 不到半秒就收到一份 JSON。20 个不同的子系统刚刚配合完成了这件事。 这篇 primer 就是索引 —— 我们把整条时间线走一遍, 然后把这个系列里的每一篇 primer 指到它该出场的位置。

登场角色:你的笔记本(curl 进程、libc、内核、网卡)、 中间的公网(~30 ms RTT、十几跳路由、一两个 CDN 边缘), 以及某处的服务器 —— nginx 前置一个后端进程, 后端连 Postgres 或 MySQL,数据库后头压着 NVMe 跑文件系统。 这几块没有哪一个是孤立设计出来的;每一层要么是对下层解决不了的问题的回应, 要么是上层要求的抽象。

我们覆盖的 4 层

CS 基础系列花了 20 篇 primer 把这摞栈搭起来。每一篇都是这同一次请求的一片切面:

  • 硬件(5 篇)。位和数表示、一颗 CPU 核到底干什么、 内存延迟的 6 个数量级、多核一致性、编译器吐出来的汇编。 从 CPU 体系结构开始。
  • 操作系统(8 篇)。进程与线程、决定谁跑的调度器、 虚拟内存与缺页、分配器、syscall / 中断的边界、文件系统与 page cache、 从阻塞到 io_uring 的 I/O 模型、并发原语。 从 进程与线程开始。
  • 网络(4 篇)。包从网卡出门的旅程、TCP 的可靠性机器、 顶上的 DNS 和 HTTP、把这一切包进机密性里的 TLS。 从 网络栈开始。
  • 存储与数据(3 篇)。磁盘的物理、 数据库依赖的 B-tree 与 LSM-tree、应用允许依赖的事务保证。 从 磁盘存储开始。

这篇收官在干什么

5 个 section,有顺序。Section 2 是脊柱 —— 一次 curl 的 12 个阶段、 从头走到尾、每篇 primer 都交叉链接到它的阶段。嵌入的瀑布图让你能可视化地翻看。Section 3 拿同一条 trace 问:时间到底花到哪了 —— 延迟预算。Section 4 问:每层最容易在什么地方坏 —— 每个图层最爱的故障模式, 映射回它的 primer。Section 5 是速查 Q&A。

教学合同是:系列里篇 primer 都在正文某处被链到 —— 不留死角。 如果你单独读懂了每一篇,这篇把它们串成一个故事; 如果你有些段落跳着读,交叉链接就是邀请,让你在「正觉得线断了」的那一点深入下去。

要点。「一次 curl 请求、端到端 ~250 ms,跨硬件、OS、网络、存储调动 20 个子系统。 每个子系统都有自己的 primer;这篇收官把时间线走一遍, 每篇 primer 都在它该出场的阶段被链到。」

02

Trace —— 20 篇 primer、一条时间线

一次请求、5 个泳道、12 个阶段。先翻瀑布图;然后我们用文字把每个阶段走一遍, primer 链接就嵌在里头,你可以随时跳进任何一层深入。

curl https://api.example.com/user/42 —— 一次请求、5 个泳道、12 个阶段阶段 1 —— 敲键 + 进程派生(~5 ms)客户端应用客户端内核线缆 / 网络服务器内核服务器应用0 ms50 ms100 ms150 ms200 ms250 ms300 ms
你按下回车;bash fork+exec curl。新地址空间、新页表、glibc 初始化、堆 brk。覆盖:进程与线程、虚拟内存、内存分配。
1 / 12
横轴是真实流逝的毫秒。每个矩形是请求的一个阶段,放在它归属的泳道上。 翻帧 —— 每个阶段都标着深入讲它的那篇 primer。 数字按典型互联网请求(~30 ms RTT、热的 DB cache、TLS 1.3、不复用连接)单后端给的。

阶段 1 —— 敲键与进程派生

你按下回车;bash fork 一个孩子、exec curl。 内核建一个新的地址空间、一组新页表、把 curl ELF 二进制的 text 和 data 段载入。 glibc 跑构造器;堆从新的 brk 开始。 在第一个网络字节之前,OS 级别的准备已经花了 ~5 ms。 细节:进程与线程 讲 fork / exec 机器、虚拟内存讲地址空间构造与按需分页加载、内存分配讲 glibc 堆底下在干啥。

阶段 2 —— DNS 解析

curl 调 getaddrinfo("api.example.com", ...)。 glibc 的 stub resolver 查 /etc/nsswitch.conf, 通常交给 systemd-resolved,它发 UDP 查询给配置的上游(常见 1.1.1.1 或 8.8.8.8)。 如果解析器没缓存,它就走 root → TLD → authoritative。 首次未命中典型 ~30 ms;有热缓存不到 1 毫秒。 细节:DNS 与 HTTP

阶段 3 —— socket() 与第一个 SYN

curl 调 socket(AF_INET, SOCK_STREAM, 0)connect()。 第一个 syscall 分配内核 struct sock;第二个让内核搭出 IP 头 + TCP SYN 段, 排到合适的 qdisc 上,递给 NIC 驱动,驱动往设备的环形缓冲里写一个 TX descriptor。 NIC DMA 包并发完成信号。 细节:系统调用与中断讲用户→内核过渡、网络栈讲头构造和驱动路径。

阶段 4 —— 三次握手

SYN 出门、服务器从 listen() 队列回 SYN-ACK、客户端发 ACK。 一个完整 RTT —— 典型互联网路径 ~30 ms。两端都进 ESTABLISHED; 序列号商定、窗口大小协商、MSS 和 selective-ACK 选项交换完。 细节:TCP 深潜

阶段 5 —— TLS 握手

https:// 时,TCP 握手之上还有第二次握手。 TLS 1.3 里客户端发 ClientHello,带 key share(一个临时 ECDHE 公钥)和它支持的密码套件; 服务器回 ServerHello、它的 X.509 证书链、一条 Finished —— 全在一个 RTT 里。客户端按系统信任库验链、推出会话密钥。再 ~30 ms。 细节:TLS 与安全

阶段 6 —— 发 HTTP 请求

现在我们发 GET /user/42 HTTP/1.1 加标准头。 字节先被加密进 TLS 记录,再被裹进 TCP 段、IP 包、以太帧,作为 TX descriptor 贴在 NIC 上, DMA 上线,跨互联网路由到服务器。半个 RTT —— 典型 ~15 ms。 细节:DNS 与 HTTP讲请求格式、网络栈讲封装、CPU 体系结构讲 NIC descriptor 环和 DMA 怎么把字节挪动而不过多打扰 CPU。

阶段 7 —— 服务器 NIC、IRQ、softirq

在服务器上,NIC DMA 把进入的包写进它的 RX 环、触发 MSI-X 中断。 驱动的 IRQ 处理器排一个 softirq —— NAPI 轮询在一批里尽可能多读包。 每个包爬上栈:IP 层(重)组、TCP 段处理、payload 追加到 socket 收 buffer、 监听 fd 标可读。 细节:系统调用与中断讲 IRQ → softirq 机制、网络栈讲爬回各层。

阶段 8 —— epoll 唤醒一个 worker

一个服务器 worker 线程一直停在 epoll_wait()。 socket 翻可读时,内核走 epoll 就绪列表、标线程可运行,CFS 调度器把它放上 CPU。 唤醒本身底下走的是 futex —— 无争用便宜、堆很多线程时贵。 细节:I/O 模型讲 epoll vs io_uring、 为啥这是每个高吞吐服务器的架构、CPU 调度讲「可运行」对 CFS 意味着什么、并发原语讲 futex 唤醒本身。

阶段 9 —— 解析 HTTP 与派发

worker 线程读请求字节、解析 HTTP 请求行和头、派发到 /user/:id handler。 解析循环是那种整个住在 L1/L2 的东西 —— 小的热 buffer、大多可预测的分支, 这种代码里「天真」和「仔细」的差别就是分支预测器抓不抓住内循环。 细节:CPU 体系结构讲流水线在干什么、内存层级讲为啥留在 L1 是快慢之差、缓存一致性讲一旦连接表被多核摸到会发生什么、汇编与 ISA讲 JIT 或 AOT 编译器为热路径吐了什么。

阶段 10 —— 数据库查询

handler 在到 Postgres 的连接池上发 SELECT * FROM users WHERE id = 42。 DB 在主键索引上做 B+tree 下降:根页、内页、叶子页;叶子给一个堆元组指针; buffer pool 要么已经有那个堆页(命中,总 ~50 μs), 要么通过文件系统和 NVMe 从磁盘取(未命中,+50 μs 到几毫秒)。 行按 MVCC 可见性规则读出。 细节:数据库存储讲 B+tree 和 buffer pool、内存层级讲 buffer pool 为啥存在、文件系统讲 buffer pool 底下的 page cache 层、磁盘存储讲 4 KB 随机读时 NVMe 在干啥。

阶段 11 —— 序列化响应

handler 建响应对象、交给 JSON 序列化器,它向分配器要一块 2 KB buffer、把 UTF-8 字节写进去。 TLS 库把生成的 HTTP 响应加密成 TLS 记录;内核把它送下 TCP、IP、驱动、NIC。 细节:内存分配讲分配器 (大多数服务器是 jemalloc / tcmalloc)、二进制与数制讲序列化器产生的 UTF-8 编码、TLS 与安全讲每条记录的 AEAD 加密。

阶段 12 —— 回程到家

包穿过互联网(~30 ms RTT,响应回程加 ~15 ms)。 在客户端,NIC RX 路径镜像阶段 7 倒着来:NIC DMA → softirq → TCP 重组 → socket 收 buffer。 curl 从 socket 读、TLS 库解密记录、curl 把明文 JSON 写到 stdout。 同时,服务器的 handler 用 COMMIT 关数据库事务 —— 它可见的效果是服务器上 N 个并发事务里的一个,按你选的隔离级别排序。 细节:TCP 深潜讲重组、网络栈讲下穿各层、I/O 模型讲 curl 怎么从 socket 读、事务讲服务器的 COMMIT 实际保证什么。

要点。「12 个阶段、5 个泳道、2 个端点、~250 ms 真实时钟。 每个阶段由一篇 primer 拥有细节、引用另外两三篇覆盖跨层关心的事。 瀑布不是一串孤立事件 —— 是同一个包流过 20 个子系统, 每个都是在假设其他都会做好自己工作的前提下设计出来的。」

03

延迟预算 —— 250 ms 实际花到哪了

同样 12 个阶段、换个镜头。瀑布显示先后;这一节显示成本。 多数工程师对「一次 HTTP 调用要多少」的心智模型,在至少一个阶段上错了一两个数量级。

典型首次 HTTPS 请求,到单后端、~30 ms RTT、热的 DB cache、不复用连接:

阶段                               典型           主导因素
─────────────────────────────────  ───────       ──────────────────────────
1. 进程派生 + glibc 初始化         ~5 ms         fork、ELF load、页表
2. DNS 查询(首次)                ~10–50 ms     上游解析器 + 权威 NS
3. TCP 三次握手                    ~30 ms        1 × RTT
4. TLS 1.3 握手                    ~30 ms        1 × RTT + 证书校验
5. 发请求(半 RTT)                ~15 ms        线 + 中间路由
6. 服务器 NIC RX + 派发            ~1 ms         softirq + epoll 唤醒
7. 解析 HTTP + 路由                ~1 ms         L1/L2 热、大多分支
8. DB 查询(热)                   ~1–50 ms      buffer pool vs 磁盘
9. 序列化 + TLS 加密               ~1–5 ms       分配器 + AEAD
10. 响应(半 RTT)                 ~15 ms        线
11. 客户端 NIC RX + TLS 解密       ~1 ms         softirq + AEAD
12. curl 写 stdout                 ~50 μs        一次 write() syscall
─────────────────────────────────  ───────
合计                               ~110–250 ms

额外空间藏在哪

表里 3 个数就是整局棋。DNS:首次查后缓存, 所以后续请求摊销到零 —— 但首次请求总要吃这一刀。看DNS 与 HTTP讲缓存层 (浏览器、OS、解析器)。TCP 握手:~30 ms 一次, keep-alive 期间后续全是零。TLS 握手:再 ~30 ms 一次, 会话复用后又是零。复用连接就能在每个请求省 60 ms —— 所以 HTTP/2 多路复用和连接池才那么要紧。HTTP/3(QUIC)再省一个 RTT, 把 TLS 和传输握手熔合。看TCP 深潜TLS 与安全讲 keep-alive 和 0-RTT 机器。

两个出乎意料的

首次 DNS 常常比你预期还慢,因为解析器链很深: 你机子 → systemd-resolved → 上游 → root → TLD → 权威。每跳都是一次真的 UDP 往返, 权威服务器可能在另一个洲。首次 80 ms 不罕见;200 ms 也会有。

WAL 上的 fsync 经常是服务器上单次最长的事 —— 上面 trace 里看不到是因为我们在做 SELECT、不是写。 把它改成更新 last_seen 的 POST,服务器的 COMMIT 就要在预写日志上 fsync。 NVMe 上 5–15 ms、降级磁盘的云块存储上 50+ ms,主宰一切持久写的延迟。看数据库存储讲 WAL、事务讲 COMMIT 实际在等什么、磁盘存储讲 fsync 让 SSD 做什么(刷易失缓存)。

P99 抖刺 —— 心智剧本

平均延迟是上面那张表;P99 延迟讲另一个故事。4 大常见嫌犯:

  • GC pause 或压实。大多数托管运行时(JVM、Go、.NET)定期 stop-the-world 去压实。 读内存分配讲分代 GC 为啥存在、代价是啥。
  • 调度延迟。CPU 紧张时你的 worker 可能在 runqueue 里坐几十毫秒才被调度器挑上。看CPU 调度
  • 冷页缺页。如果 worker 的栈或热数据页被换出(或从来没装进来), 第一次摸就是一次磁盘往返。看虚拟内存讲 major vs minor 缺页。
  • fsync 卡住。磁盘或它的写缓存进了争用态;commit 堵住了。看文件系统讲 journal commit 路径、磁盘存储讲 SSD 偶尔暂停一切去 GC 它的 NAND。

要点。「典型的 ~250 ms 里,有一整个 RTT(~60 ms)是 TCP + TLS 握手, 连接复用后消失。网络传送另外 ~30 ms,不搬服务器躲不掉。剩下都在服务器端, 大多被 DB 查询和 WAL 上的 fsync 限定。P99 是另一只兽 —— GC、调度延迟、缺页、或 fsync 卡。」

04

故障模式 —— 每层最容易在哪坏

每一层都有自己最爱的出错方式。当你能把症状(超时、长尾、断连、500)映射到 拥有那次故障的层上,你就不再猜、开始修。

  • DNS 服务器不可达。查询超时,glibc 几秒后返回 EAI_AGAIN, 应用浮出一句让人迷惑的「name resolution failed」。常见原因是 systemd-resolved 卡住、 或在不重试的网络上一个 UDP 包被丢。修法:短解析器超时、重试一次、永远别让请求 handler 同步等 DNS。看DNS 与 HTTP
  • TCP SYN 被默默丢。防火墙、PMTUD 坏掉、或太狠的云安全组。 客户端按指数退避(1 秒、3 秒、7 秒……)重发 SYN —— 这正是「无端的 5 秒挂起」的样子。看TCP 深潜网络栈
  • TLS 证书过期或链断。握手在发出任何 HTTP 之前失败。 症状:连接打开、立刻 TLS alert 关闭。生产里最常见元凶是过期 leaf 证书、缺中间证书、 或系统信任库缺相关根。看TLS 与安全
  • 临时端口耗尽。客户端开新连接比清理快;新 connect()EADDRNOTAVAIL。常见原因是每请求一条 TCP 连接(没池子)打到一个把它们放在TIME_WAIT 60+ 秒的负载均衡器。看TCP 深潜网络栈讲连接状态生命周期。
  • 服务器 NIC RX 溢出。包来得比驱动排空环还快;ethtool -Srx_dropped 可见。 TCP 重传最终能掩盖但长尾延迟飙天。看网络栈讲环形 buffer、系统调用与中断讲决定排空速率的 softirq 预算。
  • App worker 被 CPU 压力饿。负载下(或共享主机上的失控邻居), 你 worker 在 runqueue 里坐几十毫秒。长尾延迟在所有handler 上均匀升 —— 典型迹象。看CPU 调度
  • 请求中缺页。内存压力把 worker 的栈或热数据页换出; 第一次摸是一次磁盘读(5-15 ms)。vmstatsi/so 非零可以诊断。看虚拟内存内存分配
  • 数据库行锁争用。两个事务都想 UPDATE 同一行;一个等到 lock_timeout。 可串行化隔离下,输的拿到可重试错误。看事务讲隔离、并发原语讲底层锁机器。
  • COMMIT 上 fsync 卡。NVMe 降级、磁盘满、或易失缓存刷得慢。 COMMIT 堵几百毫秒。IOPS 受限的云块存储是看到这事的典型环境。看文件系统讲 journal commit 路径、磁盘存储讲 NAND 为啥偶尔暂停一切去做垃圾回收。
  • 缓存一致性伪共享。两个核反复写同一个 64 字节 struct 的邻接字段 (计数器、锁、热表槽位)。MESI 在核之间弹这行;吞吐悄悄掉 2-4×。看缓存一致性
  • HTTP/2 stream-ID 耗尽或 HOL 阻塞。一条连接耗光 stream ID (2^31,但高搅动的长连接上能撞到),或一个慢 stream 在 TCP 队首阻塞整条连接。 HTTP/3 通过迁到 QUIC 解决 HOL。看DNS 与 HTTP

要点。「服务器吐 500 几乎从来不是全部故事。 症状往上层走;原因住在某一层。知道每种常见故障归哪层管,你就能先问对问题 —— 这个系列的 20 篇 primer 正是这张映射。」

05

速查表

6 道值得能冷讲的全局问题、5 个生产系统里要看出来的红旗。

画一遍一次 curl https://... GET 的生命周期 —— 说出 12 个阶段。

(1) 进程派生、(2) DNS 查询、(3) socket + 第一个 SYN、 (4) TCP 三次握手、(5) TLS 1.3 握手、(6) 发 HTTP 请求、 (7) 服务器 NIC RX + softirq、(8) epoll 唤醒 worker、 (9) 解析 + 派发、(10) DB 查询、(11) 序列化 + TLS 加密、 (12) 回程 + stdout。5 个泳道 (客户端 app、客户端内核、线缆、服务器内核、服务器 app), 首次请求典型 ~250 ms 真实时钟。

生命周期里最长的典型延迟在哪、主导是什么?

TCP + TLS 握手加上来回的网络往返合起来 ~90 ms (~60 ms 握手 + 双向各 ~30 ms 网络传送)—— 超过预算的三分之一。 其余都在服务器端,通常被 DB 查询(看缓存还是磁盘,~1–50 ms)限定; 写的话,COMMIT 时 WAL 上的 fsync(NVMe 5–15 ms)。 冷缓存下首次 DNS 也能跟握手叫板。

如果连接复用(keep-alive / HTTP/2 / HTTP/3),会有什么不同?

阶段 3、4、5 消失 —— TCP socket 已经 ESTABLISHED、TLS 会话已经协商好。 从「决定发请求」直接到「HTTP 字节上线」,每请求省 ~60 ms。 HTTP/2 在同一条连接上多路复用很多请求; HTTP/3(QUIC)再通过让一切跑在 UDP 上、每流独立可靠性, 额外避开 TCP 层队首阻塞。首请求没变;之后的全大幅变便宜。

P99 延迟突然飙,你的心智剧本是?

按各自诊断难度从易到难查 4 大常见嫌犯。(1) GC / 压实 —— 运行时指标显示 pause 抖刺与延迟对得上?(2) 调度延迟 —— runqueue 深度上升、或云上 steal time 非零? (3) 缺页 —— vmstat 里 si/so 非零、或 major-fault 速率高? (4) fsync 卡 —— 磁盘级写延迟升高、或 WAL 本身报长 commit? 经常答案就是「共享主机上的吵闹邻居」。

加密在哪加延迟、在哪不加?

新连接上,TLS 握手在 TCP 握手之上是整一个 RTT(~30 ms)。 之后,每条记录的加密(带 AES-NI / 专用指令的 AES-GCM 或 ChaCha20-Poly1305) 每千字节单数微秒级,基本免费。代价是握手,不是持续加密。 复用连接,TLS 边际成本接近零。

挑一个你最可能被叫去生产里优化的阶段,讲讲它的空间。

数据库查询(阶段 10)。它是变化最大的单一阶段 —— 从 50 μs (buffer-pool 命中、整段在 L2 里的 B+tree 下降)到几百毫秒 (缓存未命中、等锁、全表扫描)。空间通常在索引(对的那条缺了)、 查询计划(你没想到的顺序扫描)、或隔离级别 (可串行化逼出超过你能承受的重试)。网络更难优化; 收益几乎总在数据库。

生产红旗

  • 「再加几个副本就行」。没量到延迟到底在哪,水平扩展只是 把同样浪费的握手和 fsync 等候乘以 N。先 profile 再扩容。
  • 在请求 handler 里把外部 HTTP 调用当便宜。每个请求同步 DNS + TCP + TLS 握手烧 60–90 ms。 如果你一次用户请求里要调 N 次,你就付 N 次。 复用连接、缓存 DNS、能批就批。
  • 每请求一条数据库连接、没池。每次查询都来一次新的 TCP + TLS 握手,这是连接池一行配置就能修的自伤。 PgBouncer-或类似规则。
  • 因为「请求够快」就跳过 commit 时的 fsync。有时是对的(缓存、日志);常常是只在唯一一台真断电的机器上才浮出来的丢数据 bug 的起点。 明确决定哪些写需要持久性。
  • 没分布式追踪。靠猜 20 层里哪层背锅来 debug 慢请求, 是生产工程里最大的单一生产力黑洞。每个层边界都打 span —— DNS、TCP、TLS、服务器入口、DB 查询、下游调用 —— 就把「时间花到哪了?」从几天的调查变成一屏。