上周我们线上一个核心网关服务 CPU 突然冲到 85%,P99 延迟从 15ms 飙到 320ms,告警短信直接把我轰炸醒了。
很多人遇到性能问题喜欢看代码硬猜,觉得“这里可能慢了”、“那里锁竞争可能严重”。信我,别猜,Go 的工具链极其成熟,排查生产问题只看两样东西:指标监控 和 pprof 剖析。
记录一下这两年我在生产环境抓 bug 和压榨 Go 性能的套路,直接上硬核的。
1. 线上救火第一步:pprof 必须常驻且安全暴露
生产环境排查,第一前提是你能拿到运行时的 profile 数据。很多新手怕 pprof 拖垮性能,其实平时开着开销极小(CPU 采样通常只增加 1%~2% 开销)。
代码里加上这一行,但千万别直接公网裸奔:
package main
import (
_ "net/http/pprof"
"net/http"
"log"
)
func main() {
// 单独起一个 internal 端口给监控/排障,避开业务路由
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
// 业务逻辑...
}
线上 CPU 飙高时,直接拉 30 秒的 profile:
go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/profile?seconds=30
打开 Flame Graph(火焰图),顶层最宽的那个块就是元凶。上周我们那个服务,火焰图拉下来一看,runtime.mallocgc 占了接近 40% 的 CPU。这就是典型的内存频繁分配导致的 GC 压力。
2. 内存调优杀手锏:减少逃逸与 sync.Pool 压榨
既然是 mallocgc 占满 CPU,直接查 heap 和 allocs:
go tool pprof -alloc_space http://127.0.0.1:6060/debug/pprof/heap
定位到原因是:某个高频解析协议的接口,每次请求都会 make([]byte, 4096),在 30k QPS 冲击下,内存分发器直接过载。
针对高频临时对象,直接用 sync.Pool 复用。写个 benchmark 看看差距有多离谱:
package bench_test
import (
"sync"
"testing"
)
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 4096)
return &b
},
}
func BenchmarkDirectAlloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := make([]byte, 4096)
buf[0] = 1
}
}
func BenchmarkSyncPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
bufPtr := bufPool.Get().(*[]byte)
(*bufPtr)[0] = 1
bufPool.Put(bufPtr)
}
}
跑出来的 benchmark 结果:
goos: darwin
goarch: arm64
pkg: bench_test
BenchmarkDirectAlloc-8 1428572 842.1 ns/op 4096 B/op 1 allocs/op
BenchmarkSyncPool-8 48920110 24.2 ns/op 0 B/op 0 allocs/op
看数字:
- 耗时从 842 ns 降到 24 ns,速度提升 35 倍。
- 单次分配内存从 4096 B 降到 0 B,零堆分配,GC 压力直接归零。
改完上线后,CPU 占用立马从 85% 掉到了 22%,GC 耗时平稳在 1ms 以内。
3. 隐形刺客:Goroutine 泄露与锁争用排查
除了 CPU 和内存,最容易搞崩生产的就是 Goroutine 泄露。这种问题往往潜伏几个小时甚至几天才爆。
排查套路是看 goroutine 数量随时间的变化:
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=1
如果数量一直在涨没有平稳期,100% 泄露了。排查下来最常见的三种情况:
- 往无缓冲 channel 塞数据,没有消费者退出机制。
- 读 channel 阻塞,缺少
ctx.Done()超时控制。 - HTTP 请求没有设
Timeout,或者resp.Body没有Close(),导致底层的连接读取协程挂死。
如果是延迟高但 CPU 没吃满,查锁竞争。默认情况下 Go 不会采集 block 和 mutex 数据,需要手动开启采样率:
runtime.SetBlockProfileRate(10000) // 每 10000 纳秒采样一次阻塞
runtime.SetMutexProfileFraction(5) // 采样 1/5 的锁争用
然后抓取:
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex
排查锁竞争原则很简单:
- 能用
sync.RWMutex就别用全局sync.Mutex。 - 锁内绝对不能有 I/O 操作(打日志、调 RPC 等)。
- 粒度拆细,按 ID 分段加锁(Sharding Lock)。
4. 阿蒙的生产排查 Checklist
以后遇到生产告警,按这个流程顺着过一遍,基本 15 分钟内就能定界:
- 看 Metrics 大盘:确认是 CPU 高、内存高还是延迟高。
- CPU 高:抓
profile,看是计算密集型业务逻辑,还是runtime.mallocgc导致的 GC 耗时。 - 内存高 / OOM:抓
heap看inuse_space;如果瞬时 OOM 抓不到 heap,调小GOMEMLIMIT,给内核留出 buffer,结合alloc_space查分配源头。 - 延迟高 + CPU 低:抓
goroutine看阻塞协程栈,开启block和mutexprofile,抓锁竞争和 I/O 阻塞。 - 别猜,写 Benchmark:任何代码级优化,必须有
b.ReportAllocs()的数据对比才能提 PR 合并,凭感觉改代码经常是负优化。
性能调优本质上是个收益比的游戏,先把分配大头搞定,千万别在一行位运算上磨磨蹭蹭半天。先量化,再动刀。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


