跳到内容

第 4 卷 / 第 14 章 / 第 1 课

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 请求可能经历:

text
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 获取一份阶段快照 ​

bash
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 连接,可以近似计算:

text
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 记为成功;若业务期待特定状态或响应内容,应另加相应检查。每轮输出原始指标与退出信息,失败时也保留可用阶段数据:

python
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 在稳定系统中给出:

text
L = λW
  • L:系统中平均并发请求数;
  • λ:平均完成率;
  • W:平均停留时间。

如果吞吐为 1000 req/s,平均停留 0.2 s,那么系统中平均约有 200 个请求。它不告诉你瓶颈具体在哪,也不保证系统处于稳态;但它能帮助检查容量数字是否自洽。三个量要使用同一系统边界、同一类请求和一致时间范围:不能把仅成功请求的完成率,与包含超时请求的在途数直接代入,也不能把平均停留时间换成 p99。

接近饱和时,排队延迟常会非线性增长。此时“再提高客户端并发”可能只会增加超时和重试,而不是增加有效吞吐。

一份实验记录应包含什么 ​

text
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、带宽、窗口、丢包和队列出发,判断传输层到底限制了什么。

资料入口 ​

Built with VitePress | Software Systems Atlas