处理 Go HTTP Server 性能问题,很多人的第一反应是“换框架”“上 CDN”“加机器”,但真正做过几次深挖之后你会发现,大部分性能瓶颈根本不是框架的问题,而是你自己的代码在不知不觉中把 CPU 和内存烧掉了。这篇文章是我在实际项目中做 HTTP 服务性能分析和优化的一次完整复盘,包括压测工具怎么选、pprof 怎么读、瓶颈怎么定位、优化怎么做,以及踩过的坑。适合刚接触 Go 性能调优的开发者,也适合那些被线上 CPU 毛刺和 GC 停顿折磨得头疼的同学。
1. 整体思路:先测量,再分析,最后优化
1.1 为什么必须先做压测而不是先改代码
我见过太多人一上来就“觉得”某个地方慢,然后把 net/http 换成 fasthttp,把 encoding/json 换成 jsoniter,结果压测数据一出来,QPS 没怎么涨,代码倒是改出一堆兼容性问题。
正确的顺序永远是:先跑一轮压测拿到基线数据,再用 pprof 看热点,最后对症下药。没有基线的优化是瞎改,没有 profiling 的优化是赌运气。性能分析这件事,本质上是在回答三个问题:服务目前能承受多少并发?瓶颈在 CPU、内存、锁还是 IO?优化之后到底提升了多少?
我在项目里通常会先定两个指标:一个是 QPS(每秒请求数),一个是 P99 延迟(99% 的请求在多少毫秒内完成)。QPS 代表吞吐能力,P99 代表用户体验。很多服务 QPS 看着还行,但 P99 突然从 50ms 飙到 500ms,这种隐藏问题只有压测才能暴露出来。
1.2 压测环境的搭建原则
压测最忌讳在本地笔记本上跑,也忌讳直接在线上压。我的做法是准备一台独立的压测机,和被测服务部署在同一个内网,网络延迟尽量低。压测机的配置不需要很高,但 CPU 内核数和被测机接近最好,否则压测机自己先成了瓶颈,测出来的数据全是假的。
被测服务建议单独部署一个实例,不要和别的服务混部。混部的情况下,其他服务的抖动会直接影响你的压测数据,排查的时候根本分不清是优化生效了还是隔壁服务抢了 CPU。
另外,压测数据一定要记录完整:压测时间、并发数、QPS、平均延迟、P99、P95、错误率、压测机器的 CPU 和内存占用。没有这些上下文,过几天你再回头看这组数据,根本没法判断它好不好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 压测工具选型与指标解读
2.1 主流压测工具对比
Go 社区常用的压测工具就那么几个,我按实际体验排个序:
| 工具 | 语言 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| wrk | C | 性能极高,线程模型优秀 | 不支持复杂脚本 | 简单 HTTP 接口压测 |
| hey | Go | 单二进制,支持自定义 Header/Body | 高并发下性能略逊 wrk | 快速验证、带参数的 GET/POST |
| ab | C | 老牌工具,服务器自带 | 单线程模型,高并发不稳定 | 简单 GET 压测 |
| ghz | Go | 专门压 gRPC | 只支持 gRPC | gRPC 接口压测 |
| go-wrk | Go | 完全 Go 实现,易用 | 性能一般 | 内部快速压测 |
我日常用得最多的是 wrk 和 hey。wrk 支持多线程,每个线程可以维护多个连接,而且它内部用 epoll 处理事件,压出来的数据非常稳定。hey 的优势是使用简单,一条命令就能带上 Cookie、Header、POST body,适合验证特定场景。
2.2 wrk 压测命令与参数解析
一个标准的 wrk 压测命令长这样:
bash复制wrk -t4 -c200 -d30s -R5000 --latency http://127.0.0.1:8080/api/v1/users
参数含义:
-t4:启动 4 个线程。压测机线程数一般设为 CPU 核数的 1-2 倍。-c200:建立 200 个并发连接。这里的并发是连接数,不是请求数,wrk 会在这 200 个连接上持续发送请求。-d30s:压测持续 30 秒。-R5000:限制总 QPS 上限为 5000。如果不加这个参数,wrk 会以最大速率去打;加了之后可以测服务在固定压力下的稳定性。--latency:输出延迟分布数据,包含 P50、P75、P99、P99.9。
压测结束后,wrk 会输出类似这样的结果:
code复制Running 30s test @ http://127.0.0.1:8080/api/v1/users
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 7.42ms 3.29ms 56.31ms 72.23%
Req/Sec 687.35 172.51 1.67k 71.44%
Latency Distribution
50% 6.61ms
75% 8.28ms
90% 10.82ms
99% 25.16ms
82593 requests in 30.00s, 21.01MB read
Requests/sec: 2753.68
Transfer/sec: 716.91KB
重点关注三块:Req/Sec 的平均值(QPS)、Latency Distribution 里的 P99、以及错误率。如果 Requests/sec 远低于预期,或者延迟分布在某个百分位突然飙升,说明服务进入了某种饱和状态,这时候就要开始 profiling 了。
2.3 压测时容易犯的三个错误
第一个错误是压测持续时间太短。很多人只压 10 秒甚至 5 秒,数据波动极大。我建议至少压 30 秒以上,有条件的压 1-2 分钟,这样才能看出 GC 抖动和内存增长趋势。
第二个错误是忽略了连接复用。wrk 默认使用 HTTP/1.1 keep-alive,但这不代表你的服务端也正确处理了 keep-alive。如果服务端没有正确设置 Connection: keep-alive,或者连接被频繁关闭重建,压测数据会比自己线上表现差很多。这个问题我后面单独讲。
第三个错误是只看平均延迟不看 P99。平均延迟被少数慢请求拉高的情况很常见,但更危险的是平均延迟很低、P99 却很高,这说明有少量请求被某些锁或 GC 卡住了,这种问题在平均数据里完全看不出来。
3. pprof 性能剖析实战
3.1 如何为服务开启 pprof
Go 标准库自带 net/http/pprof,接入方式非常简单。如果你的服务已经用 net/http 启动了,只需要在 main.go 里加一行匿名导入:
go复制import _ "net/http/pprof"
然后在代码里单独起一个监听的 goroutine:
go复制go func() {
// 注意:pprof 端口不要和业务端口混用,避免暴露线上数据
log.Println(http.ListenAndServe("0.0.0.0:6060", nil))
}()
如果你的项目用的是 gin 或其他 Web 框架,框架通常会有自己的 pprof 包装库。gin 的话可以直接用 gin-contrib/pprof:
go复制import "github.com/gin-contrib/pprof"
pprof.Register(router, "/debug/pprof")
这里有个很重要的操作规范:pprof 端口绝对不能暴露到公网。它包含 heap 快照和 goroutine 栈信息,直接暴露等于把服务内部结构脱光了给别人看。我通常的做法是只监听 127.0.0.1,或者走内网权限控制,压测的时候通过 SSH 隧道访问。
3.2 CPU profile 的采集与分析
CPU profile 是最常用的分析手段。在压测进行中,用 go tool pprof 采集一段 CPU 采样数据:
bash复制go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
这个命令会采集 30 秒的 CPU 采样数据,采样频率默认是 100 次/秒。采集完成后会进入 pprof 交互模式。输入 top 可以看到 CPU 消耗排名前 10 的函数:
code复制(pprof) top
Showing nodes accounting for 46.62s, 86.64% of 53.81s total
Dropped 120 nodes (cum <= 0.27s)
flat flat% sum% cum cum%
18.72s 34.79% 34.79% 18.72s 34.79% runtime.futex
10.23s 19.01% 53.80% 21.33s 39.64% runtime.lock2
5.61s 10.43% 64.23% 5.61s 10.43% sync.(*Mutex).Lock
4.32s 8.03% 72.26% 9.15s 17.01% encoding/json.(*Decoder).Decode
3.11s 5.78% 78.04% 3.11s 5.78% runtime.procPin
这一段数据非常典型。runtime.futex 和 runtime.lock2 占了 53% 的 CPU,说明这个服务有严重的锁竞争,大量的线程在自旋等待。sync.(*Mutex).Lock 单独占 10% 也印证了这一点。encoding/json 占 17%,说明 JSON 解析消耗也不小,但这通常是次要优化点。
看到锁竞争,下一步就是找锁在哪里。用 list 命令定位到具体代码行:
code复制(pprof) list sync.(*Mutex).Lock
也可以用 go tool pprof -http=:8080 起一个 Web 界面,在火焰图上直接搜索 Mutex,点开看调用链。火焰图在这种场景下比 top 直观得多。
3.3 heap profile 与内存泄漏判断
内存问题一般是两个方向:分配过多导致 GC 压力大,或者内存泄漏导致 RSS 持续上涨。采集堆数据用:
bash复制go tool pprof -inuse_space http://127.0.0.1:6060/debug/pprof/heap
如果想看历史累计分配量(更利于发现分配热点),用 -alloc_space:
bash复制go tool pprof -alloc_space http://127.0.0.1:6060/debug/pprof/heap
inuse_space 和 alloc_space 的区别很关键。inuse_space 是当前仍在使用的内存,看的是“现在谁占着内存不放”;alloc_space 是累计分配的总量,看的是“谁在频繁分配对象给 GC 制造压力”。
一个常见的判断方法是:在压测稳定后采集一次 inuse_space,再压测 10 分钟,再采集一次。如果 inuse 持续增长且不回落,多半是 goroutine 泄漏或全局缓存无上限。如果 inuse 稳定但 GC 频繁,那就是 alloc 太高,需要减少对象分配。
3.4 goroutine profile 与锁等待分析
goroutine 泄漏是 HTTP 服务最隐蔽的问题之一。采集 goroutine 栈:
bash复制go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine
如果发现大量 goroutine 卡在同一个读写操作上,比如 net/http.(*conn).serve 或 io.Copy,而且数量远超预期并发数,基本可以断定是连接泄漏或协程泄漏。举个真实例子:某个服务在 Nginx 超时断开连接后,服务端没有检测到客户端已断开,一直阻塞在 Read 上,goroutine 越积越多,最终把内存耗尽。这种情况 pprof 的 goroutine profile 一眼就能看出来。
mutex 和 block profile 用来分析锁竞争和阻塞等待,使用前需要在代码里开启采样:
go复制runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)
采集:
bash复制go tool pprof http://127.0.0.1:6060/debug/pprof/mutex
go tool pprof http://127.0.0.1:6060/debug/pprof/block
这两个 profile 默认是关闭的,因为它们本身有一定的开销。线上最好不要一直开,只在排查问题时临时开启,查完就关。
4. 从 pprof 到优化:常见瓶颈与实战手段
4.1 锁竞争:第一杀手
回到上面的压测数据,runtime.futex 占了 35% 的 CPU,这是典型的锁竞争。锁竞争的本质是多个 goroutine 同时抢一把锁,抢不到的就挂起或自旋,CPU 全部耗在切换和等待上。
解决锁竞争有几种常用手段:
第一种是缩小临界区。把锁内部的耗时操作移出去,只锁必要的变量读写。我见过有人在锁内部做了 JSON 序列化,锁持有时间一长,整个服务的并发能力直线下降。
第二种是读写锁替代互斥锁。如果某些数据是读多写少(比如配置项、路由表),用 sync.RWMutex 可以大幅提升并发读性能。但注意写读比:如果写操作也很频繁,RWMutex 反而可能比 Mutex 更差,因为写者等待时间长,还可能触发写者饥饿。
第三种是分片锁。把一把大锁拆成多把小锁,每个锁管一部分数据。比如做 KV 缓存时,按 key 哈希分到 16 个 shard,每个 shard 一把锁,冲突概率直接降到原来的 1/16。这是我实际项目里效果最明显的一次优化,QPS 直接翻了 2.5 倍。
第四种是原子操作。如果是计数器、状态标记这类简单类型,用 sync/atomic 的原子方法比加锁轻量得多。
4.2 内存分配与 GC 压力
Go 的 GC 是并发三色标记,但 GC 期间 STW 仍然存在,只是时间很短。真正影响性能的是分配速率:分配越多,GC 周期越频繁,每次 GC 扫描的对象越多,STW 时间也会变长。
从 heap profile 里看 alloc_space,最常见的分配热点有三个:
第一个是 JSON 序列化和反序列化。encoding/json 用反射实现,每次都会分配大量的临时对象。如果服务是 IO 密集型且频繁收发 JSON,这个开销尤其明显。优化方案是:
- 小对象用
json.Marshal没问题,但大对象和频繁调用的场景,建议用json.Encoder/json.Decoder,它们能复用缓冲区。 - 如果追求极致性能,可以换用
sonic或jsoniter。实测下来sonic在大部分场景比标准库快 2-3 倍,但它依赖cgo和汇编,部署时要留意架构兼容性。 - 结构体字段数量越少,序列化越快。这不是废话,很多人为了省事把整个大结构体直接返回,其中大部分字段前端根本不用。按需定义响应结构体,既能减少序列化时间,也能减小响应体积。
第二个是字符串拼接。在循环里用 += 拼字符串是性能杀手,因为字符串不可变,每次拼接都要创建新字符串。正确做法是用 strings.Builder。在 Go 1.20+ 里,strings.Builder 的 WriteString 方式是零拷贝的,比 bytes.Buffer 还快。
第三个是循环里创建的临时对象。比如:
go复制for _, item := range items {
resp := new(Response)
// ...
results = append(results, resp)
}
每次循环新建对象不算大问题,但如果对象内部包含大切片、大 map,GC 压力就上来了。更好的是提前初始化好容量,避免 append 时频繁扩容:
go复制results := make([]*Response, 0, len(items))
预分配容量能显著减少 append 触发扩容时的内存拷贝和分配。
4.3 连接管理:Keep-Alive 与连接池
HTTP 服务性能下降的另一个隐蔽原因是连接频繁重建。TCP 三次握手 + 四次挥手是有代价的,尤其在 TLS 场景下,握手成本高出一个数量级。
在压测时,如果服务端没有开启 keep-alive,wrk 的压测数据会非常难看:延迟高、QPS 低、CPU 占用却很高(因为大量 CPU 花在握手和连接处理上)。Go 的 net/http 默认是开启 keep-alive 的,但如果你在服务前面挂了 Nginx,问题就变成 Nginx 到 Go 服务这段连接的管理了。
排查思路是这样:看 Go 服务的 netstat 连接数,如果 TIME_WAIT 状态的连接非常多,说明客户端频繁断开后重建连接。这时候优先检查上游服务是否配置了 keep-alive。
另外,Go 的 HTTP Server 有几个关键超时参数一定要设置:
go复制srv := &http.Server{
Addr: ":8080",
ReadTimeout: 10 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
ReadTimeout 是读请求体超时,WriteTimeout 是写响应超时,IdleTimeout 是 keep-alive 连接的空闲超时。这几个参数不设置的话,恶意或异常客户端可以长期占用连接不释放,导致连接数无限增长。
同样的,如果服务作为客户端调第三方 HTTP API,一定要用连接池。Go 的 http.Transport 默认就有连接池,但很多人在每次调用时新建 http.Client,这样连接池完全失效。正确的姿势是在初始化时创建一次:
go复制var httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
Timeout: 10 * time.Second,
}
MaxIdleConnsPerHost 这个参数容易被忽略。默认值是 2,意味着同一个目标主机最多只复用 2 个空闲连接。高并发场景下,连接不够用就会新建,用完就关闭,频繁建连的性能损耗非常大。我一般在服务间调用场景把它调到 20-50。
4.4 路由与中间件开销
Go 标准库的 net/http 路由在 Go 1.22 之后支持了方法匹配和路径参数,性能也大幅提升。但对大多数项目来说,还是习惯用 gin、echo 或 chi。
在压测时,路由匹配开销会被放大。gin 用的是 radix tree,chi 用的是一个更简单的树实现,gorilla/mux 用的是正则匹配,性能是最差的。如果你的路由数量很多(几百上千条),路由匹配本身可能成为瓶颈。
我做过一次真实对比:在 200 条路由的情况下,net/http 原生的 ServeMux、gin、chi 的 P99 差异不大;但换成 gorilla/mux,P99 明显高出一截。如果你的服务路由有几百条,优先考虑用 gin 或 chi;如果路由只有几十条,用标准库就够了,没必要引入框架。
中间件的开销也不能忽略。每一个中间件都是一层函数调用,如果在中间件里做了解析 Body、修改 ResponseWriter 之类的操作,性能影响会被放大。排查时可以用 go test -bench 分别测中间件挂载前后的 QPS,对比数据说话。
4.5 日志:被忽视的 CPU 杀手
很多 HTTP 服务的性能问题不是出在业务逻辑上,而是出在日志上。每次请求打一条日志,如果日志里包含 JSON 序列化的数据,或者用了 fmt.Sprintf 拼接大量字符串,开销会被放大到整个请求链路。
我在一个项目里发现,访问日志 + 业务日志的序列化开销占了总 CPU 的 18%。优化方案是:
- 用 structured logging 库(如 zap、zerolog),它们把字段序列化做成了零分配。
- 日志级别在生产环境设置为
Info或Warn,不要用Debug。 - 日志输出用异步方式,避免日志 IO 阻塞请求链路。但要注意异步日志的丢日志风险和内存积压问题,建议结合队列长度做背压。
一个实用的技巧:用 zap 时,日志字段不要用 fmt.Sprintf 拼好再传,而是直接传结构化字段,让 zap 内部序列化。这样既方便日志聚合,性能也更好。
5. 真实优化案例复盘
5.1 案例一:单参数列表接口的 QPS 翻倍
这个接口是一个分页列表查询,返回 JSON 数据。压测基线:QPS 2800,P99 125ms。通过 CPU profile 发现,encoding/json.Marshal 占 25%,fmt.Sprintf 占 12%,锁等待占 8%。
优化步骤:
第一步,把响应结构体简化,去掉不需要的字段。
第二步,把循环里的 fmt.Sprintf("%d", id) 改成 strconv.Itoa(id)。strconv.Itoa 不经过反射和格式化解析,快很多。
第三步,为列表接口引入 sync.Pool 复用响应缓冲区:
go复制var respPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
}
buf := respPool.Get().([]byte)
buf = buf[:0]
defer respPool.Put(buf)
虽然看起来只是一个小优化,但配合对象预分配,GC 压力明显下降。
优化后压测:QPS 5600,P99 58ms。没有改任何业务逻辑,就是把消耗 CPU 的地方换成了更轻的实现。
5.2 案例二:高并发下 P99 飙高问题
另一个服务在并发 500 时 P99 从 60ms 飙到 800ms,但 QPS 没有明显变化。用 pprof 采集 CPU profile 后发现,runtime.lock2 占比较高,锁竞争来自一个全局的访问计数器。
这个计数器用 sync.RWMutex 保护,每次请求都会读和写。优化方案是用 atomic.AddInt64 替换互斥锁,并且把计数逻辑改成异步批量上报,不在请求路径上同步操作。
改造后 P99 稳定在 70ms 左右。这个案例说明:很多性能问题的根源是“把简单的并发操作复杂化了”,原子操作能解决的,不要上锁。
5.3 案例三:连接池配置不当导致的建连风暴
还有一个服务,主要功能是转发请求到下游服务,压测时 QPS 上不去,而且错误率时而波动。排查后发现,http.Transport 的 MaxIdleConnsPerHost 没有设置,默认只有 2,导致下游服务的连接频繁重建,TCP 连接数暴增,TIME_WAIT 大量堆积。
调整参数为 MaxIdleConnsPerHost: 50,同时设置 MaxConnsPerHost: 100 做上限保护。优化后 QPS 提高了一倍多,错误率降为零。
这个案例最值得注意的地方是:压测数据未必能直接告诉你连接池有问题,但如果你看到服务的 TCP TIME_WAIT 大量堆积、CPU 用户态不高但系统态偏高,就很有可能是建连太频繁了。
6. 系统层面调优与压测结果验证
6.1 GOMAXPROCS 与运行时参数
Go 1.5 之后默认 GOMAXPROCS 等于 CPU 核数。但在容器环境下,这个默认值可能是错的。如果你跑在 Kubernetes 里,容器限制的是 2 个 CPU,但 Go 运行时看到的是宿主机的 32 核,默认就会用 32 个 P,反而导致频繁的线程切换和调度开销。
解决方式是使用 automaxprocs 库:
bash复制go get go.uber.org/automaxprocs
在 main.go 里导入:
go复制import _ "go.uber.org/automaxprocs"
它会在 init 里自动读取容器的 CPU 配额并设置 GOMAXPROCS。这个库解决的问题比想象中更常见,容器部署的 Go 服务建议直接默认加上。
6.2 Linux 网络参数优化
如果你的 HTTP 服务要支撑高并发,几个内核参数值得检查。在压测机上执行:
bash复制sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
tcp_tw_reuse 允许内核复用 TIME_WAIT 状态的连接,对压测工具这种大量短连接的场景尤其有效。注意,tcp_tw_reuse 只对出站连接起作用,服务端处理入站连接时不会用到它,所以不要以为改了它就能解决服务端的 TIME_WAIT 堆积,服务端的 TIME_WAIT 堆积要靠 keep-alive 和连接复用来解决。
文件描述符上限也要调。Go 的 net/http 每个连接占用一个 fd,默认的 ulimit 1024 根本不够用:
bash复制ulimit -n 1048576
永久生效需要修改 /etc/security/limits.conf。
压测时还要留意压测机的性能。我们曾经加并发连接数到 2000,压测机的 CPU 先到 100% 了,导致 QPS 瓶颈出现在压测机而不是被测服务上。判断方法是看压测机的 CPU 占用,超过 80% 就说明压测机自己成了瓶颈,需要分布式压测或者减少并发。
6.3 优化前后的数据对比
每次优化完,必须重新跑同一组压测用例,记录优化前后的数据。用表格对比才是最有说服力的:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| QPS | 2800 | 5600 | 100% |
| P50 延迟 | 35ms | 18ms | 48.6% |
| P99 延迟 | 125ms | 58ms | 53.6% |
| 每请求 CPU 耗时 | 0.35ms | 0.18ms | 48.6% |
| GC 次数/分钟 | 120 | 45 | 62.5% |
压测数据需要多跑几轮取稳定值,我一般跑 3 轮,每轮 60 秒,如果三轮数据波动在 ±5% 以内,取平均值当作有效数据。
7. 问题排查经验与运维小技巧
7.1 压测时遇到的典型问题速查
我在多个项目里反复遇到过下面这些问题,整理成一张速查表:
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| QPS 上不去,CPU 用户态高 | 业务代码忙循环 | CPU profile 看 top 热点 | 优化热点函数 |
| QPS 上不去,CPU 系统态高 | 频繁系统调用/连接重建 | netstat 看 TIME_WAIT | 调连接池,开启 keep-alive |
| QPS 上不去,CPU 空闲但延迟高 | 协程阻塞/锁等待 | 采集 goroutine/mutex profile | 消除锁竞争,修复阻塞 |
| P99 远高于 P50 | GC 停顿或偶发锁 | 看 GC 日志,采集 block profile | 减少分配,优化锁 |
| 内存持续上涨 | goroutine 泄漏或缓存无上限 | 多次 heap profile 对比 | 修复泄漏,限制缓存大小 |
| 错误率随并发升高 | 连接池耗尽、超时设置过短 | 看服务端错误日志 | 调大超时,增大连接池 |
7.2 pprof 数据采集的几个注意事项
采集 pprof 数据时,有几个容易踩的坑。
第一,profile 采集时长要和压测时长匹配。CPU profile 是采样式的,采集 30 秒比采集 5 秒更准确。我建议压测 60 秒,从第 10 秒开始采集 30 秒,保证数据覆盖的是稳定期而不是刚启动的预热期。
第二,压测开始时服务要预热。Go 服务的 JIT 程度不高,但连接池、对象池、缓存都需要时间预热。刚开始压测的前几秒数据通常偏高,预热 5-10 秒后再开始记录数据。
第三,pprof 的 Web 界面很适合做快速对比。用 go tool pprof -http=:8080 <profile文件> 打开火焰图,优化前后各采一份,并排看火焰图能直观看到热点函数的高度变化。火焰图越“扁平”越说明热点分散,越“尖峰”越说明有集中热点可以打。
7.3 一个我自己保留的调试流程
总结一下我常用的完整流程,供参考:
- 写一个可复现的压测脚本,记录基线 QPS 和 P99。
- 压测 30 秒,同时采集 30 秒 CPU profile。
- 用
top和火焰图定位 CPU 热点排名前 5 的函数。 - 对内存问题,采集
alloc_space,定位分配热点。 - 一次只改一个点,改完重新压测,对比数据。
- 数据有提升就保留,没提升就回滚,不要同时改多个点。
这个“一次只改一个点”的原则最难坚持,但最重要。如果同时改了 JSON 库、日志库、连接池三个地方,出问题了根本不知道是哪一步引入的。
8. 关于性能优化的一些经验总结
性能优化不是一次性的工作,而是反复迭代的过程。服务上线后,业务量增长、代码变更、依赖升级都会让性能特征发生漂移。我的建议是把压测脚本、pprof 采集命令、数据对比表沉淀成一个固定的工具脚本,新功能上线前自动跑一轮,性能回归能第一时间发现。
另外,不要为了优化而优化。如果 P99 已经满足业务指标,某个函数虽然看起来不够高效,但改动风险很高,那就不值得动。性能优化要考虑投入产出比,优先解决数据证明是瓶颈的地方,而不是凭感觉猜。
最后分享一个印象很深的经验:有一次我花了整整一天优化某个接口的 QPS,从 3000 调到 8000,结果上线后一查真实流量,这个接口的调用量一天才几百次,真正的热点在另一个接口上。从那以后我不再做“无数据驱动的优化”,所有优化动作都先看真实流量分布和压测数据。这个习惯帮我省下了大量时间,也希望对你有所帮助。
