第 14 章 vm 调优实战案例——四个把人坑哭的真实故障

第 12 章给了”引信清单”,第 13 章给了”画像组合”。这一章上真刀真枪的事故——四个都和 /proc/sys/vm 直接相关,每个走完整迭代环:现象 → 推断(可能推错)→ 调整 → 再观察 → 改进。 你会发现:vm 的坑,90% 不是”不知道参数”,而是”知道参数但赌错了方向”,或者”调了 A 却不知道 B 在拖后腿”。这一章的价值,是把前两章的知识焊进肌肉记忆。


事故一:overcommit_memory=2 把服务卡得起不来

1.1 现象

某支付网关升级后,进程起不来,日志里反复 fork: Cannot allocate memory / mmap: Cannot allocate memory,但 free -h 显示还有 20G 内存空闲、swap 也还有 30G。内存明明够,为什么 allocation 失败?

1.2 第一推断(推错):内存真不够 / 有泄漏

你:起不来嘛,肯定是内存不够。先看 top/free——结果空闲 20G。再怀疑泄漏:valgrind 跑一遍,没泄漏。懵了。

1.3 第二推断(命中):overcommit 的 commit 上限被突破

你:查 cat /proc/sys/vm/overcommit_memory —— =2(严格模式)。回忆第 12 章:模式 2 下,允许 commit 的总量 = 物理内存 × overcommit_ratio% + swap。这台机器 overcommit_ratio=50,物理 64G → 允许 commit = 32G + 30G swap = 62G。但这台机器上还跑了别的吃内存的进程,已 commit 了 55G,新网关想再 commit 15G,55+15=70G > 62G 上限 → malloc/mmap 直接失败(EAGAIN)。而 free 看到的 20G”空闲”是已分配但未实际写(还没触缺页)的空洞,在严格模式下不被算作”可再 commit”。

墨:这是 overcommit_memory=2 最阴的地方:free 说有内存,但 commit 上限已经封顶。你看着满坑满谷的空闲,就是借不出来——因为”空头支票”的额度用光了。

1.4 调整与改进

三种解法,按代价:

  1. (治本)改回 overcommit_memory=0(启发式):允许略超,网关能起来。代价:把”不确定性”推到真 OOM 时(但该网关能优雅处理)。
  2. (保守)提高 overcommit_ratio 到 80~100:上限变 51~64G+swap,给足 commit 空间。但仍是”账面”宽松。
  3. (根因)重新规划内存:网关不该和别的吃内存进程挤一台机器;要么拆分,要么给网关独立 quota。

最终:临时设 overcommit_memory=0 让服务先起来,长期把网关迁到独立节点 + 设 overcommit_ratio=80。观察:起来后再无 allocation 失败。


事故二:swappiness=0 反而让延迟更抖

2.1 现象

一个延迟敏感的 Go 服务,有人照”调优帖”把 vm.swappiness 从 60 改成 0,“让内存别 swap,降延迟”。结果延迟 P99 不降反升,还出现了以前没有的几十毫秒毛刺。

2.2 第一推断(推错):还是 swap 的问题

你:设 0 不是为了不 swap 吗?查 vmstat 的 si/so —— 确实 swap 进出几乎为 0 了。那为什么更慢?继续往 swap 上想,浪费了半天。

2.3 第二推断(命中):page cache 被挤 + direct reclaim

你:换思路查 vmstat 的 allocstall(direct reclaim 次数)——比设 0 之前更高了。回忆第 12、13 章:swappiness=0 不是关 swap,是”绝不主动换匿名页”。后果:内核不换匿名页,page cache 一旦紧张,只能回收干净文件缓存。该服务有大量文件读(配置文件、模板),page cache 命中率从 95% 掉到 60%,未命中就要落盘(第 07/08 章,机械盘 8~15ms/次);同时回收触达 low 水位时触发 direct reclaim,卡住线程几十 ms。

墨:经典的”赌错了方向”。为了”不 swap”设 0,却牺牲了 page cache 命中率 + 引入 direct reclaim,延迟反而更抖。swap 和 page cache 是跷跷板两端,你按下一头,另一头翘起来。

2.4 调整与改进

改成 vm.swappiness=10(不是 0)——给内核一点点换出匿名页的余地,让 page cache 不被挤、命中率回升,同时匿名页极少量进 swap 几乎无害。allocstall 回落,P99 回到设 0 之前且更稳。观察:swappiness=10 比 0 和 60 都稳。

墨:这个事故把第 13 章”画像 C/D 用 110 而非 0”焊死了——**0 不是最优,110 才是”几乎不 swap 但留余地”的甜点。**


事故三:min_free_kbytes 太小,连网卡都丢包

3.1 现象

一台 128G 内存、跑高并发网络的机器,业务高峰期网卡丢包率飙升,top/iostat 都正常,内存 free 还有大把。网络组的同事坚称”不是网络问题”。

3.2 第一推断(推错):网卡/软中断(第 09 章方向)

你:丢包嘛,先查网卡。第 09 章教过:top 看 si 单列、/proc/net/softnet_stat 第三列——都正常,软中断没满,softnet 没丢。不是网卡层丢的包。

3.3 第二推断(命中):原子分配失败导致 skb 分配失败

你:深入想:网卡收包时,每个 skb( socket buffer)需要原子内存分配(atomic allocation,GFP_ATOMIC)——这种分配不能休眠、不能等 reclaim,必须立刻从”空闲且立即可用”的内存拿。而空闲内存被压到 min_free_kbytes 以下时,原子分配会直接失败,内核只能丢包(dropped 计数涨)。

查 cat /proc/sys/vm/min_free_kbytes —— 只有默认约 64MB(128G 内存上相对极小)。高峰期业务分配把 free 压到接近这 64MB,网卡原子分配时”立即可用的”不够,丢包。

墨:这是 vm 和网络的跨界事故:你以为是网络问题,根因在内核”为原子分配保留的底线”太低。内存子系统的参数,能直接影响网卡行为——因为网卡收包要内存,而那块内存必须”随时待命”。

3.4 调整与改进

把 vm.min_free_kbytes 调大到 1~2G 级别(如 2097152 即 2G),给原子分配留足立即可用的缓冲。同时 vm.watermark_scale_factor 调大到 100,让 kswapd 提前异步回收,避免 free 跌破 min。观察:丢包率从峰值 3% 降到 0,且没影响业务内存(128G 上留 2G 微不足道)。

墨:这事故把”参数之间会跨界联动”讲透了——你调 vm 的 min_free_kbytes,救的是第 09 章网卡的包。


事故四:drop_caches 定时脚本把 I/O 打爆

4.1 现象

一台 Web 服务器,每隔 5 分钟出现一次持续约 20 秒的卡顿,用户请求超时。运维查了 CPU、网络、磁盘,都没找到”周期性”来源,直到翻 crontab。

4.2 第一推断(推错):定时任务/备份

你:周期 5 分钟,肯定有 cron。先查备份、日志轮转、健康检查——都没在这个频率。卡顿来源成谜。

4.3 第二推断(命中):echo 3 > drop_caches 的 cron

你:翻到一条 */5 * * * * echo 3 > /proc/sys/vm/drop_caches,注释写着”清内存,保持服务器干净”。回忆第 12 章:drop_caches=3 清掉全部 page cache + slab(含 inode/dentry 缓存)。每 5 分钟强制清空,之后所有文件读(代码、模板、静态资源、甚至 .so 加载)都重新落盘,磁盘 I/O 瞬间打满(iostat 该时段 %util 100%),请求全卡在等 I/O。

墨:这就是”把泄洪阀当日常旋钮”的教科书案例(第 12 章分类③)。page cache 是好事,它让”读过的文件”零延迟。定时清空 = 每 5 分钟主动销毁所有读缓存,逼着所有读重新碰盘。注释越”为你好”,危害越大。

4.4 调整与改进

  • 删除该 cron(正经做法)。page cache 让内核自己管理,空闲内存多时它自然多留,紧时内核自己回收,不需要人手动清。
  • 若确有”测冷性能/释放被误占缓存”需求,仅在需要时手动执行一次,绝不入定时任务。
  • 观察:删掉 cron 后,周期性卡顿消失,磁盘 I/O 平滑,Web 响应恢复。cache 命中率稳定在高位。

墨:四个事故讲完,你该有个体感:vm 的坑几乎都源于”用一个参数的字面意思去赌,却没看它在整体里的联动”。overcommit=2 看 free 以为够、swappiness=0 以为不 swap 就快、min_free_kbytes 小以为省内存、drop_caches 以为清了更干净——每个都是”字面理解”踩的坑。


五、🔧 思考题(都配参考答案)

思考题 1(基础): overcommit_memory=2 + overcommit_ratio=50,64G 物理 + 30G swap。已 commit 55G 时,一个新进程想再 mmap 15G 会怎样?结合 free 的”空闲 20G”解释这个矛盾。

【参考答案】

模式 2 下允许 commit 上限 = 64G×50% + 30G = 62G。已 commit 55G + 新申请 15G = 70G > 62G → mmap 直接失败(EAGAIN/Cannot allocate memory)。矛盾在于:free 显示的 20G”空闲”是已分配但未写入(未触缺页)的空洞,在严格模式下不计入”可再 commit”的额度——额度是按”账面承诺”算的,不是按”实际占用物理页”算的。所以你看着满坑空闲,空头支票额度却用光了。这是 overcommit=2 最阴的地方:free 有内存,但借不出来。

思考题 2(深入): 延迟敏感服务把 swappiness 从 60 设 0 后 P99 反而升、出现新毛刺。请完整解释因果链(swap→page cache→命中率→落盘/direct reclaim),并指出为什么 swappiness=10 比 0 更优。

【参考答案】

因果链:swappiness=0 让内核”绝不主动换匿名页” → page cache 紧张时只能回收干净文件缓存(没法用 swap 腾匿名页) → 文件读命中率下降(如 95%→60%) → 未命中读要落盘(机械盘 815ms/次)+ 回收触 low 水位触发 direct reclaim 卡线程几十 ms → P99 升、新毛刺。swap 与 page cache 是跷跷板,按下一头另一头翘。swappiness=10(而非 0)给内核极少量换出匿名页的余地,让 page cache 不被挤、命中率回升,同时匿名页仅极少量进 swap 几乎无害,allocstall/落盘都降,延迟反而比 0 和 60 都稳。这就是”110 是甜点而非 0”的实证。

思考题 3(跨界联动): 高并发网络机器内存充足却网卡丢包,top/softnet_stat 都正常。请解释 min_free_kbytes 与网卡丢包的因果链,并给调参方向。

【参考答案】

因果链:网卡收包每个 skb 需要 GFP_ATOMIC 原子分配(不能休眠、不能等 reclaim,必须立刻拿”立即可用”内存) → 当 free 内存被压到低于 min_free_kbytes 时,原子分配可立即可用的内存不足 → 分配失败 → 内核丢包(dropped 计数涨)。这不是软中断满(第 09 章),而是内存子系统的”原子分配底线”太低。调参:vm.min_free_kbytes 调大到 1~2G(给原子分配留足立即可用缓冲),并 vm.watermark_scale_factor=100 让 kswapd 提前异步回收避免跌破 min。注意:这是 vm 参数跨界救网络(第 09 章)的典型——调 vm 治网卡的包。

思考题 4(进阶总账): 综合第 12~14 章,给你一张”vm 调参四大坑”速查卡:每个坑的错误直觉 → 真实机制 → 正确做法。

【参考答案】
  1. overcommit=2 看 free 有内存却 malloc 失败:错误直觉”free 有就能分配”;真实是”commit 额度按账面承诺算,非实际物理”;正确:按画像选 0/1/2,模式 2 需配好 ratio 且重规划内存。
  2. swappiness=0 更稳:错误直觉”不 swap=低延迟”;真实是”page cache 被挤 + direct reclaim 卡顿”;正确:设 1~10 留余地。
  3. min_free_kbytes 小=省内存:错误直觉”留少点给业务更多”;真实是”原子分配底线低→网卡丢包/分配卡”;正确:大内存机器调大 + watermark_scale_factor 加宽。
  4. drop_caches 定时清=服务器干净:错误直觉”清缓存好”;真实是”每周期销毁读缓存→I/O 打爆卡顿”(泄洪阀当旋钮);正确:删定时脚本,按需手动。

总纲:vm 参数之间、vm 与网络/IO 之间都联动;用”字面意思”赌必踩坑,用”整体联动”看才安全。


六、第五个实战:THP 透明大页引发的延迟抖动

📦 案例:某 MySQL 主库,256G 内存,默认 THP enabled=always、defrag=always。夜间跑批量报表任务时,MySQL 偶发 1~3 秒卡顿,DBA 以为是慢 SQL。

现象(第 0 层)

夜间批量时段,P99 偶发 1~3 秒尖峰。top CPU 不高,iostat 正常,dmesg 无 OOM。slow log 里”慢的那几秒所有 SQL 一起慢”,不是某条特定 SQL。

推断 1:慢 SQL?(推错)

你:慢查询嘛,slow log + EXPLAIN。结果:尖峰里所有 SQL 都慢,且不存在某个执行计划突变的 SQL——排除单条 SQL(和第 19 章”DB 被连带”同款判断)。

再观察:锁死 compact_stall

你:不是 SQL,那是啥? 墨:对齐时间轴,看 /proc/vmstat 的 compact_stall——在卡顿瞬间暴涨;再 cat /sys/kernel/mm/transparent_hugepage/defrag = always。MySQL 内存访问极密,缺大页时内核同步 compaction 把零散 4KB 页拼 2MB 大块,拼的过程冻住分配线程几 ms~秒。

推断 2:THP 同步 compaction 冻进程(命中)

墨:根因和第 18 章那次 8 秒超长卡顿同源——defrag=always 的同步整理代价,在”延迟极度敏感 + 内存访问极密”的数据库上被放大成 1~3 秒卡顿。

调整

echo madvise > /sys/kernel/mm/transparent_hugepage/defrag   # 不再同步整理,交后台
# 或更稳:显式 HugeTLB 给 buffer pool,彻底绕开 THP 碎片问题
echo 65536 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages  # 预留 128G 大页

改进

墨:defrag 改 madvise 后,卡顿消失;代价是 THP 覆盖率略降(TLB 命中微降,吞吐掉 ~3%,可接受)。复盘:数据库/低延迟服务,defrag=always 是隐形炸弹,要么改 madvise/defer,要么上显式大页。这是第 13 章”冲突 2:低延迟 vs 大页收益”的实战印证。



七、第六个实战:vm.panic_on_oom=1 把一次可救的 OOM 变成整机重启

📦 案例:某交易节点,一个批处理 Pod 内存泄漏,按预期它应该被 OOM Killer 杀掉、K8s 拉起,业务几乎无感。但某天凌晨,整台物理机直接重启了,上面所有 Pod 全宕,告警炸锅。

现象(第 0 层)

凌晨告警:“节点 NotReady,所有 Pod 重启”。机房带外管理看到机器”硬重启”了。不是单 Pod OOM,是整机重启。

推断 1:硬件 / 供电?(推错)

你:整机重启,第一反应是机房断电、电源、或者内核 panic 硬件问题。 墨:查带外日志——没有断电记录;查 kdump 抓到的 vmcore,或 dmesg 重启前最后一行:看到 Out of memory: Killed process ... 之后紧跟 Kernel panic - not syncing: Out of memory: system-wide OOM。根因:有人把 vm.panic_on_oom 设成了 1。

命中:panic_on_oom=1 把”杀进程”升级成”重启整机”

墨:回忆第 12 章的分类——vm.panic_on_oom 是泄洪阀③,默认 0(只杀进程不重启)。设 1 后,一旦发生整机 OOM,内核不杀进程,而是直接 panic,触发 kernel.panic(若设了非 0)自动重启整机。本该”杀一个泄漏 Pod、K8s 拉起、业务无感”的剧本,被改成了”杀不动,直接把整机炸了,所有 Pod 跟着陪葬”。一个 Pod 的 bug,变成全节点事故。

调整

墨:一剂药:sysctl -w vm.panic_on_oom=0(生产机铁律)。panic_on_oom 只在一种场景合理——单应用嵌入式系统,OOM 即意味着系统已不可用,重启即恢复(如路由器固件)。通用服务器/容器节点,永远 0。 配套:泄漏 Pod 的止血仍靠 oom_score_adj(保核心)+ cgroup memory.max(隔离爆炸半径),而不是靠 panic 把火引到整机。

改进:再无整机重启,收口

墨:改回 0 后,同样的内存泄漏只杀那个 Pod,K8s 拉起,业务无感。复盘:panic_on_oom 是”把局部问题升级成全局问题”的典型开关。它和 kernel.softlockup_panic(第 16 章)名字像、哲学却相反——softlockup_panic 是”锁死即重启自救”,而 panic_on_oom=1 是”本可只杀一个,却拉全机陪葬”。前者常开,后者生产必 0。别把两个”panic”混为一谈。


八、第七个实战:watermark_scale_factor=10 在大内存上引发 direct reclaim 周期性雪崩

📦 案例:一台 512G 内存的 Redis 集群节点,业务偶发"每隔十几分钟卡 2~5 秒",但没有任何 OOM、CPU/磁盘都正常,且卡顿和请求量不严格相关——半夜低峰也卡。

现象(第 0 层)

周期性(约每 1015 分钟)出现 25 秒卡顿,P99 尖峰。CPU、iostat、dmesg 都看不出周期性来源。Redis 慢日志里”卡的那几秒所有命令一起慢”,不是某个大 key。

推断 1:慢查询 / 大 key?(推错)

你:卡顿嘛,先看 Redis 慢日志、查大 key。结果:卡顿时段没有特定慢命令,所有命令同步变慢;大 key 扫描也干净。排除单命令问题(和第 19 章”DB 被连带”同款判断)。

推断 2:swap?(也推错)

你:查 vmstat 的 si/so——swappiness 已设 1,si/so 全程为 0,没有换入换出。排除 swap(可以收掉第 14 章事故二的思路)。

命中:allocstall 周期性飙 + watermark_scale_factor=10

墨:对齐时间轴,看 /proc/vmstat 的 allocstall_normal——在卡顿瞬间周期性飙升;再 cat /proc/sys/vm/watermark_scale_factor = 10(默认,即 0.1%)。算一下:512G × 0.1% = 512MB 缓冲带。这台 Redis 有周期性大分配(如 RDB 后台 save fork 后的 COW 写放大、或批量 pipeline 的临时缓冲),free 内存周期性跌破 low 水位,触发 direct reclaim(同步回收,卡住所有分配线程 2~5 秒)。而默认 512MB 的异步回收缓冲带太小,kswapd 来不及在跌破前平滑回收——于是每十几分钟来一次”同步回收雪崩”。

墨:这是第 12 章”大内存机器必须调 watermark_scale_factor”的实体惨案。反直觉点在于:内存越大,默认 0.1% 的绝对缓冲反而”相对越小”——4G 机器上 0.1% 是 4MB(本就小但业务也小),512G 机器上 0.1% 是 512MB,但 512G 上的业务突发动辄几个 G,512MB 缓冲带一戳就破。

调整

vm.watermark_scale_factor = 1000   # 512G 上缓冲带变 5.12G,kswapd 早早异步回收
vm.min_free_kbytes = 2097152       # 2G,保原子分配(第14章事故三)

配套(根因在应用侧):

  • 把 RDB 后台 save 避开业务高峰,或改用 repl-diskless-sync 减少 fork COW 写放大;
  • 批处理 pipeline 限流,避免瞬时大分配尖峰。

改进:卡顿消失,收口

墨:watermark_scale_factor 调到 1000 后,allocstall 归零,周期性卡顿消失,P99 回到亚毫秒。复盘:“内存越大越容易卡”不是玩笑,是 watermark_scale_factor 默认 0.1% 在大内存上的必然——≥128G 机器,这个值必须调大到 100~1000,否则 kswapd 的缓冲带永远不够宽,一有波动就跌到 direct reclaim。这和第 13 章画像 E/画像 C、第 18 章”幽灵卡顿”同源:都是”平时不回收、临界才慌”的变体。铁律焊死:大内存机器,watermark_scale_factor 是开机必查项,不是可选项。


实战补遗一:admin_reserve_kbytes / user_reserve_kbytes——OOM 时把你挡在门外的”隐形门槛”

墨:你有没有遇到过这种最操蛋的局面——机器 OOM 了,ping 得通,但 ssh 死活连不上;连已有的会话里敲个 kill 都报 bash: fork: Cannot allocate memory?你当时第一反应是什么?

你:八成以为是 sshd 挂了,或者网络抽风,赶紧跑机房或者云控制台,一通重启了事。

墨:重启能救火,但你已经把最宝贵的现场丢了。而且你有没有认真想过一句话——**凭什么 OOM 之后连 root 都进不去?**这跟你这辈子从来没动过的两个参数有关:vm.admin_reserve_kbytes 和 vm.user_reserve_kbytes。

现象:凌晨的”进不去”

一台内存常年 90%+ 的 Java 服务(开着 overcommit_memory=0 默认启发式)触发整机 OOM,dmesg 里满屏 Out of memory: Killed process。值班同学想 ssh 上去 kill -9 保命进程,结果三连击:

  • 新的 ssh 连接直接被拒,连输密码的机会都不给;
  • 旧会话里执行任何命令都报 fork: Cannot allocate memory,包括 dmesg | tail、ps、kill;
  • 连 echo hi 都跑不起来,因为 echo 也要 fork 一个 bash 子进程。

推断(可能推错):先冤枉了两个好人

第一推断:「sshd 被 OOM killer 误杀了。」——于是想去重启 sshd,但重启本身要 fork,而 fork 已经失败,死循环。

第二推断:「是不是 kernel.panic 了?」——但机器还能 ping 通、网卡还在收包,说明没 panic。

这两个推断都推错了,因为它们都没碰到一个事实:OOM 之后,连”fork 出一个新进程”这件看起来最廉价的事,都会因为内核结构体( task_struct、内核栈页、mm、页表)无处分配而失败。COW 再省,也省不掉 fork 时那一小撮内核态内存。

真相:预留是 root 的”救命绳”,但默认太细

admin_reserve_kbytes(默认通常 8192,即 8 MiB)是内核专门给 root 用户预留的一块内存,目的是保证 root 在内存耗尽时仍能 fork 出 login / kill / dmesg 这类救命命令。user_reserve_kbytes(普通用户预留,默认 min(3% 空闲, 128 MiB))则兜底普通用户会话。

坑在哪?

  1. **默认 8 MiB 太小。**OOM 发生的瞬间,内核已经把几乎所有匿名页和 page cache 吃光,8 MiB 在”刚 OOM、杀手还没杀完”的窗口里,可能被并发进程一拥而上瞬间挤占,于是 root 的 fork 照样失败。
  2. 更隐蔽的逻辑:user_reserve_kbytes 给的是”普通用户”的保命额度。如果你的服务以普通用户跑、且它把内存占满,那么你 ssh(作为普通登录会话)的 login 也 fork 不出来——你以为”root 能进”的前提,是你已经有一个 root 会话存活。冷启动的 ssh 可没有这个前提。

调整:把绳加粗,而不是去重启

  • 把 vm.admin_reserve_kbytes 提到 64 MiB(65536),保证 OOM 风暴里 root 还能 fork 出诊断命令;
  • 同时确认业务进程不以 root 跑(否则 user_reserve 形同虚设),让普通用户会话靠 user_reserve_kbytes 兜底;
  • 用 oom_score_adj=-1000 显式保护 sshd、监控 agent 这类”保命进程”,让杀手永远先动别的;
  • 保留 overcommit_memory=0(默认启发式),别用 2——=2 是”按 commit 上限提前拒绝”,会把大量正常申请在 OOM 之前就返回 ENOMEM,反而让你连错误日志都写不出来,现场更黑。

再观察:复现验证

在压测环境故意灌满内存复现 OOM:这次 ssh 能连、dmesg 能看、kill -9 生效,全程没再硬重启,事后还能 cat /proc/<pid>/status 把凶手查个底朝天。

改进(权衡:拿什么换什么)

动作拿什么换什么
admin_reserve_kbytes 调 64 MiB常驻少 64 MiB 可用内存OOM 时 root 保命通道
业务不用 root + user_reserve 兜底运维习惯要改普通会话也能进
oom_score_adj=-1000 护 sshd杀手少一个候选远程通道永不断
overcommit=0 而非 =2允许适度超卖真 OOM 时仍能写日志

🔧 思考题:admin_reserve_kbytes 是不是设得越大越安全?设成 1 GiB 会怎样?

【参考答案】

不是。它从”整机可分配内存池”里永久扣掉这 1 GiB 给 root 预留,等于可用内存直接少 1 GiB。大内存机器(256G+)尚可,小内存机器(8G/16G)会显著抬高 OOM 概率——预留是”保险丝”,按”够 fork 出几个诊断命令”就好(几十 MiB 足够),不是越大越安全。真正的防线是 oom_score_adj 护住关键进程,而不是靠堆预留。


实战补遗二:oom_kill_allocating_task=1 用反——杀的是”正在分配的”,不是”最胖的”

墨:你被 OOM 杀错进程搞烦了,设了 vm.oom_kill_allocating_task=1,心想”谁触发 OOM 杀谁,精准”。结果 OOM 时死的不是内存大户,是 health check 探针。你哪想反了?

你:触发 OOM 的不就是凶手吗?杀它没错啊。

墨:OOM 触发者≠内存占用最大者。“正在分配的那一刻恰好撞上内存耗尽”的,往往是个无辜的调用方(health check、小请求),而真正吃满内存的胖进程这会儿没在分配,反而不被挑。这个参数把”撞枪口的”当替死鬼。

现象:设了精准杀,死的是探针

某服务 OOM 频发,运维设 oom_kill_allocating_task=1 想精准。一次 OOM,dmesg 显示被杀的是 health check 探针进程(仅占 50M),而真正占 4G 的野进程活着。探针一死,Kubernetes 以为服务挂了,把 Pod 重启,雪崩。

推断(可能推错):先以为探针自己泄漏

第一推断:「探针自己内存涨了。」——看探针常驻 50M,没涨。推错,因为没理解”allocating_task”语义。

真相:默认杀”最胖”,改成杀”撞枪口的”

oom_kill_allocating_task:

  • 0(默认):杀 oom_score 最高的进程(通常最胖的那个),相对”连坐合理”;
  • 1:杀当前正在分配、且分配失败触发 OOM 的那个任务——它只是”刚好在内存耗尽那一刻来要内存”,未必是占用最大者。

health check 每几秒分配点小内存,在内存耗尽窗口里它来分配 → 撞枪口 → 被杀。真凶(常驻 4G、此刻没分配)逍遥法外。

调整:除非你能确定”触发者=凶手”,否则别开

  • 多数服务保持 oom_kill_allocating_task=0(杀最胖,连坐合理);
  • 想精准护关键进程,用 oom_score_adj(第 19 章)让胖进程分更高 + 关键进程 adj 负,配合 =0 默认;
  • 若必须用 =1(如某个分配即罪魁的场景),要先确认”触发分配的”就是你要杀的;
  • 验证:dmesg 看被杀进程的 oom_score 与内存占用,确认逻辑。

再观察

改回 oom_kill_allocating_task=0 + 野进程 oom_score_adj=+500 后,OOM 杀的是 4G 野进程,health check 活,不再误重启。

改进(权衡:拿什么换什么)

动作拿什么换什么
保持 =0(杀最胖)偶尔杀错非最胖大概率杀真凶
oom_score_adj 配改配置精准保关键

🔧 思考题:既然 =0 杀最胖更合理,为什么内核还提供 =1 这个选项?它适合什么场景?

【参考答案】

=1 适合”分配行为本身就是故障源”的场景:比如某个进程在循环里疯狂 malloc 且不释放,它持续触发分配失败,OOM 时它正是 allocating_task,杀了就对症。但多数 OOM 是”某进程长期占满、别的进程来分配时撞上”,这时 =1 杀无辜。所以 =1 是”惩罚撞枪口的”,只在你确认”撞枪口=罪魁”时用;通用服务用 =0 + oom_score_adj 更稳。这也呼应第 19 章那句:OOM 杀谁看的是”分数+触发时机”,调参前先想清你的故障模型是哪个——是”长期占满的胖子”还是”疯狂分配的疯子”,两者该杀的对象不同。


实战补遗三:脏页触顶,前台进程被”拖下水”同步写盘

墨:你调过 vm.dirty_ratio 吗?大多数人知道它默认 20,但你知道 20 代表什么吗?是”当脏页占到可用内存 20% 时,新的写请求会被迫同步把脏页刷下去”。问题来了——这个”可用内存”是哪一份?

你:应该是系统剩下的空闲内存吧?

墨:差一点。内核算的是可被回收 + 空闲那一块,不含已经被进程钉死(mlock、大页、页表)的部分。所以一台 256G 内存、跑了 200G 堆的数据库,它的”可用”可能只剩 30G,20% 就是 6G 脏页。6G 听起来不多,但你得知道写盘速度——一块 SATA SSD 顺序写也就 500MB/s,6G 全速刷也得 12 秒。这 12 秒里,凡是踩到新写、且脏页池已满的进程,全得排队等。

真实事故:一台 MySQL 从库,主从延迟告警。DBA 看 iostat,%util 100%、await 飙到 200ms+,但 tps 不高。第一反应是”磁盘坏了”,换盘、调 RAID 缓存,没用。后来 cat /proc/vmstat | grep nr_dirty 看到 nr_dirty 一直在 5–6G 附近打转,且 ps 里 mysqld 的状态经常是 D(不可中断睡眠)。

推断:脏页触顶 → 前台刷盘 → 进程卡 D。但为什么偏偏是从库?从库的写入模式是”relay log 回放 + binlog 顺序写”,几乎全是要落盘的写,脏页产生极快,而 dirty_background_ratio(默认 10)的后台 flush 线程来不及清,于是前台 write() 直接被拖去同步刷。

调整:把 dirty_ratio 从 20 降到 5、dirty_background_ratio 从 10 降到 3,并给 mysqld 单独挂一块盘、用 noop/none 调度。再观察:nr_dirty 峰值被压到 1.5G 以内,await 回到个位数,主从延迟告警消失。

你:那调太低会不会让磁盘闲着,吞吐上不去?

墨:会。dirty_ratio 太小,意味着脏页池浅,后台 flush 频繁触发,写合并率低,顺序写带宽用不满。所以这条参数的权衡是:

调高 dirty_ratio调低 dirty_ratio
写合并好,吞吐高前台被拖的概率低,延迟稳
崩溃丢的数据多(最多 20% 内存量的脏页)崩溃丢的数据少
突发写高峰时前台卡顿风险大持续写入场景下磁盘更忙、带宽利用率略降

🔧 思考题:你的服务是”吞吐优先”(如离线分析、日志收集)还是”延迟优先”(如在线交易、缓存)?两种场景下 dirty_ratio 该怎么取?

【参考答案】

吞吐优先:可以保留较高 dirty_ratio(15–20),甚至配合 dirty_expire_centisecs 拉长脏页寿命,让后台 flush 有充足时间做写合并,把顺序带宽吃满。代价是机器宕机可能丢更多未落盘数据——离线场景通常能重算,可接受。

延迟优先:建议压到 5–10,并同步压 dirty_background_ratio,让脏页池永远浅,前台 write() 几乎不等刷盘。数据库类的 binlog/redo 本就是 O_DIRECT 或 fdatasync 自己控落盘,内核脏页池对它的意义本就不大,调低更稳。

还有一个隐藏点:容器里你改的是宿主 vm.dirty_*,容器内进程共享宿主的脏页池。一台宿主机上混部”吞吐型 + 延迟型”容器时,脏页池是共享的——这是 cgroup 管不到、只能靠宿主 sysctl 一刀切的地方,也是为什么混部机型要单独压低 dirty_ratio 的原因。


实战补遗四:dirty_background_ratio 与 writeback 的”暗流”——吞吐型服务的隐形天花板

墨:dirty_ratio 管”前台触顶同步刷”(上一节讲了)。那 dirty_background_ratio 管什么?它和前者差一个词,作用域完全不同。

你:dirty_background_ratio 是后台刷盘的触发线?到了就开始后台 flush,不阻塞前台?

墨:精准。dirty_background_ratio(默认 10)= 脏页占到可用内存 10% 时,唤醒后台 writeback 线程异步刷盘,前台 write() 不阻塞;而 dirty_ratio(默认 20)= 脏页到 20% 时,前台 write() 自己被拖去同步刷。所以两者是”预警线”和”硬红线”的关系:10% 后台悄悄刷,20% 前台被迫刷。

真实事故:一个日志采集服务(吞吐型,疯狂顺序写),磁盘写带宽怎么都跑不满,iostat 看 rkB/s+wkB/s 只有盘标称的 40%。但 await 不高、%util 也没满,既不是盘慢也不是前台卡。诡异。

推断:既然盘没跑满、又不卡,说明脏页根本没攒够就刷了——后台 writeback 在脏页刚到 10% 就勤快刷,每次只刷一点,写请求被拆得太碎、缺乏批量,顺序写带宽利用率低。验证:cat /proc/vmstat | grep nr_dirty 峰值长期在 10% 线附近小幅波动,从没接近 20%——证实后台刷太”积极”,脏页池没机会攒厚。

调整(吞吐型服务的反向操作):把 dirty_background_ratio 提到 20、dirty_ratio 提到 40,让脏页池更深,后台 writeback 攒够一批再刷、写合并率高,顺序带宽吃满。再观察:nr_dirty 峰值允许到 20–30%,wkB/s 冲到盘标称 95%,采集吞吐翻倍。

你:那延迟型服务(如数据库)也这么调吗?

墨:不,延迟型正好相反(见实战补遗三):压低两者,让脏页池浅、前台几乎不等刷。所以这条参数完全取决于负载性格,没有”正确值”,只有”适合你负载的值”:

负载dirty_background_ratiodirty_ratio目的
吞吐型(日志/ETL/备份)20–3040脏页池深,写合并好,带宽吃满
延迟型(数据库/缓存)3–55–10脏页池浅,前台不卡,延迟稳
通用/混部10(默认)20(默认)折中

还有个隐藏搭档:dirty_expire_centisecs(脏页最长存活时间,默认 3000=30 秒)。即使没到 dirty_background_ratio,脏页活过 30 秒也会被后台刷。吞吐型服务可适当调大(让脏页多活会儿、攒更厚再刷),延迟型保持默认或调小(脏数据别赖太久,崩溃丢得少)。三者(dirty_background_ratio/dirty_ratio/dirty_expire_centisecs)是一套,调一个想清楚另外两个的联动。

🔧 思考题:/proc/sys/vm/dirty_bytes 和 dirty_ratio 能同时设吗?用绝对字节还是百分比,怎么选?

【参考答案】

不能同时生效——内核规定:dirty_bytes(绝对值,字节)和 dirty_ratio(百分比)是互斥的,你写其中一个,另一个自动归 0。选哪个看场景:

  • 大内存机器(如 256G)用 dirty_ratio=20 意味着 51G 脏页红线,对延迟型服务太大;这时用 dirty_bytes 直接写死”脏页池最多 1G”更直观可控:echo 1073741824 > /proc/sys/vm/dirty_bytes。
  • 小内存或内存多变的容器,百分比更省心(自适应内存大小)。

注意 dirty_bytes 不能设得比 dirty_background_bytes 小(否则后台线比前台线还高,逻辑错乱),且设了 dirty_bytes 后 dirty_ratio 显示为 0 是正常的。生产建议:容器/小机用百分比(简单),大机/延迟敏感服务用 dirty_bytes 绝对值(精确焊死脏页池上限)。这再次呼应”参数是杠杆不是定值”——同一个 dirty_* 体系,吞吐型和延迟型取的值可以差 8 倍。


实战补遗五:vfs_cache_pressure 调错——dentry 回收过度导致 stat 变慢

墨:vfs_cache_pressure(默认 100)控制内核回收目录项(dentry)和 inode 缓存的倾向。100 是”中性”,>100 更积极回收元数据缓存,<100 更保守(多留)。你知道调错它会怎样吗?

你:元数据缓存留着不是能让 ls/stat 快吗?调高让它快点回收,岂不是 stat 变慢?

墨:对,而且这是个”为了省内存、反而赔了 IO”的典型。讲真实事故。

一台文件服务器(存几百万小文件,频繁 stat/ls),运维看 meminfo 里 SReclaimable(含 dentry/inode 缓存)占了 6G,觉得”太多”,把 vfs_cache_pressure 从 100 调到 500(想更积极回收元数据缓存、省内存)。

结果:几天后,业务的 stat 延迟从 0.1ms 飙到 5ms,ls 大目录卡几秒。iostat 看 iops 暴涨(原来命中 dentry 缓存、不读盘,现在缓存被回收、每次 stat 都要读磁盘找 inode)。

推断:把 vfs_cache_pressure 调太高,内核过度回收 dentry/inode 缓存,原本 stat 命中内存缓存(µs 级),变成每次都要读盘找元数据(ms 级),IO 暴涨、延迟飙升。验证:slabtop 看 dentry/inode_cache 的占用被压得很低(相比文件数量本该很大),且 cat /proc/vmstat | grep pgfault 的 major fault(读盘)在 stat 时暴涨。根因:“省 6G 内存”的动机是好的,但这几百万文件的元数据缓存正是这个服务的命根子,回收它等于拆了自己赖以快速响应的引擎。

调整:① 把 vfs_cache_pressure 调回 100(默认),甚至对”元数据密集”服务调到 50(更保守,多留 dentry 缓存,牺牲一点内存换 stat 速度);② 真正的”省内存”不该靠砍元数据缓存,而该看 SUnreclaim(第 22 章)——那才是真该优化的;③ 确认机器内存够(留足给 dentry 缓存是值得的投资)。再观察:dentry 缓存回涨、stat 延迟回 0.1ms、磁盘 IO 回落。

你:vfs_cache_pressure 什么场景该调高?总不能永远 100 吧?

墨:有,且和”文件服务器”相反——元数据不重要、要保匿名内存的场景该调高:

  • 内存极紧、且很少 stat 大目录的服务(如纯计算、内存数据库):dentry 缓存命中率低、留着浪费,调高(如 200)让内核优先回收元数据、把内存留给匿名页/业务;
  • 容器里某 pod 疯狂 stat 却内存有限:可适度调高,但别过(500 太狠)。

权衡表:

负载vfs_cache_pressure理由
文件服务器/频繁 stat/ls50~100(保守)元数据缓存是性能命根,多留
内存数据库/纯计算100~200元数据命中低,优先回收保匿名页
通用100(默认)折中

关键认知:SReclaimable(含 dentry)是可回收的”借来内存”,它占着不意味着浪费——它像 page cache 一样,是内核”用空闲内存白嫖加速”的手段。你看到它占 6G 就手痒去砍,和”看 page cache 高就 drop_caches”(第 0、7 章)是同一个错误思维:把健康的缓存当负担。正确姿势:内存够就让它留着(加速),内存真紧先看 SUnreclaim 和匿名页,别动元数据缓存——除非你的负载确实不怎么 stat。

🔧 思考题:vfs_cache_pressure 和 min_free_kbytes(第 14 章 B1 提过,保网卡 DMA 原子分配)都会间接影响”内存回收行为”,它们各自管哪个维度?

【参考答案】

两者管回收的不同维度:

  • vfs_cache_pressure:管”回收什么”——在”元数据缓存(dentry/inode)“和”其他可回收内存(page cache/匿名页)“之间,谁优先被回收。高=优先砍元数据,低=优先留元数据。是回收对象的优先级旋钮。
  • min_free_kbytes:管”保留多少”——内核水位线里,强制保留多少内存永不分配,专门给”原子分配”(如网卡 DMA 收包)留后路,防止内存快满时连原子分配都失败、丢包甚至卡死。是保留下限的安全旋钮(第 14 章 B1 的 admin_reserve 同类思路)。

所以它俩一个管”优先级”、一个管”安全垫”,叠加决定回收行为:

  • 调 vfs_cache_pressure 高 + min_free_kbytes 小 → 元数据被猛砍、且没安全垫,可能”stat 慢 + 网卡偶发丢包”双翻车;
  • 调 vfs_cache_pressure 低(留元数据) + min_free_kbytes 足够(保原子分配) → 元数据快、网卡稳,但匿名页余量略少(被安全垫和元数据占)。

生产配合:min_free_kbytes 按内存规模留足(防 DMA 丢包,第 14 章)、vfs_cache_pressure 按负载性格设(文件服务保守、计算服务可高)。口诀:pressure 定”砍谁优先”、min_free_kbytes 定”留多少保命”,都是水位/回收体系里的杠杆,但维度不同,别混着调。



实战补遗六:K8s 里 Pod OOM 但节点还有内存——cgroup 限额与 vm 的错位

墨:老哥,你在 K8s 里跑过 Go 服务吗?遇到过”节点内存明明还剩一半,Pod 却被 OOMKill 了”吗?

你:遇到过,以为是 limit 设小了,调大就好了。

墨:调大 limit 能止血,但没问为什么节点有内存还杀你——这背后是 cgroup 限额和 vm 参数的错位,不讲清你会一直当”玄学”调。

现象:一台 64G 节点,跑了 8 个 Pod,每个 limit 4G,节点总 limit 32G,物理内存还剩 30G 空闲。但其中一个 Go Pod 突然被 OOMKill,日志只有 OOMKilled,dmesg 显示是该 cgroup 的 memory.max 触发。

推断:Pod 用了超过 4G,limit 不够。

可能推错:看 kubectl top pod 和 cat /sys/fs/cgroup/.../memory.current,Pod 内存才 3.2G,远没到 4G 的 limit,却被杀了。说明杀它的不是 cgroup 的 memory.max,而是 cgroup 里的 memory.min/low 竞争、或更隐蔽的 memory.high 节流 + 节点级内存压力联动。

调整:用 cat /sys/fs/cgroup/<pod>/memory.events 看 oom 和 oom_kill 计数,区分两类 OOM:

  • memory.max 触发:memory.current 触顶硬墙,内核在该 cgroup 内选最胖进程杀(常见是杀你的 Go 主进程)。
  • 节点整体内存紧张时,所有 cgroup 都被要求回收,你的 Pod 因为 memory.low 设得低(别人设了 high),在回收竞赛里被优先挤,加上 Go 的 GOGC 没及时回收,瞬时冲过 memory.max 被硬杀。

再观察:更诡异的是 vm 参数在容器里几乎无效——你在 Pod 里 sysctl -w vm.swappiness=1,改的是宿主的 vm 命名空间(多数容器不隔离 vm sysctl),Pod 的回收行为由宿主内核统一按节点物理内存决定。所以”在容器里调 vm 参数”常常找错门:你该调的是 cgroup 的 memory.*(limit/high/low/min),不是宿主 vm.*。这跟第 23 章”容器里调 zone_reclaim 找错门”是同一个道理。

改进:

  • Pod OOM 先看 memory.events 的 oom_kill 计数,确认是 max 触顶还是节点压力。
  • 调 cgroup 的 memory.high(软上限,超了节流不杀)做缓冲,别只设 memory.max 硬墙。
  • memory.low/min 给关键 Pod 留保底,避免被节点回收竞赛挤死。
  • 容器内 vm.* 多半不隔离,别在里面调 vm 参数,那是宿主的;Go 侧用 GOGC/GOMEMLIMIT 控堆上限更靠谱(Go 1.19+ 的 GOMEMLIMIT 能跟 cgroup limit 对齐,避免堆涨过 limit 被 OOMKill)。

🔧 思考题:Go 1.19+ 的 GOMEMLIMIT 为什么比”把 vm.swappiness 设 1”更能防止 Pod OOMKill?它和 cgroup memory.max 是怎么配合的?

【参考答案】

两层原因:

  1. 作用域不同:vm.swappiness 是宿主内核的全局旋钮,容器大多不隔离 vm sysctl,你在 Pod 里改它实际改的是宿主(影响所有 Pod),而且它只控制”swap vs cache 回收倾向”,根本管不到 cgroup 的硬限额。Pod 被 OOMKill 是因为撞上 cgroup 的 memory.max 硬墙,这道墙 vm.swappiness 碰不到。

  2. 时机不同:vm.swappiness 影响的是”内存紧张时怎么回收”,但 cgroup memory.max 是硬墙——memory.current 一旦 ≥ max,立刻在组内选进程杀,没有”回收余地”这回事。swappiness 调得再低,也拦不住 current 触顶。

GOMEMLIMIT 的价值:它告诉 Go runtime”堆软上限设到多少”,runtime 据此主动在接近上限前就 GC(而不是等 GOGC 的百分比触发),把 Go 堆控制在 memory.max 以内,从源头避免 memory.current 触顶。配合 GOMEMLIMIT = cgroup memory.max × 0.9 左右,Go 进程会自己把内存压在墙内,几乎不会被 OOMKill。这比在外层调 vm 参数(且调的还是宿主的)精准得多。

所以容器里的正确优先级:cgroup memory.high/max 定墙 → Go 侧 GOMEMLIMIT 让 runtime 自律 → GOGC 调 GC 频率。把 vm.* 调参的心思,从 Pod 里挪到宿主的全局策略上(且只在影响节点整体时才调)。

下一章预告

墨:vm 三章(12 总地图 / 13 场景化 / 14 实战)到此收口。你最早点名的 /proc/sys/vm 算是彻底刨透了——从”每个参数什么意思”,到”我的负载该配什么”,到”别人踩过的四个坑”。

但 vm 只是 sysctl 家族的一个分支。接下来第 15~17 章,我们把其他家族分类整理:第 15 章 net(你已经从第 10 章认识了一部分 tcp_*,这里补全 core/ipv4/ipv6 关键参数)、第 16 章 fs 与 kernel(file-max/inode/pid_max/threads-max/sem/panic/softlockup)、第 17 章 abi/dev/user/debug/crypto 这些冷门家族,以及长得像但不是 sysctl 的 /sys/cgroup 和 ulimit 对照。目标还是一样:每个参数讲清”守哪道门、拿什么换什么”。


本章完。其他 sysctl 家族见第 15~17 章。