跟着一个 API 请求走完全程:后端全链路解剖

写在前面

Nginx、Kestrel、Redis、数据库——单看每一层,这个博客都有专文讲过。但它们怎么串成一次真实的请求?这篇把所有层穿起来,跟着一个 GET /api/orders/123 走完全程。

每一跳只讲三件事:原理(链到已有专文,不重复展开)、大概多慢(链到《每个程序员都该知道的延迟数字》)、怎么优化。这是博客的"集大成"篇——读完你会看到这些独立的文章其实是一条线上的事。


场景

用户在 App 里点开一个订单详情,前端发:

1
2
GET https://api.example.com/orders/123
Authorization: Bearer <JWT>

后端要做:验 JWT → 查 Redis 订单缓存 → 没命中就查 DB → 订单里要显示用户名,可能调下游用户服务 → 返回 JSON。

一次请求,按本文模型拆成十个阶段(编号 0–9):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
浏览器 / App
   │  GET /orders/123 (JWT)
[DNS]  api.example.com → IP                              第 0 跳
[TCP + TLS 握手]   冷启动 2-3 RTT / 复用 0                第 1 跳
[四层 LB (LVS / 云 SLB)] ───▶ [Nginx 反代]               第 2-3 跳
                                 │ TLS 终止 / 路由 / 限流
                            [Kestrel]   epoll / IOCP     第 4 跳
                                 │ 中间件管线             第 5 跳
                            [Controller]
                              │        │
                    Redis ◀───┘        └──▶ 数据库       第 6-7 跳
                   (缓存命中)              (索引 / 磁盘 IO)
                              ▼  (可选)
                       [下游用户服务]                     第 8 跳
                       HttpClient + 可能穿 sidecar
                  JSON 返回 ▶ gzip ▶ TLS ▶ 客户端         第 9 跳

第 0 跳:DNS 解析

api.example.com 要先变成 IP。浏览器、OS 或本地解析器缓存命中通常很快;未命中时,递归解析器可能继续查询权威 DNS,耗时受缓存状态、网络路径和解析链影响,不能固定写成一个范围。

优化:DNS 预解析、合理 TTL、HTTPDNS(绕过运营商)。冷启动才痛,热请求基本免费。


第 1 跳:TCP + TLS 握手

TCP 三次握手 1 RTT;TLS 1.3 握手 1 RTT(1.2 是 2 RTT)。所以冷启动一个 HTTPS 请求要付 2–3 RTT,同机房就是 1–2ms,跨城就是几十 ms。

这一跳最重要的结论:连接复用通常是高杠杆优化。keep-alive / 连接池让后续请求复用已经建立的连接,避免重复支付 TCP 和 TLS 握手。TLS 1.3 会减少完整握手往返;0-RTT 只适用于会话恢复,并有重放风险,不能无条件用于有副作用的请求。

这正是《HttpClient 的前世今生》那篇的核心——new HttpClient() 每次新建连接,把本该复用的 TCP 连接主动掐断、端口踢进 TIME_WAIT,是 .NET 史上最著名的反模式之一。


第 2 跳:入口(CDN / 四层负载均衡)

静态资源走 CDN 就近返回(不进后端);动态 API 进四层负载均衡(LVS / 云 SLB / NLB),按 IP + 端口把包转发到某一台 Nginx。LB 本身 μs–ms 级,几乎不花钱。

优化:CDN 边缘缓存把静态挡在外面;LB 健康检查要及时摘掉挂掉的 Nginx,别把流量往死节点送。


第 3 跳:Nginx 反向代理

到七层入口 Nginx 了,它可以做四件事:TLS 终止路由(按 path / host 转发到对应上游)、限流limit_req)、可选的鉴权前置。TLS 是否在此终止、到上游是否重新加密,取决于信任边界和部署配置,不能默认上游一定走明文。

详见《Nginx 学习笔记(二):反向代理与负载均衡》《(三):HTTPS、性能优化与实战》。Nginx 自身处理是 ms 级。

优化:最容易忘的一条是 upstream keepalive——Nginx 到 Kestrel 也要复用连接,别每个请求都新建一次 TCP(又掉进第 1 跳的坑)。其次是 HTTP/2、gzip 压缩响应。


第 4 跳:Kestrel 接收(I/O 模型 + Pipelines + 线程池)

包到内核了。Kestrel 使用操作系统的异步/事件驱动 I/O 机制,并利用 System.IO.Pipelines 的池化缓冲区减少分配和不必要的复制,再把请求交给 ASP.NET Core 管线。异步 I/O 避免在等待网络时长期占用请求线程,但业务代码仍可能因同步阻塞、锁竞争或 CPU 密集工作占住线程。

这一跳的关键是避免在线程上同步等待慢 I/O:等待期间线程无法处理其他请求,负载升高时容易造成线程池排队。完整的 I/O 模型谱系(阻塞/非阻塞/IO多路复用/异步)见《操作系统学习笔记(一):I/O 模型全解》,.NET 视角的落地见《.NET 高并发编程全景》。

延迟:内核↔用户态拷贝 μs 级,几乎可以忽略。


第 5 跳:ASP.NET Core 中间件管线

请求流过中间件链:路由匹配 → 鉴权(验 JWT 签名和过期)→ 限流 → 日志 / 追踪埋点 → 进 Controller。这一段是纯 CPU,μs–ms 级。

详见《ASP.NET Core 学习笔记(一):基础架构与启动流程》(中间件管线的本质)。

优化:中间件顺序有讲究(能短路的尽量靠前,比如鉴权失败直接 401,别白跑后面的活);热路径上别 new 重对象、别做重活;依赖注入按需,别把瞬态服务当万金油。


第 6 跳:Redis 缓存层

Controller 先查 Redis(order:123)。命中 → 直接反序列化返回,跳过第 7 跳

详见《Redis 学习笔记(二):缓存实战》。同机房 GET 大约 ~0.1–0.3ms

优化:缓存 key 设计、过期 + 预刷新、防穿透(空值也缓存一层)、防击穿(热 key 互斥重建)、防雪崩(过期时间加随机抖动)。

这一跳命中省下的是第 7 跳的 ~ms–10ms,是全链路杠杆最大的一处。缓存命中率从 90% 提到 99%,DB 压力差 10 倍。


第 7 跳:数据库(缓存未命中)

Redis 没命中 → 查 DB。SELECT ... WHERE id = 123 走一遍:解析 → 优化器选执行计划 → 走索引(B+ 树)→ 命中 buffer pool 直接返回;没命中触发磁盘 IO。

  • buffer pool 命中:~0.5–2ms
  • 触发磁盘 IO:再 +1ms(SSD)/ +10ms(HDD)。

索引原理见《数据库系列(三):索引原理》,慢查询定位和 SQL 调优见《数据库性能优化实战》,《数据库系列(四):事务与 ACID》里给过 fsync 的实测对照(HDD ~10ms / SSD ~1ms / NVMe ~0.1ms)。

优化索引建对是 DB 性能的头号杠杆(一个该建没建的索引能让查询从 1ms 变 100ms);其次是连接池、慢查询监控、把随机写转顺序写(WAL)。绝大多数"接口慢"的根因在这一跳,不在代码里。


第 8 跳:下游服务调用(微服务场景)

订单要显示用户名,本地没有,得调用户服务:应用层 HttpClient 发请求 → 可能穿过 sidecar(Envoy / ztunnel)→ 网络 → 对端 sidecar → 对端应用。

延迟 = 网络往返 + 两端序列化 + 对端处理 + 可能的代理成本。代理会增加数据路径、CPU 和排队成本,但没有适用于所有环境的固定“每 hop 2–5ms”。Istio 官方基准明确指出,延迟取决于协议、连接数、请求率、负载、worker 数和启用的功能;应在自己的拓扑和策略下测 p50/p99。

优化

  • 并行调用——要调多个下游,用 Task.WhenAll 并发,别串行 await 三次(串行 vs 并行差的是整倍 RTT,见《.NET 高并发编程全景》);
  • 连接池(同第 1 跳,HttpClient 必须复用);
  • 缓存下游结果(用户名这种不常变的,缓存几分钟省一次跨服务调用);
  • 如果 sidecar 税痛,考虑 sidecar-less(Ambient / Cilium)。

第 9 跳:序列化 + 返回

对象 → JSON(中等对象 ~10–100μs)→ 原路返回:中间件收尾 → Kestrel 写响应 → Nginx gzip 压缩 → TLS 加密 → 客户端。

优化:压缩省带宽(但别 gzip 已经压过的图/视频);大响应用异步流,别一次性 buffer 进内存;字段精简,别返回前端用不上的列(既浪费序列化又浪费带宽)。


把全程延迟叠起来

下面只是展示如何列预算的示例假设,不是可直接套用的基准。真实系统要用 trace 分阶段测量,并分别观察 p50、p95 和 p99:

热请求冷启动 / 缓存未命中
DNS<1ms(命中)10–100ms
TCP + TLS0(连接复用)1–2ms(2–3 RTT)
LB + Nginx~1ms~1ms
Kestrel + 中间件~1ms~1ms
Redis(命中)~0.2ms
DB(未命中才走)~2–10ms
下游服务取决于 RTT 与对端处理取决于 RTT、握手与对端处理
序列化 + 返回~1ms~1ms
合计按各阶段实测相加按各阶段实测相加

核心洞察:总延迟是各跳之和,抓最贵的那几跳——几乎总是 DB 的磁盘 IO + 网路的 RTT。在一个 μs 级的 JSON 序列化上纠结,不如把一个 DB 查询的索引建对(省 10ms)、把一个跨服务调用并行化(省一个 RTT)。这就是延迟数字篇"把现实操作拆回原子项"方法论的全链路应用。


全链路可观测

这么多跳,出问题了怎么定位"慢在哪"?给每个请求一个 traceId,跨所有跳透传(HTTP header),每一跳是一个 span。OpenTelemetry 把它们串成一条 trace,一眼看到哪一跳吃了 80% 的时间。

没有全链路追踪,慢请求是个黑盒;有了它,瓶颈一目了然。详见《后端架构实战(七):可观测性——微服务的神经系统》。这是全链路的"运维面",和前面十跳的"原理面"同样重要。


小结

  • 一次请求可以拆成十个阶段(编号 0–9):DNS → TCP/TLS → LB → Nginx → Kestrel → 中间件 → Redis → DB →(下游服务)→ 序列化返回。不是每个部署都包含全部阶段。
  • 总延迟 = 各阶段之和,先抓实测大头:远程 I/O 和网络 RTT 经常占主要部分,但 CPU、锁竞争和排队在高负载下同样可能成为瓶颈。
  • 杠杆最大的两处:缓存命中(直接省掉 DB 那一跳)+ 连接复用(省掉握手那两跳)。
  • 微服务多一层通信成本:RTT、序列化、对端处理和代理都会进入预算,能安全并行的独立调用不要无谓串行。
  • 排障靠全链路 trace:traceId 贯穿所有跳,是"慢在哪"的答案。

单看每一层是知识点,把它们串成一次请求,才是系统。这也是这个博客写到一百多篇时,该有的那张"总图"。


参考资料

这篇是 capstone,主要交叉链接本博客的专题文章(见正文《》引用)。延迟量级全部来自《每个程序员都该知道的延迟数字》。想看跨层的系统性视角,推荐《Designing Data-Intensive Applications》(Martin Kleppmann)。