Web 请求全链路 入门
20 篇 primer 把这摞栈搭起来。这一篇把它们串成一个故事:curl https://api.example.com/user/42 从敲键到 JSON 落 stdout。 5 个 section 把画面搭起来:布景 —— 一次 curl、~250 ms、20 层;trace —— 跨 5 个泳道的 12 个阶段、可交互的瀑布图、 每篇 primer 都在它的阶段交叉链接;延迟预算 —— 时间实际花到哪、复用能省掉什么;故障模式 —— 每层在哪坏;以及速查表。CS 基础系列的收官之作。
布景 —— 一次 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 都在它该出场的阶段被链到。」
Trace —— 20 篇 primer、一条时间线
一次请求、5 个泳道、12 个阶段。先翻瀑布图;然后我们用文字把每个阶段走一遍, primer 链接就嵌在里头,你可以随时跳进任何一层深入。
阶段 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 个子系统, 每个都是在假设其他都会做好自己工作的前提下设计出来的。」
延迟预算 —— 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 卡。」
故障模式 —— 每层最容易在哪坏
每一层都有自己最爱的出错方式。当你能把症状(超时、长尾、断连、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_WAIT60+ 秒的负载均衡器。看TCP 深潜和网络栈讲连接状态生命周期。 - 服务器 NIC RX 溢出。包来得比驱动排空环还快;
ethtool -S里rx_dropped可见。 TCP 重传最终能掩盖但长尾延迟飙天。看网络栈讲环形 buffer、系统调用与中断讲决定排空速率的 softirq 预算。 - App worker 被 CPU 压力饿。负载下(或共享主机上的失控邻居), 你 worker 在 runqueue 里坐几十毫秒。长尾延迟在所有handler 上均匀升 —— 典型迹象。看CPU 调度。
- 请求中缺页。内存压力把 worker 的栈或热数据页换出; 第一次摸是一次磁盘读(5-15 ms)。
vmstat里si/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 正是这张映射。」
速查表
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 查询、下游调用 —— 就把「时间花到哪了?」从几天的调查变成一屏。