|
|
[TITLE]
Hallmark 内存泄漏定位与修复:从 RSS 阶梯到 heap diff
[/TITLE]
[CATEGORY]
tech
[/CATEGORY]
[KEYWORDS]
华强北,CoPaw,科技数码,AI,热点
[/KEYWORDS]
[FID]
41
[/FID]
[CONTENT]# Hallmark 内存泄漏定位与修复:从 RSS 阶梯到 heap diff
常驻服务的内存曲线,值得警惕的形状就两种。锯齿形,说明 GC 在正常干活。阶梯形,那就是有一批对象跨过 GC 周期还活得好好的。Hallmark 这种长期跑着、既接请求又跑定时任务的组件,出问题几乎全是后者:刚重启时 180 MB,三天后稳稳站到 1.2 GB 以上,第四天 OOM Killer 上门收拾。
为什么非得先花篇幅讲"判断性质"?因为在真实生产环境里,RSS 往上爬的案例中真泄漏占比并不高。Detectify 团队 2019 年公开过一份复盘:他们的 Go 微服务在监控上稳定吃 400 MB,`docker stats` 也报 400 MB,可 `pprof` 的 `top` 只有 7 MB,一度怀疑是监控在骗人。HeapDump 社区还有更极端的记录——一台 2 GB 内存的实例 RSS 冲到 80%(约 1.6 GB),而 heap profile 里最大的 `metrics.flushClients()` 只占 72 MB,整个 heap 才 170 MB。两次事故最后一行业务代码都没动,问题出在 runtime 向内核归还内存的策略上。这类"假泄漏"不排掉,后面抓 profile 纯属浪费时间。
## 现象:先分清是泄漏、缓存膨胀还是内存没还回去
三种毛病,处理路径完全不同,第一步就得切开。
- 内存泄漏:GC 之后 live heap 还在往上走,`go tool pprof` 的 inuse_space 单调增长。
- 缓存膨胀:live heap 也涨,但压力一撤就回落,属于容量策略的问题。
- 内存未归还:live heap 平得很,RSS 却一路向上。Go runtime 回收堆内存后要不要马上把物理页还给内核,看版本也看 `madvise` 策略,所以 `VmRSS` 完全可能虚高。
把三者摆一张表里对照,比背定义管用:
| 观察项 | 真泄漏 | 缓存膨胀 | 内存未归还 |
|---|---|---|---|
| `inuse_space` | 单调上升,不回落 | 随流量起伏 | 基本平稳 |
| `VmRSS` | 同步上升 | 同步起伏 | 只涨不跌 |
| `NumGoroutine` | 常见同步爬升 | 平稳 | 平稳 |
| 重启后 | 从低位重新爬 | 从低位重新爬 | 立刻掉回基线 |
| 处置方向 | heap diff 定位代码 | 调整容量与淘汰 | 换归还策略或升级版本 |
量化手段要够轻,能在生产环境里一直跑着:
```bash
while :; do
awk '/VmRSS|VmSize/{printf "%s ", $2}' /proc/$PID/status
grep -E '^(Rss|Pss|Anonymous):' /proc/$PID/smaps_rollup | awk '{printf "%s ", $2}'
echo "$(date +%s)"
sleep 60
done >> /var/log/hallmark-rss.log
```
多采一个 `smaps_rollup` 是有原因的。`VmRSS` 把共享库、页缓存映射全算进去了,`Anonymous` 才是进程自己申请、真正能把 OOM 招来的那部分。两者打架的时候,信 `Anonymous`。
判断标准也不复杂:压力持平的前提下,`Anonymous` 斜率超过 5 MB/h 且连着 6 小时不回落,就当真实泄漏处理。只在高峰期涨、过后会掉,先去看缓存淘汰策略,别急着上 profile。
## RSS 为什么会骗人:一段被忽略的 runtime 演进史
Go 的 GC 把对象收走之后,内存不会自动回内核。runtime 里有个后台 scavenger 负责把空闲 span 还给操作系统,但它历史上相当恋旧——先攥手里,确认一阵子用不上了才松手。Detectify 的复现实验很直观:跑一轮高负载,停下等 5 秒,再跑第二轮,发现 Go 压根没向内核申请新内存,直接把扣着的那部分复用上了。
更麻烦的是 1.12 到 1.15 这段。Go 1.12(2019 年初)在 Linux 4.5+ 内核上把归还方式从 `MADV_DONTNEED` 换成了 `MADV_FREE`。前者立刻让 RSS 掉下来,后者只是跟内核打个招呼"这些页你随便用",内核在内存压力不大时压根懒得动手。结果就是 RSS 只涨不跌,`pprof` 里风平浪静。HeapDump 那篇复盘写得很清楚,他们正好是 Go 1.15 + 内核 4.14,踩得严丝合缝。
Go 1.16(2021 年 2 月)的 release notes 把这事改回来了:"在 Linux 上,runtime 现在默认及时把内存归还给操作系统(使用 MADV_DONTNEED),而不是在系统面临内存压力时才惰性归还(使用 MADV_FREE)。这意味着 RSS 这类进程级内存统计会更准确地反映 Go 程序实际使用的内存量。"
所以判这一项,先看版本:
- Go 1.12 ~ 1.15:加 `GODEBUG=madvdontneed=1` 做个对照实验,RSS 应声回落就能定性。但这开关会触发 TLB shootdown 和更多 page fault,延迟敏感的线上实例悠着点,最好只在单台灰度机上验。
- Go 1.16 及以上:默认已经及时归还了。RSS 还只涨不跌,那就不是这个原因,别在这儿耗。
- Go 1.19 起多了 `GOMEMLIMIT`(软内存上限,环境变量或 `runtime/debug.SetMemoryLimit` 都行),堆逼近上限时 GC 会干活更勤。容器里特别有用,配 `GOGC=off` 能按绝对内存而不是增长比例来控。但它治的是 GC 节奏,不治泄漏。
还有件事得提一句。不少团队拿 `debug.FreeOSMemory()` 当止血药。它确实能让 RSS 当场掉下来,代价是一次强制 GC 加同步归还,Go 1.13 之后某些场景还会失效。写进定时任务就是自欺欺人——曲线好看了,泄漏还在那儿。
## 成因:六类高频选手
1. 全局 map 没有上限。拿 URL、租户 ID、trace ID 当 key 做聚合统计,只写不删,是 Hallmark 这类服务最常见的漏点。特征也好认:inuse_space 的增长跟请求量正相关,QPS 翻倍斜率也翻倍。HeapDump 那篇提到的 `tagCache` 就是典型,代码里已经用"每 0x3fff 次清一次 map"的土办法兜底,照样挡不住 tag 维度爆炸。
2. goroutine 泄漏。往没有消费者的 channel 里发数据,发送方永久阻塞,整条调用栈上的对象全跟着陪葬。`runtime.NumGoroutine()` 会单调爬升。变体还有不少:HTTP 客户端没设超时,第三方接口卡死;`sync.WaitGroup` 的 Add 和 Done 数量对不上,Wait 等到天荒地老;互斥锁在某条 error 分支上忘了 Unlock。
3. Ticker 与 Timer 没停。`time.NewTicker` 不配 `defer ticker.Stop()`,定时任务重建时又造新实例,旧的连同闭包里的引用一起堆着。`time.After` 更容易被忽略——循环里每轮都 `case <-time.After(3 * time.Minute)`,等于每轮新建一个 Timer,到期前不会被回收,长周期叠高频循环,纯堆。
4. 切片和子串抱着底层数组不放。`s = big[:10]` 只截了长度没截容量,底层那几 MB 的数组一直被引用;`string(bytes[:n])` 在旧版本里同样共享底层。pprof 里表现为"分配点看着挺小,inuse 却老大一片",得靠 `list` 追到那一行。
5. 资源没关。`http.Response.Body` 读完不 Close,连接和缓冲区都留在原地;文件句柄、sql.Rows、gzip.Reader 一个道理。这类问题往往 fd 和内存一起涨。
6. cgo 和第三方分配器。Hallmark 内嵌 C 库或 SQLite 的时候,这部分内存不进 Go heap,pprof 看不见,只能上 `pmap -x` 或者 jemalloc 的 `jeprof --show_bytes`。AI 推理类服务还多一层:模型权重、显存映射、KV cache 常常在 Go 堆之外,`RSS - heap` 的差额才是要盯的地方。
## 定位:五步收敛
**第一步,开 GC 追踪。** 单实例设 `GODEBUG=gctrace=1`,重点看两列:`gc N @Ts X%: ... -> Y MB`。每轮 `-> Y MB` 之后的堆底值持续抬升,说明 GC 收不动存活对象,泄漏可以确认,不是瞬时压力。顺手看看 `X%`,那是 GC 占 CPU 的比例,长期高于 25% 意味着 GC 已经在替泄漏买单,业务延迟多半也开始抖了。开销就是每轮 GC 一行日志,线上短时开着完全能接受,排查完记得关。
**第二步,间隔抓两份 heap profile。** 单份只能看出"谁占得多",看不出"谁在长",必须 diff:
```bash
curl -s "http://127.0.0.1:6060/debug/pprof/heap?seconds=30" -o heap_t1.pb.gz
sleep 1800
curl -s "http://127.0.0.1:6060/debug/pprof/heap?seconds=30" -o heap_t2.pb.gz
go tool pprof -sample_index=inuse_space -base heap_t1.pb.gz heap_t2.pb.gz
```
进交互界面跑 `top -cum` 和 `list <函数名>`,把范围收到具体那条分配语句上。`-base` 的语义就是"后者减前者",增长量最大的节点基本就是主漏点。
两个容易踩的细节。一是 `-base` 和新版 pprof 的 `-diff_base` 在正负号上不一样,前者只显示增量,后者保留负值,想看清"哪些调用把内存放掉了"用后者更直观。二是 `inuse_space` 和 `alloc_space` 别搞混——前者是还活着的内存,后者是历史分配总量。判泄漏只认 `inuse_space`,`alloc_space` 大只说明这函数被调得勤,不代表它漏。
采样率也会骗人。runtime 默认 `MemProfileRate` 是 512 KB,小对象按概率采样,profiler 报出来的数天然偏低,这也是 Detectify 那边 400 MB 对 7 MB 的部分原因。压测环境可以临时调到 1,代价是开销明显上升;生产环境别动,宁可多抓几份。
**第三步,看 goroutine 栈的聚合结果。**
```bash
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1" | head -50
```
`debug=1` 输出的是按调用栈聚合后的计数。某个栈两次采样之间从 40 涨到 4000,那就是它了。`debug=2` 打印每一份完整栈,适合确认"卡在哪一行"。channel 阻塞类的问题,`/debug/pprof/block` 更能说话——谁在等锁、谁在等 channel、等了多久,一目了然。
**第四步,处理非 Go 内存。** Python 侧用 `tracemalloc.take_snapshot()` 抓两次快照做 `compare_to`;C/C++ 侧用 `valgrind --tool=massif` 或 `jeprof`;JVM 侧用 `jmap -histo:live` 前后对比实例数。注意 `-histo:live` 会触发 Full GC,生产环境得限流跑。
**第五步,压测环境复现。** 固定 RPS 回放 2 小时。斜率能复现,修完再对比同一时间窗口的 RSS 曲线,这才算闭环。复现时三个变量必须钉死:请求分布、并发数、数据体量。少钉一个,斜率就可能对不上,最后变成"修了但没完全修"的拉锯战。
安全上也提醒一句。`net/http/pprof` 注册的端点会暴露调用栈、堆快照甚至命令行参数,绝不能直接挂公网入口。常见做法是只监听回环地址,或者挂独立内部端口走跳板访问,别图省事。
## 一次完整的收敛过程
把步骤串起来看会清楚很多。某次 Hallmark 的曲线是标准阶梯:重启后 180 MB,第 12 小时跳到 320 MB,第 36 小时 600 MB,第 72 小时破 1.2 GB,之后每 20 小时涨 100 MB 左右,第四天被 OOM Killer 干掉。
先看 gctrace,`-> Y MB` 的底值从 96 稳步抬到 780,存活对象在堆积,瞬时压力这条排除了。接着抓两份间隔 30 分钟的 profile,`-base` 之后 `top -cum` 的头名是个统计聚合函数,`list` 进去发现是往 `map[string]*bucket` 里写,key 由"接口路径 + 上游 IP + 小时窗口"拼成,只增不删。同一时间 `/goroutine?debug=1` 的计数从 210 涨到 2400,栈顶集中在同一个上报协程——它往一个容量 100 的 channel 写,消费端被下游超时拖慢,写端开始堆积,每个阻塞的协程还各自攥着一份待上报的样本。
到这儿就清楚了:主因是那个无界 map,次因是上报协程没做背压。修复动作对应两处——map 换成带 TTL 的 LRU,key 里把 IP 这种高基数维度踢掉;上报改成 `select` 带 `ctx.Done()` 和 `default` 直接丢,宁可丢样本也别堵调用链。改完在压测环境固定 200 RPS 回放 2 小时,RSS 斜率从 8.4 MB/h 降到 0.6 MB/h,`NumGoroutine` 稳在 180 上下不漂。
## 修复:三类改法
六类成因对应的动作,归归拢也就三组。
**容量类。** 给全局容器加容量上限和 TTL,推荐带驱逐的 LRU,key 里别塞请求级对象。落地有两个坑:TTL 得配后台清理协程,只靠"读的时候判断过期",冷 key 等于永远没删;容量上限要按"单条大小 × 条目数"估,光数条数没用——一条塞 200 KB 的样本,一万条就是 2 GB。
```go
// 有界 + TTL:容量和过期双保险
type cache struct {
mu sync.Mutex
m map[string]*list.Element
l *list.List // 头部最新,尾部最旧
max int
}
func (c *cache) Set(k string, v []byte, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.m[k]; ok {
c.l.MoveToFront(e)
e.Value.(*entry).val, e.Value.(*entry).exp = v, time.Now().Add(ttl)
return
}
c.m[k] = c.l.PushFront(&entry{key: k, val: v, exp: time.Now().Add(ttl)})
for len(c.m) > c.max {
if tail := c.l.Back(); tail != nil {
c.l.Remove(tail)
delete(c.m, tail.Value.(*entry).key)
}
}
}
```
**并发类。** channel 写入放进 `select` 并带上 `ctx.Done()`,服务退出路径统一 cancel;对外调用一律设超时,HTTP 客户端用带 `Timeout` 的 `http.Client`,别用默认实例;`WaitGroup` 的 `Add` 和 `Done` 尽量写在同一个函数作用域里,方便肉眼核对。
```go
select {
case ch <- sample:
case <-ctx.Done():
return ctx.Err()
default:
dropped++ // 有背压就丢,别堵住调用链
}
```
**生命周期类。** 每个 Ticker 都配 `defer Stop()`,任务重建时显式关掉旧实例;循环里别用 `time.After`,换成复用的 `Timer` 加 `Reset`;cgo 侧用 `runtime.SetFinalizer` 或显式 `Close` 释放。
```go
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
run()
case <-ctx.Done():
return
}
}
```
容器环境再叠一层保险:`GOMEMLIMIT` 设成 limit 的 80% 左右,让 runtime 在接近上限时提前收紧,而不是等内核 OOM Killer 上门。给太高没意义,给太低 GC 会空转,压测时按实际 heap 峰值往上留 30% 比较稳。
验收指标建议钉死三条:同等压力下 RSS 斜率小于 1 MB/h;`inuse_space` 曲线 24 小时内进平台期;`NumGoroutine` 在压测结束后 5 分钟内回落到基线 ±5%。
## 把斜率变成告警
修完不收尾,等于给下个版本留了复发窗口。最小可用的做法,是在 Prometheus 里对 `process_resident_memory_bytes` 做长窗口斜率:
```promql
deriv(process_resident_memory_bytes{job="hallmark"}[6h]) * 3600 > 5e6
and
process_resident_memory_bytes{job="hallmark"} > 1.5 * avg_over_time(process_resident_memory_bytes{job="hallmark"}[7d] offset 7d)
```
配套再盯两个指标:`go_goroutines` 的 24 小时斜率,以及 `go_memstats_heap_inuse_bytes` 与 RSS 的差值。前者抓协程泄漏,后者抓堆外内存,三个一起看,绝大多数场景都能兜住。
## 常见问题
### 1. Hallmark 内存泄漏定位与修复哪个更值得入手?
优化Hallmark 内存泄漏定位与修复内存占用可从三处入手:缩小上下文/缓存、限制并发与队列长度、定期裁剪会话/释放对象;同时用持续压测确认是否有泄漏趋势。
### 2. Hallmark 内存泄漏定位与修复怎么选?
优化Hallmark 内存泄漏定位与修复内存占用可从三处入手:缩小上下文/缓存、限制并发与队列长度、定期裁剪会话/释放对象;同时用持续压测确认是否有泄漏趋势。
### 3. Hallmark 内存泄漏定位与修复适合什么人买?
优化Hallmark 内存泄漏定位与修复内存占用可从三处入手:缩小上下文/缓存、限制并发与队列长度、定期裁剪会话/释放对象;同时用持续压测确认是否有泄漏趋势。
## 小结
排查内存泄漏,八成成本花在"判断问题性质"上。缓存膨胀和内存未归还先排掉,剩下的靠 heap diff 加 goroutine 栈聚合,基本都能定位到代码行。Hallmark 这类组件真正的风险不在泄漏本身,而在重启把泄漏盖住了——每次发版顺手重启一下,曲线永远只画到第二天上午。能让问题真解决的,是把斜率纳入监控告警。不然同一条曲线,下个版本还会再演一遍。你们线上 RSS 现在是什么形状,锯齿还是阶梯?
[/CONTENT] |
|