"接口慢"这三个字背后至少是四种互斥的原因:多绕了一段路、多握了一次手、丢了包在重传、上游排队。它们的修复手段彼此不通用——扩带宽治不了握手,加机器治不了丢包。所以网络架构的第一件事不是选组件,而是把延迟拆成能单独归因的段,再决定哪一段值得为它引入复杂度。
1. 延迟预算的五段拆法
| 段 | 典型量级 | 归因手段 | 常见误判 |
|---|---|---|---|
| DNS 解析 | 首次数十至上百分之一秒,缓存命中后趋近 0 | dig +trace、curl -w 的 time_namelookup |
只看平均值,忽略冷启动与 TTL 抖动 |
| TCP 握手 | 1 个 RTT | time_connect - time_namelookup |
把排队等 accept 算进"握手慢" |
| TLS 握手 | TLS 1.2 约 2 RTT,1.3 约 1 RTT,会话恢复可再省 | time_appconnect |
忘记证书链过大、OCSP 往返 |
| 服务端处理 | 应用自身 P50/P99 | 服务端埋点、Server-Timing |
用客户端总耗时倒推服务端 |
| 传输与序列化 | 与 payload 大小、带宽、压缩比相关 | 抓包看 bytes_in/out 与分片 |
把大响应体当"网络慢" |
最常被忽略的是第一段和最后一段:DNS 在迁移、灰度、多 CDN 场景下反复变化,却很少进监控;响应体大小极少被当成架构指标。
curl -s -o /dev/null -w \
'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} \
first=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' \
https://TARGET/health
五个数一摆,"慢在哪一段"就不再是猜。time_starttransfer - time_appconnect 才是应用时间。
2. 带宽与延迟是两件事
带宽管"一次能搬多少",延迟管"一次往返多久"。二者的判据完全不同:
| 症状 | 更可能是 | 判据 |
|---|---|---|
| 大文件下载慢、小请求快 | 带宽/拥塞窗口 | 吞吐随并发线性上升,单流速度受 BDP 限制 |
| 小请求也慢,且随距离增加 | 延迟/往返次数 | 单次请求耗时 ≈ RTT × 往返数 |
| 并发一上来就崩 | 连接数与排队 | accept 队列溢出、TIME_WAIT 堆积 |
| 首屏慢但接口快 | 资源数与串行依赖 | 请求瀑布图、关键路径长度 |
一个常被混淆的换算:吞吐 ≈ 窗口 / RTT。窗口没放大时,跨地域的吞吐上限由 RTT 决定,与买多大带宽无关。这也是"上专线不一定快"的原因——专线降低的是丢包与抖动,若窗口与并发没配,RTT 仍在那里。
BDP = 带宽 × RTT
例:100 Mbps × 40 ms ≈ 500 KB 在途数据
→ 单连接窗口小于 BDP 时,吞吐被窗口锁死,而不是被带宽锁死
3. 丢包与重传:1% 为什么比直觉严重
TCP 的丢包不是"重发一次就好"。拥塞控制把丢包当作拥塞信号:一旦判定丢包,窗口回退,之后靠慢启动爬回。经典 Reno 的近似吞吐关系是:
吞吐 ≈ (MSS / RTT) × C / sqrt(p) C 约 1~1.5,p 为丢包率
p 从 0.1% 升到 1%:sqrt 项放大 3 倍以上,吞吐下探近一个数量级
所以 1% 丢包不是"损失 1% 的包",而是整条连接的稳态吞吐掉一个档位,表现为"偶发几十毫秒的毛刺"和"高峰期整体变慢"。归因手段:
ss -ti dst TARGET # 看 retrans、cwnd、rtt、rto
nstat -az | grep -i retrans # 系统级重传计数
sar -n ETCP 1 5 # 每秒新增重传段
mtr -rwzbc 100 TARGET # 逐跳丢包与抖动(区分链路丢包与端上丢包)
判读要点:ss -ti 里 retrans 持续增长而 cwnd 上不去,是链路问题;cwnd 正常但 rtt 抖动大,是排队(bufferbloat)而不是丢包。
4. 代理链路的五类代价
| 代价 | 机制 | 表现 |
|---|---|---|
| 多一跳 | 每跳增加 1 个 RTT 与一次排队 | 内网 P99 抬升,长尾更明显 |
| 连接复用失效 | 代理到上游默认短连接 | 上游 TIME_WAIT 暴涨、握手开销翻倍 |
| TLS 终结位置 | 在代理终结则后端明文;透传则无法按路径路由 | 证书管理分散、mTLS 需重新设计 |
| 头部改写 | X-Forwarded-*、Host、Content-Length |
鉴权、限流、跳转地址错乱 |
| 超时叠加 | 每层各有一套超时 | 上游已返回 504,下游仍在等 |
反向代理不等于负载均衡。 代理解决的是"入口收敛、TLS 终结、路径路由";负载均衡解决的是"多实例分摊与健康剔除"。nginx 做反代时若没配 upstream 复用,它同时也是一个放大器:
upstream app {
server 10.0.0.11:8080;
server 10.0.0.12:8080;
keepalive 64; # 到上游的空闲长连接池
keepalive_requests 1000;
keepalive_timeout 60s;
}
location /api/ {
proxy_http_version 1.1; # 1.0 不支持 keep-alive
proxy_set_header Connection ""; # 清掉 close,否则复用失效
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
这三行(http_version、Connection ""、keepalive)缺任何一行,上游都会退化成短连接,代价体现在上游的连接数与时延上,而不是在代理自己的指标里。
5. CDN 的收益边界
CDN 的收益完全由缓存命中率决定,而不是由节点数量决定。命中率上不去时,CDN 只是给每个请求加了一跳。
| 命中率区间 | 净收益 | 说明 |
|---|---|---|
| 高(静态资源、指纹化文件名) | 显著降低源站带宽与 RTT | 收益主要来自就近与回源减少 |
| 中(带参数的列表接口) | 视 Vary 与查询串规范化而定 |
查询串一乱,命中率断崖 |
| 低(鉴权、个性化、实时库存) | 可能为负 | 多一跳 + 回源,且缓存一致性风险上升 |
动态内容的三条路:拆静态与个性化(边缘拼装)、鉴权下沉为短时令牌(Cache-Control: private)、或承认这类请求该走就近部署而非 CDN。Vary: Cookie 一类的头会把命中率打到接近 0,属于典型的"配了 CDN 但没生效"。
6. 跨机房与跨地域的三条路
| 路径 | 做法 | 一致性代价 | 适用 |
|---|---|---|---|
| 就近部署 | 每个地域一套完整服务 | 写冲突需仲裁或分区归属 | 读多写少、可接受最终一致 |
| 异步化 | 跨域只传事件,不传请求 | 延迟可见、需幂等与去重 | 可容忍秒级~分钟级延迟 |
| 数据本地化 | 数据跟着用户走,跨域只读副本 | 跨域读可能读到旧值 | 有数据驻留要求 |
三条路的共同点是都要在"读旧数据"和"等更久"之间选一个。声称"既强一致又不加延迟"的方案,通常是把延迟藏在了别处(写时同步、或读时补齐)。
7. 连接池与超时预算
代理前后,连接池的语义会变:客户端池化的是"到代理"的连接,代理池化的是"到上游"的连接,两段容量不匹配时,一段空闲一段排队。对齐原则:下游池容量 ≤ 上游可接受并发,且各级超时严格递减。
超时预算(自上而下必须递减,且留重试余量)
网关/客户端 总超时 3000 ms
└ 代理 read_timeout 2500 ms
└ 应用 单次下游调用 800 ms × 最多 3 个下游,串行时 ≤ 2400 ms
└ 重试:仅对幂等请求,最多 1 次,且计入总预算
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(300))
.version(HttpClient.Version.HTTP_2)
.executor(Executors.newFixedThreadPool(32))
.build();
// 单请求超时用 request.timeout(),不要只靠 connectTimeout
常见错误是把"重试"当免费:一次重试等于把下游压力乘 2,且在超时预算里占了两份时间。重试必须有预算上限,否则尾延迟会被自己放大。
8. 可观测的三层与"平均延迟骗人"
| 层 | 看什么 | 能回答 | 不能回答 |
|---|---|---|---|
| 网络层 | RTT、重传率、连接数、队列 | 链路是否健康 | 哪段代码慢 |
| 应用层 | P50/P95/P99、错误率 | 用户体验分布 | 慢在哪个下游 |
| 追踪 | span 时长与父子关系 | 慢点的具体归属 | 网络排队本身 |
延迟是长尾分布:P99 翻倍时平均值几乎不动,但受影响请求的比例已从 1% 变成 5%。判据是看分位不看均值,看分位的时间序列不看单点。
9. IPv6 / HTTP2 / HTTP3 的收益前提
| 技术 | 真实收益前提 | 不满足时 |
|---|---|---|
| IPv6 | 端到端可达、链路与中间设备支持 | 双栈复杂度上升,NAT64 引入新瓶颈 |
| HTTP/2 | 多路复用 + 头部压缩 + 单连接并发 | 队头阻塞在 TCP 层仍在;单大流仍抢占 |
| HTTP/3 | 丢包率偏高或移动网络、支持 UDP 通路 | 中间设备丢 UDP 时回退,收益归零 |
三者都属于"链路质量差时才显价值"的优化:内网低丢包、同机房调用时,收益通常小于运维复杂度。
排错
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 首字节慢、总耗时快 | 服务端处理或 accept 排队 |
看 time_starttransfer - time_appconnect |
上游 TIME_WAIT 暴涨 |
代理未复用上游连接 | 补 keepalive + Connection "" + HTTP/1.1 |
| 偶发几十 ms 毛刺 | 链路丢包触发重传/窗口回退 | ss -ti 看 retrans,mtr 定位跳 |
| 上了 CDN 但没变快 | 命中率低(Vary、查询串、私有头) |
看 CDN 命中率与回源比,拆分动静内容 |
| 超时层层叠加 | 各级超时未递减、重试翻倍 | 重写超时预算表,限制重试次数 |
| 鉴权/跳转地址错 | X-Forwarded-* 未传或 Host 被改写 |
显式设置转发头,应用侧信任白名单 |
边界
| 场景 | 不该引入的复杂度 | 理由 |
|---|---|---|
| 内网同机房、RTT 亚毫秒 | CDN、跨域就近、HTTP/3 | 收益在噪声以下 |
| 单体应用、单实例 | 多层代理与连接池调优 | 没有多跳就没有叠加 |
| 无跨地域需求 | 数据本地化与冲突仲裁 | 一致性代价换来不存在的收益 |
| 请求量极低 | 复杂缓存分层 | 命中率方差大,调试成本高于收益 |
| 团队无链路追踪能力 | 自建全链路采样 | 数据拿不到反而增加误判 |
自测
- 一次 HTTPS 请求的五个时间段里,哪一段最容易被误算进"服务端慢"?
- 为什么"加带宽"解决不了跨地域的小请求慢?
- 1% 丢包为什么会带来接近一个数量级的吞吐下探?
- nginx 反代到上游时,哪三行配置决定连接能否复用?
- CDN 命中率被打到接近 0 最常见的一个响应头是什么?
- 超时预算里,"留重试余量"具体指哪两件事?