总结摘要
记录一份排查 Go 服务 RSS 偏高时常用的 pprof 现场采集脚本。重点是先判断 RSS 升高到底是不是 Go 堆导致,再决定往 heap、allocs、goroutine 还是其他方向继续查。
线上看见某个 Go 进程 RSS 一直涨,第一反应通常都是“是不是内存泄漏了”。但 RSS 这件事本身就不只等于 Go 堆,所以如果一上来就盯着 heap 看,很容易把方向看偏。
这篇文章只聚焦一件事:RSS 偏高时,怎么先用一份 pprof 采集脚本把现场保住,并快速判断问题是不是主要出在 Go 堆上。其他像 CPU、锁竞争、阻塞这些,只放在后面当补充说明。
一、背景
前段时间碰到一个很典型的问题:监控上 RSS 一路往上走,但业务侧又说没有明显流量上涨。这个时候最麻烦的不是“没有工具”,而是很容易一开始就把 RSS 和 Go 堆画等号。
实际上排这类问题,第一步不是立刻下结论,而是先回答下面几个问题:
- RSS 涨,Go 堆是不是也在涨
- Go 堆如果在涨,是存活对象变多,还是分配太猛导致堆迟迟降不下来
- 如果 Go 堆并不高,那 RSS 是不是更可能来自 goroutine 栈、cgo、mmap 或其他运行时开销
所以我后来固定了一个动作:先把和内存相关的 profile 一次性抓下来,再决定往哪个方向深挖。
前提很简单:目标服务已经暴露了 /debug/pprof,比如:
1
| http://127.0.0.1:6060/debug/pprof
|
如果你碰到的是 RSS 偏高,这份脚本最直接的用途就是先帮你判断:
- 是不是 Go 堆本身导致 RSS 上升
- 是不是很多小对象一直活着
- 是不是分配量过猛把 GC 压上去了
- 是不是 goroutine 堆积把栈内存一起带上来了
二、采集脚本
脚本本身没什么花活,主要目标就一个:先把现场保住。
下面是完整版本。示例里的 BASE_URL 是实际服务地址,使用时改成你自己的即可。
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
| #!/usr/bin/env bash
set -euo pipefail
OUT_ROOT="${OUT_ROOT:-${HOME}/pprof}"
BASE_URL="${BASE_URL:-http://your-service.namespace.svc.cluster.local:6060/debug/pprof}"
CPU_PROFILE_SECONDS="${CPU_PROFILE_SECONDS:-30}"
PPROF_HTTP_ADDR="${PPROF_HTTP_ADDR:-127.0.0.1:18080}"
BASE_HOST="${BASE_URL#*://}"
BASE_HOST="${BASE_HOST%%/*}"
HOST_PREFIX="${BASE_HOST%%.*}"
TS="$(date +%Y%m%d-%H%M%S)"
OUT_DIR="${OUT_ROOT}/${HOST_PREFIX}-${TS}"
mkdir -p "${OUT_DIR}"
curl_opts=(
--fail
--silent
--show-error
--progress-bar
--connect-timeout 3
--max-time 90
--retry 2
--retry-delay 1
)
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
}
fetch_pprof() {
local name="$1"
local query="${2:-}"
local output_name="${3:-$1.pb.gz}"
local url="${BASE_URL}/${name}"
local file="${OUT_DIR}/${output_name}"
local err_file="${file}.curl.err"
local start_ts end_ts elapsed
if [[ -n "${query}" ]]; then
url="${url}?${query}"
fi
log "start fetching ${name} ${query:+(${query})}"
start_ts="$(date +%s)"
if ! curl "${curl_opts[@]}" \
-o "${file}" \
"${url}" \
2>"${err_file}"; then
rm -f "${file}"
log "failed ${output_name}; details saved to ${err_file}"
return 1
fi
rm -f "${err_file}"
end_ts="$(date +%s)"
elapsed="$((end_ts - start_ts))"
log "done ${output_name} (${elapsed}s)"
}
fetch_text() {
local name="$1"
local query="${2:-}"
local output_name="$3"
local url="${BASE_URL}/${name}"
local file="${OUT_DIR}/${output_name}"
local err_file="${file}.curl.err"
local start_ts end_ts elapsed
if [[ -n "${query}" ]]; then
url="${url}?${query}"
fi
log "start fetching text ${name} ${query:+(${query})}"
start_ts="$(date +%s)"
if ! curl "${curl_opts[@]}" \
-o "${file}" \
"${url}" \
2>"${err_file}"; then
rm -f "${file}"
log "failed ${output_name}; details saved to ${err_file}"
return 1
fi
rm -f "${err_file}"
end_ts="$(date +%s)"
elapsed="$((end_ts - start_ts))"
log "done ${output_name} (${elapsed}s)"
}
main() {
log "pprof collection started"
log "output dir: ${OUT_DIR}"
log "base url : ${BASE_URL}"
log "prefix : ${HOST_PREFIX}"
cat > "${OUT_DIR}/README.txt" <<EOF
Collected at: $(date '+%F %T %Z')
Base URL: ${BASE_URL}
Host Prefix: ${HOST_PREFIX}
Files:
- heap.pb.gz : current in-use heap profile
- heap_gc.pb.gz : heap after forced GC, better for leak suspicion
- allocs.pb.gz : historical allocation profile
- goroutine.pb.gz : goroutine profile
- goroutine.txt : goroutine text dump
- profile.pb.gz : ${CPU_PROFILE_SECONDS}s CPU profile (optional)
- mutex.pb.gz : mutex contention profile
- block.pb.gz : blocking profile
- threadcreate.pb.gz : OS thread creation profile
- cmdline.txt : process cmdline
EOF
log "collecting metadata"
fetch_text cmdline "" "cmdline.txt"
log "collecting memory-related profiles"
fetch_pprof heap "" "heap.pb.gz"
fetch_pprof heap "gc=1" "heap_gc.pb.gz"
fetch_pprof allocs "" "allocs.pb.gz"
log "collecting goroutine/thread profiles"
fetch_pprof goroutine "debug=0" "goroutine.pb.gz"
fetch_text goroutine "debug=2" "goroutine.txt"
fetch_pprof threadcreate "" "threadcreate.pb.gz"
log "collecting contention profiles"
fetch_pprof mutex "" "mutex.pb.gz" || log "warn: mutex profile unavailable or empty"
fetch_pprof block "" "block.pb.gz" || log "warn: block profile unavailable or empty"
log "collecting cpu profile (${CPU_PROFILE_SECONDS}s)"
if ! fetch_pprof profile "seconds=${CPU_PROFILE_SECONDS}" "profile.pb.gz"; then
log "warn: cpu profile unavailable (endpoint returned non-200 or timed out)"
fi
log "collection completed"
log "generated files:"
ls -lh "${OUT_DIR}"
cat <<EOF
Next steps:
1) 看当前存活堆内存排行:
go tool pprof -sample_index=inuse_space -top "${OUT_DIR}/heap_gc.pb.gz"
2) 看当前存活对象数量排行:
go tool pprof -sample_index=inuse_objects -top "${OUT_DIR}/heap_gc.pb.gz"
3) 看累计分配内存排行:
go tool pprof -sample_index=alloc_space -top "${OUT_DIR}/allocs.pb.gz"
4) 看累计分配调用链:
go tool pprof -sample_index=alloc_space -top -cum "${OUT_DIR}/allocs.pb.gz"
5) 看 goroutine 文本栈:
less "${OUT_DIR}/goroutine.txt"
6) 交互式查看:
go tool pprof "${OUT_DIR}/heap_gc.pb.gz"
go tool pprof "${OUT_DIR}/allocs.pb.gz"
7) 本地打开 Web UI:
go tool pprof -http="${PPROF_HTTP_ADDR}" "${OUT_DIR}/heap_gc.pb.gz"
go tool pprof -http="${PPROF_HTTP_ADDR}" "${OUT_DIR}/allocs.pb.gz"
More commands:
8) 看累计分配对象数量排行:
go tool pprof -sample_index=alloc_objects -top "${OUT_DIR}/allocs.pb.gz"
9) 看锁竞争和阻塞:
go tool pprof -top "${OUT_DIR}/mutex.pb.gz"
go tool pprof -top "${OUT_DIR}/block.pb.gz"
10) 过滤 goroutine 栈里的常见阻塞点:
grep -nE 'chan receive|select|semacquire|IO wait|sleep' "${OUT_DIR}/goroutine.txt"
11) 常用交互命令:
top
top -cum
list <func>
peek <regexp>
traces
web
12) 指标含义:
inuse_space 当前还活着的堆内存字节数
inuse_objects 当前还活着的对象数量
alloc_space 累计分配的字节数
alloc_objects 累计分配的对象数量
flat 当前函数自身消耗
cum 当前函数加上其下游调用的累计消耗
13) 建议排查顺序:
先看 heap_gc.pb.gz 的 inuse_space
再看 heap_gc.pb.gz 的 inuse_objects
然后看 allocs.pb.gz 的 alloc_space 和 alloc_objects
对可疑函数执行 list <func>
如果怀疑调用链,执行 top -cum 或 traces
14) 场景速查:
内存泄漏:
先看 inuse_space 和 inuse_objects,重点盯住 GC 后仍然存活且持续增长的函数
再用 top -cum / traces 找是谁把对象一路引用住了
小对象堆积:
先看 inuse_objects,不要只看 inuse_space
常见是 map / slice / buffer / cache / queue 元素越来越多
分配过猛:
看 alloc_space 和 alloc_objects
如果 alloc 很高但 inuse 不高,通常更像频繁分配导致的 GC 压力,不一定是泄漏
CPU 高:
如果拿到了 profile.pb.gz,先看 top 和 top -cum
再对热点函数执行 list <func>
goroutine 泄漏:
先看 goroutine.txt 里是否有大量重复栈
再 grep chan receive / select / semacquire / IO wait 看卡点
锁竞争:
看 mutex.pb.gz
flat 高说明锁本身热点明显,cum 高说明上游调用路径问题更大
阻塞慢:
看 block.pb.gz
再结合 goroutine.txt 判断是 channel、锁、IO 还是 sleep 导致
EOF
}
main "$@"
|
RSS 问题里,我最关心的不是“脚本抓得全不全”,而是“能不能尽快回答方向问题”。
第一,它会把 heap、heap?gc=1 和 allocs 一起抓下来。排查 RSS 时,这三个文件基本是最核心的。
heap.pb.gz 用来看当前堆heap_gc.pb.gz 用来看 GC 之后还有谁活着allocs.pb.gz 用来看是不是分配太猛
第二,它把 goroutine 也一起抓了。很多 RSS 偏高的问题,最后不一定是堆泄漏,而是 goroutine 数量上去了,栈空间也被一起放大了。这个时候只看 heap 会误判。
第三,像 mutex、block、profile 这些附加 profile 即使失败,也不会影响这次采集。对 RSS 问题来说,它们本来就不是第一优先级,所以我不希望它们反过来拖垮整次采集。
四、使用方式
把脚本保存成 collect-pprof.sh 之后,直接执行:
1
2
| chmod +x collect-pprof.sh
./collect-pprof.sh
|
想改输出目录的话:
1
| OUT_ROOT=/tmp/pprof ./collect-pprof.sh
|
想把 CPU 采样时间缩短一点的话:
1
| CPU_PROFILE_SECONDS=15 ./collect-pprof.sh
|
目标服务地址不一样,就直接改 BASE_URL:
1
| http://your-service:6060/debug/pprof
|
我一般不会一上来把所有 profile 都点开,而是先确认一件事:RSS 高,到底是不是 Go 堆解释得通。
1. 先看 heap_gc.pb.gz
1
| go tool pprof -sample_index=inuse_space -top "${OUT_DIR}/heap_gc.pb.gz"
|
这一步主要回答一个问题:GC 之后,Go 堆里到底还有多少对象在持续存活。
如果这里已经能看到几个明显的大头函数,而且它们的占用量和 RSS 增长趋势基本一致,那方向就比较明确了,先沿着这些函数往下查。
然后我会再看一眼对象数量:
1
| go tool pprof -sample_index=inuse_objects -top "${OUT_DIR}/heap_gc.pb.gz"
|
这个指标在 RSS 问题里特别有用,因为很多时候不是几个大对象把内存顶上去,而是大量小对象慢慢堆着不走。比如缓存条目、队列元素、map 里的对象、不断扩大的 slice,这类问题 inuse_objects 通常比 inuse_space 更早暴露信号。
2. 再看 allocs.pb.gz
1
2
| go tool pprof -sample_index=alloc_space -top "${OUT_DIR}/allocs.pb.gz"
go tool pprof -sample_index=alloc_objects -top "${OUT_DIR}/allocs.pb.gz"
|
如果 alloc 很高,但 heap_gc 里的存活量并不高,我一般不会先下“泄漏”的结论。更常见的情况是对象分配太频繁,GC 压力很大,堆一时降不下来,于是 RSS 看起来也一直居高不下。
3. 再看 goroutine 有没有一起涨
1
| less "${OUT_DIR}/goroutine.txt"
|
很多人会先开 Web UI,我自己的习惯反而是先扫文本。特别是怀疑阻塞时,这样更快:
1
| grep -nE 'chan receive|select|semacquire|IO wait|sleep' "${OUT_DIR}/goroutine.txt"
|
如果几百上千个 goroutine 都卡在同一段栈上,RSS 偏高就不一定只是堆问题了。goroutine 多了之后,栈空间、调度开销、相关运行时结构都会跟着上来,这种情况我会把“堆泄漏”先放一放,转去看为什么 goroutine 堆住了。
这一步反而是 RSS 排查里最重要的分界点。
如果你看完 heap_gc.pb.gz 以后,发现 Go 堆里的存活量并不大,和监控上的 RSS 完全对不上,那我一般不会继续在 heap 里硬找原因。
这个时候更应该怀疑的是:
- goroutine 栈空间
- cgo 分配
mmap 或其他非 Go 堆内存- 运行时元数据
- 某些外部库带来的进程内存占用
也就是说,pprof 这一步的价值,不只是“找到谁占内存”,还有一个很重要的作用:尽快判断 RSS 问题是不是主要由 Go 堆造成的。
六、补充说明
下面这些 profile 不是 RSS 问题的主入口,但我还是会顺手采下来,因为现场难得。
1. CPU 高时再看 profile.pb.gz
1
2
| go tool pprof -top "${OUT_DIR}/profile.pb.gz"
go tool pprof -top -cum "${OUT_DIR}/profile.pb.gz"
|
这里我一般会把 flat 和 cum 一起看。flat 高,说明函数自己就很热;cum 高,说明它自己未必重,但它调用下游的那条链很重。
2. 最后补看锁竞争和阻塞
1
2
| go tool pprof -top "${OUT_DIR}/mutex.pb.gz"
go tool pprof -top "${OUT_DIR}/block.pb.gz"
|
单看 mutex.pb.gz 或 block.pb.gz 有时候不够,我通常会和 goroutine.txt 对着看。这样比较容易分清楚到底是锁问题、channel 问题,还是代码里自己在等 IO、等定时器。
进入交互模式:
1
| go tool pprof "${OUT_DIR}/heap_gc.pb.gz"
|
进去之后我用得最多的是这几个:
1
2
3
4
5
6
| top
top -cum
list <func>
peek <regexp>
traces
web
|
想看图的话,直接起本地 Web UI:
1
| go tool pprof -http="127.0.0.1:18080" "${OUT_DIR}/heap_gc.pb.gz"
|
我自己平时就按下面这个理解:
inuse_space:当前仍然存活的堆内存字节数inuse_objects:当前仍然存活的对象数量alloc_space:历史累计分配的总字节数alloc_objects:历史累计分配的总对象数量flat:当前函数自身消耗cum:当前函数加上下游调用链的累计消耗
不用背太多,够排障就行:
- 想看 GC 后还活着多少,优先看
inuse_* - 想看是不是分配过猛,重点看
alloc_* - 想往调用链上追,重点看
cum
如果当时完全没方向,我一般就按这个顺序来:
- 看
heap_gc.pb.gz 的 inuse_space - 看
heap_gc.pb.gz 的 inuse_objects - 看
allocs.pb.gz 的 alloc_space - 看
allocs.pb.gz 的 alloc_objects - 看
goroutine.txt,确认 goroutine 有没有一起涨 - 对可疑函数执行
list <func> - 如果怀疑调用链,执行
top -cum 或 traces - 如果
pprof 里的 Go 堆解释不了 RSS,就转去查非 Go 堆方向 - 如果 CPU、锁、阻塞也异常,再补看
profile、mutex、block
十、总结
这份脚本不是为了把所有 pprof 能力都展示一遍,它更像一个 RSS 问题的第一现场保留工具。
我自己现在排 RSS 偏高,基本就是固定动作:先跑脚本保现场,再从 heap_gc、allocs、goroutine.txt 三个入口开始看。先判断 Go 堆能不能解释 RSS,再决定是继续往堆里查,还是转去查 goroutine、cgo、mmap 这些方向。
这样至少能避免一上来就把 RSS 和堆泄漏画等号,也能少走很多弯路。