在 tRPC-Go 服务中集成 pprof 性能分析端点时,遇到了一个典型的"端口监听正常,但路由全部 404"的问题。本文记录完整的排查思路与修复过程,希望对遇到同类问题的同学有所帮助。

背景

在服务中加入了如下 pprof 调试代码,期望通过 http://localhost:6060/debug/pprof/ 访问性能分析页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import (
"net/http"
_ "net/http/pprof"
"runtime"
)

func Debug() {
runtime.GOMAXPROCS(1)
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)

go func() {
defer utils.RecoverLog()
log.Info("pprof listened 127.0.0.1:6060")
if err := http.ListenAndServe(":6060", nil); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
}

服务启动后,访问 curl http://localhost:6060/debug/pprof/ 却返回 404


第一步:排查端口是否真的在监听

首先怀疑是 goroutine 调度问题(代码中有 runtime.GOMAXPROCS(1) 限制并发),导致 pprof server 的 goroutine 根本没有被调度执行。

lsof 验证端口状态:

1
lsof -i :6060

输出结果:

1
2
COMMAND     PID     USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
___1go_bu 42250 edenzeng 40u IPv6 0x589b2d8d06bd2d95 0t0 TCP *:6060 (LISTEN)

端口确实在监听,说明 pprof server 已经正常启动,问题不在 goroutine 调度,而在路由层。


第二步:验证是路由问题还是框架接管

进一步访问根路径,观察响应特征:

1
curl -v http://localhost:6060/

响应如下:

1
2
3
4
5
6
7
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< X-Content-Type-Options: nosniff
< Date: Sat, 28 Feb 2026 03:31:30 GMT
< Content-Length: 19
<
404 page not found

关键线索:

  • 响应头包含 X-Content-Type-Options: nosniff,这是 Go 标准库 net/http 的特征,说明请求确实打到了 pprof server,而不是被其他进程拦截。
  • 返回的是 404 page not found,说明 DefaultServeMux没有任何路由,pprof 的路由注册已经丢失。

根本原因分析

_ "net/http/pprof"init() 函数会在程序启动时,将 pprof 相关路由注册到 http.DefaultServeMux

1
2
3
4
5
6
7
8
// net/http/pprof 包内部 init() 的行为
func init() {
http.HandleFunc("/debug/pprof/", Index)
http.HandleFunc("/debug/pprof/cmdline", Cmdline)
http.HandleFunc("/debug/pprof/profile", Profile)
http.HandleFunc("/debug/pprof/symbol", Symbol)
http.HandleFunc("/debug/pprof/trace", Trace)
}

但是,项目使用了 tRPC-Go 框架,在 trpc.NewServer()thttp.RegisterNoProtocolServiceMux() 的初始化过程中,框架内部重置或替换了 http.DefaultServeMux,导致 pprof 在 init() 阶段注册的路由全部丢失。

最终 http.ListenAndServe(":6060", nil) 使用的 DefaultServeMux 是一个空的 mux,所有请求自然返回 404。

整个问题的时序如下:

1
2
3
4
5
6
7
8
9
10
11
程序启动

├─ init() 阶段:pprof 路由注册到 DefaultServeMux ✅

├─ main() 阶段:
│ ├─ trpc.NewServer() ← tRPC 初始化,DefaultServeMux 被重置 ❌
│ ├─ thttp.RegisterNoProtocolServiceMux(...) ← 框架接管路由
│ └─ go Debug()
│ └─ http.ListenAndServe(":6060", nil) ← 使用已被清空的 DefaultServeMux

└─ 访问 /debug/pprof/ → 404

修复方案

核心思路:不依赖 DefaultServeMux,改为创建独立的 ServeMux,显式注册 pprof 路由,完全隔离 tRPC 框架的影响。

修改 import

1
2
3
4
5
// 修改前:匿名导入,依赖 init() 自动注册到 DefaultServeMux
_ "net/http/pprof"

// 修改后:显式导入,手动注册路由
"net/http/pprof"

同时移除不再使用的 "os" 导入。

修改 Debug 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 修改前
func Debug() {
runtime.GOMAXPROCS(1) // 限制并发,存在隐患
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)

go func() {
defer utils.RecoverLog()
log.Info("pprof listened 127.0.0.1:6060")
if err := http.ListenAndServe(":6060", nil); err != nil { // nil = DefaultServeMux,已被污染
log.Fatal(err) // Fatal 会导致整个进程退出
}
os.Exit(0)
}()
}

// 修改后
func Debug() {
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)

go func() {
defer utils.RecoverLog()
// 使用独立的 ServeMux,避免被 tRPC 框架污染 DefaultServeMux
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
log.Info("pprof listened :6060")
if err := http.ListenAndServe(":6060", mux); err != nil {
log.Errorf("pprof server error: %v", err) // 改为 Errorf,不影响主进程
}
}()
}

改动对比总结

改动项 修改前 修改后 原因
pprof 导入方式 _ "net/http/pprof" "net/http/pprof" 改为显式导入,手动注册路由
ServeMux nil(DefaultServeMux) http.NewServeMux() 独立 mux,隔离 tRPC 影响
pprof 路由注册 依赖 init() 自动注册 显式调用 HandleFunc 确保路由不被框架清除
GOMAXPROCS(1) 存在,限制并发 已移除 不应在 pprof 函数中限制全局并发
错误处理 log.Fatal + os.Exit(0) log.Errorf pprof server 异常不应终止主进程

经验总结

  1. net/http/pprofinit() 注册时机很早,但不是线程安全的。如果后续有框架重置 DefaultServeMux,注册会静默丢失,且没有任何报错,排查成本较高。

  2. 在使用任何 HTTP 框架时,都应避免依赖 DefaultServeMux。显式创建独立的 ServeMux 是更安全、更可控的做法,尤其是在多框架共存的场景下。

  3. 端口监听正常 ≠ 路由注册正常。遇到 404 时,先用 curl -v 访问根路径,通过响应头特征判断是哪个 server 在响应,再进一步定位路由问题。

  4. log.Fatal 不应出现在后台 goroutine 中。pprof server 是辅助功能,其异常不应导致整个服务进程退出,应改用 log.Errorf 记录错误后让 goroutine 自然退出。