free 小不是病, available 下跌才是 — 泄漏判定看锯齿形状, OOM 先分宿主还是 cgroup, runtime profile 找分配方
Linux 内存像一台回收费品换现金的机器: 应用不要的页它先攒成 page cache (算 used 但随时能卖掉换钱), 所以 free 小是常态, available 才是钱包余额。应用内存像蓄水池: 进水 (alloc) 和出水 (GC/释放) 平衡时水位锯齿波动, 出水口堵住 (引用没放) 水位直线爬升 — 这就是泄漏判定。而 OOM 是"水位顶到天花板, 内核直接拆墙" — 先分清撞的是宿主机天花板还是cgroup 天花板, 再去抓"谁在进水"。
$ free -h # total used free buff/cache available # Mem: 31Gi 22Gi 2.0Gi 8.6Gi 9.4Gi # free 2G 很正常 — available 9.4G 才是判断依据
# used=22G 的 32G 机器, cache 占 8.6G # 应用真吃 RSS 12G + cache 8.6G + 其他 → 别慌, 看 available
$ ps -o pid,rss,vsz,%mem,cmd -p 1234 # PID RSS VSZ %MEM CMD # 1234 1.2G 18G 3.8 ./api ← VSZ 大不是事, RSS 才算数
$ vmstat 1 # swpd=2G 但 si=0 so=0 → 历史遗留, 不慌 # swpd=2G 且 si=500 so=800 持续 → memory pressure, 处理!
$ pidstat -r 1 # PID minflt/s majflt/s RSS # 1234 1200 85 1.2G ← majflt 持续高 = 在读磁盘换页
$ dmesg -T | grep -i -E "oom|killed process" # [Mon ...] Out of memory: Killed process 1234 (api) total-vm:18G # anon-rss:31G ← 供词里带 RSS, 直接定案
$ cat /sys/fs/cgroup/.../memory.max # → 2147483648 (2G) $ cat /sys/fs/cgroup/.../memory.events # oom_kill 12 ← 容器已被杀 12 次
$ pmap -x 1234 | tail -1 # total kB 1258000 RSS ← 布局与最大块一目了然 $ smem -k | head # PSS 列比 RSS 更公平
# QPS 稳定, RSS: 2G→3G→5G→8G (12h) → 泄漏实锤 # QPS 稳定, RSS: 2G→4G→2G→4G 锯齿 → 正常, 别修
$ GODEBUG=gctrace=1 ./api # gc 18 @12.3s 2%: 0.12+18+0.05 ms clock, 64→128→32 MB # live 涨到 128MB 还在涨 → live set 在变大, 查泄漏
import tracemalloc; tracemalloc.start(10) # 跑一段流量后: # app/cache.py:42: size=812MiB, count=120000 ← 元凶定位到行
# 错: cache[k] = v # 全局 map, key 无限涨 # 对: lru.New(10000) + TTL 5m # 有界 + 过期
MALLOC_ARENA_MAX 和定期重启兜底。 # 错: RSS 涨 → 直接定罪 leak → 白查三天 # 对: 先 tracemalloc 证明无泄漏 → MALLOC_ARENA_MAX=2 + max_requests
监控群凌晨炸锅: 32G 内存用了 30G。一行命令区分"真急"和"Linux 常态"。
$ free -h; vmstat 1 3 # total used free buff/cache available # Mem: 31Gi 30Gi 0.5Gi 1.5Gi 21Gi # si=0 so=0 cs 正常 ← cache 可回收, available 21G # 判定: available 充足 + si/so 为 0 → 不处理, 回复"Linux 正常行为" # 真急的样子: available 趋势下跌 + si/so 活跃 + majflt 上升 # 给监控加的正确指标: available 百分比, 而不是 used 百分比
从此告警规则改为 available < 10% 持续 5m, 误报降为零。
容器外物理机/虚机上, 进程消失且 supervisor 日志只有 exit 137 — 先问内核。
$ dmesg -T | grep -i -E "oom|killed process" | tail -5 # [Tue Sep 26 03:12:44] Out of memory: Killed process 1234 (api) # total-vm:18GB, anon-rss:31GB, oom_score_adj:0 # [Tue Sep 26 03:12:44] oom_reaper: reaped process 1234 # 定案: 03:12 rss 到 31G 触发宿主 OOM # 下一步: sar -r -f /var/log/sa/sa26 看爬升起点 → 对齐 22:00 上线
供词里带 anon-rss, 与发布时间对齐后, 直接锁定新版本里的缓存回归。
宿主内存空闲 40G, 容器却每天重启三次 — 撞的是 cgroup 天花板。
$ kubectl describe pod api-7d9f | grep -A3 "Last State" # Reason: OOMKilled # Exit Code: 137 # Go 侧配合: 把 GOGC 从默认 100 改为 GOMEMLIMIT 模式 (1.19+) # GOMEMLIMIT=1800MiB ← 软上限, 留 200M 给非堆开销 # GOGC=off ← 交给 GOMEMLIMIT 控制, 逼近才 GC # 并把 limit 从 2G 调到 2.5G 覆盖业务高峰
原理: 限制只在逼近时触发 GC, 而不是让堆撞墙被内核杀。
流量平稳, RSS 五小时翻了四倍。按"排除法"收网, 别一上来就怪 runtime。
# 1) 是不是 cache/共享库? smem 看 PSS 按进程分摊 $ smem -k -P api | head -3 # 2) 是不是 mmap 映射的文件? pmap 找大块映射 $ pmap -x 1234 | sort -k3 -rn | head -5 # 3) 是不是线程数在涨 (每线程栈 8M)? $ grep Threads /proc/1234/status # 4) 排除后进 runtime 对比两个时间点 heap: $ go tool pprof -base heap_10am.pb.gz heap_15pm.pb.gz # bytes: app/realtime_hub.go:88 +5.2GB ← ws 连接对象堆积
根因: 断线的 ws 连接没从注册表删除 — 增量 profile 直接点名。
单张 heap 快照只能看存量, 泄漏要两个时间点做差。
# 上午采一次, 下午再采一次 (流量同水位) $ curl -s http://svc:6060/debug/pprof/heap > heap_10am.pb.gz # ... 5 小时后 ... $ curl -s http://svc:6060/debug/pprof/heap > heap_15pm.pb.gz $ go tool pprof -base heap_10am.pb.gz heap_15pm.pb.gz (pprof) top3 # flat flat% sum% cum% # 5.2GB 68.4% 68.4% hub.register ← 增量集中在这 # 0.8GB 10.5% 78.9% bytes.Buffer # 修复: hub.register 的 map 增加 TTL 清理 → RSS 稳定在 2.1G
配套: inuse_space 看存量, alloc_space 看累计分配 (找 GC 压力)。
gunicorn worker 从 400M 涨到 3G 不落。先证明"代码在漏", 再决定要不要定期回收。
import tracemalloc tracemalloc.start(25) # 保留 25 帧栈, 利于回溯 # ... 复现一段负载 ... snap = tracemalloc.take_snapshot() for st in snap.statistics("lineno")[:3]: print(st) # app/exporter.py:77: size=1.8GiB, count=3400 ← 全量导出在攒列表 # 修复: 改成生成器流式写 → worker 稳定 600M # 兜底: gunicorn --max-requests 5000 --max-requests-jitter 500
先修代码, 再用 max_requests 兜底碎片与膨胀, 顺序不能反。
"本地缓存加速"没有上限约束, key 随业务无限增长, 是最常见的慢性泄漏。
var cache sync.Map // 错: 无容量, 无 TTL — key=订单号, 无限多 // 对: hashicorp/golang-lru 有界 + 主动过期 import lru "github.com/hashicorp/golang-lru/v2" var cache, _ = lru.New[string, *Order](100_000) // 硬上限 func Get(id string) *Order { if v, ok := cache.Get(id); ok { return v } o := loadFromDB(id) cache.Add(id, o) // 淘汰最旧, 内存有界 return o }
上线后 RSS 从"每天涨 2G"变为稳定 2.3G, 淘汰命中率监控同步加上。
swap 用了不一定是病, 反复横跳一定是病。决策点: 加内存、降缓存上限、还是关 swap。
$ vmstat 1 5 # si=800 so=1200 持续 5 秒 ← 页在来回搬, 性能雪崩中 $ smem -k -r | head -5 # 谁吃最大, 能不能限 # 决策树: # 应用 live set 真的需要 30G → 加内存 (扩容正确解) # 本地缓存占了 20G 可压缩 → 缓存上限减半 (LRU 容量) # 混合服务混部署 → 迁移拆分, 别让批处理挤线上
swappiness 调低只是让换页来得更晚, 治标; live set 真实超量就得加内存。
RSS 直接相加把共享库与 COW 页重复计费, 多进程服务的真实占用要用 PSS。
$ ps aux | grep gunicorn | awk '{s+=$6} END {print s/1024 " MB"}' # 16384 MB ← 粗暴相加, 大幅虚高 $ smem -k -t | grep -c gunicorn # 8 workers, PSS 合计约 4.2G ← 共享的 2G 只算一份的分摊 # 结论: 不是泄漏, 是统计口径问题 # 监控改造: 容器层用 cgroup memory.current, 别用 ps 相加
向管理层汇报容量时用 PSS, 排障单进程时才看 RSS。
P99 毛刺伴随 CPU 上升, GC 日志显示频率翻倍 — 追分配大户而不是调 GC 参数。
$ GODEBUG=gctrace=1 ./api 2> /tmp/gc.log # gc 120 @60.1s 4%: ... 64→190→64 MB, 0.9ms pause ×3 # 频率: 之前 60s 一轮 → 现在 0.5s 一轮 (翻倍不止) $ go tool pprof -top http://svc:6060/debug/pprof/allocs # flat flat% sum% cum% # 1.9GB 42.1% 42.1% json.Unmarshal ← 每请求全量反序列化 # 修复: 只解析需要的字段 (部分结构体) → alloc 降 70%
经验值: GC CPU 占比 >10% 或 pause 进 P99 视野, 都该查 alloc rate。
available。 # 错: used 95% → systemctl restart # 对: free -h → available 60% → 不处理
# 错: "RSS 涨的都是 cache"混为一谈 # 对: 进程 RSS (/proc/PID/status VmRSS) 与系统 cache 分开看
# 错: alert: vsz > 8G # 天天误报 # 对: alert: rss > limit * 0.85
si/so 流量不是 swpd 存量。 # 错: swpd 2G → "内存危机!" → 全员加班 # 对: vmstat 1 → si=0 so=0 → 平静
# 错: 看一眼 si=500 就翻篇 # 对: vmstat 1 10 持续活跃 → 找大户/加内存
# 错: 只看 %MEM 列 # 对: pidstat -r 1 → majflt/s 85 → 查 mmap 冷读/换页
memory.max / memory.current / memory.events。 # 错: "宿主还剩 40G, 不可能 OOM" # 对: cat memory.events → oom_kill 12 → 容器层问题
-base 增量对比)。 # 错: limit 2G→4G 收工 # 一周后再爆 # 对: heap -base 对比 → hub.register +5G → 修引用
# 错: cache[orderID] = o # 千万订单 = 千万条目 # 对: lru.New(100000) + 每条 TTL 5m
# 错: go handleConn(conn) # 泄漏时无上限 # 对: ctx 超时 + goroutine 数量上限 semaphore
# 错: 64→190→64 循环 → "正常" # 对: 64→128→190→240 → live 在爬 → 查泄漏
max-requests + jitter 平滑回收。 # 错: --workers 8 就完事 # 对: --max-requests 5000 --max-requests-jitter 500
# 错: out += line # 10GB 文件 OOM # 对: for line in f: write(line) # 逐行流式
Content-Length。 # 错: b, _ := io.ReadAll(resp.Body) # 对: json.NewDecoder(resp.Body).Decode(&v) # 流式
pmap -x 全景再定位。 # 错: "heap 才 500M, RSS 3G 一定是泄漏" # 对: pmap -x → 2G 是 mmap 的索引文件 → 正常
lsof +L1 找到后重启/截断, 配 logrotate。 # 错: rm app.log 后 df 仍满 # 对: lsof +L1 | grep deleted → 重启该进程或 : > file
# 错: mem_used_percent > 90 告警 # 对: mem_available_percent < 10 for 5m
smem PSS 或 cgroup memory.current。 # 错: 8 worker × 2G RSS = "吃 16G" # 对: smem PSS 合计 4.2G → 按真实占用规划
# 错: "pause 0.9ms, GC 没问题" # 对: gctrace 频率 0.5s/轮 + alloc 42% json → 治分配
-base 做差。 # 错: 看一张 heap → "json 占 60%!" → 修错方向 # 对: heap_10am vs heap_15pm 增量 → 泄漏点现形