02 · 性能工程:从观测到调优
“如果你不能测量它,你就不能改进它。“——但更重要的是:如果你测错了地方,你会改进错的东西。
开场:一次”成功”的优化
小林:我优化了服务的热点函数,用 pprof 看到它占了 30% CPU,我把它从 O(n²) 优化成了 O(n log n)。
老陈:效果如何?
小林:……P99 从 45ms 变成了 44ms。几乎没变。
老陈:为什么?
小林:……不明白。CPU 降了 10% 啊。
老陈:我问你几个问题: ① 你的目标是什么?
小林:降低延迟。
老陈:② 延迟由什么构成?
小林:……处理时间?
老陈:排队论告诉我们:延迟 ≈ 排队时间 + 处理时间。当 CPU 使用率是 30% 时,哪一项占主导?
小林:……处理时间?
老陈:错。CPU 使用率 30%,说明大部分时间 CPU 是闲的,那请求在等什么?
小林:…………等 IO?
老陈:对!你的服务是 IO 密集型的。优化 CPU 只能改善 30% 里的那部分,而且如果 CPU 不是瓶颈,排队时间几乎不变。
这就是我要教你的第一课:先确定瓶颈在哪个资源上,再决定用什么工具。
第一部分:USE 方法
什么是 USE
Brendan Gregg(Netflix 性能工程师,《Systems Performance》作者)提出的方法论:
对于每一个资源,检查三个指标:
- Utilization(使用率):资源有多少时间在忙?
- Saturation(饱和度):有多少工作在排队?
- Errors(错误):有没有错误发生?
为什么要三个都看
只看使用率的陷阱:
场景:磁盘使用率 100%
→ 看起来满了?
→ 但如果是顺序大块 IO,100% 使用率可能是"高效"
→ 如果是随机小块 IO,可能早就卡死了
★ 使用率是"过去一段时间的平均值",不反映排队情况
正确:
使用率 + 饱和度一起看
饱和度(队列长度、等待时间)才是延迟的直接来源
资源清单
| 资源 | Utilization | Saturation | Errors |
|---|---|---|---|
| CPU | top 的 %us+%sy | load average / runq-sz | 无 |
| 内存 | 已用/总量 | si/so(swap 换入换出) | OOM Kill 次数 |
| 磁盘 IO | iostat 的 %util | await、avgqu-sz | 设备错误 |
| 网络 | 带宽/总带宽 | 重传率、队列丢包 | netstat -i 的错误 |
| 锁 | 持有时间 | 等待队列长度 | 死锁 |
| 线程池 | 忙碌线程/总线程 | 队列积压 | 拒绝数 |
USE 的实际应用:一个决策树
延迟高
│
├─ CPU 饱和?(runq 有积压,load 高)
│ ├─ 是 → 优化 CPU(算法、减少分配、SIMD)
│ └─ 否 ↓
│
├─ 内存饱和?(swap 活跃,major fault 多)
│ ├─ 是 → 减少内存占用 / 加内存
│ └─ 否 ↓
│
├─ IO 饱和?(await 高,队列长)
│ ├─ 是 → 优化 IO(批量、异步、缓存、换 SSD)
│ └─ 否 ↓
│
├─ 网络问题?(重传、丢包、延迟)
│ ├─ 是 → 优化网络
│ └─ 否 ↓
│
└─ ★ 那瓶颈是"等待"——可能是锁、channel、
下游服务、或者纯粹是排队( Little's Law)
老陈:这个决策树看起来简单,但大多数人跳过了它。
他们看到”延迟高”就直接去优化代码。这跟”看到身体不舒服就吃止痛药”一样——可能暂时缓解,但没找对病因。
第二部分:性能工具全景
工具分层
┌─────────────────────────────────────────────────┐
│ 第 5 层:应用级 │
│ · Go pprof (CPU / heap / goroutine / block) │
│ · 业务指标、链路追踪 │
├─────────────────────────────────────────────────┤
│ 第 4 层:语言运行时 │
│ · GODEBUG=gctrace=1 │
│ · go tool trace │
│ · runtime/trace │
├─────────────────────────────────────────────────┤
│ 第 3 层:动态追踪 ★ 最强大 │
│ · eBPF (bcc / bpftrace) │
│ · perf probe │
│ · SystemTap │
├─────────────────────────────────────────────────┤
│ 第 2 层:性能计数器 │
│ · perf stat / perf record │
│ · 硬件 PMU 事件(cache miss、TLB miss) │
├─────────────────────────────────────────────────┤
│ 第 1 层:基础工具 │
│ · top / vmstat / iostat / sar │
│ · /proc 文件系统 │
└─────────────────────────────────────────────────┘
★ 原则:从下往上排查
先用第 1 层快速定位"哪个资源有问题"
再用第 3-5 层找"具体是哪行代码"
第 1 层:60 秒快速诊断
Brendan Gregg 的 “Linux Performance Analysis in 60 seconds”:
#!/bin/bash
# 60 秒诊断脚本
# 1. 系统负载(看饱和度)
uptime
# 看 load average:如果远高于 CPU 核数,说明有排队
# 2. 内核消息(OOM、网络错误等)
dmesg | tail -20
# 3. CPU 整体
vmstat 1
# 关键列:
# r 运行队列长度 ★ 如果 > CPU 核数,说明 CPU 饱和
# b 阻塞(等 IO)的进程数
# si/so swap 换入换出 ★ 非 0 说明内存压力
# us/sy/id/wa/st CPU 时间分布
# 4. 每个 CPU 核
mpstat -P ALL 1
# 看是否有某个核特别忙(可能是中断集中或者绑核问题)
# 5. 进程级 CPU
pidstat 1
# 找出 CPU 占用最高的进程
# 6. 磁盘 IO
iostat -xz 1
# 关键列:
# %util 设备使用率
# await IO 平均等待时间(毫秒)★
# avgqu-sz 平均队列长度 ★
# r/s w/s 读写次数
# 7. 内存
free -m
# 看 available(不是 free!)
# 8. 网络
sar -n DEV 1
# 看各网卡的吞吐量、包速率
sar -n TCP,ETCP 1
# 关键:
# retrans/s 重传率 ★
# active/s passive/s 连接建立速率
# 9. 系统概览
top第 2 层:perf —— 硬件计数器
# 基础:统计各种事件
perf stat ./your_program
# 输出示例:
# Performance counter stats for './your_program':
#
# 10,234.56 msec task-clock # 1.002 CPUs utilized
# 1,234 context-switches # 0.121 K/sec
# 45 cpu-migrations # 0.004 K/sec
# 12,345 page-faults # 0.001 M/sec
# 32,123,456,789 cycles # 3.138 GHz
# 45,678,901,234 instructions # 1.42 insn per cycle
# 5,678,901,234 branches
# 56,789,012 branch-misses # 1.00% of all branches
#
# 10.234567890 seconds time elapsed
# ★ 关键指标:
# insn per cycle (IPC): 每个时钟周期执行多少条指令
# 现代 CPU 理论上限约 4
# 如果 < 1,说明大量时间在等待(内存/分支预测失败)
#
# branch-misses %: 分支预测失败率
# > 5% 说明分支预测是瓶颈更深入的计数器:
# 缓存命中率
perf stat -e cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses \
./your_program
# TLB
perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses \
./your_program
# 内存带宽
perf stat -e LLC-loads,LLC-load-misses,LLC-stores,LLC-store-misses \
./your_program读取性能:
# 采样,生成火焰图
perf record -F 99 -g --call-graph dwarf ./your_program
# -F 99 每秒采样 99 次(避免与某些频率共振)
# -g 记录调用栈
# --call-graph dwarf 用 DWARF 调试信息展开栈(Go 需要这个)
# 查看
perf report
# 生成火焰图
perf script | ./FlameGraph/stackcollapse-perf.pl | ./FlameGraph/flamegraph.pl > out.svg第 5 层:Go pprof
import (
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ...
}五种 profile:
| Profile | 看什么 | 何时用 |
|---|---|---|
| cpu | CPU 时间花在哪 | CPU 使用率高 |
| heap | 内存分配热点 | 内存占用高 / GC 频繁 |
| goroutine | 所有 goroutine 的栈 | 泄漏、死锁 |
| block | 阻塞在同步原语上 | 并发度不够 |
| mutex | 锁竞争 | CPU 不高但延迟高 ★ |
# CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
(pprof) top # 最耗 CPU 的函数
(pprof) list funcName # 看具体行
(pprof) web # 生成调用图
# 内存
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
# -alloc_space: 总共分配了多少(包括已回收)
# -inuse_space: 当前还占着多少
(pprof) top
(pprof) list funcName
# ★ 锁竞争(需要提前开启)
# 在代码里:
# runtime.SetMutexProfileFraction(1)
(pprof) top
# 阻塞(需要提前开启)
# runtime.SetBlockProfileRate(1)
(pprof) top★ 最容易被忽略的两个 profile:block 和 mutex
// 必须在程序里开启,默认是关闭的!
func init() {
runtime.SetBlockProfileRate(1) // 记录所有阻塞事件
runtime.SetMutexProfileFraction(1) // 记录所有锁竞争
}老陈:我再强调一遍:如果 CPU 使用率不高但延迟高,99% 是锁竞争或者阻塞。
而这两个 profile 默认关闭。 很多人查了一周,就是因为没开这个开关。
第 3 层:eBPF —— 性能分析的核武器
eBPF 是什么?
eBPF (extended Berkeley Packet Filter):
允许用户态程序向内核注入"安全的、经过验证的"代码
在内核的特定事件点执行
★ 能力:
· 追踪任意内核函数(kprobe)
· 追踪任意用户态函数(uprobe)
· 追踪系统调用(tracepoint)
· 动态统计聚合(不需要把每个事件都传到用户态)
★ 优势:
· 无侵入(不需要改代码、不需要重启)
· 低开销(在内核里聚合,只传结果)
· 安全(有 verifier 检查,不会让内核崩溃)
工具:bcc 和 bpftrace
# bpftrace 的一行命令(类似 DTrace)
# 1. 统计系统调用次数
bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'
# 2. 按进程统计 read 的延迟分布
bpftrace -e '
tracepoint:syscalls:sys_enter_read /pid == 1234/ { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
@us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
# 3. 追踪 Go 的函数(uprobe)
bpftrace -e '
uprobe:/path/to/binary:main.handleRequest { @start[tid] = nsecs; }
uretprobe:/path/to/binary:main.handleRequest /@start[tid]/ {
@ns = hist(nsecs - @start[tid]);
delete(@start[tid]);
}'
# 4. 统计 TCP 重传
bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb { @[comm] = count(); }'
# 5. 追踪文件打开
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'bcc 的现成工具:
# 这些是 bcc 提供的,开箱即用
# CPU
/usr/share/bcc/tools/profile -F 99 30 # CPU 火焰图
/usr/share/bcc/tools/offcputime # ★ 阻塞时间火焰图!
/usr/share/bcc/tools/runqlat # 调度延迟分布
/usr/share/bcc/tools/cpudist # CPU 使用分布
# 内存
/usr/share/bcc/tools/memleak # 内存泄漏检测
/usr/share/bcc/tools/oomkill # OOM Kill 记录
/usr/share/bcc/tools/slabratios # slab 分配统计
# IO
/usr/share/bcc/tools/biolatency # IO 延迟分布 ★
/usr/share/bcc/tools/biosnoop # 单个 IO 追踪
/usr/share/bcc/tools/tcplife # TCP 连接生命周期 ★
/usr/share/bcc/tools/tcpconnect # TCP 连接建立
# 系统
/usr/share/bcc/tools/execsnoop # 进程创建
/usr/share/bcc/tools/opensnoop # 文件打开 ★
/usr/share/bcc/tools/syscount # 系统调用统计
/usr/share/bcc/tools/argdist # 函数参数分布★ offcputime 是最有用的工具之一
CPU profile 只能告诉你"时间花在哪"
offcputime 告诉你"时间在等什么"
场景:
· 服务延迟高,但 CPU 使用率只有 20%
· CPU profile 看不出问题(因为没在 CPU 上跑)
· ★ offcputime 会告诉你:60% 的时间在等某个 mutex
用法:
/usr/share/bcc/tools/offcputime -p $(pidof app) 30 > out.stacks
./FlameGraph/flamegraph.pl --color=io out.stacks > offcpu.svg
第三部分:四个子系统的排查清单
CPU
症状:延迟高,runq 有积压
排查:
① top / vmstat → 确认是 CPU 问题
② perf top → 哪个函数最热
③ perf record + 火焰图 → 完整调用链
④ Go pprof cpu → Go 层面的热点
常见原因:
· 算法复杂度问题
· 频繁的内存分配(GC 压力)
· 锁竞争(CPU 用在自旋上)
· 系统调用过多
· 上下文切换过多
Go 特有:
· GC(GOGC 太小 / 分配太多)
· 调度(GOMAXPROCS 不对 / goroutine 太多)
· defer / interface 调用(Go 1.14+ 已优化 defer)
内存
症状:延迟高,major fault 多,swap 活跃
排查:
① free -m → available 是否充足
② vmstat → si/so 是否非 0(swap)
③ perf stat -e page-faults,major-faults → 缺页异常次数
④ Go pprof heap → 内存分配热点
⑤ bcc memleak → 内存泄漏
常见原因:
· 内存泄漏(goroutine、cache、未关闭的资源)
· 内存占用过大(超过容器 limit)
· swap 开启(★ 生产环境应该关闭 swap)
· NUMA 跨节点访问
Go 特有:
· 未设置 MemoryLimit
· 大量长生命周期对象
· sync.Pool 使用不当
IO
症状:延迟高,await 高,%util 高
排查:
① iostat -xz 1 → await、avgqu-sz、%util
② bcc biolatency → IO 延迟分布
③ bcc biosnoop → 单个 IO 的详细信息
④ strace -e trace=read,write,openat -p <pid> → 系统调用
关键指标解读:
· await < 1ms → SSD,正常
· await 1-10ms → SSD 有压力,或者 HDD
· await > 10ms → ★ 严重问题
· avgqu-sz > 1 → 有排队
· %util = 100% → 设备饱和
★ 但要结合 await 看:
100% + 低 await = 高效(顺序大块)
100% + 高 await = 灾难(随机小块)
常见优化:
· 批量 IO(减少系统调用次数)
· 异步 IO(io_uring)
· 缓存(减少 IO 次数)
· 顺序化(把随机写变顺序写)
· 换 SSD / NVMe
网络
症状:延迟高,重传多
排查:
① sar -n DEV 1 → 吞吐量、包速率
② sar -n TCP,ETCP 1 → 重传、连接建立
③ ss -s → socket 统计
④ ss -ti → 每个连接的详细信息(RTT、拥塞窗口)★
⑤ bcc tcplife → TCP 连接生命周期
⑥ bcc tcpretrans → 重传事件
关键指标:
· retrans/s 重传率,> 0.1% 就要关注
· RTT ss -ti 里能看到
· cwnd 拥塞窗口,小说明有丢包
· 连接队列 ss -ltn 看 Recv-Q / Send-Q
常见优化:
· 连接池(减少握手)
· 批量发送(TCP_NODELAY 的取舍)
· 调优内核参数(tcp_rmem/wmem、somaxconn)
· 更近的机房(减少 RTT)
第四部分:Go 特有的调优清单
1. GC 调优
// 关键参数
debug.SetGCPercent(100) // 默认 100,堆翻倍才 GC
debug.SetMemoryLimit(2<<30) // Go 1.19+,软内存上限 ★
// 诊断
// GODEBUG=gctrace=1
// gc 1 @0.015s 4%: 0.031+2.9+0.015 ms clock,
// 0.50+1.2/2.6/0+0.24 ms cpu, 4->4->4 MB, 5 MB goal, 8 P
// ↑ assist time
// ↑ 后台标记
// 优化方向:
// ① 减少分配(sync.Pool、复用 buffer、避免逃逸)
// ② 调大 GOGC 或设置 MemoryLimit
// ③ 减少指针(用 []int32 索引代替 []*Node)2. 调度调优
// GOMAXPROCS
runtime.GOMAXPROCS(runtime.NumCPU()) // CPU 密集
// 容器环境用 automaxprocs
import _ "go.uber.org/automaxprocs"
// goroutine 数量(影响 STW 时间)
// 10 万个 goroutine → STW 可能几十 ms
// 优化:合并 goroutine、用连接池3. 内存分配优化
// ① 预分配
make([]T, 0, n) // 而不是 make([]T, 0)
// ② sync.Pool
var pool = sync.Pool{New: func() any { return new(Buffer) }}
// ③ 避免逃逸
// 用 -gcflags="-m" 检查
go build -gcflags="-m" main.go
// ④ 结构体对齐(减少 padding)
// 大字段在前,小字段在后
// ⑤ 用 []byte 而不是 string(热路径)
// string 转换会分配4. 并发优化
// ① 锁分片
type ShardedMap struct {
shards [32]struct {
mu sync.Mutex
m map[string]int
}
}
// ② 原子操作代替锁
var counter atomic.Int64
// ③ 无锁数据结构(ring buffer)
// ④ 减少临界区
// ❌ 在锁里做 IO
// ✅ 在锁外做 IO
// ⑤ 用 channel 还是 mutex?
// · 传递数据所有权 → channel
// · 保护共享状态 → mutex
// · 性能敏感 → 考虑 atomic思考题 ·【应用层】
你的 Go 服务(HTTP API)在压测时表现:
- QPS 上限 5000,加机器也不涨
- CPU 使用率 40%
- P99 延迟 200ms,但 P50 只有 5ms
iostat显示磁盘 %util = 5%- 下游服务响应时间正常
请给出完整的诊断流程和可能的根因。
参考答案
现象的关键矛盾
★ CPU 40%,磁盘 5%,下游正常,但 QPS 上不去
★ P50 = 5ms,P99 = 200ms(差 40 倍!)
这两点合起来说明:
大部分请求很快,但有一部分请求被"卡住"了很久
而且卡住的原因不是 CPU、不是磁盘、不是下游
这是典型的”排队”或者”阻塞”问题。
诊断流程
第 1 步:确认不是资源饱和
# CPU 饱和度(runq 有积压吗?)
vmstat 1
# 看 r 列
# 如果 r ≈ CPU 核数,说明 CPU 饱和
# ★ 如果 r 很小,说明不是 CPU 问题
# 磁盘
iostat -xz 1
# await 和 avgqu-sz
# ★ 5% 使用率,肯定不是磁盘
# 内存
vmstat 1
# 看 si/so(swap)
# 如果非 0,说明内存压力
# 网络
sar -n DEV 1
# 看是否达到网卡带宽上限如果这些都正常,继续第 2 步。
第 2 步:看延迟分布(这是关键)
P50 = 5ms, P99 = 200ms
★ 双峰分布!
大部分请求 5ms(正常)
1% 的请求 200ms(被卡住了)
可能的原因:
① GC 停顿
② 锁竞争
③ 连接池耗尽(等待获取连接)
④ goroutine 调度延迟
⑤ 某些请求路径特别慢(大请求、缓存未命中)
第 3 步:用 pprof 定位
# ★ 先看 mutex profile(最可能!)
# 需要提前开启:runtime.SetMutexProfileFraction(1)
go tool pprof http://localhost:6060/debug/pprof/mutex
(pprof) top
(pprof) list <热点函数>
# ★ 再看 block profile
# 需要提前开启:runtime.SetBlockProfileRate(1)
go tool pprof http://localhost:6060/debug/pprof/block
(pprof) top
# CPU profile(确认没有 CPU 热点)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
(pprof) top
# goroutine profile(看有没有大量阻塞的 goroutine)
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top第 4 步:用 trace 看时间线
# 采集 trace
curl http://localhost:6060/debug/pprof/trace?seconds=10 > trace.out
go tool trace trace.out
# ★ 重点看:
# 1. "Goroutine analysis" → 有多少 goroutine 在阻塞
# 2. "Network blocking profile"
# 3. "Synchronization blocking profile" ← ★ 看这个
# 4. "Syscall blocking profile"
# 5. "Scheduler latency profile" ← ★ 还有这个第 5 步:GC 检查
GODEBUG=gctrace=1 ./app 2>&1 | tail -20
# 看 STW 时间:
# gc 123 @45.678s 3%: 0.031+12.5+0.042 ms clock
# ↑STW1 ↑并发 ↑STW2
# 如果 STW 超过 10ms,就是 GC 问题五个最可能的根因
根因 1:全局锁竞争 ★ 最可能
// ❌ 典型问题代码
var (
mu sync.Mutex
cache = make(map[string]*Item)
)
func Get(key string) *Item {
mu.Lock()
defer mu.Unlock()
return cache[key]
}
// 高并发下,所有请求抢这一把锁
// → 大部分请求 5ms(运气好,没排队)
// → 1% 的请求运气不好,排了 200ms诊断证据:
- mutex profile 显示
sync.(*Mutex).Lock占大量时间 - CPU 使用率中等(等待不消耗 CPU)
解决:
// 方案 A:锁分片(推荐)
const numShards = 64
type ShardedCache struct {
shards [numShards]struct {
sync.RWMutex
m map[string]*Item
}
}
func (c *ShardedCache) getShard(key string) *struct {
sync.RWMutex
m map[string]*Item
} {
h := fnv32(key)
return &c.shards[h%numShards]
}
func (c *ShardedCache) Get(key string) *Item {
shard := c.getShard(key)
shard.RLock()
defer shard.RUnlock()
return shard.m[key]
}
// 效果:锁竞争降低 64 倍
// 方案 B:用 sync.Map(读多写少场景)
var cache sync.Map
// 方案 C:用 RWMutex(如果读远多于写)
var mu sync.RWMutex根因 2:连接池耗尽
// ❌ 连接池太小
db.SetMaxOpenConns(10) // ★ 太小!
// 5000 QPS,每个请求要查 3 次 DB
// → 需要 15000 次查询/秒
// → 每个查询 1ms,10 个连接只能支撑 10000 次/秒
// → 请求排队等连接诊断证据:
- block profile 显示阻塞在
semacquire(信号量等待) - 或者数据库的
wait_count指标很高
解决:
// 调大连接池
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(50)
db.SetConnMaxLifetime(time.Hour)
// ★ 但注意:连接池不是越大越好
// 数据库本身有连接数上限
// 太多连接会让数据库变慢
//
// 经验值:
// 连接数 ≈ (CPU 核数 × 2) + 磁盘数量
// 或者:连接数 ≈ QPS × 平均查询时间根因 3:GC 停顿
如果 GODEBUG=gctrace 显示 STW 有几十 ms:
→ 1% 的请求正好赶在 GC → P99 飙升
诊断证据:
- gctrace 显示 STW 时间长
- trace 里看到 GC 的 STW 区间
解决:
// ① 设置 MemoryLimit(Go 1.19+)
debug.SetMemoryLimit(...)
// ② 减少分配
var bufPool = sync.Pool{...}
// ③ 调大 GOGC
debug.SetGCPercent(200)根因 4:goroutine 调度延迟
如果 goroutine 数量巨大(10 万+):
· STW 时间变长(要扫所有栈)
· 调度器负载高
· 某个 goroutine 可能等很久才被调度
诊断证据:
runtime.NumGoroutine()很大- trace 的 “Scheduler latency profile” 显示高延迟
解决:
- 减少 goroutine 数量
- 用 worker pool 模式
- 检查 goroutine 泄漏
根因 5:请求大小不均(大请求拖慢)
场景:
· 99% 的请求返回 1KB
· 1% 的请求返回 10MB(比如导出接口)
★ 那 1% 的大请求会:
· 占用大量内存(触发 GC)
· 占用网络带宽
· 占用 CPU(序列化)
· 影响其他请求(排队)
诊断:
- 按接口维度统计 P99(不要只看整体)
- 看响应大小的分布
解决:
- 把大请求隔离到独立的实例/线程池
- 分页 / 流式返回
- 限流
我的排查顺序
第 1 步: vmstat 确认不是 CPU/内存饱和 30 秒
第 2 步: 开 mutex + block profile,看 top 5 分钟 ★ 最高概率
第 3 步: GODEBUG=gctrace 看 STW 2 分钟
第 4 步: go tool trace 看时间线 10 分钟
第 5 步: 按接口维度拆 P99 5 分钟
我赌 80% 的概率是根因 1(锁竞争)或根因 2(连接池)。
为什么”加机器也不涨”
这是本题的一个重要线索。
如果瓶颈在 CPU/磁盘:
→ 加机器应该能提升
如果加机器不涨,说明瓶颈是:
① 下游服务的连接数/容量限制
② 共享资源的锁(但这是单机的,加机器应该有效)
③ ★ 客户端的并发度不够(压测工具没压够)
④ 负载均衡的问题
★ 最可能:压测工具的并发度设置太低
根据 Little's Law:
吞吐 = 并发数 / 平均延迟
如果压测并发 = 100,平均延迟 = 20ms
吞吐 = 100 / 0.02 = 5000 QPS ← ★ 正好是你的上限!
加机器没用,因为并发度是瓶颈,不是服务端的处理能力
验证方法:
# 增加压测并发,看 QPS 是否提升
wrk -t12 -c400 -d30s http://api/endpoint
# ↑ 从 100 提到 400
# 如果 QPS 提升了 → 是压测并发不够
# 如果 QPS 不涨,但延迟涨了 → 服务端有瓶颈一句话总结
“CPU 不高、磁盘不忙、延迟双峰”这三个特征合起来,几乎可以肯定是”阻塞”问题。
诊断的关键是:
- 别只看 CPU profile——它看不到等待
- 开 mutex 和 block profile——这两个默认关闭
- 看延迟分布——双峰分布说明有”排队”
- 用 Little’s Law 验证——并发度是否是瓶颈
这跟第 1 章小林的教训一脉相承:先测量,再优化。而且要确保你测的是对的指标。
第 6 章总结
两个核心方法
-
USE 方法:对每个资源检查使用率、饱和度、错误。饱和度是延迟的直接来源,使用率只是平均值。
-
分层工具:从底往上排查。先用
vmstat/iostat定位”哪个资源”,再用 perf/pprof/eBPF 找”哪行代码”。
五个关键工具
| 工具 | 用途 | 关键点 |
|---|---|---|
| pprof mutex/block | 锁竞争、阻塞 | ★ 默认关闭,必须手动开启 |
| offcputime (bcc) | 阻塞时间火焰图 | CPU 不高时用它 |
| biolatency (bcc) | IO 延迟分布 | 看 await 的分布,不只是平均值 |
| go tool trace | Go 运行时的时间线 | 看调度延迟、GC、网络阻塞 |
| perf stat | 硬件计数器 | IPC < 1 说明在等内存;branch-miss > 5% 是瓶颈 |
四个排查清单
- CPU:算法、GC、分配、锁自旋、系统调用
- 内存:泄漏、OOM、swap、NUMA
- IO:await、%util、随机 vs 顺序
- 网络:重传、RTT、连接队列
与全书的联系
第 1 章: 理解了 CPU 缓存、内存、NUMA
第 3 章: 理解了 GC、调度器
第 4 章: 理解了存储引擎
第 5 章: 理解了网络和分布式
第 6 章: 学会观测和调优这一切 ← 你在这里
观测能力是把前面所有知识”变现”的手段。 你知道了原理,但如果没有测量,你永远不知道你的系统到底在哪一层出了问题。
下一章
老陈的预告:Spring 那套 IoC/AOP 思想,Go 生态里全都有对应物(wire、dig、fx)。但只有自己写一遍,你才知道那些”魔法”背后是什么。
下一章我们用 Go 手写 IoC 容器、AOP 拦截器链、Web 容器——然后你会发现,所谓的”框架”,不过是几个设计模式的组合。