关于 go-troubleshooting
面向 Go 开发的故障排查技能,强调先复现和收集证据,再定位根因并修改代码。它按崩溃、死锁、泄漏、竞态、测试和构建等症状提供诊断步骤及参考资料。
主要功能
- 01先复现问题并记录证据
- 02按症状选择诊断流程
- 03使用 pprof 分析内存与 Goroutine
- 04排查竞态、间歇性测试和构建错误
- 05通过回归测试验证修复
适用场景
- 01根据 panic 堆栈定位 Go 代码问题
- 02调查服务卡顿、死锁或资源泄漏
- 03复现并排查间歇性测试失败
- 04处理 Go 模块或工具链构建错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-troubleshooting安装后在 Claude Code 中输入/go-troubleshooting确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/common-bugs.mdflaky-and-build.mdmethodology.md详细介绍
Go 故障排查
先诊断,再修复。先复现、观察、定位,然后再改代码。 绝不要“修复”一个尚未解释清楚的症状——否则 Bug 只会转移到别处。
详细参考资料,按需加载:
references/methodology.md— 调试循环、先追踪调用方再归咎于代码、git bisect,以及自身推理中的危险信号。references/flaky-and-build.md— 复现不稳定测试及其常见原因;构建、模块和工具链错误及其修复方法。references/common-bugs.md— Go 中能顺利编译却行为异常的 Bug 的“症状 → Bug → 修复”对照表。
仅当下方摘要不足以解决问题时,才阅读参考文件。
0. 方法
- 说明预期结果与实际结果,并收集完整证据(完整堆栈、精确输入、跟踪 ID)。
- 在失败的测试中复现问题,然后缩小复现范围。
- 一个假设、一次更改、一次观察——绝不叠加多项更改。
- 修复原因,而非触发条件:不断追问错误值为何会存在。
- 三次修复失败,说明思维模型有误——从证据重新开始。如果问题根源早于你,
git bisect run可以找到引入问题的提交。
1. 根据症状选择排查流程
| 症状 | 流程 |
|---|---|
| 结果错误,没有崩溃 | §0 方法,然后查看 references/common-bugs.md |
| 崩溃并有堆栈跟踪 | §2 阅读 panic |
| 程序挂起/请求停滞 | §3 转储 goroutine,找出阻塞点 |
fatal error: all goroutines are asleep | §3 — Go 检测到完全死锁 |
| 内存持续增长,直到 OOM | §4 堆配置文件差异 |
| Goroutine 数量持续增长 | §5 Goroutine 配置文件差异 |
| 间歇性数据损坏/异常值 | §6 竞态检测器 |
| 需要交互式检查状态 | §7 Delve |
| 生产环境中正在发生事故 | §11 重启前先采集信息 |
| 测试间歇性失败 | §9 不稳定测试 |
| 构建、模块或工具链错误 | §10 构建失败 |
2. 阅读 Panic
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6bb0e4]
goroutine 43 [running]:
myapp/internal/service.(*UserService).Notify(0x0, {0xc000123456?, ...})
/app/internal/service/user.go:87 +0x24
myapp/internal/handler.(*Handler).Create(0xc0001a2000, ...)
/app/internal/handler/user.go:41 +0x1c5
按以下方式逐项读取:
- 第一行:panic 的类型。
nil pointer dereference+addr=0x0表示接收者、字段、map 或指针参数为 nil。 - 你自己的代码中的顶部栈帧:
user.go:87— 前往该位置。 - 栈帧中的接收者值:
(*UserService).Notify(0x0, ...)— 第一个 参数0x0就是接收者:服务本身为 nil。 追踪它在哪里被构造(或为何未被构造)。 goroutine 43— 如果不是 goroutine 1,找到启动它的位置,并检查该处是否应该有 recover 边界。
3. 挂起与死锁
从挂起的进程获取 goroutine 转储:
kill -QUIT <pid> # dumps all goroutine stacks to stderr, then exits
# or, if net/http/pprof is mounted:
curl 'localhost:6060/debug/pprof/goroutine?debug=2'
然后对栈进行分类:
sync.(*Mutex).Lock上出现[semacquire]— 找出哪个 goroutine 持有互斥锁:查找另一个位于临界区内的栈。两个 goroutine 各自持有一把锁 = 锁顺序反转。[chan send]/[chan receive]— 通道另一端已经不存在。找到本应接收/发送的 goroutine,并查明它为何退出(或为何从未启动)。[select]中缺少ctx.Done()分支 — 阻塞调用忽略了取消信号。- 数百个相同的栈 — 这是泄漏(§5),不是死锁。
如果只是变慢而非卡住?启用 runtime.SetMutexProfileFraction /
SetBlockProfileRate 并读取互斥锁和阻塞配置文件,或采集 5 秒跟踪
(/debug/pprof/trace?seconds=5),查看那些可运行却未运行的 goroutine。
kill -QUIT 会结束进程——在生产环境中,优先使用 pprof 端点。
4. 内存泄漏
在长期运行的服务中,将 net/http/pprof 挂载到私有监听器
(localhost:6060),绝不要挂载到公共 mux 上。
对比不同时段的堆配置文件——持续增长且从不回落就是泄漏:
curl -s localhost:6060/debug/pprof/heap > heap1.pb.gz
sleep 300 # let the leak accumulate
curl -s localhost:6060/debug/pprof/heap > heap2.pb.gz
go tool pprof -base heap1.pb.gz heap2.pb.gz
(pprof) top # biggest positive delta = the leak
(pprof) list FuncName
常见原因:没有淘汰机制的无界缓存/map、固定住大型数组的子切片、从未停止的 time.Ticker、未关闭的响应体、不断增长的全局切片、遗忘的 goroutine 持有缓冲区。
5. Goroutine 泄漏
在 Go 1.27+ 中,先查看泄漏剖析数据——这些 goroutine 阻塞在通道或锁上,且永远不会有人将其释放:
go tool pprof http://localhost:6060/debug/pprof/goroutineleak
它无法检测通过全局变量存活的 goroutine,或仍在运行的 goroutine;对于这些情况,请对比两份 goroutine 剖析数据:
curl -s localhost:6060/debug/pprof/goroutine > g1.pb.gz
sleep 300
curl -s localhost:6060/debug/pprof/goroutine > g2.pb.gz
go tool pprof -base g1.pb.gz g2.pb.gz
(pprof) top # the growing stack is your leak site
泄漏的调用栈会告诉你哪个 go 语句始终无法终止。
修复终止路径(context、关闭通道)——并发技能中的相关模式。在测试中,goleak(uber-go/goleak)会让遗留 goroutine 的测试失败。
6. 竞态检测器
go test -race ./... # in CI, always
go build -race ./cmd/api # staging binaries under real traffic
报告会展示两个调用栈:写入操作和并发的读取/写入操作,并标出各自 goroutine 的创建位置。解决方法绝不是“加个 sleep”——保护状态(互斥锁)、转移所有权(通道),或将其设为不可变。-race 只会报告实际执行过的竞态:一次干净的运行无法证明未经测试的路径没有问题。
7. Delve
dlv test ./internal/service -- -test.run TestTransfer # debug a test
dlv attach <pid> # running process
dlv core ./api core.1234 # post-mortem
(dlv) break user.go:87
(dlv) continue
(dlv) print svc.repo # inspect exact values
(dlv) goroutines -t # all goroutines with stacks
(dlv) goroutine 43 bt # switch and backtrace
需要查看实际值或 goroutine 状态,而不仅仅是位置时,请使用 delve。若要快速定位问题,添加一条有针对性的 t.Logf 或 slog.Debug,再运行一次测试,通常更快。
8. 诊断环境变量
GOTRACEBACK=all ./api # panic dumps ALL goroutines, not just one
GODEBUG=gctrace=1 ./api # GC cycles: pacing, heap goal, pause times
GOMEMLIMIT=512MiB ./api # soft memory limit — mitigates OOM while
# you find the real leak
GOTRACEBACK=crash ./api # also write a core dump, for `dlv core`
GODEBUG=schedtrace=1000 ./api # scheduler state every second
Go 1.27+ 会在回溯中打印 pprof goroutine 标签;如果标签中包含敏感信息,请设置 GODEBUG=tracebacklabels=0。
9. 不稳定的测试
先确保测试能按需失败,再着手修改:使用 -count=200 -failfast,然后使用 -race、-shuffle=on(用打印出的种子重放),以及 -cpu=1,2,8。常见原因包括用 sleep 进行同步、使用真实时钟、共享包级状态、断言依赖 map 顺序、端口固定,以及先前测试遗留的 goroutine。在 CI 中重试只会让你看不到这个错误,用户却仍会遇到。
10. 构建失败
只看第一个错误——后续错误通常都是由它引起的。工具链版本过旧(GOTOOLCHAIN)、缺少 go.sum 条目、v1/v2 模块并存导致导入有歧义、操作系统专属文件、缺少 C 编译器、私有模块(GOPRIVATE),以及 Go 1.27 的 stdversion 检查,各自都有固定的解决方法。
命令以及完整的原因 → 修复对照表:references/flaky-and-build.md。
11. 生产事故
重启前先采集信息——重启会销毁证据:
- Goroutine 转储(
/debug/pprof/goroutine?debug=2)、堆剖析数据、30 秒 CPU 剖析数据,以及 5 秒跟踪数据;如果服务运行了 flight recorder,也可以采集其快照(Go 1.25+)。 - 确切的构建信息:
go version -m ./bin/api会显示 Go 版本、模块版本和 VCS 修订版本。 - 然后重启,并按照上述流程离线分析采集到的数据。pprof 仅监听内部端口——它会暴露调用栈和命令行。
验证清单
- 在更改代码前复现症状(或通过转储/剖析数据捕获)
- 说明根本原因:能够解释为什么故障会在该位置发生
- panic 修复针对 nil/越界问题的根源,而不是使用包装器
recover - 死锁修复建立统一的锁顺序,或移除共享锁
- 验证泄漏修复:修复后,goroutine/堆剖析数据保持平稳
- 并发相关修复后,
go test -race ./...通过 - 添加回归测试,确保没有修复时测试会失败
- pprof 端点仅绑定到 localhost/私有接口