高级后端 · 性能分析总纲

时间到底花在哪里: 请求延迟 = On-CPU + Off-CPU — 指标找方向, 链路找段, 画像找行, 分析就是把这本时间账单拆清楚

一次请求的 200ms 时间账单 P99 视角 user 40 sys 15 DB 等待 80ms 锁 30 队列 25 网络5 On-CPU 55ms (在算) Off-CPU 140ms (在等) 总延迟 = On-CPU + Off-CPU + 网络传输; 性能分析 = 给每一毫秒记账 延迟 100ms → 2s, QPS 不变时 (Little's Law): 并发 = 1000×0.1 = 100 → 1000×2 = 2000, 池子瞬间打满 → 连接池等待↑ → 超时 → 重试 → 雪崩 (延迟放大并发的根源) 网络传输 On-CPU user On-CPU sys DB/下游等待 锁等待 队列/调度 其他 诊断漏斗: 从现象到根因, 逐层缩小 ① Metrics 指标 — 哪里出问题? Grafana: P99 120ms → 4s, QPS 没变, 错误率 0 ② Trace 链路 — 哪一段出问题? Trace: db.query span 占 3.8s / 总 4s ③ Profile 画像 — 哪一行代码? pprof: sqlkit.Scan 40% / json.Marshal 15% 推理边: 现象 → 假设 → 指标验证 → 工具定位 → 根因; 不要随机敲命令 利用率 vs 延迟: 排队后不是线性 延迟 95% 后爆炸 排队区 50% 70% 85% 95% 100% 资源利用率 → 现象 → 假设 的条件反射 (六问) CPU 高 + 延迟高 → 计算 / GC / 序列化 (On-CPU) CPU 低 + 延迟高 → 在等: IO / 锁 / 网络 / 连接池 (Off-CPU) P50 正常 + P99 高 → 长尾: 竞争 / 缓存 miss / 重传 QPS 不变 + in-flight 涨 → 延迟在涨 (Little's Law: L = λW) 只有一个实例异常 → 实例级故障 / 流量倾斜 / 热点 队列持续上涨 → 消费能力 < 生产能力, 迟早爆 永远先问: 时间花在哪里 / 谁在等什么 — 而不是"哪个指标高就修哪个"

四个核心指标

  • • Latency: 看分位数 P50/P95/P99/Max, 不看平均
  • • Throughput: 吞吐高 ≠ 健康, 必须与延迟同看
  • • Concurrency ≈ QPS × Latency (Little's Law)
  • • Saturation: 利用率高不危险, 饱和才危险

两套方法论框架

  • • 服务层用 RED: Rate / Errors / Duration
  • • 资源层用 USE: Utilization / Saturation / Errors
  • • 适用对象: HTTP/RPC/DB/Redis/MQ → RED
  • • CPU/内存/磁盘/网络/池 → USE

三层定位与两大定律

  • • Metrics 找方向 → Trace 找段 → Profile 找行
  • • On-CPU 解释"在算什么", Off-CPU 解释"在等什么"
  • • Amdahl: 永远优化耗时占比最大的那段
  • • 排队论: util 85% 后延迟非线性起飞

💡 一句话理解

性能分析像医院分诊: P99 报警是"病人发烧", 它只告诉你有问题, 不告诉你病因; 你要按 指标 → 链路 → 画像 三层漏斗逐层缩小, 先分清是"整体变慢"还是"长尾拖垮", 再分清是"在算"(On-CPU) 还是"在等"(Off-CPU)。所有性能问题最终都归到一本时间账单: 这 200ms 里, CPU 算了多久, 等锁多久, 等 DB 多久, 在队列里排了多久 — 高手和新手的差距, 就是能不能把这本账拆清楚, 而不是"看到 CPU 高就加机器"。

🧠 必知必会 必考 & 必会

Latency 与分位数
延迟永远用分位数评估: P50 是"典型用户", P99 是"最惨的 1%"。平均值会被极端值劫持, 也会把极端值抹平。
lat = [10]*99 + [10000]         # 99 个 10ms + 1 个 10s
mean = sum(lat)/len(lat)      # → 109.9ms 平均"看起来还行"
p99  = sorted(lat)[98]        # → 10ms  P99 也看不见它
max  = max(lat)               # → 10000ms 只有 Max 暴露真相
分位数组合读法
单看一个分位数没有意义, 三条线放一起才能分诊: 整体变慢 / 长尾问题 / 极端异常, 三种病三种药。
# P50↑ P95↑ P99↑  → 整体变慢: 容量 / 依赖变慢 / 发布回归
# P50 平 P99↑     → 长尾: 锁竞争 / 缓存miss / GC / 重传
# P99 平 Max↑     → 极端异常: 单实例故障 / 个别超大请求
Throughput 吞吐
单位时间处理的工作量 (QPS/TPS/MB·s)。吞吐必须和延迟一起读: 吞吐高 + 延迟爆炸 = 系统在硬撑, 随时崩。
# QPS = 10000, P99 = 10s  → 吞吐亮眼, 系统半死
# 健康判断三件套: 吞吐 + 延迟分位数 + 错误率, 缺一不可
Concurrency 与 Little's Law
L = λW: 系统平均并发 = 到达率 × 平均耗时。延迟恶化时 QPS 不变并发也暴涨, 这是"突然雪崩"的数学根源。
L = 1000 * 0.1    # 1000 QPS × 100ms → 常态并发 100
L = 1000 * 2.0    # 延迟恶化到 2s → 并发 2000, 池子打满
Saturation 饱和
资源有多接近极限: runnable 队列堆积、连接池 wait、队列积压。利用率 (utilization) 高只是忙, 出现排队 (saturation) 才是危险信号。
$ vmstat 1
# r=40 b=0  us=80  wa=0  → 8核 runnable=40: CPU 饱和排队
# r=2  b=25 us=10  wa=70 → b=25: 进程在等 IO, 磁盘饱和
RED (服务层)
每个服务/接口/依赖调用都该有 Rate、Errors、Duration 三件指标, 覆盖 HTTP、RPC、DB query、Redis、MQ 消费、外部 API。
# Rate:     rate(http_requests_total[5m])
# Errors:   sum(rate(http_requests_total{code=~"5.."}[5m])) / rate(...)
# Duration: histogram_quantile(0.99, rate(..._bucket[5m]))
USE (资源层)
每种资源问三句: 利用率多高 (Utilization)、有没有排队 (Saturation)、有没有报错 (Errors)。资源问题用 USE, 服务问题用 RED, 脑子里要自动切换。
# CPU:  util=1-idle   sat=vmstat r      err=dmesg mce
# Disk: util=%util    sat=aqu-sz        err=/proc/diskstats
# Net:  util=带宽     sat=retrans/drop  err=errors
平均值陷阱
平均值是集群最大的骗子: 聚合 CPU 50% 可能是 A=100% B=30% C=20%; 聚合延迟 200ms 可能藏着 pod-3 的 5s。永远看分布、看 max、看 per-instance。
# 集群平均 CPU 50%:
#   pod-A 100% ← 冒烟   pod-B 30%   pod-C 20%
# 正解: sum by (instance) (...) 拆开看 + max_over_time 兜底
Tail Latency 长尾
P50 50ms / P95 100ms / P99 2s = 少数请求非常慢。成因: GC、锁竞争、池等待、缓存 miss、慢 SQL、重传、第三方抖动。扇出会放大长尾。
p_ok = 0.99          # 单个下游 P99 达标
all  = p_ok ** 20    # 扇出 20 个 → 整体 P99 达标率 0.818
Amdahl's Law
优化收益 = 该段耗时占比。DB 占 90% 时把 JSON 优化 10 倍, 整体几乎不动。动手前先 profile 占比, 永远打最大头。
total, json_pct = 100, 1   # DB 占 90, JSON 占 1
new = 100 - 1 + 1/10      # → 99.1ms: JSON 快 10 倍也白搭
排队论拐点
utilization 接近 100% 时延迟不是线性涨而是爆炸 (M/M/1: W = s/(1−ρ))。所以容量水位线定在 70~80%, 不是 95%。
# W = s/(1-ρ), s=20ms:
# ρ=0.50 → 40ms   ρ=0.70 → 67ms   ρ=0.85 → 133ms   ρ=0.95 → 400ms
On-CPU / Off-CPU
请求时间 = On-CPU (在算: 业务/序列化/GC/压缩) + Off-CPU (在等: DB/网络/锁/磁盘/调度)。CPU 低但延迟高的答案几乎总在 Off-CPU 里。
# On-CPU:  perf record -F 99 -p PID -g -- sleep 30
# Off-CPU: offcputime-bpfcc -p PID 30   (线程没在 CPU 上时在等谁)
三件套分工
Metrics 告诉你"哪里异常", Trace 告诉你"哪一段耗时", Profile 告诉你"哪一行吃资源"。三者是一条流水线, 不是三选一。
# Grafana: P99 4s ↑            → Metrics: 哪里出问题
# Trace:   db.query span 3.8s   → Trace:   哪一段出问题
# pprof:   sqlkit.Scan 40%      → Profile: 哪一行代码

🏭 生产实战 real world

场景 1 · P99 报警了, 先分诊"整体变慢"还是"长尾拖垮"

告警只说明 P99 越线。先画 P50/P95/P99/Max 四条线对齐时间线, 再按 instance 拆, 5 分钟内把问题空间从"全系统"缩到一句话。

# 1) 分位数全景 (Prometheus): 四条线放同一张图
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

# 2) 变更对齐: 出事时间点附近有没有 deploy / 配置变更
ls -lt /opt/app/releases | head -3
sar -q -f /var/log/sa/sa26        # 历史负载, 对齐 10:00 deploy → 10:02 P99↑

# 3) 按 instance 拆: 只有一台高 → 单实例故障, 不是容量问题
topk(3, sum by (instance) (rate(http_requests_total[5m])))

分诊结论决定方向: 整体变慢查依赖与容量; 长尾查锁/GC/重传; 单实例查热点与倾斜。

场景 2 · 用 Little's Law 反推连接池与线程池大小

池子按"常态并发"设计是事故源: 延迟恶化 8 倍时并发也涨 8 倍, 池子瞬间打满。容量设计必须用"恶化后的并发"。

QPS, lat = 800, 0.15
inflight = QPS * lat              # 120 → 常态并发
pool     = int(inflight * 1.5)    # 180 → 池留 50% 余量

# 压测/事故推演: 依赖变慢 lat=0.8s 时
inflight = QPS * 0.8              # 640 → 必须能扛 640 并发
# 若池只有 180: 剩 460 个请求在池口排队 → wait 时间↑ → 超时 → 重试
# 结论: 池子是 backpressure 阀门, 宁可排队拒绝, 不可无限放大

上线后持续监控 pool.wait_count / wait_duration, 等待时间上涨比连接满更危险。

场景 3 · 给 Go API 埋 RED 三件套 (histogram + counter)

没有 RED 指标, 一切分诊都是空谈。一个中间件同时产出 Rate / Errors / Duration, 之后所有告警与画像都从这里来。

var reqDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{
    Name:    "http_request_duration_seconds",
    Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
}, []string{"route", "code"})

func wrap(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        d := &statusRecorder{ResponseWriter: w, code: 200}
        next.ServeHTTP(d, r)
        reqDur.WithLabelValues(r.URL.Path, strconv.Itoa(d.code)).
            Observe(time.Since(start).Seconds())  // Duration + (code→Errors) 一次埋好
    })
}

bucket 必须覆盖 SLO 边界 (如 P99 目标 200ms 就要有 0.2 桶), 否则分位数算不准。

场景 4 · USE 巡检表: 每类资源的 U/S/E 三问

把 USE 固化成巡检配置, 值班同学照单执行, 不靠记忆。每行一个资源, 三列分别是利用率、饱和度、错误的取数方法。

# perf-checklist.yaml — 值班巡检 / 事故 first-5-min
checks:
  cpu:    {util: "1 - mpstat idle",      sat: "vmstat r > 2*cores",  err: "dmesg mce"}
  memory: {util: "free available",       sat: "si/so 持续活跃",     err: "dmesg oom"}
  disk:   {util: "iostat %util",         sat: "aqu-sz 持续 > 1",    err: "io error"}
  net:    {util: "sar -n DEV 带宽",      sat: "retrans/drops 增长", err: "carrier errors"}
  pool:   {util: "active/max",           sat: "wait_count 增长",    err: "acquire timeout"}

巡检口径统一后, "CPU 80% 正不正常"这类争论变成查表: 看 sat 列, r 队列堆积才是真饱和。

场景 5 · Amdahl 落地: 先看 profile 占比再决定优化什么

"JSON 慢所以要换 protubuf"是拍脑袋。先采 CPU profile 看 flat 占比, 占比小的段优化十倍也是白干。

# 30 秒 CPU 画像, 看 flat 占比
go tool pprof -top http://svc:6060/debug/pprof/profile?seconds=30
#       flat   flat%   sum%        cum%
#      12.3s   45.2%  45.2%  encoding/json.Marshal   ← 大头, 优先
#       0.8s    3.1%  48.3%  strings.Builder         ← 先放放

# 同理 Python: py-spy top --pid 1234 看热点函数占比

把每段 cum% 记进优化清单按占比排序, 优化 45% 的段收益是 3% 段的 15 倍。

场景 6 · 用排队论给容量定水位线, 而不是拍 90%

告警线为什么是 70~80% 而不是 95%? 因为延迟随利用率指数起飞, 95% 时 P99 已经爆炸, 扩容来不及了。

def mm1_latency(util, s):
    # M/M/1 排队模型: W = s / (1 - ρ), ρ=utilization, s=服务时间
    return s / (1 - util)

s = 0.02                                  # 单请求服务时间 20ms
for u in (0.5, 0.7, 0.85, 0.95):
    print(u, round(mm1_latency(u, s)*1000), "ms")
# 0.5 → 40ms   0.7 → 67ms   0.85 → 133ms   0.95 → 400ms
# 结论: 85% 后延迟非线性起飞 → 告警/扩容线定 70~80%

把这条曲线贴进容量评审文档, 从此"CPU 90% 要不要扩"有了数学答案。

场景 7 · On-CPU / Off-CPU 双画像定位"CPU 低但延迟高"

CPU 15%、P99 5s 的服务, On-CPU profile 一无所获 — 因为时间都在等。切换 Off-CPU 画像, 等待点直接现形。

# 第一步: syscall 统计, 看线程在等什么
strace -c -p 1234 -o /tmp/st.txt && head -8 /tmp/st.txt
# futex   3800   92.1%    ← 92% 时间在等锁

# 第二步: off-CPU 画像, 按等待对象聚合
offcputime-bpfcc -p 1234 30 > /tmp/off.txt
# jdbc.Pool.Acquire   2800ms   ← 等连接池
# sync.Mutex.Lock      900ms   ← 等全局锁

排查链: CPU 低 → 先想"在等" → strace/futex 验证 → offcputime 定位等待对象 → 代码修复。

场景 8 · OpenTelemetry Trace 拆解一个 2s 请求

"用户说慢"到"哪一段慢"之间隔着一个 Trace。关键路径分段埋 span, 2 秒去哪了一目了然。

ctx, span := tracer.Start(ctx, "GET /orders")
defer span.End()

_, auth := tracer.Start(ctx, "auth")         // 10ms
_, dbq := tracer.Start(ctx, "db.orders")     // 100ms
_, rpc := tracer.Start(ctx, "rpc.price")     // 1.85s ← 元凶
_, ser  := tracer.Start(ctx, "serialize")   // 20ms

// Jaeger 里一眼看到: 2s 中 1.85s 在 rpc.price
// 下一步去 price 服务的 RED/USE, 继续往下钻

span 命名用资源动作规范 (db.orders / rpc.price), 跨团队才能对得上。

场景 9 · 告警规则按 instance 拆, 别让平均掩盖单实例故障

集群平均 P99 200ms 时 pod-3 已经 5s。平均值把事故藏到"总体还行"里, 告警必须按实例检查。

# prometheus-rules.yaml
- alert: InstanceP99High
  expr: |
    histogram_quantile(0.99,
      sum by (instance, le) (rate(http_request_duration_seconds_bucket[5m]))) > 1
  for: 5m
  labels: {severity: page}

# 配套: max_over_time(cpu{mode="idle"}[5m]) 找冒烟实例

同类地, CPU/内存/连接池告警全部加 by (instance), 热点与倾斜才藏不住。

场景 10 · 60 秒分诊脚本: 黄金 15 问的前 6 问固化成命令

事故头 5 分钟最值钱。把"现象→范围→机器层→变更"固化成脚本, 值班照跑, 输出直接贴事故频道。

#!/bin/bash
# triage.sh — 事故分诊前 6 问
echo "== Q1/Q2 哪个服务, 哪个 endpoint 慢 (从告警拿) =="
echo "== Q3 P50 还是 P99: 分位数四条线 =="
curl -s "http://prom:9090/api/v1/query?query=histogram_quantile(0.99,sum by (le)(rate(http_request_duration_seconds_bucket[5m])))"
echo "== Q4/Q5 所有实例还是部分 / 机器层总览 =="
uptime; vmstat 1 3; iostat -xz 1 3
echo "== Q6 最近发布/变更 =="
ls -lt /opt/app/releases | head -3

禁止事项写进脚本注释: 先别重启、先别扩容、先别改配置 — 那是在销毁证据。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 用平均值评估 API 性能 — 99 个 10ms 加 1 个 10s 平均 110ms"还行", 但真有人等了 10s. 原因: 平均值对极端值不敏感. 正解: P50/P95/P99 + Max 四件套。
# 错: avg = sum(lat)/len(lat)      # → 109.9ms, 10s 被抹平
# 对: p99 = quantile(lat, 0.99); mx = max(lat)
坑 2 · 吞吐高就当系统健康 — 10000 QPS + P99 10s 是半死不是健康. 原因: 吞吐与延迟是独立维度. 正解: 吞吐/延迟/错误率三线同看。
# 错: report(qps=10000)                      # "扛住了"
# 对: report(qps, p99, err_rate)            # 三件套齐全
坑 3 · Utilization 高就忙着扩容 — CPU 80% 但 r=4、P99 稳定, 其实健康. 原因: 利用率高≠饱和, 排队才是. 正解: 看 vmstat r / aqu-sz / pool wait。
# 错: cpu=80% → scale_out()          # 白花钱
# 对: sat = run_queue > 2*cores 判断后再扩
坑 4 · 只盯 P50 汇报 — P50 永远好看, 长尾全被藏住. 原因: 中位数天然忽略尾部一半请求. 正解: P50/P95/P99/Max 同图。
# 错: "P50 20ms, 很稳"           # P99 已 2s
# 对: dashboard: p50/p95/p99/max 四条线
坑 5 · Little's Law 单位混用 — QPS×ms 得出 100000"并发"吓自己, 或忘了换算秒. 原因: λ 与 W 单位不一致. 正解: 统一换成秒再乘。
# 错: L = 1000 * 100          # → 100000 (把 ms 当 s)
# 对: L = 1000 * 0.1          # → 100 并发
坑 6 · 优化耗占比小的段 — JSON 占 1% 优化 10 倍, 整体 100→99.1ms. 原因: Amdahl 定律, 收益=占比. 正解: 先 pprof -top 看占比排序再动手。
# 错: 优化 strings.Builder (3%)  # 收益≈0
# 对: 优化 json.Marshal (45%)   # 收益最大
坑 7 · 指标只画全局聚合 — 平均 CPU 50% 藏着 pod-3 100%. 原因: 聚合抹平了实例差异. 正解: 所有资源/延迟指标加 by (instance)。
# 错: avg(cpu) 全局一条线
# 对: cpu by (instance) + max_over_time 兜底
坑 8 · 告警阈值拍脑袋定 95% — 到 95% 时 P99 已暴涨, 扩容窗口没了. 原因: 忽略排队论拐点. 正解: 水位线定 70~80%, 留扩容时间。
# 错: alert: cpu > 0.95
# 对: alert: cpu > 0.75 and run_queue > cores
坑 9 · 出事先重启再说 — 重启把现场/内存画像/连接状态全销毁. 原因: 复位冲动. 正解: 先留证据 (sar / pprof / dmesg) 再处置。
# 错: systemctl restart app    # 证据没了
# 对: curl :6060/debug/pprof/heap > heap.pb.gz 再重启
坑 10 · Trace 全量采集 — QPS 10k 全采样, 存储成本爆炸还拖慢业务. 原因: 不分策略. 正解: tail sampling 保 errors/慢请求/稀有路由。
# 错: sampler: always_on
# 对: tail_sampling: [error=100%, latency>1s, default 1%]
坑 11 · 延迟问题只做 On-CPU profile — CPU 15% 时 CPU profile 里全是噪音. 原因: 时间在 Off-CPU. 正解: offcputime / strace -c / block profile。
# 错: 只跑 perf record            # 看不出"在等"
# 对: offcputime-bpfcc -p PID 30
坑 12 · 把"GC 频繁"当根因收工 — GC 频繁是症状, 元凶是分配方. 原因: 停在第一层解释. 正解: 追问谁在分配 (allocs profile)。
# 错: "调大 GOGC" 收工          # 下一波又爆
# 对: go tool pprof allocs → json.Decode 65%
坑 13 · histogram bucket 粒度太粗 — bucket 只有 0.1/1/10, P99=1s 没法分辨 200ms 和 900ms. 原因: 桶不覆盖 SLO. 正解: bucket 对齐目标分位数附近。
# 错: Buckets: [0.1, 1, 10]
# 对: Buckets: [.005,.01,.025,.05,.1,.25,.5,1,2.5,5,10]
坑 14 · rate() 窗口太短 — 1m 窗口在低流量下分位数剧烈抖动, 告警狂响. 原因: 样本不足. 正解: 用 5m 窗口平滑 + for 持续时间。
# 错: rate(http_requests_total[1m])
# 对: rate(http_requests_total[5m])  + for: 5m
坑 15 · 调用不带 timeout — 没有超时的调用是给未来事故存定期炸弹. 原因: 默认信任网络. 正解: connect/read/total 分层超时全显式。
# 错: http.Get(url)                 # 默认无 deadline
# 对: client{Timeout: 2s} + ctx deadline 传递
坑 16 · 压测只看平均 RPS — 压测报告 RPS 5000 就上线, 结果 P99 爆炸. 原因: 压测也必须记分布. 正解: 压测工具输出分位数 + 错误率。
# 错: wrk -t4 -c100 只看 Requests/sec
# 对: 加 --latency 看 p99, 配合服务端指标
坑 17 · 忽略 coordinated omission — 服务卡 5s 时压测端没按计划发请求, 测出的延迟比真实好看. 原因: closed-loop 同步发送. 正解: open-loop 按固定速率压, 记录计划内请求。
# 错: 收到响应才发下一个 (closed-loop)
# 对: 固定速率发压 + 记录"应发未发"的延迟
坑 18 · 指标单位命名混乱 — 一边 _seconds 一边 _ms, P99 面板差 1000 倍. 原因: 没有命名规范. 正解: 全司统一 _seconds (Prom 约定)。
# 错: api_latency_ms 与 api_latency_seconds 并存
# 对: 统一 *_seconds, 展示层再转 ms
坑 19 · 复盘没有时间线 — 只写"系统慢了 20 分钟", 说不清因果. 原因: 没对齐 deploy/流量/指标. 正解: 时间线逐条对齐发布与流量拐点。
# 错: "10:00~10:20 系统慢"
# 对: 10:00 deploy → 10:02 payload↑ → 10:03 CPU↑ → 10:05 P99↑
坑 20 · 说"慢"却说不出谁在等 — 每层都没有归属指标, 排查全靠猜. 原因: 没建"时间账单"视图. 正解: 每层埋 span/histogram, 慢=哪一段可量化。
# 错: "接口偶尔慢"           # 无归属
# 对: auth 5ms / db 80ms / rpc 1.85s 逐段可查