第 22 章 内存泄漏 vs 缓存挤压——“free 很低”到底是不是泄漏
第 06 章我们讲内存机制,第 12-14 章讲 vm 参数,第 19 章讲 OOM。这一章专治一个最常被误判、且最容易引发”盲目重启/盲目调参” 的问题: 监控告警”内存使用率 95%“,你以为泄漏了,跑去查半天,最后发现——根本不是泄漏,是 page cache 把
free挤低了,而那是健康现象。 这一章把free命令的三态误读、MemAvailable才是真话、泄漏与缓存挤压的一眼区分、以及PSI(Pressure Stall Information,内核在喊”我扛不住了”)讲透,照样走完整迭代环。
一、先拆 free:三态误读是万恶之源
墨:老哥,先考你一个每天都能看见、但 90% 人看错的命令。free -h 输出里,你到底该看哪一行判断”内存够不够”?
你:used 吧?used 高就是内存不够了。
墨:这是最常见的误读,今天先给你掰正。 free 的三态是:
total used free shared buff/cache available
Mem: 15Gi 12Gi 0.3Gi 0.5Gi 3.2Gi 2.1Gi
free:完全没被用的内存。注意——free低不等于内存紧张!因为 Linux 会主动用空闲内存做 page cache(文件缓存),让free长期很低是正常且健康的。buff/cache:buffer(块设备元数据)+ cache(page cache 文件数据)。这部分随时可被回收给新分配用(脏页需先写回)。available:真正”现在就能拿来用”的内存 = free + 大部分可回收的 cache。这是唯一该用来判断”够不够”的数字,比free准,比used准。
你:used 怎么算的?为什么 used 高不一定是坏事?
墨:used = total - free - buff/cache(老版本)或包含一点共享。used 高,可能只是 cache 多(你读了大量文件、page cache 撑起来了),这部分随时能还。所以告警如果盯着 used/total 或 (1-free/total) 报”内存 95%“,大概率是 cache 撑的假告警,不是泄漏。
一句话铁律:判断内存紧不紧,看
available,别看free,更别看used。
二、事故全景:“内存 95%“的假警报
📦 案例:某文件服务,16G 内存,跑一个读大量小文件的索引程序。监控按 "(1-free/total)" 告警"内存 95%",值班慌了,怀疑泄漏,准备重启。
现象(第 0 层)
监控面板:内存使用率 = (1 - free/total) = 95%,持续红色。但:
- 没有 OOM(第 19 章,
dmesg无 OOM kill); swap几乎没动(si/so长期 0);- 服务 RT 完全正常,没变慢。
墨:三个信号已经能初步判断”不像真泄漏”:真泄漏会单调涨、挤占 available、最终触发 swap/OOM/变慢。本案 free 低但 swap 不动、RT 正常——强烈指向”cache 挤压”,不是泄漏。但还是走完迭代环,别凭感觉下定论。
推断 1:内存泄漏?(可能推错)
你:95% 了,肯定是哪里泄漏,我 top 按 RES 排序看看哪个进程涨。
墨:按 RES 排序、watch 观察几分钟——没有任何进程的 RES 在单调上涨, Go 主进程常驻 2G、索引子进程各几百 M,都稳定。而且总 RES 远小于 12G 的 used——说明那 12G used 里,大头不是进程,是 buff/cache。
结论:推错。不是进程泄漏。
再观察:把 free 拆开看,真相浮现
你:那 12G used 哪来的?
墨:看 free -h 的 buff/cache 那列——3.2G 是 cache,而且这机器其实还跑了别的读盘程序,page cache 实际撑到 ~10G。索引程序狂读小文件,内核把读过的文件全塞进 page cache(第 07 章:读过的文件下次命中内存,纳秒级),于是 free 被挤到 0.3G,但 available 还有 2.1G(可回收 cache 折算)。
这才是真相:free 低 = page cache 健康地占着内存,不是泄漏。 内核的设计哲学正是”空闲内存不用白不用,拿来缓存文件,哪天进程要分配,立刻回收给你”。free 低是特性不是 bug。
推断 2:区分”泄漏”与”缓存挤压”的判据
墨:给你一套一眼区分的判据,记住这四看:
| 看什么 | 泄漏 | 缓存挤压(健康) |
|---|---|---|
available 趋势 | 单调降,不回头 | 稳定 / 随负载波动但能回收 |
swap(si/so) | 后期飙升 | 几乎不动 |
| 某进程 RES | 单调涨,不被回收 | 稳定 |
| 服务 RT | 后期变慢/抖动 | 正常 |
echo 3 > drop_caches 后 | used 不降多少(真被进程占着) | used 瞬间掉一大截(cache 释放) |
最后一条是终极判别法:手动 echo 3 > /proc/sys/vm/drop_caches(第 12 章正经用法之一),如果 used 瞬间掉一大截 → 证明是 cache,健康;如果掉了很少 → 真被进程占着,泄漏嫌疑大。
调整:别重启,别乱调,先确认
墨:运维做了三件事,一个都没”治”,只是”确认”:
- 改告警口径:把”(1-free/total)“改成盯
MemAvailable / total(或PSI)。假告警立刻消失。 - 跑
drop_caches验证:used从 12G 掉到 3G,确认是 cache。 - 没动任何 vm 参数、没重启——因为根本没病。
你:那如果真的泄漏,怎么定位是哪个?
墨:真泄漏的武器(不是本章重点但给你钥匙):
- Go:开
pprof(net/http/pprof),go tool pprof看inuse_space火焰图,定位哪个对象没释放。 - C/C++:
valgrind --leak-check=full或AddressSanitizer编译跑。 - 通用:
/proc/<pid>/smaps看某进程各内存区占用;nmon/smem按进程排。 - 容器:
kubectl top pod+kubectl exec进容器看smem。
改进:假告警消除,收口
墨:这次”故障”最终判定为监控误报 + free 三态误读,不是系统病。复盘最有价值的一句话:
free低是 Linux 的正常态,不是警报。判内存紧不紧看available,判有没有泄漏看”available 是否单调降 + 某进程 RES 是否只涨不回 + drop_caches 后 used 掉不掉”。 这四点能挡掉你职业生涯里一大半”假内存告警引发的盲目重启”。
三、配套:PSI——内核在喊”我扛不住了”
墨:最后给你一个新时代的诊断利器:PSI(Pressure Stall Information,/proc/pressure/ 目录,内核 4.20+)。前面我们用的都是”间接信号”(swap、allocstall),PSI 是内核直接报告的”压力失速”——谁在等资源、等了多久。
cat /proc/pressure/memory
# 输出:
# some avg10=0.00 avg60=0.00 avg300=0.00 total=12345
# full avg10=0.00 avg60=0.00 avg300=0.00 total=678含义:
some:至少有一个任务因等资源而停滞的时间占比(avg10/60/300 是 10/60/300 秒滑动均值)。some高 = 系统有压力但还能喘。full:所有非空闲任务都因等资源停滞(完全卡死)的时间占比。full高 = 系统基本瘫了。
对应还有 cpu、io 两个文件。PSI 的价值:它比 used/free 准一万倍地告诉你”系统现在到底在为什么等资源”,而且能直接喂给告警(memory.full.avg60 > 1% 才告警,而不是 free 低就告警)。cgroup v2 下每个 cgroup 也有自己的 pressure/ 文件,容器级压力一目了然。
四、参数与观测总表(这一章涉及的”看什么 / 调什么”)
| 看什么 / 参数 | 含义 | 拿什么换什么 / 用法 |
|---|---|---|
free -h 的 available | 真正可用内存 | 唯一该用来判”够不够”的数字 |
free -h 的 buff/cache | 可回收的文件缓存 | 低 free 多半是它撑的,健康 |
echo 3 > drop_caches | 手动清 cache(验证用) | 掉得多=是 cache;掉得少=真被进程占 |
vm.vfs_cache_pressure | 回收 dentry/inode 积极性 | >100 更积极回收目录缓存(省内存但 ls 慢) |
/proc/pressure/memory | 内存压力失速 | full.avg60>1% 才告警,比 free 准 |
vm.min_free_kbytes | 保留硬下限 | 保原子分配、防 direct reclaim |
pprof / valgrind | 泄漏定位 | 真泄漏时用,不是缓存挤压时用 |
墨:vfs_cache_pressure 这里容易和”缓存挤压”混淆——它是**调”目录缓存(inode/dentry)回收积极性”**的,和 page cache(文件数据)是两回事(第 12 章)。如果你发现 slab(用 slabtop 看)里 dentry/inode 占太多、想回收,才动它;普通”page cache 多”不必动,那是好事。
五、🔧 思考题(都配参考答案)
思考题 1(基础): 监控按 “(1 - free/total)” 报”内存 95%“,为什么这是假告警?你应该改用哪个指标判断”内存够不够”?
【参考答案】
Linux 会主动用空闲内存做 page cache(文件缓存),所以 free(完全没用的内存)长期很低是正常健康的,free 低 ≠ 内存紧张。按 (1-free/total) 算”使用率”,会把”健康的 cache 占用”算成”已用”,于是报出假 95%。
正确指标:MemAvailable(free -h 最后一列)——它是”现在就能拿来用的内存 = free + 大部分可回收 cache”,是唯一该用来判断够不够的数字。进一步可看 PSI(/proc/pressure/memory 的 full 均值)判断是否真在失速。本案 available 还有 2.1G、swap 不动、RT 正常,确认是 cache 挤压而非紧张。
思考题 2(深入): 给出你能”一眼区分内存泄漏 vs 缓存挤压”的 4 条判据,并说明 echo 3 > drop_caches 为什么是终极判别法。
【参考答案】
4 条判据:
available趋势:泄漏=单调降不回头;缓存挤压=稳定或随负载波动且能回收。swap(si/so):泄漏后期飙升;缓存挤压几乎不动。- 某进程 RES:泄漏=某进程只涨不回;缓存挤压=各进程稳定。
- 服务 RT:泄漏后期变慢/抖动;缓存挤压正常。
终极判别 drop_caches:echo 3 > /proc/sys/vm/drop_caches 会强制释放 page cache/slab(第 12 章正经用法之一)。若 used 瞬间掉一大截 → 证明占用是 cache(健康);若掉了很少 → 真被进程常驻占着(泄漏嫌疑大)。 因为 cache 本质是”可回收的冗余”,而泄漏是”进程死抓着不放的 RES”,一释放高下立判。
思考题 3(权衡): 有人为了防止 free 低,写 cron 每 5 分钟 echo 3 > drop_caches。结合第 12 章,指出这为什么是作死,以及正确的”让 free 好看”的方法是什么。
【参考答案】
作死原因(第 12 章):drop_caches 清的是 page cache(文件数据缓存)和/或 slab。page cache 的存在意义是”读过的文件下次命中内存,纳秒级不碰盘”。每 5 分钟强制清空 = 每 5 分钟主动销毁所有读缓存,之后所有 read/文件访问重新走块层落盘(机械盘随机读 8~15ms/次),后果是磁盘 I/O 暴涨、读延迟陡增、cache 命中率掉到近 0、业务周期性卡顿。这是”用 I/O 性能换一个 free 数字的心理安慰”。
正确做法:别让 free 好看,让它低着。判内存看 available/PSI;若真想减少 cache 占用(极少需要),调 vm.vfs_cache_pressure 影响目录缓存回收,或确认应用不需要那些缓存时手动释放一次。监控告警口径应改为盯 MemAvailable/PSI.full,而非 free。
思考题 4(进阶总账): 假设容器环境,K8s 节点 free 告警,但 Pod 没 OOM。请设计一套从”节点级”到”容器级”的 PSI + cgroup 排查顺序,说明如何区分”节点 cache 挤压(误报)“和”某容器真泄漏(该杀)”。
【参考答案】
节点级先看真假:
- 节点
free -h:看available而非free;available充足 → 节点 cache 挤压,误报,改告警口径即可。 - 节点
/proc/pressure/memory:full.avg60若近 0,节点无真失速。
容器级定位泄漏者(cgroup v2):
3. 遍历各 Pod 的 cgroup cat /sys/fs/cgroup/<pod>/memory.pressure 的 full 均值,谁 full 高谁在真失速;
4. 配合 memory.current(当前占用)和它的历史趋势——单调涨不回头 = 真泄漏候选;
5. 进该 Pod kubectl exec 跑 smem/cat /proc/<pid>/smaps 定位进程;用 pprof(Go)或 valgrind(C++) 定对象。
区分结论:节点 available 足 + full≈0 = 误报(cache 挤压),不杀;某 cgroup full 高 + memory.current 单调涨 = 真泄漏,该限流/重启/查代码。PSI + cgroup 两级对照,能精确区分”假告警”和”该杀的容器”,避免盲目重启整个节点。
六、第二个现场:透明大页(THP)的”内部碎片”假占用
墨:第 22 章第一个现场讲了”page cache 挤压”被误判为泄漏。这第二个现场,是另一种”可用内存被假占用”——透明大页的内部碎片,它和 cache 挤压长得像(available 低、告警),但机理不同,治法也不同。
📦 案例:某 Java 服务,监控 available 低告警,但 swap 没动、OOM 没发生、进程 RES 也没单调涨。
现象(第 0 层)
free -h 的 available 偏低,和第一个现场一样触发”内存快满”告警。但**free 的 buff/cache 不高**(排除了第一个现场的 page cache 挤压),且某进程 RES 稳定不涨(排除了真泄漏)。
推断 1:泄漏?(推错)
你:available 低,我看哪个进程 RES 涨。
墨:watch 观察——没有任何进程 RES 单调涨,且 smaps 看各进程内存区都稳定。排除真泄漏(第 22 章第一现场的判法)。
再观察:THP 内部碎片(命中)
墨:查 /proc/meminfo——AnonPages 高,且 /proc/vmstat 的 thp_fault_fallback(申请大页失败回退到 4KB)偏高;再 cat /sys/kernel/mm/transparent_hugepage/enabled = always。根因:THP 把内存按 2MB 大页对齐分配,但应用里大量”不是 2MB 整数倍”的小块匿名内存,每个大页内部剩的零头用不上,造成”大页内部碎片”——这些碎片内核不能拿去给别的进程用,于是 available 被”假占用”压低,看着像内存紧张,其实没泄漏也没 cache。
调整
墨:三选一:
defrag=madvise:减少强制整理,碎片增长放缓(第 18/23 章同源)。enabled=never(关 THP):彻底无大页碎片,换回 4KB 页(数据库常选,代价丢 TLP 收益)。- 接受:确认没 OOM/没 swap,这”假占用”不影响服务,只调监控口径(看
MemAvailable趋势而非绝对值),别慌着重启。
改进:确认健康,收口
墨:确认是 THP 碎片而非泄漏/缓存后,不动任何东西,只改告警口径(用 PSI + available 趋势)。复盘:“available 低”至少三种健康假象——page cache 挤压(第一现场)、THP 内部碎片(本现场)、以及只是内存确实规划得紧但够用。三者都不该触发”重启/清缓存”的盲目操作。判法不变:看 available 趋势 + swap + 某进程 RES 是否只涨不回 + drop_caches 验证 + smaps 定位。
七、内存排障总账(把第 6、12、22 章收成武器)
墨:把内存相关排障收成一张决策表:
| 症状 | 第一怀疑 | 验证 | 治本 |
|---|---|---|---|
available 低 + cache 高 + swap 0 | page cache 挤压(健康) | free 的 buff/cache;drop_caches 验证 | 改告警口径,不动 |
available 低 + cache 不高 + RES 稳 | THP 碎片(健康假象) | thp_fault_fallback;enabled | defrag=madvise/never |
available 单调降 + RES 只涨不回 | 真泄漏 | smaps/pprof/valgrind | 查代码/修复 |
swap si/so 跳动 + free 有 | swap 颠簸 | vmstat si/so | swappiness↓+去大分配 |
MemAvailable 稳但 PSI.full 高 | 真失速 | /proc/pressure/memory | 加内存/降负载/限流 |
| OOM kill | 边界撞了 | dmesg 前缀(整机/cgroup) | 对齐 limit/overcommit |
墨:内存排障的总心法:free 低是常态不是病,判紧张看 available + PSI;available 降看”是 cache/碎片(健康)还是 RES 单调(泄漏)“;OOM 看 dmesg 前缀分整机/cgroup。 这六条能挡掉你职业生涯里一大半”假内存告警引发的盲目重启”。
八、第三个现场:slab(dentry/inode)缓存暴涨,vfs_cache_pressure 压不下来
墨:前两个现场讲”available 低”的两种健康假象(page cache 挤压、THP 碎片)。这第三个现场,讲另一种内核对象缓存——slab(具体是 dentry/inode 缓存)——无节制增长,把 free 吃低,常被误判成”用户态泄漏”去查应用,结果查半天查不到。它的根子在内核,不在你的进程。
📦 案例:某做海量小文件遍历的索引服务,free -h 的 buff/cache 持续涨,available 一路走低,但所有业务进程 RES 稳定,drop_caches 一清就回血、过会儿又涨回去。
现象(第 0 层)
available 单调降,告警”内存快满”。但 smaps 看各业务进程 RES 都稳定不涨(排除真泄漏、排除第一个现场的普通 page cache——因为这次 cache 涨得异常快且 drop_caches 一清就回)。
推断 1:泄漏 / page cache 挤压?(推错)
你:available 低,先看哪个进程 RES 涨,再看 cache。
墨:进程 RES 都不涨;drop_caches(写 2 清 slab)一清,available 立刻回血一大截——说明吃掉内存的是 slab,不是普通 page cache,更不是用户态泄漏。第一个现场的 drop_caches=1(清 page cache)回血少,写 2(清 slab 的 inode/dentry)才大幅回血,这是关键区分。
再观察:slab 里的 dentry/inode 缓存撑爆(命中)
墨:查 cat /proc/meminfo | grep SReclaimable(可回收 slab)和 slabtop -o | head——dentry 和 inode_cache 两个 slab 占了几 GB 且只涨不回。根因:索引服务海量遍历小文件,每访问一个文件就在内核建一个 dentry(目录项)+ inode 缓存;遍历完这些对象本应被回收,但内核的 vfs_cache_pressure 默认 100,对 dentry/inode 的回收积极性只算”中等”,当创建速度 >> 回收速度,slab 就一路涨,挤低 available。
调整
墨:两剂药:
- 提高
vfs_cache_pressure:sysctl -w vm.vfs_cache_pressure=200(或更高),让内核更积极回收 dentry/inode 缓存,给”路径解析/小文件遍历”场景腾地方(第 12 章讲过:>100 更积极回收目录缓存,换来ls/路径解析略慢,但换来内存不被 slab 吃光)。 - 应用层减少无谓遍历:索引服务改成增量遍历 / 缓存目录结构,别每次全量
walk一遍百万小文件——这是治本。slabtop观察对应 slab 是否回落。
改进:available 回稳,收口
墨:调 vfs_cache_pressure=200 + 应用改增量遍历后,slab 不再只涨不回,available 回稳。复盘:“available 低”至少有四种健康/半健康假象——page cache 挤压(第一现场)、THP 内部碎片(第二现场)、slab(dentry/inode)缓存膨胀(本现场)、以及单纯规划得紧但够用。drop_caches 的”清 1(页缓存)回血少、清 2(slab)回血多”是区分 page cache 与 slab 的终极判别;再看 slabtop 定位是哪个 slab。判法不变:别一见 available 低就杀进程、清缓存,先用 drop_caches(验证用)+ slabtop + 进程 RES 三连,确定”假象类型”再下药。
实战补遗一:skbuff_head_cache / TCP slab 暴涨——网络侧的”假泄漏”,drop_caches 救不了
墨:free 一看 used 高得离谱,但 available 也不低,page cache 没占多少。你按老套路 echo 3 > /proc/sys/vm/drop_caches,结果 used 一点没降。你这时候会怎么定性这个”泄漏”?
你:page cache 清不掉,pprof heap 也没异常,那多半是真泄漏,准备重启或者上 valgrind。
墨:先别急着重启。drop_caches 只清 page cache / slab 里的 reclaimable 部分(而且对 slab 也只清 dentries/inodes)。如果涨的是网络收发包的 slab(skbuff、TCP),它属于 slab_unreclaimable——drop_caches=3 也救不了,得另看。
先分清楚:slab 有”可回收”和”不可回收”两本账
slab_reclaimable:dentry、inode 缓存这类,内存压力时内核能自动回收,drop_caches=2也能手动清。前面”现场三”讲的就是它。slab_unreclaimable:skbuff_head_cache、skbuff_fclone_cache、TCP/TCPv6、kmalloc-*这类内核对象,内核不会自动回收,drop_caches也清不动。它们涨,等于真泄漏。
free / /proc/meminfo 里 SReclaimable 和 SUnreclaim 两行就是这两本账。很多人只看 used,不拆这两行,于是把”网络对象没释放”当成”内存泄漏”或”page cache 挤占”。
现象:连接一多,SUnreclaim 一路狂飙
某 Go 长连接网关,压测连接数从 1 万涨到 10 万后,SUnreclaim 从 200 MiB 涨到 4 GiB,free 的 used 同步涨,但 SReclaimable(page cache)几乎没动。运维 echo 3 > drop_caches 毫无效果,怀疑 Go runtime 泄漏,pprof heap 却只有几百 MiB。
推断(可能推错):先冤枉 page cache 和 Go runtime
第一推断:「page cache 把内存吃了。」——drop_caches=3,没用,因为涨的不是 page cache。
第二推断:「Go 内存泄漏。」——pprof heap 正常,但运维没看 SUnreclaim,于是怀疑是 cgo / 某 native 库漏,计划上 valgrind,成本高、且生产不敢跑。
都推错,因为没拆 SReclaimable vs SUnreclaim,也没看 slab 到底哪个 cache 在涨。
真相:连接没关,skbuff 排队等着
slabtop -o -s c(按 cache 占用排序)一看,排第一的是 skbuff_head_cache 和 TCP。每个活跃连接在内核收/发队列里都挂着 skbuff 结构;连接泄漏(goroutine 没退、conn 没 Close)→ skbuff 永远不被释放 → SUnreclaim 随连接数线性涨。
佐证:
cat /proc/net/sockstat里TCP: inuse远超业务”应有”连接数(比如业务说 1 万,实际 10 万);ss -s看TCP: Established巨大,且很多处于CLOSE-WAIT(对端关了、本端没Close的经典信号);SUnreclaim涨速 ≈ 每连接几十~几百字节 × 连接数。
调整:根因在代码,drop_caches=2 只是止血
- **治本:**修代码关连接——加连接池上限、读超时
context、所有defer conn.Close()、CLOSE-WAIT一堆就是漏关的铁证; - **止血(非治本):**临时
echo 2 > drop_caches清 dentries/inodes 释放一点 reclaimable 空间,给内核喘息——但对skbuff没用,根因不减连接就是白搭; - **监控:**把
SUnreclaim和sockstat的TCP: inuse纳入告警,区分可回收与不可回收——SUnreclaim涨才是真泄漏信号,SReclaimable涨多半是缓存健康。
再观察:修关连接后
补上 defer Close + 读超时后,CLOSE-WAIT 归零,TCP: inuse 回落到业务量,SUnreclaim 从 4 GiB 掉回 300 MiB,drop_caches 不再需要。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
| 修代码关连接 | 改动 + 回归测试 | 根因消除,SUnreclaim 回落 |
drop_caches=2 止血 | 一次性 I/O 抖动 | 临时腾空间(不治本) |
监控 SUnreclaim+inuse | 告警配置 | 早一步区分真泄漏/缓存 |
🔧 思考题:为什么 SReclaimable 涨通常不用慌,而 SUnreclaim 涨必须当真泄漏处理?
【参考答案】
SReclaimable(dentry/inode 等)是内核的”缓存”,内存紧张时内核会自动回收它,不影响业务正确性,涨了只是”缓存用得多”,压力一来就吐。而 SUnreclaim(skbuff、TCP 控制块、内核对象)内核不会自动回收,drop_caches 也清不动,它涨的唯一合理解释是”有内核对象没被释放”——要么是连接/资源泄漏,要么是内核 bug,不会自己好,只会越涨越 OOM。所以二者定性完全不同:一个看趋势即可,一个涨就必须查根因。
实战补遗二:PSI 的 full 与 some 误读——把”即将 OOM”当成”只是有点忙”
墨:你把 PSI(/proc/pressure/memory)接进监控,看 full 一直是 0,就放心了。结果业务已经大面积超时,OOM 也快了。你哪里读错了?
你:full=0 不是说明”没有任何任务被完全卡住”吗?难道这不对?
墨:full 是”所有任务都因资源 stalled 的比例”,它要求”全员卡死”才涨。但很多真实事故是”部分任务卡、业务已凉”,这时 some 早涨了、full 还是 0。只看 full 等于只看”全员窒息”,漏了”半数人窒息”。
现象:PSI full=0,但 P99 已经爆
某服务内存压力上升,/proc/pressure/memory 的 some avg10 从 0 涨到 12%,full avg10 还是 0。运维看 full=0 以为”没大问题”,没处理。随后 some 涨到 30%,业务 P99 从 50ms 飙到 800ms,再后面 full 才开始涨、紧接着 OOM。
推断(可能推错):先信了 full=0
第一推断:「full=0 说明内存没瓶颈,超时是别的原因。」——去查网络/CPU,浪费半天。其实 some 早已报警,只是没人看。
真相:some 是”有人卡”,full 是”全员卡”
PSI 三个文件(cpu/memory/io)各报 some 和 full:
some:至少有一个任务因该资源 stalled 的时间占比——早早期预警;full:所有非 idle 任务都因该资源 stalled 的时间占比——晚期、已全员窒息。
内存压力发展路径:some 先涨(部分分配变慢、部分请求延迟升)→ 业务 P99 爆 → full 才涨(连内核回收都卡)→ OOM。只看 full 等于在 some 阶段装瞎,等 full 涨时已经晚了。
调整:告警看 some,扩容/查根因看 full
- 监控
memory.someavg10/avg60 设阈值(如 avg60 > 5% 告警、> 15% 严重),不等 full; io.some同理:磁盘开始排队时some涨,业务 IO 延迟已升,别等full;full用作”已濒危、马上 OOM”的紧急信号,触发自动扩容/限流;- 配合 cgroup
memory.events(PSI 版)看oom_kill计数,三者交叉验证; - 验证:压测时观察
some何时涨、full何时涨,定你自己的阈值(别抄别人的)。
再观察
把告警从 full=0 改成 some avg60 > 5% 即告警 后,内存压力在 P99 爆之前 3 分钟就拉响,自动扩容介入,没再 OOM。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
告警看 some | 略敏感的误报 | 早 3 分钟发现瓶颈 |
full 作紧急信号 | 晚发现 | 濒危时强干预 |
🔧 思考题:some 涨了但 full 是 0,业务 P99 已经爆了,这时候根因在内存吗?还是可能误报?
【参考答案】
some 涨说明”至少部分任务因内存分配/回收 stalled”,通常根因就是内存压力(回收慢、匿名页多、slab 涨等,呼应本章前面几现场)。但要排除”误报”:some 是时间占比,若你的服务本来就是低并发、偶发一个大分配卡 100ms,some 的 avg 可能被单次毛刺拉高——所以看 avg60(1 分钟均值)而非瞬时,且结合 full/memory.events/实际 P99 交叉判断。若 some avg60 持续 > 5% 且 P99 同步涨,基本坐实内存瓶颈;若 some 跳一下就回、P99 不跟,是偶发毛刺非瓶颈。PSI 是”比例”不是”绝对值”,解读要配业务指标,这也是第 25 章”量化先行”的体现。
实战补遗三:/proc/<pid>/smaps 定位”哪段地址空间在涨”——一个 mmap 文件没 munmap 的缓慢泄漏
墨:你 top 看某进程 RSS 每周涨几百 MB,pprof heap 没异常,SUnreclaim(第 22 章)也没涨。你说:RSS 涨但堆和 slab 都没涨,那内存去哪了?
你:RSS 不就是进程占的内存吗?它涨不就是泄漏?
墨:RSS 涨,但不是堆(malloc)、不是 slab(内核对象)——那它只可能在”地址空间映射”里:mmap 了文件/匿名页却没 munmap,这些映射算 RSS。关键:RSS 涨但 heap/slab 都不背锅 → 去 smaps 看”哪个 mapping 段在涨”,而不是瞎猜。
现象:RSS 缓涨,heap/slab 都清白
某 Go 服务 RSS 每周 +300MB,pprof heap 稳(业务对象没漏),/proc/meminfo 的 SUnreclaim 也不涨(不是 skbuff/内核对象,第 22 章实战补遗五)。运维困惑:内存去哪了?
推断(可能推错):先查堆和连接泄漏
第一推断:「Go 堆泄漏(pprof 看不出?)。」——pprof 明确稳,排除。
第二推断:「连接/slab 泄漏。」——SUnreclaim 稳,排除(第 22 章)。推错,因为没看”地址空间映射”。
真相:smaps 暴露”匿名 mmap 没释放”
cat /proc/<pid>/smaps 逐段看 Rss/Pss,发现某个 anon 段(匿名映射)每周涨 300MB、且数量随某操作累积。根因:代码里用 mmap(或 CGO 库、或某 syscall.Mmap)映射内存/文件做处理,但路径上没 munmap,且这些映射是匿名的(不走 page cache),所以算 RSS、且不被内核自动回收 → 缓慢泄漏。
文件 mmap(第 3 章)泄漏则表现不同:算 SReclaimable(cache),内存紧会被回收,不”真漏”;匿名 mmap 不 munmap 才是真漏(和 malloc 泄漏同类,只是走了 mmap 系统调用)。
调整:smaps 定位 + munmap/复用
- 用
smaps/pmap -x看哪段Rss在涨,定位到”哪个操作每次多一段映射”; - 根因:加
munmap(CGO 场景)或复用映射(别每次 mmap); - Go 里避免不必要的
syscall.Mmap后不释放; - 监控进程 RSS 斜率 +
smaps段数,异常涨即告警; - 验证:修后 RSS 斜率归零。
再观察
定位到某 CGO 压缩库”每次压缩 mmap 一块、忘了 munmap”,加 munmap 后 RSS 稳定,周涨消失。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
munmap / 复用映射 | 改代码 | 泄漏止 |
smaps 监控段数 | 多指标 | 早定位映射漏 |
🔧 思考题:smaps 里的 Rss 和 Pss 有何不同?定位泄漏该看哪个?
【参考答案】
Rss 是该映射段独占 + 共享的物理内存(共享页被多个段重复计);Pss(Proportional Set Size)把共享页按比例分摊(如共享页被 4 个进程映射,每段记 1/4),更真实反映”这段实际吃掉多少”。定位泄漏:先看 Rss 找”哪段在涨”,再用 Pss 评估”实际占用多少”(避免被共享页误导)。匿名 mmap 不 munmap 时,Rss 段数和总量持续涨、Pss 同步涨——这就是”地址空间泄漏”的铁证。一句话:RSS 涨但 heap(slab)清白,第一反应不是”再查一遍堆”,而是 smaps 看映射段——本书第 3/22 章都在教你”先拆分内存的账(堆/栈/cache/slab/映射),再定位哪本账在涨”,smaps 就是把”映射这本账”摊开给你看;和 slabtop(看 slab 那本账)、SReclaimable/SUnreclaim(看 cache/slab 两本账)是一个完整的内存账本体系。
实战补遗四:SReclaimable 与 SUnreclaim——你能回收的,和 drop_caches 救不了的
墨:前面讲 page cache 可回收。但内核内存还有一块叫 slab,它分两种:SReclaimable(可回收)和 SUnreclaim(不可回收)。你知道哪些东西进了 SUnreclaim 吗?
你:SReclaimable 应该是 dentry、inode 缓存这些吧?
墨:对。SReclaimable 主要是 dentry cache(目录项)和 inode cache——它们本质上是”文件系统的缓存”,内核内存紧了会自动回收。SUnreclaim 就杂了:socket 缓冲区(skbuff)、TCP 连接控制块、各种内核对象(struct 实例)。这些东西”正在被用”,内核不敢随便回收。
真实事故:一台网关机器,free 看 available 越来越小,但 buff/cache(free 第二行那个)并不大。按老经验 echo 3 > /proc/sys/vm/drop_caches 想清缓存腾内存——结果内存几乎没降。这就诡异了:drop_caches 不是清 page cache 和 slab 吗?怎么没用?
推断:既然 drop_caches 清不动,说明涨的不是 page cache、也不是 SReclaimable,而是 SUnreclaim——那些”正在被用、drop 不掉”的内核对象。
验证:cat /proc/meminfo | grep -E 'SReclaimable|SUnreclaim'。发现 SReclaimable 才 300MB,而 SUnreclaim 一路涨到 12G。再用 slabtop -s c(按缓存大小排序)看,涨得最猛的是 skbuff_head_cache 和 TCP 相关 slab——也就是网络包缓冲区和 TCP 连接对象。
根因:这台网关开了 net.netfilter.nf_conntrack 连接跟踪,且 nf_conntrack_max 设得过大、连接超时(nf_conntrack_tcp_timeout_established 默认 5 天!)又长。海量短连接进来,每个都在 conntrack 表里占一个 struct,且 skbuff 在收发包路径上常驻。这些全进 SUnreclaim,drop_caches 一个都救不了,因为内核认为”这连接还活着,不能回收”。
调整:① 把 nf_conntrack_tcp_timeout_established 从 5 天压到 2 小时(短连接场景不需要 5 天);② 评估是否真需要 conntrack(纯转发网关可关掉,用 NOTRACK 或 raw 表旁路);③ 确认应用没有”连接不关”的泄漏(用 ss -tan | wc -l 看 ESTABLISHED 数是否合理)。再观察:SUnreclaim 停止增长并缓慢回落,available 回升,drop_caches 虽仍清不动它,但已不需清——根本是别再往里灌。
你:那 SUnreclaim 真的一点都回收不了吗?
墨:正常路径下不行——只要对象”被引用”,内核不回收。但有两个”极端手段”:① 重启相关内核子系统(如 rmmod nf_conntrack 再 modprobe,等于清空整个连接跟踪表,会断开所有现有连接,生产慎用);② 真 OOM 时内核会尝试先回收 SReclaimable,不够才杀进程。所以 *Unreclaim 涨的本质是应用/内核配置在持续制造不可回收对象,正确解法是”掐住源头”,而不是”想办法清”。drop_caches 在这一类问题上是完全无效的——这也是为什么很多人”清了缓存内存没回来”会困惑。
🔧 思考题:free 命令里的 buff/cache 这一列,到底包不包含 SUnreclaim?你平时看 available 判断内存够不够,会不会被 SUnreclaim 骗?
【参考答案】
不包含。free 的 buff/cache = Buffers + Cached + SReclaimable(部分版本才含 SReclaimable),不含 SUnreclaim。而 available 的算法是 MemFree + 可回收部分(含 buff/cache 和 SReclaimable) - 一点保留,也不含 SUnreclaim。
所以会被骗的场景:当内存主要涨在 SUnreclaim(如 skbuff、conntrack、内核对象泄漏)时,free 显示 buff/cache 不大、available 也还行(因为 available 不算 SUnreclaim 的账),但实际上真能用的内存正在被不可回收对象悄悄吃掉。等你发现时往往已经临近 OOM。
正确判断姿势:free 只能看个大概,真要排查”内存去哪了”,必须 cat /proc/meminfo 看 SReclaimable/SUnreclaim 的绝对值和趋势,配合 slabtop 定位是哪个 slab 在涨。口诀:drop_caches 无效 + 内存持续跌 → 先看 SUnreclaim,别再迷信 available。
实战补遗五:误把 page cache 当泄漏——一场”内存泄漏”告警乌龙
墨:第 22 章讲了怎么用 Pss 趋势区分真泄漏和缓存挤压。但还有一种更常见的乌龙:监控把 page cache 上涨当成”内存泄漏”告警,半夜把值班叫起来,结果啥事没有。
你:page cache 涨不是正常的吗?监控怎么会把它当泄漏?
墨:因为很多监控的”内存使用率”指标,算的是 used(含 page cache),或把 cached 也算进”已用”。当某个服务跑批量读(如报表生成扫全表),page cache 从 20G 涨到 80G,监控看”内存使用率”从 30% 飙到 85%,触发”内存泄漏”告警。值班爬起来,top 看那个服务 RES 没涨,Pss 也没涨——根本没泄漏,是 page cache 在干活。
真实事故:一个每晚跑的报表任务,触发了”节点内存泄漏”告警。值班按”泄漏”流程查:看 smaps 的 Pss 趋势(平稳)、看进程数(没涨)、看 SUnreclaim(平稳)——三样都正常,但告警还亮着。最后发现告警规则是 100*(1 - available/total) > 80,而 available 在 page cache 涨时会下降(因为 available 把可回收 cache 折进去,但折得保守)。其实 available 下降是因为 cache 多,不是内存真紧——内核一有需要就会回收 cache 给应用。
推断:告警规则用错了指标。available 下降 ≠ 内存紧,尤其当下降原因是 page cache 增多时(这是健康行为)。验证:free -h 看 available 虽然降,但 buff/cache 同步涨,且 si/so(swap 进出)是 0、b(D 状态进程)没涨——证明机器健康,只是 cache 多。根因是告警阈值基于”使用率”而非”真压力信号”(swap/IO等待/分配失败)。
调整:① 告警规则改用真压力信号:pgfault 里的 major fault(要读盘)占比、si/so 是否 >0、MemAvailable 是否逼近 0(而非”下降”)、malloc 失败/OOM 事件——这些才是”真内存紧”的证据;② 把 page cache 的波动排除在”泄漏”告警之外,或单独建一条”cache 增长”信息性指标(不告警);③ 给报表任务单独绑核/限 cgroup,避免它的 cache 行为干扰全局 available 计算。再观察:报表任务照跑,available 照降,但告警不再误报,值班睡安稳。
你:available 到底怎么算的?它为什么会把 cache 折进去却又”折得保守”?
【参考答案】
墨:MemAvailable 是内核估算的”不用换页/不杀进程就能马上给应用的内存”,算法大致是:MemFree + 可回收的 page cache 的一部分 + 可回收的 SReclaimable 的一部分 - 一点保留。“折得保守”是因为内核不敢假设所有 cache 都能瞬间回收(有些正在被用的页、dirty 页要写回才能回收),所以只算”大概率能回收”的那部分。于是:
- cache 暴涨 →
available下降(因为可回收部分占比变小/或保守估计)→ 但机器仍能正常服务(cache 真要时立刻回收); - 这是健康信号的假象:
available低不代表”要 OOM”,只代表”cache 多、立即可用余量保守估计低”。
所以监控纪律:available 的绝对值低,要看伴随信号——有 si/so>0 或 major fault 暴涨或 OOM 才是真紧;单纯 available 低而 cache 高,是 cache 行为,不是泄漏。口诀:泄漏告警看”真压力信号”(swap/majflt/OOM),别看”使用率/available 绝对值”——后者会把 page cache 的正常波动误判成事故,制造告警疲劳,真出事时反而没人信。
实战补遗六:Go pprof heap 与 RSS 对不上——为什么”堆才 400M,RES 却 1.2G”
墨:老哥,你用 pprof 看 Go 服务的堆,inuse_space 才 400M,但 top 里 RES 1.2G。差的那 800M 是哪?你查过吗?
你:……以为是别的,但说不清。
墨:这是 Go 面试和排障都爱问的”RSS 对不上堆”问题。差的那块通常是三种东西:arena 没还 OS、缓存未回收、或 mmap 映射。这节用 smaps + pprof 把账对平。
现象:Go 网关压测完撤流量,RSS 仍停在 1.2G 不降,但 go tool pprof 的 inuse_space 只有 400M。运维以为”内存泄露”,要重启。你用 pprof 看 heap 没泄露,困惑。
推断:是堆泄露,pprof 没抓到。
可能推错:第一,pprof heap 只统计 Go runtime 管理的堆对象,不含 runtime 自己向 OS mmap 的 arena、线程栈、mcache 元数据、以及 mmap 的调度器/GC 结构——这些 RES 算,pprof 不算。第二,RSS 高但流量撤了不降,是因为 runtime 把空闲 arena 留着没还 OS(见第 2 章 goroutine 栈实战补遗的 heap arena 解释):对象 free 只是回 mcache,arena 的 mmap 映射不 munmap。第三,还有 page cache 映射的文件页(inuse_space 当然不含,那是文件不是堆)。
调整:用 pprof 不只看 heap,还看几张更全的图:
go tool pprof http://svc/debug/pprof/heap:堆对象(inuse_space/alloc_space)。runtime.MemStats的HeapSys(runtime 向 OS 要的堆总大小)、HeapAlloc(在用)、StackSys(goroutine 栈)、MSpanInuse/MCacheInuse(runtime 元数据)。cat /proc/<pid>/smaps的Rss/Pss逐段加总:区分[heap]段、[stack]、各mmapanon 段(arena)、含文件的段。
再观察:那次对不上,根因是流量撤了但 GOGC 默认 100% 没触发足够 GC,runtime 觉得”内存够用”不回收 arena,加上没有显式 debug.FreeOSMemory(),1.2G 里约 600M 是空闲但没还 OS 的 arena + runtime 元数据,400M 是真堆,其余是 goroutine 栈和 mmap。这不是泄露(arena 里对象空闲、可复用),但 RES 虚高。
改进:
- 别用 RES 当”真内存占用”,结合
pprof heap+MemStats+smaps三段对账。 - 想让 RSS 真降:调
GOGC更激进回收、或低谷期debug.FreeOSMemory()(有 STW 代价)、或 Go 1.16+ 的MADV_DONTNEED(GODEBUG=madvdontneed=1,释放即降 RSS,但下次访问重新缺页)。 - 容器里用
GOMEMLIMIT(Go 1.19+)让 runtime 在接近 cgroup limit 时主动 GC,避免堆涨过 limit 被 OOMKill(见第 14 章 K8s OOM 实战补遗)。 - 真泄露判定:连续多次 GC 后
HeapAlloc/HeapSys仍单调涨、且 pprof 能看到明确增长的对象类型,才是泄露;arena 留着不还是常态,不是泄露。
🔧 思考题:怎么用 pprof 区分”真泄露”和”arena 留着没还 OS 的虚高”?如果 inuse_space 平稳但 RSS 一直涨,问题大概率在哪个方向?
【参考答案】
区分”真泄露” vs “arena 虚高”,看两个独立信号是否一起涨:
-
真泄露:
pprof heap的inuse_space(或alloc_space在多次 GC 后)单调上涨,且 pprof 火焰图/拓扑里能定位到某类对象(如某个 map、slice、channel buffer)持续增长、GC 后不回落。同时runtime.MemStats.HeapAlloc也单调涨。这是对象被分配了但引用没断、GC 回收不了——代码 bug(goroutine 泄漏持有大对象、全局 map 只增不删、channel 没人收)。 -
arena 虚高(非泄露):
inuse_space平稳甚至下降(GC 正常回收了对象),但 RSS 高且不降。说明对象释放了,runtime 内部标为空闲可复用,但mmap的 arena 没munmap还给 OS——RSS 虚高但内存”可复用”,不是泄露。典型场景:流量高峰后撤流量,arena 留着等下次用。
所以:inuse_space 平稳 + RSS 涨 = 不是泄露,是 arena 未还(方向在”runtime↔OS 的归还机制”,调 GOGC/GOMEMLIMIT/madvdontneed)。inuse_space 涨 + RSS 涨 = 真泄露(方向在”代码里的对象引用没断”,查 pprof 定位类型)。
一个实用诊断组合:
go tool pprof -http=:8080 heap:看 inuse 对象分布与调用栈。GODEBUG=gctrace=1:看每次 GC 的heap_in_use和heap_released,heap_released涨说明在还 OS。cat /proc/<pid>/smaps | grep -A1 AnonHugePages+ 逐段 Rss 加总,核对 RES 构成。
补充:有些”看起来像泄露”其实是 goroutine 泄漏——goroutine 没退出,它的栈 + 它引用的对象都活着。用 pprof 的 goroutine profile 看 goroutine 数是否只涨不跌,比看堆更先一步发现。容器场景务必配 GOMEMLIMIT 对齐 cgroup limit,否则 arena 虚高 + 突发流量会把 memory.current 顶过 memory.max 触发 OOMKill,被误判成泄露。
下一章预告
墨:第 22 章我们拆穿了”free 低 = 泄漏”的最大误读,你学会了看 available、用 drop_caches 做终极判别、用 PSI 听内核喊救命。记住:cache 挤压是健康,泄漏是病变,两者治法天差地别。
下一章(第 23 章)我们上NUMA 与大页——这是大内存机器上最隐蔽、且参数最容易被”无脑抄”放大的两类故障。NUMA 下”跨节点访问慢一倍”,zone_reclaim_mode=1 这个曾被推荐的参数反而引发局部回收毛刺;大页(THP)默认 always 在数据库上可能引发 compaction 冻进程(和第 18 章的 8 秒卡顿同源)。这两类,我们照样走完整迭代环,把”为什么大内存机器要特别小心 NUMA/大页”讲透。
本章完。NUMA 与大页见第 23 章。