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,可能早就卡死了

★ 使用率是"过去一段时间的平均值",不反映排队情况

正确:
  使用率 + 饱和度一起看
  饱和度(队列长度、等待时间)才是延迟的直接来源

资源清单

资源UtilizationSaturationErrors
CPUtop 的 %us+%syload average / runq-sz无
内存已用/总量si/so(swap 换入换出)OOM Kill 次数
磁盘 IOiostat 的 %utilawait、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看什么何时用
cpuCPU 时间花在哪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 不高、磁盘不忙、延迟双峰”这三个特征合起来,几乎可以肯定是”阻塞”问题。

诊断的关键是:

  1. 别只看 CPU profile——它看不到等待
  2. 开 mutex 和 block profile——这两个默认关闭
  3. 看延迟分布——双峰分布说明有”排队”
  4. 用 Little’s Law 验证——并发度是否是瓶颈

这跟第 1 章小林的教训一脉相承:先测量,再优化。而且要确保你测的是对的指标。


第 6 章总结

两个核心方法

  1. USE 方法:对每个资源检查使用率、饱和度、错误。饱和度是延迟的直接来源,使用率只是平均值。

  2. 分层工具:从底往上排查。先用 vmstat/iostat 定位”哪个资源”,再用 perf/pprof/eBPF 找”哪行代码”。

五个关键工具

工具用途关键点
pprof mutex/block锁竞争、阻塞★ 默认关闭,必须手动开启
offcputime (bcc)阻塞时间火焰图CPU 不高时用它
biolatency (bcc)IO 延迟分布看 await 的分布,不只是平均值
go tool traceGo 运行时的时间线看调度延迟、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 章: 学会观测和调优这一切  ← 你在这里

观测能力是把前面所有知识”变现”的手段。 你知道了原理,但如果没有测量,你永远不知道你的系统到底在哪一层出了问题。


下一章

第 7 章 · 框架设计思想与造轮子

老陈的预告:Spring 那套 IoC/AOP 思想,Go 生态里全都有对应物(wire、dig、fx)。但只有自己写一遍,你才知道那些”魔法”背后是什么。

下一章我们用 Go 手写 IoC 容器、AOP 拦截器链、Web 容器——然后你会发现,所谓的”框架”,不过是几个设计模式的组合。