14.1 网络性能测量与瓶颈定位
“接口很慢”不是可操作的问题。它没有说明慢的是哪些用户、哪个百分位、请求的哪个阶段,也没有区分失败请求与成功请求。性能优化的第一步不是改参数,而是建立一组能重复、能对照、能解释的测量。
本课目标
- 区分 latency、throughput、goodput、concurrency 与 loss;
- 正确解释 curl 的分阶段时间;
- 设计包含基线、变量和回滚条件的实验;
- 用百分位与错误率描述用户体验;
- 把症状收敛到 DNS、连接、TLS、首字节或传输阶段。
性能不是一个数字
| 指标 | 回答的问题 | 常见误区 |
|---|---|---|
| latency | 单次操作要等多久 | 只报平均值,隐藏长尾 |
| throughput | 单位时间完成多少工作或传多少字节 | 与 goodput 混用 |
| goodput | 真正有用的应用数据速率 | 忽略重传、首部和重复数据 |
| concurrency | 同时处于处理或等待中的操作数 | 直接等同于吞吐 |
| error/timeout rate | 有多少请求未成功完成 | 只统计成功请求的延迟 |
| saturation | 哪个资源接近容量边界 | 看到 CPU 不高就认定无排队 |
用户体验通常需要 p50、p95、p99 等分位数,而不是只有平均值。平均 100 ms 可能来自“所有请求约 100 ms”,也可能来自“99% 为 10 ms,1% 为 9.01 s”;两者的工程对策不同。
必须把超时和错误单独计入报告。如果性能脚本只对成功响应算分位数,系统越差、超时越多,剩下的成功样本反而可能显得更快。
请求时间由哪些部分组成
一个新的 HTTPS 请求可能经历:
name resolution
-> route/address selection
-> TCP or QUIC connection
-> TLS handshake
-> request queued/sent
-> edge/proxy/application/upstream work
-> first response byte
-> response body transfer连接复用后,DNS、TCP 和 TLS 可能不再发生;经过 HTTP proxy 时,还会多出到代理和代理到目标的阶段;HTTP/3 基于 QUIC,也不能套用“TCP 时间差”的解释。因此任何分解都要同时记录协议版本、是否复用、代理和测试位置。
用 curl 获取一份阶段快照
curl --disable --silent --show-error --output /dev/null \
--connect-timeout 3 --max-time 15 \
--write-out $'url=%{url_effective}\nremote_ip=%{remote_ip}\nhttp_version=%{http_version}\nresponse_code=%{response_code}\nnum_connects=%{num_connects}\nnum_redirects=%{num_redirects}\nnamelookup=%{time_namelookup}\nconnect=%{time_connect}\nappconnect=%{time_appconnect}\npretransfer=%{time_pretransfer}\nstarttransfer=%{time_starttransfer}\ntotal=%{time_total}\nsize_download=%{size_download}\nspeed_download=%{speed_download}\n' \
https://example.com/curl 的这些时间大多是从传输开始累计到某个事件的时间点,不是每阶段独立耗时。对于直连、无重定向、未复用的新 TCP + TLS 连接,可以近似计算:
DNS = time_namelookup
TCP after DNS = time_connect - time_namelookup
TLS after TCP = time_appconnect - time_connect
pre-first-byte = time_starttransfer - time_pretransfer
body after first = time_total - time_starttransfer这只是特定请求形态下的近似分解:
- redirect 会引入多个请求;同时记录
num_redirects; - proxy 会改变连接目标;
- connection reuse 可能令连接阶段接近零;
- 非 TLS 请求的
time_appconnect通常为零; - HTTP/3 的连接时间语义与 TCP 不同;
time_starttransfer包含请求发送、网络往返、代理和服务端处理,不能直接命名为“后端执行时间”。
先查看 curl --version,并以目标版本官方 man page 为准。--disable 放在首个选项位置以忽略默认 curl 配置文件;环境中的代理仍可能生效,实验记录要注明实际连接路径。此命令不会跟随 redirect,HTTP 4xx/5xx 默认也不导致 curl 返回非零退出码,必须结合响应状态判断。
一次测量不是基线
下面的脚本需要支持 %{json} 的 curl 7.70.0 或更高版本。它把传输成功且 HTTP 为 2xx 记为成功;若业务期待特定状态或响应内容,应另加相应检查。每轮输出原始指标与退出信息,失败时也保留可用阶段数据:
from __future__ import annotations
import json
import statistics
import subprocess
import time
FORMAT = "%{json}"
def sample(url: str, count: int = 10) -> None:
rows: list[dict[str, object]] = []
failures: list[str] = []
for run in range(count):
started_at = time.time()
completed = subprocess.run(
[
"curl", "--disable", "--silent", "--show-error",
"--output", "/dev/null", "--connect-timeout", "3",
"--max-time", "15", "--write-out", FORMAT, "--url", url,
],
text=True,
capture_output=True,
check=False,
)
record: dict[str, object] = {
"run": run,
"started_at": started_at,
"returncode": completed.returncode,
"stderr": completed.stderr.strip(),
}
try:
row = json.loads(completed.stdout)
except json.JSONDecodeError:
record["raw_write_out"] = completed.stdout
failures.append(f"run={run} invalid write-out; rc={completed.returncode}")
else:
record["metrics"] = row
code = int(row["http_code"])
if completed.returncode == 0 and 200 <= code < 300:
rows.append(row)
else:
failures.append(f"run={run} rc={completed.returncode} http={code}")
print(json.dumps(record, ensure_ascii=False))
time.sleep(0.2)
totals = [float(row["time_total"]) for row in rows]
print(f"success={len(rows)} failure={len(failures)}")
if totals:
print(f"median={statistics.median(totals):.6f}s")
print(f"min={min(totals):.6f}s max={max(totals):.6f}s")
for failure in failures:
print(failure)
if __name__ == "__main__":
sample("https://example.com/")这里用 curl 自带 JSON,避免连接失败的状态 000 被手拼成非法 JSON 数字。程序每轮启动独立 curl,连接和 TLS 会重新建立,系统 DNS cache 等状态仍可能复用;它不是连接复用基准,也没有控制缓存和后台流量。输出中位数仅覆盖成功样本,必须连同失败数阅读。十个样本不足以稳定描述长尾,因此不报告 p95。
串行脚本在一次请求变慢后会推迟下一次请求,负载也随之下降。容量测试还应明确是固定到达率还是固定并发模型,并记录实际发起速率;不能用这种轻量探针证明高峰承载能力。
冷路径与热路径必须分开
至少区分:
- 冷 DNS cache 与热 DNS cache;
- 新连接与连接复用;
- TLS full handshake 与 resumption;
- CDN miss、revalidated、hit;
- 应用冷启动与稳态;
- 小响应与大响应;
- 单请求与目标并发。
若把这些样本混在一起,分布变化可能只是在混合比例变化,而不是系统本身变快或变慢。
主动探测、真实用户与服务端追踪
三类观测互相补足:
| 方法 | 优点 | 缺口 |
|---|---|---|
| synthetic probe | 条件可控、可持续对照 | 覆盖不了全部用户网络和设备 |
| RUM | 接近真实用户分布 | 受浏览器 API、隐私、采样和客户端状态影响 |
| server trace/metrics | 能定位排队、应用与下游依赖 | 看不到全部客户端 DNS 和接入路径 |
当 curl 显示首字节慢,应使用 trace ID 把边缘、负载均衡、应用、数据库与外部依赖的时间拼起来。仅凭客户端 TTFB 不能断言“数据库慢”。
延迟、吞吐与并发的关系
Little's Law 在稳定系统中给出:
L = λWL:系统中平均并发请求数;λ:平均完成率;W:平均停留时间。
如果吞吐为 1000 req/s,平均停留 0.2 s,那么系统中平均约有 200 个请求。它不告诉你瓶颈具体在哪,也不保证系统处于稳态;但它能帮助检查容量数字是否自洽。三个量要使用同一系统边界、同一类请求和一致时间范围:不能把仅成功请求的完成率,与包含超时请求的在途数直接代入,也不能把平均停留时间换成 p99。
接近饱和时,排队延迟常会非线性增长。此时“再提高客户端并发”可能只会增加超时和重试,而不是增加有效吞吐。
一份实验记录应包含什么
hypothesis: 首字节长尾来自某个上游连接池排队
scope: region=..., endpoint=..., payload=...
baseline: p50/p95/p99, error rate, throughput, saturation
change: 只调整一个连接池上限
controls: 相同流量模型、数据集、协议和缓存状态
duration: 覆盖预热与稳态
success: p99 降低且错误率、下游饱和度不恶化
rollback: 超过指定错误率或资源阈值立即回滚
evidence: raw result、dashboard、trace、version、timestamp“改完感觉更快”无法复核,也无法在回归时定位变化。
本课小结
性能优化始于可重复测量。curl 的阶段时间是一处客户端视角,必须结合连接复用、协议、代理与服务端追踪解释。下一课从 RTT、带宽、窗口、丢包和队列出发,判断传输层到底限制了什么。
资料入口
- curl:write-out 变量与命令选项;
- RFC 2330:Framework for IP Performance Metrics;
- RFC 7312:Advanced Stream and Sampling Framework for IPPM;
- RFC 5481:Packet Delay Variation Applicability Statement。