跳转至

深入专题:一次请求如何穿过系统

这篇把网络、并发、连接池、数据库、缓存、超时和可观测性串成一条完整路径。它回答的不是“HTTP 是什么”,而是:

为什么一个看起来只有几十行代码的查询接口,到了生产环境会变慢、超时、占内存,甚至拖垮其他接口?

先看一个具体接口

用户打开设备列表:

GET /api/tenants/42/devices?limit=100&cursor=9000

页面需要:

  • 设备 ID、名称、型号;
  • 当前在线状态;
  • 最近告警数量;
  • 返回时间不超过 500ms。

应用代码看起来可能只有:

查数据库设备
→ 查 Redis 状态
→ 查告警
→ 组装 JSON

但真实执行链更长:

手机
→ DNS
→ TCP
→ TLS
→ FRP/Nginx
→ 应用连接
→ 身份认证
→ 权限检查
→ 数据库连接池
→ PostgreSQL
→ Redis 连接池
→ Redis
→ JSON 编码
→ 网络发送
→ 手机解析与渲染

每一段都消耗时间、连接、内存和并发额度。

第一层:网络不是瞬间传递

DNS

域名需要解析成地址。操作系统、浏览器和本地网络通常有缓存,但首次解析、缓存失效或 DNS 故障都会增加延迟。

TCP

建立新 TCP 连接至少需要握手。跨地域网络中,往返时间可能是几十到几百毫秒。

TLS

HTTPS 还需要协商加密。现代协议会复用连接和会话,但如果每次请求都新建连接,握手成本会放大。

连接复用

这就是 HTTP 客户端和数据库客户端都需要连接池的原因:

没有连接池:
每次请求 → 新建连接 → 握手 → 使用 → 关闭

有连接池:
预先维护少量连接 → 借用 → 使用 → 归还

连接池不是越大越好。下游数据库如果只能稳定处理 50 个活跃查询,应用开 500 个连接只会让竞争和上下文切换更严重。

第二层:请求在服务器里怎样并发

假设一秒到达 1,000 个请求,每个请求平均处理 200ms。

粗略并发量可以用 Little's Law 的直觉估算:

并发中的请求 ≈ 到达速率 × 平均停留时间
             ≈ 1000/s × 0.2s
             ≈ 200

如果下游变慢到 2 秒:

并发中的请求 ≈ 1000/s × 2s = 2000

流量没有变,但系统中同时存活的请求扩大十倍。每个请求都可能占用:

  • 一个 goroutine/协程;
  • 请求上下文;
  • 数据库或 Redis 等待;
  • 中间结果;
  • 日志和 Trace;
  • 响应缓冲。

所以延迟上升往往会进一步推高内存和并发,形成恶性循环。

第三层:连接池会成为显式队列

假设:

HTTP 并发:200
数据库连接池:30
每次 SQL:100ms

同一时刻最多 30 个查询真正进入数据库,其余请求在等待连接。

一个请求的数据库时间不是只有 SQL 执行时间:

数据库阶段耗时
= 等待连接
+ 网络
+ SQL 执行
+ 结果传输
+ 解码

如果只记录 SQL 100ms,却不记录等待连接 800ms,就会误以为数据库很快而接口莫名其妙地慢。

应该分别记录:

  • pool wait duration;
  • active/idle connections;
  • query duration;
  • rows returned;
  • timeout count。

第四层:一个页面为什么容易出现 N+1

错误实现:

查询 100 台设备
for 每台设备:
    查一次告警数量
    查一次 Redis 状态

这会产生:

1 次设备 SQL
+ 100 次告警 SQL
+ 100 次 Redis GET
= 201 次下游交互

即使每次只有 2ms,串行也可能超过 400ms,还没算排队和网络。

改进不是简单“全并发”:

并发 200 个下游请求

可能瞬间耗尽连接池。更合理:

  • 数据库按 device_id 批量聚合;
  • Redis 对本页设备 MGET/HMGET;
  • 告警数量预聚合或批量查询;
  • 页面只取需要字段。

第五层:应用内存到底占在哪里

查询 100 条数据时,应用可能同时持有:

  1. 数据库驱动的原始行;
  2. 转换后的领域对象;
  3. Redis 返回数组;
  4. 合并后的响应对象;
  5. JSON 编码缓冲;
  6. Web 框架的响应缓冲。

如果每条最终 JSON 1KB,100 条只有约 100KB;但对象和临时分配可能是最终 JSON 的数倍。

把数量扩大到十万条:

最终 JSON 约 100MB
临时对象和复制可能数百 MB
多个并发请求会继续相乘

因此分页首先保护的不是数据库,而是整条链路:

  • 数据库扫描和传输;
  • Redis 返回;
  • 应用内存;
  • JSON 编码;
  • 网络带宽;
  • 手机渲染。

第六层:超时怎样向下传播

假设用户请求总预算 500ms:

入口和认证:50ms
数据库:150ms
Redis:80ms
组装和发送:70ms
剩余安全余量:150ms

不能给数据库、Redis 各设置 500ms。否则串行最坏时间超过总预算。

使用 deadline:

请求剩余预算
→ 传给数据库
→ 传给 Redis
→ 传给外部服务

请求已经超时时,下游工作应尽快取消,避免用户已经离开,服务器还在消耗资源。

第七层:为什么重试会放大故障

数据库开始变慢,客户端 300ms 超时后立即重试三次:

原始 1000 请求/s
→ 最坏变成 3000 次尝试/s

下游本来已经过载,重试继续增加压力,形成重试风暴。

安全重试需要:

  • 只重试暂时性错误;
  • 指数退避;
  • 随机抖动;
  • 总次数限制;
  • 总时间预算;
  • 写操作幂等;
  • 熔断或降级。

完整故障推演

场景:Redis 延迟从 2ms 上升到 800ms。

第一步:直接影响

  • 状态查询等待;
  • API P95 延迟上升;
  • Redis 连接被长时间占用。

第二步:排队

  • 连接池耗尽;
  • 新请求等待连接;
  • 更多请求同时存活。

第三步:资源

  • 内存上升;
  • goroutine/协程上升;
  • 超时和取消增加;
  • 日志量增加。

第四步:重试

  • 客户端或应用重试;
  • Redis 压力进一步增加。

第五步:连带影响

如果设备列表和登录 Session 共用同一 Redis,登录也可能失败。

改进

  • 不同业务设置独立连接池或实例边界;
  • 状态查询设置短超时;
  • 允许状态显示为 unknown,而不是拖垮整个列表;
  • 限制重试;
  • 监控 pool wait 和 Redis P95;
  • 为核心认证保留容量。

怎样定位一次慢请求

从入口 Trace 开始:

HTTP total 1240ms
├─ auth 18ms
├─ PostgreSQL pool wait 310ms
├─ PostgreSQL query 95ms
├─ Redis pool wait 420ms
├─ Redis MGET 360ms
└─ encode 37ms

结论不是“整个接口慢”,而是:

  • 两个连接池都在等待;
  • Redis 延迟异常;
  • SQL 本身并不慢。

深度自测

  1. 为什么下游从 200ms 变成 2s,流量不变也会让内存上升?
  2. 数据库连接池从 30 调到 300,为什么可能更慢?
  3. 分页为什么同时保护数据库、应用和手机?
  4. N+1 为什么不能只用无限并发修复?
  5. 为什么各下游超时之和必须小于总请求预算?
  6. Redis 变慢为什么可能影响登录?
参考答案要点
  1. 请求在系统停留更久,同时存活数量按到达率乘停留时间增加。
  2. 数据库的 CPU、IO 和锁容量没有同步增加,更多连接只会排队和竞争。
  3. 它限制扫描、传输、临时对象、JSON、带宽和前端渲染的全链路数据量。
  4. 无限并发会把压力集中到连接池和下游;应批量查询并控制并发。
  5. 总预算还包括入口、排队、编码、网络和安全余量。
  6. 如果共用连接池、线程池或 Redis 实例,局部故障会耗尽共享资源。

达到“理解”而不是“看过”

  • 能画出设备列表请求的完整链路
  • 能计算到达率、延迟和并发量的关系
  • 能指出连接池等待与 SQL 执行的区别
  • 能解释 N+1、分页和批量读取
  • 能推演 Redis 变慢后的连锁反应
  • 能给出限时、限流、重试和降级策略