在线上服务里,CPU 飙高往往不是一个孤立现象。它常常伴随着请求延迟上升、实例扩容、上下游超时、GC 压力增加,甚至会进一步放大成稳定性事故。对 Go 服务而言,定位 CPU 问题不能只停留在“哪个进程占用高”,而要继续回答下面几个问题:
- CPU 到底消耗在什么函数上?
- 是计算真的变多了,还是存在低效实现?
- 是业务逻辑本身重,还是锁竞争、调度延迟、序列化等基础问题?
- 如何用 benchmark 证明优化是否有效,而不是“感觉快了”?
本文会围绕 Go 线上排障中最常用的一组工具展开:pprof、trace 和 benchmark。同时结合一个完整案例,把 CPU 飙高故障的排查流程串起来。
一、先建立正确认知:CPU 高不一定等于“代码写得差”
看到 CPU 飙高时,第一反应往往是“有死循环”或者“算法有问题”。这当然有可能,但实际线上排查时,更常见的是下面几类情况:
- 请求量短时间突增,CPU 被正常业务流量打满
- 某个接口出现异常重试,单位时间内执行次数暴涨
- 正则、JSON、字符串拼接等热点路径存在高频低效操作
- 锁竞争严重,CPU 时间消耗在 runtime 调度与同步原语上
- goroutine 过多,调度成本显著上升
- GC 压力高,间接推高 CPU 消耗
因此,CPU 问题排查的核心不是“猜原因”,而是先采样,再定位热点,再结合代码与流量背景解释热点。
二、pprof CPU profile 使用与分析
2.1 为什么先看 pprof
pprof 是 Go 世界里最常用的性能分析工具之一。对于 CPU 问题,它能回答一个最关键的问题:
程序在采样期间,CPU 时间主要花在了哪些函数上。
CPU profile 本质上是采样结果,不会记录每一条指令的完整时间线,但它足够轻量,也足够适合线上快速定位问题。
2.2 一个可运行的 CPU profile 示例
下面这段程序故意制造了几个典型热点:
- 在循环中反复编译正则
- 大量 JSON 序列化
- 使用全局锁保护共享 map
将下面代码保存为 main.go 后即可直接运行。
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
_ "net/http/pprof"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
type Payload struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
Attrs map[string]string `json:"attrs"`
Created int64 `json:"created"`
}
var (
mu sync.Mutex
store = make(map[string]int)
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
rand.Seed(time.Now().UnixNano())
go func() {
for {
hotFunction()
}
}()
http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
for i := 0; i < 2000; i++ {
hotFunction()
}
_, _ = w.Write([]byte("ok"))
})
log.Println("server started at :6060")
log.Println("pprof endpoint: http://127.0.0.1:6060/debug/pprof/")
log.Fatal(http.ListenAndServe(":6060", nil))
}
func hotFunction() {
for i := 0; i < 100; i++ {
text := fmt.Sprintf("order_%d_status_paid_user_%d", rand.Intn(100000), i)
// 反例 1:循环中重复编译正则
re := regexp.MustCompile(`order_(\d+)_status_(\w+)_user_(\d+)`)
match := re.FindStringSubmatch(text)
if len(match) == 0 {
continue
}
// 反例 2:频繁 JSON 编码
p := Payload{
ID: i,
Name: strings.Repeat("service", 4),
Tags: []string{"golang", "pprof", "cpu", "json"},
Attrs: map[string]string{
"order_id": match[1],
"status": match[2],
"user_id": match[3],
"trace_id": strconv.Itoa(rand.Intn(1000000)),
},
Created: time.Now().UnixNano(),
}
_, _ = json.Marshal(p)
// 反例 3:高频锁竞争
mu.Lock()
store[match[1]]++
mu.Unlock()
}
}
启动程序:
go run main.go
然后压测一段时间,让服务有足够采样数据:
while true; do curl -s "http://127.0.0.1:6060/work" > /dev/null; done
在另一个终端抓取 30 秒 CPU profile:
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
如果想本地保存 profile 文件:
curl -o cpu.pb.gz "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"
go tool pprof cpu.pb.gz
2.3 pprof 的常用命令
进入 pprof 交互界面后,最常用的是这几个命令:
top:查看最耗 CPU 的函数top -cum:按累计耗时排序,适合看调用链上谁“带出了”热点list 函数名:查看某个函数逐行热点web:生成图形调用图peek 关键字:查看和某个关键字相关的函数
例如:
(pprof) top
(pprof) top -cum
(pprof) list hotFunction
(pprof) peek regexp
(pprof) web
2.4 如何理解 flat 与 cumulative
很多人第一次看 top 时,会被两个指标绕晕:
flat:函数自身直接消耗的 CPU 时间cum:函数自身 + 它调用的下游函数一共消耗的 CPU 时间
例如一个函数本身只负责调度,但它内部调用了 JSON 编码、正则匹配和数据库解析,那么它的 flat 可能不高,但 cum 很高。
排查时可以这样理解:
- 想知道CPU 真正烧在哪一行,先看
flat - 想知道哪条调用链把问题带出来了,看
cum
2.5 火焰图怎么解读
火焰图是 CPU 热点分析里最直观的视图。它的阅读方法建议记住三句话:
- 横向宽度越大,说明耗时越多。
- 纵向代表调用栈深度,不代表时间先后。
- 最上层宽函数,往往就是最值得优先分析的热点函数。
排查火焰图时,重点看这几类信号:
- 某个基础库函数特别宽,比如
regexp.(*Regexp).doExecute encoding/json相关函数在热点区域持续出现runtime.lock2、sync.(*Mutex).Lock、runtime.semacquire1很宽,提示锁竞争明显runtime.mallocgc、scanobject、gcDrain较宽,提示对象分配和 GC 压力较大
下面是一个典型判断思路:
| 火焰图现象 | 可能原因 | 下一步动作 |
|---|---|---|
regexp 相关函数很宽 |
正则使用频繁,或循环中重复编译 | 检查是否可预编译、改用字符串匹配 |
encoding/json 很宽 |
高频序列化或大对象序列化 | 减少字段、复用 buffer、评估替代实现 |
sync / runtime.semacquire 很宽 |
锁竞争严重 | 缩小临界区,分片锁,减少共享状态 |
runtime.findrunnable 或调度函数异常显著 |
goroutine 数量多或调度拥塞 | 结合 trace 分析调度延迟 |
mallocgc 很宽 |
分配过多 | 看对象逃逸、减少临时对象 |
2.6 线上采样时的注意事项
线上抓 CPU profile 时,建议注意以下几点:
- 优先在问题发生时采样。CPU 恢复正常后再抓,价值会大幅下降。
- 一次抓 20~60 秒通常比较合适。时间太短样本不足,太长容易混入无关波动。
- 尽量结合实例维度看。确认是否所有实例都高,还是某个实例异常。
- 结合业务时间窗。比如是否正好碰上流量峰值、定时任务、批处理。
三、常见 CPU 热点:正则滥用、JSON 序列化、锁竞争
很多 CPU 问题并不神秘,往往就是几个经典热点反复出现。
3.1 正则滥用
常见反例
把 regexp.MustCompile 写在循环里,是最常见的性能错误之一。
func badMatch(inputs []string) int {
count := 0
for _, s := range inputs {
re := regexp.MustCompile(`^user_(\d+)_active$`)
if re.MatchString(s) {
count++
}
}
return count
}
问题在于:正则编译本身就不便宜,如果每次循环都编译,相当于把大量 CPU 浪费在重复准备工作上。
正确姿势
把正则预编译成全局变量或初始化时构造:
var userActiveRegexp = regexp.MustCompile(`^user_(\d+)_active$`)
func goodMatch(inputs []string) int {
count := 0
for _, s := range inputs {
if userActiveRegexp.MatchString(s) {
count++
}
}
return count
}
如果需求只是前缀、后缀、包含判断,往往可以直接用字符串函数代替正则:
func fasterMatch(inputs []string) int {
count := 0
for _, s := range inputs {
if strings.HasPrefix(s, "user_") && strings.HasSuffix(s, "_active") {
count++
}
}
return count
}
实践经验是:能不用正则,就先别用正则;必须用时,尽量预编译。
3.2 JSON 序列化热点
encoding/json 使用方便,但在高 QPS、高对象量场景里,经常成为 CPU 热点。
常见问题
- 每次请求都构造很大的临时对象再编码
- map 过多,字段层级深
- 重复序列化同一结构
- 只需要少量字段,却编码整个对象
下面是一个简单示例:
package main
import (
"encoding/json"
"fmt"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Tags []string `json:"tags"`
Profile map[string]string `json:"profile"`
}
func main() {
u := User{
ID: 1,
Name: "alice",
Email: "alice@example.com",
Tags: []string{"go", "backend", "perf"},
Profile: map[string]string{
"city": "beijing",
"position": "engineer",
},
}
data, err := json.Marshal(u)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
优化时可以考虑这些方向:
- 精简结构体字段,避免“为了方便直接全量返回”
- 避免在热点路径里频繁构造
map[string]string - 可以复用的对象、buffer 尽量复用
- 如果字段稳定,用结构体替代 map
- 对于极致性能场景,再评估替代序列化方案
这里有一个很重要的原则:先用 pprof 证明 JSON 是热点,再优化 JSON。 不要在没有证据时过早引入复杂方案。
3.3 锁竞争
锁竞争是另一类非常常见、也非常容易被忽略的 CPU 问题。很多同学看到 CPU 高,会默认以为“说明线程很忙”。实际上,锁竞争严重时,CPU 也可能大量消耗在 runtime 调度、唤醒和同步上。
一个容易出问题的例子
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex
m = map[int]int{}
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100000; j++ {
mu.Lock()
m[j%1024]++
mu.Unlock()
}
}(i)
}
wg.Wait()
fmt.Println("done", len(m))
}
这段代码功能上没问题,但所有 goroutine 都在争抢同一把锁,随着并发升高,很容易在 profile 里看到锁相关热点。
减少锁竞争的思路
- 缩小临界区,只把真正共享的数据保护起来
- 减少共享状态,优先局部聚合后再合并
- 用分片锁替代单全局锁
- 读多写少时评估
sync.RWMutex - 更进一步时,用 channel 或无锁结构重构数据流
例如简单的分片锁写法:
package main
import (
"hash/fnv"
"strconv"
"sync"
)
const shardCount = 32
type shard struct {
mu sync.Mutex
m map[string]int
}
type ShardedCounter struct {
shards [shardCount]shard
}
func NewShardedCounter() *ShardedCounter {
s := &ShardedCounter{}
for i := 0; i < shardCount; i++ {
s.shards[i].m = make(map[string]int)
}
return s
}
func (s *ShardedCounter) Inc(key string) {
idx := hash(key) % shardCount
sh := &s.shards[idx]
sh.mu.Lock()
sh.m[key]++
sh.mu.Unlock()
}
func hash(key string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(key))
return h.Sum32()
}
func main() {
counter := NewShardedCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100000; j++ {
counter.Inc(strconv.Itoa(j % 1024))
}
}(i)
}
wg.Wait()
}
四、trace 工具:goroutine 调度延迟分析
4.1 为什么有了 pprof 还要看 trace
pprof 适合回答“CPU 花在哪”。
但如果你怀疑问题在这些方面,仅看 pprof 往往不够:
- goroutine 被调度得太慢
- 某些任务明明不重,但执行总是排不上
- 锁阻塞、网络阻塞、系统调用阻塞混在一起
- goroutine 爆炸导致调度器压力增大
这时就该上 trace。它更像是程序运行时行为的“时间线录像”。
4.2 trace 示例代码
下面程序会制造大量 goroutine 和短任务,适合观察调度现象。
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"os"
"runtime/trace"
"sync"
"time"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
http.HandleFunc("/burst", func(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
busyWork()
}()
}
wg.Wait()
_, _ = w.Write([]byte("done"))
})
log.Println("listen on :8080")
go func() {
for {
_, _ = http.Get("http://127.0.0.1:8080/burst")
time.Sleep(200 * time.Millisecond)
}
}()
log.Fatal(http.ListenAndServe(":8080", nil))
}
func busyWork() {
end := time.Now().Add(2 * time.Millisecond)
for time.Now().Before(end) {
}
}
运行后,程序会在当前目录生成 trace.out。查看方式:
go tool trace trace.out
浏览器里常看的几个视图包括:
- View trace:时间线总览
- Goroutine analysis:goroutine 生命周期、阻塞原因
- Scheduler latency:调度延迟统计
- Network blocking profile:网络阻塞情况
- Synchronization blocking profile:同步阻塞情况
4.3 如何看 goroutine 调度延迟
如果一个 goroutine 已经 ready,但迟迟得不到执行,这段时间就是调度延迟。常见原因包括:
- 可运行 goroutine 太多,P/M 资源不足
- 单个 goroutine 持续占用 CPU 太久
- 大量短生命周期 goroutine 带来高调度成本
- 锁或系统调用造成运行队列挤压
实际分析时,可以重点观察:
- goroutine 数量是否异常多
- runnable 状态等待时间是否明显偏长
- 某些 handler 是否在短时间内创建了大量 goroutine
- 调度延迟是稳定存在,还是只在突发流量时出现
如果你在 trace 中看到“任务很短,但排队很久”,那往往说明问题不完全是业务逻辑慢,而是调度层已经拥塞。
五、性能基准测试(benchmark)的正确姿势
优化前后如果不做 benchmark,结论往往不可靠。因为:
- 肉眼观察容易被偶然波动误导
- 本地一次执行快,不代表统计意义上更快
- 可能只是减少了日志、缓存命中变好了,并非核心逻辑优化成功
5.1 benchmark 基本写法
下面用“坏正则写法”和“预编译正则写法”做对比。
将如下代码保存为 match_test.go:
package main
import (
"regexp"
"strings"
"testing"
)
var inputs []string
var compiled = regexp.MustCompile(`^user_(\d+)_active$`)
func init() {
for i := 0; i < 10000; i++ {
inputs = append(inputs, "user_123_active")
inputs = append(inputs, "user_456_inactive")
inputs = append(inputs, "user_789_active")
}
}
func badMatch(inputs []string) int {
count := 0
for _, s := range inputs {
re := regexp.MustCompile(`^user_(\d+)_active$`)
if re.MatchString(s) {
count++
}
}
return count
}
func goodMatch(inputs []string) int {
count := 0
for _, s := range inputs {
if compiled.MatchString(s) {
count++
}
}
return count
}
func stringMatch(inputs []string) int {
count := 0
for _, s := range inputs {
if strings.HasPrefix(s, "user_") && strings.HasSuffix(s, "_active") && !strings.Contains(s, "inactive") {
count++
}
}
return count
}
func BenchmarkBadMatch(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = badMatch(inputs)
}
}
func BenchmarkGoodMatch(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = goodMatch(inputs)
}
}
func BenchmarkStringMatch(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = stringMatch(inputs)
}
}
执行 benchmark:
go test -bench=. -benchmem
5.2 benchmark 的几个关键原则
原则 1:一次只验证一个问题
如果你同时改了算法、数据结构、缓存策略和并发模型,最后很难知道性能提升到底来自哪一部分。
原则 2:关注时间,也关注内存分配
-benchmem 很重要,因为很多 CPU 问题背后其实是“分配太多,GC 太重”。
原则 3:避免把无关逻辑混进 benchmark
比如在 benchmark 里打印日志、读文件、发网络请求,都会污染结果。
原则 4:准备稳定输入
输入数据过小、过于理想化,可能得不到接近真实场景的结论。应该尽量让输入规模和线上热点场景接近。
原则 5:防止编译器优化掉测试代码
如果 benchmark 结果没有被使用,编译器可能进行优化,导致结果失真。必要时可以把结果保存到包级变量。
示例:
var result int
func BenchmarkGoodMatchStable(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
result = goodMatch(inputs)
}
}
5.3 benchmark 与 pprof 联合使用
benchmark 不只是用来看快了多少,还可以直接生成 profile。
go test -bench=BenchmarkGoodMatch -cpuprofile cpu.out -memprofile mem.out
然后继续用 pprof 分析:
go tool pprof cpu.out
go tool pprof mem.out
这套组合特别适合做“优化前后对比验证”:
- benchmark 证明指标有没有提升
- pprof 证明热点是否真的转移或下降
六、案例:CPU 飙高故障排查流程
下面给出一个更贴近线上值班场景的排查过程。
6.1 故障现象
某 Go API 服务在中午流量高峰期出现告警:
- 实例 CPU 从 30% 升到 85% 以上
- P99 延迟从 80ms 升到 450ms
- 错误率没有明显升高
- 扩容后略有缓解,但单实例 CPU 仍然偏高
这类现象说明:
- 服务不是完全不可用
- 更像是性能退化而不是功能错误
- 问题很可能集中在某条高频请求路径
6.2 第一步:确认是流量问题还是代码路径异常
先对齐几个基础事实:
- 总请求量是否显著上涨
- 是否只有某个接口 QPS 暴增
- 是否有新版本发布
- 是否存在上游重试、定时任务、数据膨胀
结果发现:
- 总流量上涨约 15%,不足以单独解释 CPU 翻倍
/search接口平均请求体变大- 近期刚上线了一段关键词匹配逻辑
这时基本可以判断,方向应该放在 /search 相关代码路径。
6.3 第二步:抓取 CPU profile
在高 CPU 实例上执行:
curl -o cpu.pb.gz "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"
go tool pprof cpu.pb.gz
查看 top 后发现:
regexp.(*Regexp).doExecute占比很高regexp.compile也有明显占比encoding/json.(*encodeState).marshal次之
这说明问题已经比较清晰:
- 正则相关调用是主要热点。
- 而且不仅匹配耗时高,连编译本身都在占 CPU。
- JSON 序列化也偏重,但还不是第一矛盾。
6.4 第三步:回看代码,找到根因
定位到新上线逻辑后,发现代码类似这样:
func matchKeyword(keyword, content string) bool {
re := regexp.MustCompile(keyword)
return re.MatchString(content)
}
这个函数被放在请求内循环里,对每个关键词都执行一次。问题点有两个:
- 每次请求都在重复编译正则
- 请求体变大后,匹配次数和匹配文本长度一起上升
这就是一个非常典型的“流量变化放大代码低效点”的案例。
6.5 第四步:修复与验证
优化方案:
- 对稳定关键词提前编译并缓存
- 能用精确匹配、前缀匹配解决的场景不用正则
- 对响应对象裁剪字段,减少 JSON 编码成本
优化后重新做 benchmark,并在压测环境抓取 profile:
- 单请求 CPU 时间下降明显
regexp.compile热点基本消失encoding/json成为新的相对热点,但整体 CPU 已回落到可接受范围
这里有一个很重要的工程实践:
不要试图一步把所有热点都优化掉。先解决主要矛盾,再重新采样。
因为 profile 反映的是相对占比。第一热点下降后,第二热点自然会“显得更突出”。这并不代表系统变差了,而是视角更清楚了。
6.6 第五步:如果 pprof 看不清,再补 trace
假设优化正则后,CPU 虽然下降了,但延迟还是高。这时可以进一步看 trace:
- 是否因为 goroutine 数量暴涨导致调度延迟
- 是否有锁竞争导致请求排队
- 是否某些短任务被长期挂在 runnable 队列
排障中常见的一种情况是:
- 代码计算本身已经不算重
- 但并发模型不合理,创建了过多 goroutine
- 最终瓶颈转移到调度和同步
这类问题只看 pprof top 不一定够,而 trace 往往更直观。
七、一套实战可落地的 CPU 排查清单
当你下次再遇到 Go 服务 CPU 飙高,可以按下面顺序处理:
- 先确认范围:单实例还是全体实例,单接口还是全局接口。
- 先看背景:流量、发布、重试、任务、数据规模是否变化。
- 抓 CPU profile:优先在高峰实例、问题时段抓 20~60 秒。
- 看 top 与火焰图:找出最宽的热点函数和主调用链。
- 结合代码解释热点:区分是正常业务计算,还是明显低效实现。
- 关注经典热点:正则、JSON、锁竞争、对象分配、goroutine 膨胀。
- 必要时补 trace:分析调度延迟、阻塞与并发模型问题。
- 用 benchmark 验证优化:不要凭感觉判断“已经变快”。
- 优化后重新采样:确认热点是否真的下降,避免误判。
CPU 问题排查最怕两种情况:
- 一种是完全凭经验猜
- 另一种是工具用了很多,但没有把工具结果和代码语义、流量背景联系起来
真正高效的排查方式,是把这三件事连起来:
监控现象 → profile / trace 证据 → 代码与业务解释。
做到这一步,很多看似复杂的 CPU 问题,都会从“玄学”变成“可证明、可复现、可优化”的工程问题。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!