返回首页

Golang横向:08 CPU 飙高与性能瓶颈分析

在线上服务里,CPU 飙高往往不是一个孤立现象。它常常伴随着请求延迟上升、实例扩容、上下游超时、GC 压力增加,甚至会进一步放大成稳定性事故。对 Go 服务而言,定位 CPU 问题不能只停留在“哪个进程占用高”,而要继续回答下面几个问题:

  • CPU 到底消耗在什么函数上?
  • 是计算真的变多了,还是存在低效实现?
  • 是业务逻辑本身重,还是锁竞争、调度延迟、序列化等基础问题?
  • 如何用 benchmark 证明优化是否有效,而不是“感觉快了”?

本文会围绕 Go 线上排障中最常用的一组工具展开:pproftracebenchmark。同时结合一个完整案例,把 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 热点分析里最直观的视图。它的阅读方法建议记住三句话:

  1. 横向宽度越大,说明耗时越多
  2. 纵向代表调用栈深度,不代表时间先后
  3. 最上层宽函数,往往就是最值得优先分析的热点函数

排查火焰图时,重点看这几类信号:

  • 某个基础库函数特别宽,比如 regexp.(*Regexp).doExecute
  • encoding/json 相关函数在热点区域持续出现
  • runtime.lock2sync.(*Mutex).Lockruntime.semacquire1 很宽,提示锁竞争明显
  • runtime.mallocgcscanobjectgcDrain 较宽,提示对象分配和 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 次之

这说明问题已经比较清晰:

  1. 正则相关调用是主要热点。
  2. 而且不仅匹配耗时高,连编译本身都在占 CPU。
  3. 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 飙高,可以按下面顺序处理:

  1. 先确认范围:单实例还是全体实例,单接口还是全局接口。
  2. 先看背景:流量、发布、重试、任务、数据规模是否变化。
  3. 抓 CPU profile:优先在高峰实例、问题时段抓 20~60 秒。
  4. 看 top 与火焰图:找出最宽的热点函数和主调用链。
  5. 结合代码解释热点:区分是正常业务计算,还是明显低效实现。
  6. 关注经典热点:正则、JSON、锁竞争、对象分配、goroutine 膨胀。
  7. 必要时补 trace:分析调度延迟、阻塞与并发模型问题。
  8. 用 benchmark 验证优化:不要凭感觉判断“已经变快”。
  9. 优化后重新采样:确认热点是否真的下降,避免误判。

CPU 问题排查最怕两种情况:

  • 一种是完全凭经验猜
  • 另一种是工具用了很多,但没有把工具结果和代码语义、流量背景联系起来

真正高效的排查方式,是把这三件事连起来:

监控现象 → profile / trace 证据 → 代码与业务解释。

做到这一步,很多看似复杂的 CPU 问题,都会从“玄学”变成“可证明、可复现、可优化”的工程问题。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang横向:07 内存暴涨与 GC 调优

下一篇

Golang横向:09 死锁与并发问题排查