深入专题:一次请求如何穿过系统¶
这篇把网络、并发、连接池、数据库、缓存、超时和可观测性串成一条完整路径。它回答的不是“HTTP 是什么”,而是:
为什么一个看起来只有几十行代码的查询接口,到了生产环境会变慢、超时、占内存,甚至拖垮其他接口?
先看一个具体接口¶
用户打开设备列表:
页面需要:
- 设备 ID、名称、型号;
- 当前在线状态;
- 最近告警数量;
- 返回时间不超过 500ms。
应用代码看起来可能只有:
但真实执行链更长:
手机
→ DNS
→ TCP
→ TLS
→ FRP/Nginx
→ 应用连接
→ 身份认证
→ 权限检查
→ 数据库连接池
→ PostgreSQL
→ Redis 连接池
→ Redis
→ JSON 编码
→ 网络发送
→ 手机解析与渲染
每一段都消耗时间、连接、内存和并发额度。
第一层:网络不是瞬间传递¶
DNS¶
域名需要解析成地址。操作系统、浏览器和本地网络通常有缓存,但首次解析、缓存失效或 DNS 故障都会增加延迟。
TCP¶
建立新 TCP 连接至少需要握手。跨地域网络中,往返时间可能是几十到几百毫秒。
TLS¶
HTTPS 还需要协商加密。现代协议会复用连接和会话,但如果每次请求都新建连接,握手成本会放大。
连接复用¶
这就是 HTTP 客户端和数据库客户端都需要连接池的原因:
连接池不是越大越好。下游数据库如果只能稳定处理 50 个活跃查询,应用开 500 个连接只会让竞争和上下文切换更严重。
第二层:请求在服务器里怎样并发¶
假设一秒到达 1,000 个请求,每个请求平均处理 200ms。
粗略并发量可以用 Little's Law 的直觉估算:
如果下游变慢到 2 秒:
流量没有变,但系统中同时存活的请求扩大十倍。每个请求都可能占用:
- 一个 goroutine/协程;
- 请求上下文;
- 数据库或 Redis 等待;
- 中间结果;
- 日志和 Trace;
- 响应缓冲。
所以延迟上升往往会进一步推高内存和并发,形成恶性循环。
第三层:连接池会成为显式队列¶
假设:
同一时刻最多 30 个查询真正进入数据库,其余请求在等待连接。
一个请求的数据库时间不是只有 SQL 执行时间:
如果只记录 SQL 100ms,却不记录等待连接 800ms,就会误以为数据库很快而接口莫名其妙地慢。
应该分别记录:
- pool wait duration;
- active/idle connections;
- query duration;
- rows returned;
- timeout count。
第四层:一个页面为什么容易出现 N+1¶
错误实现:
这会产生:
即使每次只有 2ms,串行也可能超过 400ms,还没算排队和网络。
改进不是简单“全并发”:
可能瞬间耗尽连接池。更合理:
- 数据库按 device_id 批量聚合;
- Redis 对本页设备 MGET/HMGET;
- 告警数量预聚合或批量查询;
- 页面只取需要字段。
第五层:应用内存到底占在哪里¶
查询 100 条数据时,应用可能同时持有:
- 数据库驱动的原始行;
- 转换后的领域对象;
- Redis 返回数组;
- 合并后的响应对象;
- JSON 编码缓冲;
- Web 框架的响应缓冲。
如果每条最终 JSON 1KB,100 条只有约 100KB;但对象和临时分配可能是最终 JSON 的数倍。
把数量扩大到十万条:
因此分页首先保护的不是数据库,而是整条链路:
- 数据库扫描和传输;
- Redis 返回;
- 应用内存;
- JSON 编码;
- 网络带宽;
- 手机渲染。
第六层:超时怎样向下传播¶
假设用户请求总预算 500ms:
不能给数据库、Redis 各设置 500ms。否则串行最坏时间超过总预算。
使用 deadline:
请求已经超时时,下游工作应尽快取消,避免用户已经离开,服务器还在消耗资源。
第七层:为什么重试会放大故障¶
数据库开始变慢,客户端 300ms 超时后立即重试三次:
下游本来已经过载,重试继续增加压力,形成重试风暴。
安全重试需要:
- 只重试暂时性错误;
- 指数退避;
- 随机抖动;
- 总次数限制;
- 总时间预算;
- 写操作幂等;
- 熔断或降级。
完整故障推演¶
场景: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 本身并不慢。
深度自测¶
- 为什么下游从 200ms 变成 2s,流量不变也会让内存上升?
- 数据库连接池从 30 调到 300,为什么可能更慢?
- 分页为什么同时保护数据库、应用和手机?
- N+1 为什么不能只用无限并发修复?
- 为什么各下游超时之和必须小于总请求预算?
- Redis 变慢为什么可能影响登录?
参考答案要点
- 请求在系统停留更久,同时存活数量按到达率乘停留时间增加。
- 数据库的 CPU、IO 和锁容量没有同步增加,更多连接只会排队和竞争。
- 它限制扫描、传输、临时对象、JSON、带宽和前端渲染的全链路数据量。
- 无限并发会把压力集中到连接池和下游;应批量查询并控制并发。
- 总预算还包括入口、排队、编码、网络和安全余量。
- 如果共用连接池、线程池或 Redis 实例,局部故障会耗尽共享资源。
达到“理解”而不是“看过”¶
- 能画出设备列表请求的完整链路
- 能计算到达率、延迟和并发量的关系
- 能指出连接池等待与 SQL 执行的区别
- 能解释 N+1、分页和批量读取
- 能推演 Redis 变慢后的连锁反应
- 能给出限时、限流、重试和降级策略