高级后端 · 内存与 GC 分析

free 小不是病, available 下跌才是 — 泄漏判定看锯齿形状, OOM 先分宿主还是 cgroup, runtime profile 找分配方

free -h: 32G 机器的内存账本 应用 RSS 12G page cache 10G free 10G used 22G 里一半是 page cache — 空闲内存拿来缓存文件是 Linux 的本职 available ≈ 15G: free + 可回收的 cache, 这才是"还能用多少" 真正危险的四个信号 (看趋势, 不看快照) ① available 持续下降 ② si/so 持续活跃 (swap 抖动) ③ dmesg 出现 oom-kill ④ 应用 RSS 单调上涨不回落 VSZ = 虚拟地址空间 (可以巨大, 不代表真用了) · RSS = 实际驻留物理内存 泄漏 vs 正常增长: 看曲线形状 RSS 24h → 0:00 12:00 锯齿 = 正常: 分配↑ GC 回收↓ 周期回落 直线爬升 = 泄漏: 流量稳定 RSS 不回落 Go: heap/allocs profile 两次对比 · Python: tracemalloc/memray · 先排除 page cache 与 mmap 进程"莫名消失"的 OOM 定位链 ① 现象 进程消失 / 重启 exit code 137 ② 查内核日志 dmesg -T | grep -i oom journalctl -k 同效 ③ 内核招供 Out of memory: Killed process 1234 (api) 连同 oom_score 全文截图 ④ 分清两层 limit 宿主 64G? 容器 cgroup 2G? 两层都要单独看 ⑤ 找分配方 heap profile / tracemalloc 修复分配, 不是加内存 容器证据链: kubectl describe pod → Last State: OOMKilled · 节点上 /sys/fs/cgroup/.../memory.events 的 oom_kill 计数 swap 与缺页: 两个别漏看的仪表 vmstat si/so 持续活跃 页在内存与磁盘间反复搬运 → 性能雪崩 pidstat -r 的 majflt/s 高 major fault 要读磁盘换页 → 明显拖慢 free 的 available 逼近 0 离 OOM 只差一次流量毛刺 GC 频率上升 + CPU 上升 alloc rate 暴涨: 大量临时对象 (JSON)

三个概念先掰正

  • • available 才是"还能用多少", free 小很正常
  • • page cache 算 used 但可回收, 不是泄漏
  • • RSS 是真实驻留, VSZ 只是地址空间

泄漏判定曲线

  • • 流量稳定 + RSS 单调涨几小时不回落 = 泄漏
  • • 分配↑GC↓ 的锯齿是健康形态
  • • 先排除缓存增长 / page cache / mmap
  • • 再进 runtime: heap profile / tracemalloc

OOM 三层定位

  • • exit 137 → dmesg 找 "Killed process"
  • • 宿主 OOM 与 cgroup limit OOM 分开判
  • • 容器看 memory.events 的 oom_kill 计数
  • • 修复分配方, 不是无脑加内存

💡 一句话理解

Linux 内存像一台回收费品换现金的机器: 应用不要的页它先攒成 page cache (算 used 但随时能卖掉换钱), 所以 free 小是常态, available 才是钱包余额。应用内存像蓄水池: 进水 (alloc) 和出水 (GC/释放) 平衡时水位锯齿波动, 出水口堵住 (引用没放) 水位直线爬升 — 这就是泄漏判定。而 OOM 是"水位顶到天花板, 内核直接拆墙" — 先分清撞的是宿主机天花板还是cgroup 天花板, 再去抓"谁在进水"。

🧠 必知必会 必考 & 必会

free -h 字段
total = used + free; used 含 page cache; available 才是"还能给应用多少"。看 available 趋势, 不看 free 快照。
$ free -h
#        total  used  free  buff/cache  available
# Mem:    31Gi  22Gi  2.0Gi       8.6Gi     9.4Gi
# free 2G 很正常 — available 9.4G 才是判断依据
page cache
Linux 把空闲内存拿来做文件缓存, 读过一次的文件第二次走内存。它算在 used 里但可随时回收 — 不是泄漏, 反而是性能红利。
# used=22G 的 32G 机器, cache 占 8.6G
# 应用真吃 RSS 12G + cache 8.6G + 其他 → 别慌, 看 available
RSS vs VSZ
VSZ 是申请的虚拟地址空间 (含未触页/共享库映射), 可以天文数字; RSS 是真实驻留物理内存的页, 监控与排障以 RSS 为准。
$ ps -o pid,rss,vsz,%mem,cmd -p 1234
# PID   RSS    VSZ    %MEM  CMD
# 1234  1.2G   18G    3.8   ./api   ← VSZ 大不是事, RSS 才算数
swap 与 si/so
swap 被用过≠有问题 (可能只是历史换出); si/so 持续活跃才是病 — 页在内存与磁盘间反复横跳, 一切都慢。
$ vmstat 1
# swpd=2G 但 si=0 so=0        → 历史遗留, 不慌
# swpd=2G 且 si=500 so=800 持续 → memory pressure, 处理!
Page Fault 两档
minor fault: 缺页但数据在内存 (便宜); major fault: 要从磁盘读页 (贵)。majflt/s 高说明内存不足在换页或 mmap 冷读。
$ pidstat -r 1
# PID  minflt/s  majflt/s  RSS
# 1234   1200     85      1.2G  ← majflt 持续高 = 在读磁盘换页
OOM 证据链
进程 exit 137 / 莫名重启, 第一反应查内核日志: OOM killer 会留下完整供词 (进程名、内存水 位、oom_score)。
$ dmesg -T | grep -i -E "oom|killed process"
# [Mon ...] Out of memory: Killed process 1234 (api) total-vm:18G
#               anon-rss:31G  ← 供词里带 RSS, 直接定案
cgroup limit
容器环境撞的是 cgroup 天花板: 宿主 64G 很空, 容器 limit 2G 照样 OOMKilled。两层内存必须分开看。
$ cat /sys/fs/cgroup/.../memory.max   # → 2147483648 (2G)
$ cat /sys/fs/cgroup/.../memory.events
# oom_kill 12   ← 容器已被杀 12 次
pmap / smem PSS
pmap 看进程地址布局 (heap/共享库/匿名映射); smem 的 PSS 把共享内存按比例分摊, 统计 gunicorn 这类多进程才不虚高。
$ pmap -x 1234 | tail -1
# total kB       1258000 RSS    ← 布局与最大块一目了然
$ smem -k | head           # PSS 列比 RSS 更公平
Leak vs Growth
判定公式: 流量平稳 + RSS 单调上涨数小时不回落 = 泄漏; 涨了又落是正常增长。缓存无界、连接不关、全局 map 是三大来源。
# QPS 稳定, RSS: 2G→3G→5G→8G (12h) → 泄漏实锤
# QPS 稳定, RSS: 2G→4G→2G→4G 锯齿   → 正常, 别修
Go GC 四指标
heap size、alloc rate、GC 频率、GC pause。危险模式: alloc rate↑ → GC 频率↑ → CPU↑ → P99↑, 元凶是临时对象。
$ GODEBUG=gctrace=1 ./api
# gc 18 @12.3s 2%: 0.12+18+0.05 ms clock, 64→128→32 MB
#         live 涨到 128MB 还在涨 → live set 在变大, 查泄漏
Python 内存特征
Python 的痛常不是 GC pause, 而是对象太多 + 碎片 + worker 长期膨胀。工具: tracemalloc (代码级)、memray、py-spy。
import tracemalloc; tracemalloc.start(10)
# 跑一段流量后:
# app/cache.py:42: size=812MiB, count=120000 ← 元凶定位到行
缓存无界 = 第一泄漏源
本地 map 缓存没有 TTL 和容量上限, 等于慢性泄漏: key 无限多时内存只进不出。正解: LRU + 容量上限 + TTL。
# 错: cache[k] = v            # 全局 map, key 无限涨
# 对: lru.New(10000) + TTL 5m  # 有界 + 过期
allocator 不还内存
glibc malloc 向 OS 要的内存未必归还 (碎片/arena), gunicorn worker RSS 涨了不降不一定是泄漏 — 用 MALLOC_ARENA_MAX 和定期重启兜底。
# 错: RSS 涨 → 直接定罪 leak → 白查三天
# 对: 先 tracemalloc 证明无泄漏 → MALLOC_ARENA_MAX=2 + max_requests

🏭 生产实战 real world

场景 1 · "内存快满了"值班定心丸: available 判定法

监控群凌晨炸锅: 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, 误报降为零。

场景 2 · 应用凌晨"自己挂了": dmesg 找 OOM killer 要供词

容器外物理机/虚机上, 进程消失且 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, 与发布时间对齐后, 直接锁定新版本里的缓存回归。

场景 3 · K8s 容器反复重启: OOMKilled 与 limit 配合修复

宿主内存空闲 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, 而不是让堆撞墙被内核杀。

场景 4 · RSS 爬升排查链: 2G 涨到 8G 的四步收网

流量平稳, 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 直接点名。

场景 5 · Go 堆画像: -base 对比抓增量泄漏

单张 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 压力)。

场景 6 · Python worker 膨胀: tracemalloc 到行 + uWSGI 回收

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 兜底碎片与膨胀, 顺序不能反。

场景 7 · 全局 map 缓存无界: 一周撑爆 16G 的 LRU 化改造

"本地缓存加速"没有上限约束, 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, 淘汰命中率监控同步加上。

场景 8 · swap 抖动: si/so 持续活跃的取舍决策

swap 用了不一定是病, 反复横跳一定是病。决策点: 加内存、降缓存上限、还是关 swap。

$ vmstat 1 5
# si=800 so=1200 持续 5 秒   ← 页在来回搬, 性能雪崩中

$ smem -k -r | head -5     # 谁吃最大, 能不能限
# 决策树:
#   应用 live set 真的需要 30G → 加内存 (扩容正确解)
#   本地缓存占了 20G 可压缩  → 缓存上限减半 (LRU 容量)
#   混合服务混部署           → 迁移拆分, 别让批处理挤线上

swappiness 调低只是让换页来得更晚, 治标; live set 真实超量就得加内存。

场景 9 · gunicorn 8 worker"吃了 16G"? smem PSS 修正账本

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。

场景 10 · GC 频率突然翻倍: gctrace + allocs profile 双证

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。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · used 高就重启应用 — used 里大半是 page cache, 重启把缓存清了反而更慢. 原因: 看错指标. 正解: 判定只看 available。
# 错: used 95% → systemctl restart
# 对: free -h → available 60% → 不处理
坑 2 · 把 page cache 当泄漏追查 — cache 随文件读写自然增长, 回收是内核的事. 原因: 不懂 used 构成. 正解: 应用内存看 RSS/heap, 系统看 available。
# 错: "RSS 涨的都是 cache"混为一谈
# 对: 进程 RSS (/proc/PID/status VmRSS) 与系统 cache 分开看
坑 3 · 用 VSZ 报内存告警 — Go/Java 进程 VSZ 18G 是地址空间预留, RSS 才 1G. 原因: 概念不清. 正解: 告警挂 RSS / cgroup usage。
# 错: alert: vsz > 8G        # 天天误报
# 对: alert: rss > limit * 0.85
坑 4 · swap 非零就慌 — swpd=2G 只是历史换出, si=0 表示没人回来闹事. 原因: 把存量当流量. 正解: 盯 si/so 流量不是 swpd 存量。
# 错: swpd 2G → "内存危机!" → 全员加班
# 对: vmstat 1 → si=0 so=0 → 平静
坑 5 · si/so 持续活跃不处理 — 页反复换进换出, 所有请求都在陪跑磁盘. 原因: 只看一次快照. 正解: 连续观察 + 处理 live set。
# 错: 看一眼 si=500 就翻篇
# 对: vmstat 1 10 持续活跃 → 找大户/加内存
坑 6 · 忽略 majflt — major fault 要读磁盘, 每秒几百次就是隐形 IO 瓶颈. 原因: pidstat -r 没人看. 正解: majflt/s 持续高进巡检。
# 错: 只看 %MEM 列
# 对: pidstat -r 1 → majflt/s 85 → 查 mmap 冷读/换页
坑 7 · 容器看宿主机内存 — 宿主 64G 空闲, 容器 limit 2G 照样 OOMKilled. 原因: 两层天花板混淆. 正解: memory.max / memory.current / memory.events。
# 错: "宿主还剩 40G, 不可能 OOM"
# 对: cat memory.events → oom_kill 12 → 容器层问题
坑 8 · OOM 后直接加内存 — 泄漏型 OOM 加 10G 也就是晚几天再爆. 原因: 处理症状. 正解: 先 profile 找分配方 (-base 增量对比)。
# 错: limit 2G→4G 收工        # 一周后再爆
# 对: heap -base 对比 → hub.register +5G → 修引用
坑 9 · 本地缓存无 TTL 无上限 — key 随业务无限增长, 内存只进不出. 原因: 默认 key 空间有限. 正解: LRU 容量 + TTL 双保险。
# 错: cache[orderID] = o    # 千万订单 = 千万条目
# 对: lru.New(100000) + 每条 TTL 5m
坑 10 · goroutine 闭包拖着大对象 — 300 个卡住的 goroutine 各持 8MB buffer, heap 纹丝不降. 原因: 引用链未断. 正解: goroutine profile + 限时 ctx。
# 错: go handleConn(conn)      # 泄漏时无上限
# 对: ctx 超时 + goroutine 数量上限 semaphore
坑 11 · live set 单调涨当正常 — gctrace 里 live(第二个数) 每轮抬升是泄漏前兆. 原因: 只看 alloc 不看 live. 正解: 趋势告警 live set。
# 错: 64→190→64 循环 → "正常"
# 对: 64→128→190→240 → live 在爬 → 查泄漏
坑 12 · Python worker 不设回收 — uWSGI/gunicorn worker 内存只涨不跌是常态 (碎片+arena). 原因: 没有兜底. 正解: max-requests + jitter 平滑回收。
# 错: --workers 8 就完事
# 对: --max-requests 5000 --max-requests-jitter 500
坑 13 · 循环里 += 拼大字符串/列表 — 每轮全量拷贝, 峰值内存翻倍. 原因: 不可变结构代价. 正解: 流式处理 / 生成器。
# 错: out += line            # 10GB 文件 OOM
# 对: for line in f: write(line)  # 逐行流式
坑 14 · 全量 io.ReadAll 大响应 — 8MB 响应 × 并发 1000 = 8GB 瞬时堆. 原因: 贪方便. 正解: 流式 decode / 限制 Content-Length。
# 错: b, _ := io.ReadAll(resp.Body)
# 对: json.NewDecoder(resp.Body).Decode(&v)  # 流式
坑 15 · 忽略 mmap / 共享库映射 — pmap 里几 G 的文件映射不是"应用泄漏", 但能吃掉 page cache 预算. 原因: 只看 heap. 正解: pmap -x 全景再定位。
# 错: "heap 才 500M, RSS 3G 一定是泄漏"
# 对: pmap -x → 2G 是 mmap 的索引文件 → 正常
坑 16 · rm 掉的日志还在吃内存/磁盘 — 进程握着 deleted 文件 fd, 空间不释放. 原因: 只删文件不轮转. 正解: lsof +L1 找到后重启/截断, 配 logrotate。
# 错: rm app.log 后 df 仍满
# 对: lsof +L1 | grep deleted → 重启该进程或 : > file
坑 17 · 监控只报 used% — used 95% 触发的全是无效告警, 真危险信号反而没监控. 原因: 指标选错. 正解: available% + oom_kill + swap in/out 三件套。
# 错: mem_used_percent > 90 告警
# 对: mem_available_percent < 10 for 5m
坑 18 · 多进程 RSS 直接相加 — 共享库/COW 页被重复计费, 容量规划翻倍买机器. 原因: 统计口径错误. 正解: smem PSS 或 cgroup memory.current。
# 错: 8 worker × 2G RSS = "吃 16G"
# 对: smem PSS 合计 4.2G → 按真实占用规划
坑 19 · 只盯 GC pause 一个指标 — pause 1ms 很好看, 但频率翻了 10 倍照样吃 CPU. 原因: 指标不全. 正解: 频率 + alloc rate + live set 一起看。
# 错: "pause 0.9ms, GC 没问题"
# 对: gctrace 频率 0.5s/轮 + alloc 42% json → 治分配
坑 20 · heap profile 只拍一张 — 单快照只能看存量, 大存量≠泄漏. 原因: 缺时间维度. 正解: 两个时间点 -base 做差。
# 错: 看一张 heap → "json 占 60%!" → 修错方向
# 对: heap_10am vs heap_15pm 增量 → 泄漏点现形