第 06 章 内存分配与回收——一张永远在超发的空头合同

本章追一个问题:你 malloc(1GB) 返回成功的那一刻,内核到底给了你什么? 答案会让你不太舒服:它什么都没给。它签了一张空头合同。 这张合同什么时候兑现、兑现不了会怎样、谁来决定杀谁——就是 /proc/sys/vm 下面绝大多数参数存在的理由。这一章我们把这个战场彻底刨开。


一、从一个荒谬的实验开始

墨:老哥,咱先做个实验。你机器多大内存?

你:32G。

墨:好。看这段代码:

#include <stdlib.h>
#include <stdio.h>
int main() {
    void *p = malloc(100L * 1024 * 1024 * 1024);  // 申请 100 GB
    printf("malloc 返回: %p\n", p);
    getchar();
    return 0;
}

墨:32G 内存的机器上申请 100G,你猜结果?

你:肯定返回 NULL 吧,不够啊。

墨:跑一下。它返回了一个有效地址。 而且这时候你去看 free -h,内存几乎没变化。

你:……啥?100G 从哪来的?

墨:哪都没来。这就是本章的第一个、也是最重要的认知:

malloc 成功 ≠ 你拿到了内存。它只意味着”内核在你的地址空间账本上,记了一笔 100G 的虚拟地址区间”。物理内存一页都没分配。

这叫 overcommit(内存超发)。内核在赌你不会真用满。

你:这不就是银行的准备金制度吗……存款人不会同时来取钱,所以银行只留一小部分现金。

墨:绝妙的类比,而且是精确的类比。 我们就用它:

📦 类比:银行的部分准备金制度

  • malloc = 你在银行开了个额度 100G 的账户。银行(内核)在账本上记一笔,不需要真的把 100G 现金搬进金库。
  • 真正访问那块内存 = 你去柜台取钱。这时候银行必须给你真金白银(物理页)。
  • overcommit = 银行敢开出总额远超金库现金的账户。因为它知道:大部分人开了额度不会真用满。
  • OOM = 挤兑。所有人同时来取钱,金库空了,银行崩了。这时候银行只能做一件很不体面的事:挑一个客户,当场宣布”你的钱不给了”——这就是 OOM Killer。

墨:记牢这个类比。这一章后面所有的参数,本质都在回答两个问题:

  1. 银行敢超发多少?(vm.overcommit_*)
  2. 挤兑时挑谁下手、金库现金留多少缓冲?(vm.min_free_kbytes、oom_score_adj、vm.swappiness……)

二、为什么要 overcommit?——先理解设计动机

墨:老哥,你可能第一反应是”这设计太危险了,为什么不老老实实按需分配?”。那我反问你:如果不允许超发会怎样?

我给你三个场景,你自己判:

场景一:fork() 你的 Nginx master 进程占了 2G 内存,它 fork() 一个 worker。按”不超发”的严格逻辑,子进程要拷贝父进程的全部地址空间——得再准备 2G。可实际上 worker fork 完马上 exec 或者只读那些数据,99% 的页压根不会被写。要是不允许超发,你 8G 机器上 fork 三次就爆了。

墨:所以内核用了 COW(Copy-On-Write,写时复制):fork 时父子共享同一份物理页,标记为只读;谁真的去写,才在那一刻复制一份给它。第 03 章讲过这个机制,现在你看到它的经济学意义了——COW 本身就是一种超发。

场景二:稀疏使用的大数组 你写个程序 int *arr = malloc(10GB),做个哈希表用,但实际只往里稀疏地写了 500MB。要是不超发,你得真占 10G。

场景三:程序的保守申请习惯 很多程序(JVM 的堆预留、数据库的缓冲池、Go runtime 的 arena)会先申请一大块虚拟地址空间占位,后面慢慢用。这是良好的编程习惯(避免地址空间碎片),要是不超发,这些程序全跑不起来。

墨:看明白了吗?

overcommit 不是内核偷懒,是对”程序申请的内存和真正用的内存严重不匹配”这一现实的务实妥协。 如果严格按申请量预留,内存利用率会低到荒谬——就像银行如果 100% 准备金,整个信贷系统就不存在了。

⚠️ 但——赌就有输的可能。输的时候就是 OOM。所以内核给了你旋钮,让你决定”这个赌局有多激进”。

参数详解:vm.overcommit_memory 与 vm.overcommit_ratio

vm.overcommit_memory 有三档,这三档的设计意图截然不同:

值名字策略适用场景
0启发式(默认)“看着不太离谱就批”。内核做个粗略估算,明显荒谬的申请(如单次超过总内存+swap)拒绝,其余一律批准通用桌面/服务器。99% 的场景用这个
1总是批准来者不拒,任何申请都成功科学计算、某些数据库(Redis!)。它们知道自己在干什么
2严格核算总承诺量不得超过 swap + 物理内存 × overcommit_ratio%。超了直接 malloc 失败极端要求”绝不 OOM Kill”的场景

墨:重点讲两个容易搞错的:

关于 = 1(总是批准):很多人一看”总是批准”觉得危险,其实它有非常正当的用途。

📦 Redis 为什么建议 vm.overcommit_memory = 1?

Redis 做持久化(RDB/AOF rewrite)时会 fork() 一个子进程。父进程占 10G 内存,fork 出来的子进程账面上也要 10G(虽然靠 COW 实际只用一点点)。

  • 如果 overcommit_memory = 0(启发式),内核可能觉得”你机器 16G,已用 10G,现在又要 10G,太离谱”→ fork 直接失败 → Redis 无法持久化!
  • 设成 1,内核放行,COW 实际只消耗父进程在 fork 期间被修改的那部分页。

权衡本质:=1 是拿”OOM 风险”换”fork/大块申请的可行性”。适用于”我清楚自己实际用量远低于申请量”的程序。代价是:如果你判断错了,真用满了,OOM Killer 会来,而且来得毫无预警。

关于 = 2(严格核算):这档最反直觉的地方是——它并不能让你”更安全”,很多时候只是让你”更早失败”。

  • 公式:CommitLimit = swap 大小 + 物理内存 × overcommit_ratio / 100
  • 默认 overcommit_ratio = 50。注意!默认是 50%! 32G 无 swap 的机器,CommitLimit 只有 16G——你可能连 JVM 都起不来。
  • 所以用 =2 必须同时调 overcommit_ratio(常设 80~95),或者用更精确的 vm.overcommit_kbytes(直接给绝对值,比百分比更可控)。

墨:这里有个特别值得体会的权衡:

=2 把”内存不足”这件事的发生点,从”运行到一半被 OOM 杀掉”提前到”malloc 时返回失败”。

好处:失败发生在你的代码里、有返回值可以处理,程序可以优雅降级,而不是被内核当街正法。 坏处:① 很多程序压根不检查 malloc 返回值(直接段错误);② 因为超发被禁,内存利用率显著下降,你得为”最坏情况”买单;③ 那些依赖超发习惯的程序(JVM/Redis/科学计算)可能直接起不来。

这是一个”故障可预测性”vs”资源利用率”的经典交易。 航天、医疗、金融核心这类”绝不能被随机杀掉”的系统会选 =2;互联网服务基本都选 =0,宁可赌。

📦 怎么看当前的超发情况?

grep -E "Commit" /proc/meminfo
# CommitLimit:    16777216 kB   ← 上限(仅 overcommit_memory=2 时强制)
# Committed_AS:   28000000 kB   ← 当前所有进程"承诺"的总量

⚠️ Committed_AS 超过 CommitLimit 在 =0 模式下是完全正常的(这就是超发)。很多人看到这个比值 >1 就慌,其实在默认模式下这个数只是个参考。别对着一个不生效的限制值紧张。


三、合同兑现的那一刻:从缺页到物理页

墨:好,你 malloc 拿到地址了,现在你写第一个字节 p[0] = 1。这一瞬间发生了什么?第 03 章我们讲过缺页,现在从分配器的角度再走一遍。

3.1 完整链条

你的代码: p[0] = 1
   ↓
CPU MMU 查页表 → 这个虚拟地址没有对应物理页 → 触发 缺页异常 (Page Fault)
   ↓ (进内核,还记得第 04 章那道墙吗?这次是硬件"押"你进去的)
内核 do_page_fault()
   ↓ 查这个地址属于哪个 VMA,权限对不对(不对就 SIGSEGV,段错误就是这儿来的)
   ↓ 是匿名内存的首次写?→ 需要一个干净的物理页
   ↓
调用 页分配器 alloc_pages() —— 向 buddy system(伙伴系统)要一页
   ↓
   ├─ 快路径:free list 里有现成的空闲页 → 直接拿走(纳秒级)
   └─ 慢路径:空闲页不够了 → 触发回收!(这里是所有内存问题的核心战场)
   ↓
拿到物理页 → 清零(安全要求:不能让你看到别人用过的数据)
   ↓
填页表,建立虚拟地址 → 物理页 的映射
   ↓
返回用户态,重新执行 p[0] = 1 这条指令 → 这次成功

墨:注意那个分岔:快路径 vs 慢路径。你的程序性能是”平顺”还是”抖动”,全看它落在哪条路上。

3.2 buddy system:为什么用”伙伴”这种奇怪的算法?

墨:内核管物理页的分配器叫 buddy system。先问:为什么不用简单的链表管所有空闲页?

你:……链表不够快?

墨:更根本的问题是外部碎片。假设你有 1000 个空闲页,但它们散落在物理内存各处不连续。现在有人要 4 个连续的页(比如 DMA 传输、大页、或者内核某些结构必须物理连续)——你有 1000 页空闲却给不出来!

buddy system 的设计:

  • 把空闲页按大小分级(2⁰=1页、2¹=2页、2²=4页……最大通常 2¹⁰=1024页),每级一个链表;
  • 要 4 页?去 order=2 那条链表拿。没有?去 order=3 拿一块 8 页的,劈成两半(两个”伙伴”),一半给你,一半放回 order=2 的链表;
  • 释放时:检查它的”伙伴”(相邻的、同大小的、地址对齐的那一块)是否也空闲 → 是的话合并成大块,往上升级。

墨:这个设计的精妙在于合并的判定极快——因为伙伴的地址可以直接用位运算算出来(buddy_addr = addr XOR (size)),不需要遍历查找。

设计意图:用”分级 + 严格对齐的伙伴关系”换来 O(log n) 的分配释放 + 自动的碎片合并能力。 代价是有内部碎片(你要 5 页,得给你 8 页那一档)。

⚠️ 实战关联:什么是”内存碎片化”故障?

你可以直接看 buddy 的健康状况:

cat /proc/buddyinfo
# Node 0, zone   Normal   1520  843  200  45  12  3  0  0  0  0  0
#                        order:  0    1    2   3   4  5  6  7  8  9 10
  • 左边(低 order)数字大、右边全是 0 → 内存碎片化严重:你有很多零散单页,但一块连续的大块都拿不出来。
  • 症状:需要连续内存的操作失败或极慢——网卡驱动分配大 skb 失败、透明大页(THP)无法分配、dmesg 里出现 page allocation failure: order:4。

墨:这时候你会用到两个参数:

vm.min_free_kbytes —— 这是本章最重要的参数之一,单独一节讲(见下)。

/proc/sys/vm/compact_memory(写 1 触发内存规整)

  • 控制背景:内核可以主动搬动可移动的页(用户态匿名页、page cache),把零散的空闲页挤到一起,腾出连续大块。
  • 权衡:规整要拷贝页、改页表、刷 TLB,期间会造成延迟抖动。所以内核默认只在需要时被动规整。手动 echo 1 > /proc/sys/vm/compact_memory 是个运维急救手段——比如你要给 DPDK/大页腾地方。
  • 相关:vm.compaction_proactiveness(内核 5.9+,默认 20)控制后台主动规整的积极程度。调高 = 碎片更少、大块更容易拿到,代价是后台持续的 CPU 开销和潜在延迟抖动。 这是”平时花小钱”还是”关键时刻交大代价”的选择。

3.3 slab/slub:为什么页不够,还要再套一层?

墨:buddy 管的最小单位是页(4KB)。但内核里到处是小对象——一个 task_struct(进程描述符)几 KB、一个 dentry(目录项)几百字节、一个 inode……如果每个小对象都占一整页,浪费得离谱。

所以内核在 buddy 之上套了一层 slab 分配器(现代 Linux 默认实现叫 SLUB):

  • 向 buddy 要几个连续页,叫一个 slab;
  • 把这个 slab 切成固定大小的小格子,专门放某一类对象(比如全放 dentry);
  • 分配 = 从格子里挑一个空的,极快;
  • 而且因为同类对象聚在一起,缓存局部性好;
  • 还有 per-CPU 的本地缓存,分配时常常连锁都不用加。

墨:你可以直接看:

slabtop        # 实时看哪类内核对象吃内存最多
cat /proc/slabinfo

⚠️ 一个非常经典的生产事故形态:slabtop 里 dentry 或 inode_cache 占了十几 G。

这是目录项/inode 缓存膨胀——你的程序疯狂创建/访问大量小文件(比如某个缓存目录堆了几百万文件),内核把每个路径查找的结果都缓存成 dentry。这些是”可回收的”缓存,但它们挤压了应用可用内存,而且回收时(shrink_dcache)可能造成明显卡顿。

对应参数:vm.vfs_cache_pressure(默认 100)

  • 控制什么:内核回收 dentry/inode 缓存的积极程度,相对于回收 page cache 的积极程度。
  • 控制背景:内存紧张时,内核要决定”是丢文件内容缓存(page cache),还是丢目录结构缓存(dentry/inode)“。这个参数就是这个天平的砝码。
  • 权衡:
    • 调高(如 500):更积极地丢 dentry/inode → 释放内存快,但下次路径查找要重新走磁盘,文件操作变慢。适合”小文件海量但访问不重复”的场景(如日志清理机器)。
    • 调低(如 50):更倾向保留 dentry/inode → 文件系统操作快,但这部分内存长期占着。适合”反复访问同一批文件”的场景(如 web 服务器的静态文件)。
  • 一句话:它是”文件元数据查询速度”和”可用内存”之间的天平。

四、金库现金不够了:回收机制与水位线

墨:现在到最核心的战场。你申请一页,buddy 发现空闲页不多了。内核怎么办?

4.1 三条水位线:内核的”现金储备”设计

内核给每个内存 zone 设了三条水位线:

可用内存
   ↑
   │  ┌────────────────────────────────┐
   │  │  充裕区:随便分,快路径          │
   │  ├─── high 水位 ──────────────────┤ ← kswapd 回收到这里就停手
   │  │  kswapd 后台回收区              │
   │  ├─── low 水位 ───────────────────┤ ← 跌到这里,唤醒 kswapd 开始后台回收
   │  │  警戒区(kswapd 拼命干)         │
   │  ├─── min 水位 ───────────────────┤ ← 跌破这里!申请者必须"自己动手回收"
   │  │  紧急区:只有特权分配能碰        │        (direct reclaim,同步阻塞!)
   │  └────────────────────────────────┘
   ↓  0 → OOM

墨:这套设计里最关键的分野,是 low 和 min 之间的区别——它决定了你的程序是”顺滑”还是”卡死”:

  • 跌到 low 以下:内核唤醒后台线程 kswapd,让它慢慢回收。你的程序毫无感觉,该分配的照样从剩下的空闲页里拿。这是”异步回收”,体验良好。
  • 跌破 min:来不及了!这时候申请内存的那个进程(就是你的程序)必须自己在申请路径上同步做回收——这叫 direct reclaim(直接回收)。

你:自己回收?那意思是我 malloc 一下,可能被卡在里面帮内核扫内存?

墨:一字不差。 而且这个”卡”可能是几毫秒、几十毫秒,极端情况几百毫秒——因为它可能要:扫描 LRU 链表、把脏页写回磁盘(同步磁盘 IO!)、做内存规整……

这是 Linux 内存问题里最隐蔽、最恶毒的延迟来源:direct reclaim。 现象是:你的程序某次 malloc/某次内存访问莫名卡了 50ms。你看 CPU 不高、看内存”还有剩”,看 free 显示 available 还有几个 G。查半天查不出来。 真相是:内核在你的调用栈上,借你的 CPU 时间,替它自己扫内存、写脏页去了。

⚠️ 怎么确诊 direct reclaim?

# 关键指标:allocstall(直接回收发生的次数)
grep -E "allocstall|pgscan_direct|pgsteal_direct" /proc/vmstat
# allocstall_normal 12043     ← 这个数在涨,说明正在发生直接回收
# pgscan_direct     8912345   ← 直接回收扫了多少页
# pgscan_kswapd     40123456  ← kswapd 后台扫了多少页

判定法则:如果 pgscan_direct 相对 pgscan_kswapd 占比明显(比如超过 10~20%),说明 kswapd 跟不上分配速度,大量回收压力落到了应用进程头上 → 延迟抖动的元凶。

更现代的入口:PSI

cat /proc/pressure/memory
# some avg10=15.32 avg60=8.21 avg300=2.11 total=...
# full avg10=3.05  avg60=1.12 avg300=0.31 total=...
  • some:至少有一个任务因内存压力被卡住的时间占比;
  • full:所有任务都因内存压力被卡住(系统性停摆)的时间占比。

full 只要不是接近 0,你的系统就已经在因内存压力周期性停摆了——哪怕 free 看起来还有余量。PSI 是内核 4.20 给的礼物,它直接量化”痛苦”而不是”用量”,比任何传统指标都贴近你关心的因果。

4.2 参数详解:vm.min_free_kbytes —— 那道保命的现金底线

墨:上面那三条水位线,min 是根,low 和 high 都是从它推算出来的。而 min 由 vm.min_free_kbytes 决定。

  • 它控制什么:系统必须始终保留的空闲内存量(KB)。默认值由内核按物理内存大小自动算(大约 sqrt(内存) × 常数),一台 64G 机器上通常几十 MB 到一百多 MB。

  • 为什么必须留这一块?——这是设计的精髓:

    1. 回收本身需要内存! 内核要写回脏页,可能需要分配 IO 请求结构、网络包缓冲(NFS 场景)。要是内存彻底为 0,连回收都做不了 → 死锁。这块预留就是”救援队自己的口粮”。
    2. 中断上下文不能等。网卡收包时在中断里要分配 skb,它不能睡眠、不能等回收。必须有现货。
    3. 原子分配(GFP_ATOMIC)的保底。内核里很多路径不允许阻塞,只能从这块紧急储备里拿。
  • 权衡(重要):

    • 调高(比如从 90MB 调到 1GB):
      • 得到:更早触发 kswapd 后台回收(因为 low/high 水位也跟着抬高),大幅降低跌破 min 触发 direct reclaim 的概率 → 延迟更平滑;给需要连续大块/原子分配的场景(网卡驱动高吞吐、大页)留足余地。
      • 代价:这部分内存被”锁”起来当储备,应用永远用不到 → 可用内存变少;而且水位线抬高意味着回收得更早、更频繁,page cache 会被压得更小 → 文件缓存命中率下降,IO 变多。
    • 调低:多出可用内存,但极易撞 direct reclaim,还可能在高吞吐网络场景下出现 page allocation failure。
  • 实战经验值:

    • 通用服务器:默认值通常够。
    • 高吞吐网络机器 / 大内存机器(>64G):常调到 1~4GB。因为默认公式在大内存机器上给的值相对偏小,而万兆网卡的突发收包对”现货”要求高。
    • 对延迟极度敏感的服务:调高是常规操作,用”损失一点可用内存”换”避免 direct reclaim 造成的 P99 尖刺”。这笔账几乎总是划算的。

配套参数:vm.watermark_scale_factor(默认 10,单位万分之一)

  • 控制什么:low 和 high 水位线离 min 有多远(即 kswapd 的”工作缓冲区”有多宽)。
  • 控制背景:光抬高 min 是抬高了底线,但如果 low 紧贴着 min,kswapd 一被唤醒就已经快跌破 min 了,它没有反应时间。
  • 权衡:调高(如 100~200)= 给 kswapd 更宽的提前反应空间,让它更早开始、回收得更多才停 → 更少 direct reclaim;代价是常态下保持的空闲内存更多(可用内存少)、kswapd 更活跃(后台 CPU 和 IO 开销)。
  • 一句话:min_free_kbytes 是”底线在哪”,watermark_scale_factor 是”预警线离底线多远”。在高吞吐/低延迟场景,两个通常一起调。

4.3 回收谁?——LRU 与”活跃/不活跃”的双链表

墨:决定了”要回收”,下一个问题是”回收哪些页”。内核的答案是 LRU(最近最少使用),但它的实现有个漂亮的细节。

内核为每类页维护两条链表:

匿名页(anon):  active_anon  ←→  inactive_anon
文件页(file):  active_file  ←→  inactive_file
  • 新读进来的页进 inactive;
  • 被再次访问才升级到 active;
  • 回收时只从 inactive 尾部拿。

墨:为什么要分两条,而不是一条 LRU?你想想这个场景:

📦 场景:一次全表扫描

你的数据库做了一次 SELECT * FROM huge_table,读了 50G 数据。如果只有一条 LRU 链表,这 50G 只用一次的数据会把链表整个冲刷一遍,把你辛苦缓存了一天的热点数据全挤出去。等扫描结束,缓存全凉了。

这个现象有个名字:cache pollution / LRU 被顺序扫描”冲刷”。

双链表的解法:那 50G 只读一次的页,进了 inactive_file 就再没被访问过,永远升不到 active。回收时优先从 inactive 拿,它们首当其冲被丢掉,而 active_file 里的热点数据安然无恙。

设计意图:用”必须被访问两次才算热”这个门槛,把”一次性扫描流量”和”真正的热数据”隔离开。 这是对 LRU 最重要的一次工程加固。

你可以看到同样的思路遍布各处:MySQL InnoDB 的 buffer pool 也有 young/old 分区(midpoint insertion),原因一模一样——防止一次大扫描毁掉整个缓存。

📦 顺带解一个千古疑问:free -h 里那些数字到底怎么看?

              total   used   free   shared  buff/cache   available
Mem:            31G    8.2G   1.1G    120M       22G        22G
  • 很多人看到 free 只有 1.1G 就慌了:“内存快满了!”
  • 错。 那 22G 的 buff/cache 是 page cache,绝大部分可以立即回收。
  • 你唯一该看的是 available——它是内核估算的”不引起 swap 的情况下,新应用大概能拿到多少”。这里是 22G,非常健康。

free 少不是问题,available 少才是问题。 空闲内存对操作系统来说是浪费——内核会尽可能把它拿来做缓存。看到 free 很小,应该说明内核在好好干活。


五、Swap:被误解最深的机制

墨:老哥,我猜你的直觉是”swap 就是内存不够时用磁盘顶,所以 swap 一用起来就说明要完了,应该关掉”。对不对?

你:……差不多是这么想的。生产上我们都关 swap。

墨:这个观念一半对一半错,而且错的那一半会让你误判很多问题。我们把它掰开。

5.1 Swap 的真实作用:不是”救命”,是”腾挪”

swap 真正的价值场景:

  1. 把长期不用的匿名页赶走,换来更多 page cache。你的程序启动时初始化了一堆数据结构,之后再也没碰过。这些页占着内存纯浪费——swap 出去,腾出的内存给 page cache 用,整体 IO 反而变快。
  2. 给内存回收留一条退路。没有 swap 的机器,匿名页压根无处可去(文件页可以丢掉再从磁盘读,匿名页丢了数据就没了)。所以无 swap 时,内存压力下内核只能拼命回收 page cache 和 slab,回收压力全压在文件缓存上——page cache 被反复丢弃再读回,IO 放大。
  3. 缓冲突发。突发内存尖峰时,swap 给你几秒钟的缓冲,让系统撑过去而不是直接 OOM Kill。

5.2 那为什么大家都说要关 swap?

墨:因为关 swap 换来的是”故障模式的确定性”。

  • 有 swap:内存不足时,系统变慢(疯狂换页,叫 thrashing——磁盘狂响、CPU 大量在 iowait、什么都干不动)。这种”半死不活”状态可能持续很久,而且监控上很难判定,人工介入也很难(连 SSH 都卡)。
  • 无 swap:内存不足时,系统直接 OOM Kill——快、干脆、有明确日志(dmesg 里一坨 OOM 报告),编排系统(K8s)能立刻感知并重启/重调度。

这是一个纯粹的”故障模式选择”:你要”慢性病(thrashing,难诊断难恢复)“还是”急性发作(OOM,快速失败可自动恢复)”?

在有编排系统自动重启的云原生环境,答案很明确:选急性发作。Pod 被 OOMKill,K8s 5 秒后重启一个新的,业务几乎无感;而一个 thrashing 的 Pod 会持续拖累整个节点(包括 kubelet 自己!),把邻居一起搞死。

这也是 K8s 早期硬性要求关 swap 的根本原因:swap 让”内存 limit”这个语义变得模糊——你限了 2G 内存,结果它用了 2G 内存 + 5G swap,隔离性和可预测性全毁了。(K8s 1.22+ 开始有实验性的 swap 支持,并且必须显式配置,就是因为要重新定义清楚这个语义。)

墨:所以正确的态度是:

  • 云原生 / 容器化环境:关 swap(或用 K8s 的受控 swap),追求快速失败。
  • 单机数据库 / 传统服务器:留少量 swap(如 2-8G)+ 低 swappiness,让它做”腾挪冷页”和”缓冲突发”,但不指望它救命。同时必须监控 swap 的换入换出速率(不是用量!)。
  • 绝不要:靠大 swap 掩盖内存不足。那只是把 OOM 换成了持续的性能地狱。

⚠️ 关键指标纠正:判断 swap 是否在害你,不要看 free 里 swap 的”已用量”——一块长期躺在 swap 里没人动的冷数据,对你毫无伤害。要看速率:

vmstat 1
# procs -----------memory---------- ---swap-- -----io----
#  r  b   swpd   free  buff  cache   si   so    bi    bo
#  2  0  512000  1.1G   80M   22G     0    0   120    80
#                                    ↑↑   ↑↑
#                            si=换入速率  so=换出速率

si/so 持续非零(尤其是 si 大)= 正在 thrashing,这才是灾难。 swpd(用量)大但 si/so 是 0 = 一堆冷数据躺着,无害。

5.3 参数详解:vm.swappiness —— 一个被普遍误解的旋钮

  • 常见误解:“swappiness 是 swap 的使用比例""设成 0 就不用 swap”。都不对。

  • 真实含义:它调节内核在回收时,倾向回收匿名页(需要 swap)还是文件页(直接丢弃/写回) 的天平。范围 0-100(内核 5.8+ 上限扩到 200)。

    • 高(如 100):更愿意 swap 出匿名页,保护 page cache;
    • 低(如 1-10):更愿意丢 page cache,尽量不动匿名页;
    • 0:只在快要 OOM 时才 swap(注意:不是”永不 swap”)。
  • 控制背景与设计意图:内核面对一个选择——回收 1 页匿名内存(要写到 swap,代价:一次磁盘写 + 以后要用还得读回来)还是回收 1 页文件缓存(干净页直接丢,代价:以后要用重新从文件读)。哪个更划算,取决于你的负载。这个参数就是让你告诉内核你的判断。

  • 权衡与场景:

场景建议值理由
数据库(MySQL/PG,自己管大缓冲池)1(而非 0)缓冲池是匿名内存且是热数据,换出去就是灾难(查询延迟暴涨)。设 1 而非 0 是为了保留最后的紧急余地
通用 web 服务器默认 60平衡
文件服务器 / 大量文件读可调高(80-100)page cache 才是命脉,匿名内存少且冷,乐意换出去
桌面10-30减少交互卡顿(swap 换入会让点击有延迟感)
K8s 节点(关 swap)无意义没有 swap,这参数基本不起作用

⚠️ 经典事故:MySQL 服务器 swappiness=60(默认),内存充足但 NUMA 不均(见下节),内核把 InnoDB buffer pool 的一部分 swap 出去了。现象:查询突然从 2ms 变成 200ms,而内存监控显示”充足”。因为它在从磁盘换回 buffer pool 的页——你花大钱买内存做的缓存,被换到磁盘上了,荒谬但真实。修法:swappiness=1 + 处理 NUMA(见下)。

5.4 NUMA:一个能让”内存充足却 OOM”的坑

墨:这个坑必须讲,因为它反直觉到能让人怀疑人生。

现代多路服务器是 **NUMA(非统一内存访问)**架构:CPU 分组(node),每组有自己”本地”的内存。访问本地内存快(~80ns),访问其他 node 的”远程”内存慢(~130ns,且要走 CPU 互联总线)。

   ┌─────────────────┐        ┌─────────────────┐
   │   Node 0        │        │   Node 1        │
   │  CPU 0-15       │◄──────►│  CPU 16-31      │
   │  Mem 64G (本地) │  QPI/  │  Mem 64G (本地) │
   └─────────────────┘  UPI   └─────────────────┘
        ↑ 快(本地)                  ↑ 对 Node0 来说是"远程",慢

坑来了:内核默认倾向从本地 node 分配(局部性优化,合理)。但如果 Node 0 的 64G 用满了,而 Node 1 还有 60G 空闲——

  • 老内核(或 vm.zone_reclaim_mode 配置不当)可能选择在 Node 0 上拼命回收甚至 swap,而不是去 Node 1 拿!
  • 极端情况:整机还有 60G 空闲,却触发了 OOM Kill。

墨:这就是那个著名的 **“NUMA 导致 MySQL swap 抖动”**问题(Jeremy Cole 那篇 “The MySQL swap insanity problem” 讲的就是这个)。

相关参数:

vm.zone_reclaim_mode(默认 0)

  • 控制什么:当本地 zone/node 内存不足时,是优先本地回收还是去别的 node 拿。
  • 0 = 不做本地区域回收,直接去其他 node 分配(牺牲访问局部性,保证不无谓回收);
  • 1 = 优先本地回收;2 = 回收时写回脏页;4 = 允许 swap 来做本地回收。
  • 权衡:设成非 0(尤其是 4)在数据库场景是灾难——为了”本地性”这点收益,不惜把热数据 swap 掉。现代内核默认 0 是正确的,如果你在老系统上看到非 0,尤其是数据库机器,该改。

numactl --interleave=all(不是 sysctl,但同一战场)

  • 让进程的内存均匀分散在所有 node。牺牲一部分本地访问速度(平均下来一半的访问是远程),换来**不会出现”单个 node 用满而整机还剩很多”**的失衡。
  • MySQL/Redis 大内存实例的经典做法。权衡是明确的:用”平均访问慢一点”换”绝不因单 node 失衡而 swap/OOM”。

⚠️ 诊断入口:

numactl --hardware        # 看每个 node 的内存总量和剩余
cat /proc/<pid>/numa_maps # 看某进程的内存分布在哪些 node
numastat -m               # 各 node 的内存明细

如果 numactl --hardware 显示某个 node 的 free 接近 0 而其他 node 很空闲,你就找到问题了。


六、挤兑到来:OOM Killer 挑人的逻辑

墨:所有回收手段都用尽了,内存还是不够。内核到了最后一步——杀人。

6.1 它是怎么挑的?

内核给每个进程算一个 oom_score,分最高的被杀。计算的核心因素:

  1. 占用内存越多,分越高(基础分基本就是”你占了多少 RSS + swap + 页表”的比例);
  2. oom_score_adj(-1000 ~ +1000):人工干预项,直接加到分上。设 -1000 = 绝对豁免(永不被杀);
  3. root 进程有轻微优惠(老内核有此逻辑);
  4. 注意:不看优先级、不看运行时长、不看”是否重要” ——内核不知道什么叫”重要”。

墨:所以有个经典的悲剧:OOM 时被杀的,常常是你最重要的那个进程。因为”最重要”往往意味着”占内存最多”(数据库、JVM、缓存服务)。

内核的逻辑很朴素也很残酷:杀一个能释放最多内存的,让系统一次就活过来。 它优化的是”系统存活”,不是”你的业务存活”。这两个目标不一致时,你必须自己介入。

6.2 参数与实操:保护你真正在乎的进程

/proc/<pid>/oom_score_adj

# 保护数据库:让它几乎不可能被选中
echo -998 > /proc/$(pidof mysqld)/oom_score_adj
 
# 让一个"可牺牲"的批处理进程当替死鬼
echo 900 > /proc/$(pidof batch_job)/oom_score_adj
  • 权衡:这是把”该死谁”的判断权从内核收回自己手里。但要小心——如果你把所有重要进程都豁免了,内核可能杀掉 sshd 或系统关键服务,让你彻底失联。豁免要节制,而且最好配一个明确的”替死鬼”。
  • K8s 的做法:kubelet 会按 QoS 类自动设 oom_score_adj——Guaranteed 类设很低(受保护),BestEffort 设很高(优先牺牲),Burstable 居中。这正是”把可牺牲性显式化”的工程实践。

vm.panic_on_oom(默认 0)

  • 0 = 杀一个进程,系统继续跑;
  • 1 = 直接 panic(整机重启);
  • 权衡:panic 听起来疯狂,但在某些场景是对的——比如一个集群节点,你宁愿它干净地死掉并被替换,也不愿它”被杀了个不知道哪个进程之后半残地活着提供错误服务”。这是”快速彻底失败”vs”部分降级存活”的选择。 金融/强一致系统常选 1(配合 kdump 留现场)。

vm.oom_kill_allocating_task(默认 0)

  • 0 = 扫描全部进程挑分最高的杀(慢但”合理”);
  • 1 = 直接杀那个正在申请内存的进程(快,不用扫描)。
  • 权衡:1 更快、避免扫描开销,而且有种”谁捅破的谁负责”的朴素正义感;但可能杀掉一个很小的无辜进程,释放的内存不够,于是接着再 OOM 一次、再一次——形成连环杀。默认 0 是更稳的选择。

memory.oom.group(cgroup v2)

  • cgroup v2 新增:OOM 时把整个 cgroup 里的进程一起杀,而不是挑一个。
  • 设计意图:一个容器里的进程往往是协作的(主进程 + 辅助进程),杀掉一个会让剩下的处于不一致的半死状态。整组杀掉更干净,让编排系统重建。
  • 这是个很好的演化例子:从”进程粒度”进化到”应用粒度”的故障边界。

6.3 怎么读 OOM 现场?

dmesg -T | grep -i -A 30 "out of memory"
# 或 journalctl -k | grep -i oom

关键要读的几段:

Out of memory: Killed process 12345 (java) total-vm:8388608kB, 
    anon-rss:6291456kB, file-rss:12000kB, shmem-rss:0kB, 
    UID:1000 pgtables:14000kB oom_score_adj:0
  • anon-rss:这是真正的重点——它匿名内存占了多少。6G,凶手大概是它。
  • total-vm:虚拟地址空间(这个数常常很大,不代表真实占用,别被它误导)。

前面还会有一大坨内存状态和进程列表:

[  pid  ]   uid  tgid total_vm      rss ... oom_score_adj name
[  12345] 1000 12345  2097152  1572864 ...              0 java
[  12400] 1000 12400    45000     3000 ...              0 nginx

把这个列表按 rss 排序,你就知道当时内存被谁吃了。 这是事后复盘 OOM 唯一可靠的证据(因为进程已经死了,你没法再去 top)。

⚠️ 容器场景的重要区分:

  • cgroup OOM(容器内存超 limit):只杀容器内进程,dmesg 里会有 memory cgroup out of memory,K8s 里表现为 OOMKilled。这是”你自己超了”。
  • 系统级 OOM(整机内存耗尽):可能杀任何进程,包括别的容器甚至系统服务。这是”节点超卖了”。

两者根因和修法完全不同:前者调容器 limit / 修应用内存泄漏;后者查节点超卖、检查 requests 设置是否合理、检查是否有容器没设 limit 在裸吃内存。


七、脏页与写回:另一条延迟暗线

墨:还有一大类 vm.* 参数,管的是脏页写回。这条线是磁盘 IO 抖动的头号嫌疑人,而且它跟第 08 章(块层 IO)接得上。

7.1 机制:为什么写文件那么快?

你 write() 一个文件,函数立刻返回了。数据到磁盘了吗?没有。 它只是被写进了 page cache,那一页被标记为脏页(dirty)。真正落盘是内核后台异步干的。

  • 好处:写操作延迟极低(内存速度),而且可以合并多次小写、排序成顺序写,大幅提升磁盘效率。
  • 代价:① 断电丢数据(所以有 fsync);② 脏页攒太多时,会有一次剧烈的”集中写回”,造成延迟尖刺甚至整机卡顿。

②就是我们要治的病。

7.2 参数详解:脏页四件套

vm.dirty_background_ratio(默认 10)/ vm.dirty_background_bytes

  • 脏页占可用内存的比例超过这个值,内核后台开始写回(kworker 干活,应用无感)。

vm.dirty_ratio(默认 20)/ vm.dirty_bytes

  • 脏页超过这个比例,写文件的进程被强制阻塞,自己去写回脏页(和 direct reclaim 是同一种”让你替内核干活”的机制)。
  • 这是脏页导致卡顿的直接机理:你的 write() 突然卡住几百毫秒,因为你被抓去写磁盘了。

vm.dirty_expire_centisecs(默认 3000 = 30 秒)

  • 一个脏页最多能”脏”多久。超过这个时间,即使没到比例阈值也要写回。
  • 设计意图:防止一个很少写的文件的脏页无限期躺在内存里(断电就丢)。这是数据安全的兜底。

vm.dirty_writeback_centisecs(默认 500 = 5 秒)

  • 写回线程多久醒来检查一次。设 0 = 完全禁用周期写回(危险)。

墨:这四个参数的权衡怎么想?我给你两个相反的场景:

📦 场景 A:大内存机器上的”写回海啸”

一台 256G 内存的机器,dirty_ratio=20,意味着脏页可以攒到 ~51G!

一个日志/备份程序疯狂写文件,脏页一路涨到 51G,然后撞上 dirty_ratio —— 内核开始强制同步写回 51G 数据到磁盘。磁盘带宽 500MB/s,要 100 秒。这 100 秒里:

  • 所有写文件的进程被阻塞;
  • 磁盘队列被塞满,连读操作也被拖死(排在后面);
  • 现象:整机 IO 卡死一两分钟,iowait 飙到 90%,await 几百毫秒,然后自己恢复,周期性发作。

修法:把阈值从”比例”改成”绝对字节数”!

vm.dirty_background_bytes = 268435456   # 256MB 就开始后台写
vm.dirty_bytes = 1073741824             # 1GB 就强制阻塞
# 注意:设置 *_bytes 会让对应的 *_ratio 失效(互斥)

这是一个非常重要的实战知识点:dirty_ratio 这种”按比例”的设计,在大内存机器上会失效——比例参数在设计时假设内存是几百 MB 到几 G 的年代。内存涨到几百 G 后,20% 是个荒谬的绝对值。所以内核后来加了 *_bytes 版本。

设计教训:任何”按比例”的参数,都要检查它在规模变化 100 倍后是否还合理。 (这跟 K8s 那本里”规模逼结构优化”是同一条规律。)

📦 场景 B:反过来的场景——阈值太小也不行

如果你把 dirty_bytes 设得极小(如 16MB),那么:

  • 写回极其频繁,丧失了”合并小写为大写”的机会 → 磁盘上全是小随机写 → 吞吐暴跌;
  • 后台 kworker 持续忙碌,CPU 和 IO 都有额外开销。

所以脏页阈值的权衡是:大 = 吞吐好(能充分合并)+ 风险大(一次写回海啸,断电丢更多);小 = 延迟平滑 + 吞吐差。

经验法则:让 dirty_bytes 大致等于”你的磁盘在可接受的卡顿时间内能写完的量”。 磁盘 500MB/s,你能接受 2 秒卡顿,那就 ~1GB。这个算法比拍脑袋比例靠谱得多。

⚠️ 诊断入口:

grep -E "Dirty|Writeback" /proc/meminfo
# Dirty:      2048000 kB   ← 当前脏页量,持续很大就危险
# Writeback:   512000 kB   ← 正在写回的量
 
# 实时看脏页涨落
watch -n1 'grep -E "Dirty|Writeback" /proc/meminfo'

vm.drop_caches——一个你几乎永远不该在生产用的东西

echo 3 > /proc/sys/vm/drop_caches   # 1=page cache, 2=dentry/inode, 3=both
  • 它干什么:立即丢弃所有可回收的缓存。
  • 为什么不该用:① 它把你所有热数据缓存清空,之后所有读都要走磁盘,性能断崖式下跌,要几十分钟到几小时才能”预热”回来;② 大量人用它是为了”让 free 里的数字好看”——这是纯粹的自我欺骗,缓存被内核用着是好事;③ 它可能引发一次剧烈的元数据回收,造成卡顿。
  • 唯一正当用途:做 benchmark 时,为了让每次测试从”冷缓存”开始,保证可比性。这是第 25 章 benchmark 陷阱那节的必备动作。

八、真实事故:一场三次推错的 OOM 连环杀

墨:该走诊断环了。这个案子有点长,但它把这一章的东西串了个遍,而且我推错了三次。

现象

一个内容服务,8 核 32G 物理机(未容器化),跑着:

  • 一个 Go 写的 API 服务;
  • 一个 Python 的图片处理 worker(多进程);
  • 本地一个 Redis(做缓存);
  • 一个日志采集 agent。

症状:每隔 3-5 天,凌晨会出现一次”Redis 进程消失”,服务大面积缓存击穿,DB 被打满。查 Redis 日志——没有任何异常记录,像是被凭空抹掉了。

墨:“进程凭空消失,自己日志里啥也没有”——这个特征你现在应该有条件反射了。

你:被 OOM Killer 杀了?

墨:对,第一步方向是对的。 去 dmesg -T | grep -i "out of memory":

[Sun Aug 17 03:14:22 2025] Out of memory: Killed process 3921 (redis-server)
    total-vm:9012340kB, anon-rss:8123456kB, file-rss:0kB, oom_score_adj:0

确认:系统级 OOM,Redis 因为 anon-rss 8G 最大,被选中处刀。

推断一:Redis 内存泄漏(推错了)

假设:Redis 占 8G 太多了,一定是数据涨了或者泄漏。

验证:看 Redis 自己的监控。used_memory 长期稳定在 7.8G,maxmemory 设的 8G,maxmemory-policy 是 allkeys-lru。它一直在设计容量内,没涨、没泄漏。

推断一否掉。 但收获一个关键认知:Redis 是”稳定占 8G”的,它不是变量,它是常量。 那么变量在别处。

墨:这一步的方法论很重要——当被杀的进程是”无辜的最胖者”时,真正的凶手是那个”让内存突然变紧的变量”。 OOM Killer 杀的是最胖的,不是肇事的。不要盯着尸体查,要查谁开的枪。

推断二:Python worker 内存泄漏(部分对,但不是根因)

假设:那就是 Python worker 涨了。

验证:把 OOM 日志里那份进程列表按 rss 排序:

[  pid  ]  ...      rss   ... name
[  3921 ]  ...  2030864  ... redis-server   (约 7.9G)
[ 18234 ]  ...   410000  ... python3        (约 1.6G)
[ 18235 ]  ...   398000  ... python3        (约 1.5G)
[ 18236 ]  ...   405000  ... python3        (约 1.6G)
... (共 12 个 python3)
[  2210 ]  ...   180000  ... api-server     (约 700M)

12 个 Python worker 加起来约 18G! 加上 Redis 8G,再加 API 和系统,32G 确实爆了。

修法一:把 Python worker 从 12 个降到 6 个。

观察:好了……11 天。然后又 OOM 了一次。

墨:老哥,这里是本案最重要的一课。“改完之后好了一段时间”是最危险的信号——它可能只是把发作周期拉长了,根因还在。 你必须问:为什么 Python worker 会占 1.6G?它该占这么多吗?

推断三:以为是 Python 对象泄漏(又推错了方向)

假设:Python 代码里有引用没释放。

验证:上 tracemalloc 和 objgraph 查 Python 堆里的对象。结果:Python 层面的对象总量只有约 200MB,而进程 RSS 是 1.6G。1.4G 不在 Python 的对象堆里!

墨:这个矛盾是转折点。RSS 远大于解释器管理的对象量,说明内存被 Python 的内存分配器(或底层 glibc malloc)持有着没还给内核。

你:所以是 glibc 的 arena 没释放?

墨:方向对了。 这里牵出一个非常经典的、和本章第三节直接相关的机制——

真根因:glibc malloc 的 arena 与内存碎片

glibc 的 malloc 实现里:

  • 为了减少多线程锁竞争,它为每个线程分配独立的 arena(内存池),默认最多 8 × CPU 核数 个 arena。8 核机器 → 最多 64 个 arena!
  • 每个 arena 通过 mmap 拿一大块(默认 64MB 一个 heap)地址空间;
  • 释放的内存优先留在 arena 里复用,不还给内核(还给内核要系统调用,贵——第 04 章那道墙);
  • 而且 brk 管理的主 arena,只有顶部连续空闲才能收缩——如果顶部有一个小对象还活着,底下几百 MB 空闲也还不回去(经典的 top chunk 问题)。

这个 Python worker 的行为是:处理大图片时申请大块内存 → 释放 → 再申请不同大小的 → 释放……产生严重的堆内碎片,RSS 只涨不跌。多进程 × 多 arena,放大成 1.4G 的”幽灵内存”。

最终的调整、观察与改进

改进 1:限制 glibc arena 数量

export MALLOC_ARENA_MAX=2      # 默认 8×核数,限制到 2
  • 权衡:arena 少了 → 多线程分配时锁竞争加剧(可能有性能损失),换来内存占用大幅下降。对这个 worker(计算密集、分配不算特别频繁)是划算的。
  • 观察:worker RSS 从 1.6G 降到 约 700MB。

改进 2:定期主动归还 在 worker 处理完一批任务后调用 malloc_trim(0)(Python 里可通过 ctypes 调),主动把 arena 顶部空闲还给内核。

  • 观察:RSS 稳定在 约 400MB。

改进 3:架构层面——加保护和边界(治本)

  1. 给 Redis 上 oom_score_adj:echo -900 > /proc/$(pidof redis-server)/oom_score_adj。
    • 理由:Redis 是”最胖但最无辜”的,它被杀的代价(缓存全空、DB 被打穿)远大于杀掉一个 worker。把”可牺牲性”显式告诉内核。
    • 同时给 worker 设 oom_score_adj = 500,让它当替死鬼(worker 挂了有重试,业务无感)。
  2. 给每个 worker 套 cgroup 内存 limit(如 800MB)。
    • 理由:把”整机 OOM”变成”单个 worker 的 cgroup OOM”。前者是随机灾难,后者是可预期的局部失败,有日志、可自动重启。这是故障边界的收敛。
    • 这也正是容器化的核心价值之一。
  3. 调高 vm.min_free_kbytes 从默认 90MB 到 512MB。
    • 理由:给 kswapd 更多提前反应空间,减少在内存紧张时的 direct reclaim 尖刺。
    • 代价:少 400 多 MB 可用内存,值。
  4. 加监控:
    • /proc/pressure/memory 的 full avg60(内存压力,PSI);
    • /proc/vmstat 的 allocstall_*(直接回收次数);
    • 各进程 RSS 的趋势(而不只是总内存);
    • OOM 事件告警(从 dmesg/journald 抓)。
    • 理由:上次查了三天,因为事后什么现场都没留。这一步的价值可能比前面所有修复都大。

最终观察:三个月无 OOM。偶发的单 worker cgroup OOM 被自动重启,业务无感知。

复盘:这个环里的三次错和四个教训

现象:进程凭空消失 → dmesg 确认系统级 OOM(方向对)
  → 推断1:Redis 泄漏 → Redis 自身监控否掉 → 认知:被杀者≠肇事者
  → 推断2:worker 太多 → OOM 日志进程表证实 → 改进:减少 worker 数
     → 观察:好了 11 天又复发 ← 假性痊愈!
  → 推断3:Python 对象泄漏 → tracemalloc 否掉(RSS 1.6G vs 对象 200MB)
     → 矛盾定位到"分配器持有内存"这一层
  → 真根因:glibc arena 碎片 + 多进程放大
  → 改进:MALLOC_ARENA_MAX + malloc_trim + cgroup 边界 + oom_score_adj + min_free_kbytes + 监控
  → 验证:三个月无系统级 OOM

四个教训:

  1. OOM 杀的是最胖的,不是肇事的。 别对着尸体查,要问”是什么让内存突然变紧了”。OOM 日志里那份按 rss 排序的进程表,是唯一可靠的案发现场。
  2. “改完好了一阵”是最危险的假象。 只要你没解释清楚”为什么会到那个数”,就说明根因还在,只是周期被拉长。必须追问到”这个数字合理吗”。
  3. RSS 和”应用层认为自己用了多少”之间的差额,是一个独立的调查方向。 差额可能藏在:glibc arena 碎片、JVM 的 Metaspace/直接内存/线程栈、Go 的 MADV_FREE 未归还页、内存池、mmap 的文件映射……这一层很多人压根不知道存在,查三天查不出来就是因为漏了这一层。
  4. 最有价值的改进往往是”收窄故障边界”和”加观测”,而不是”修那个 bug”。 cgroup limit 把随机的整机灾难变成可预期的局部失败;oom_score_adj 把”该死谁”的判断权拿回来;监控让下次 5 分钟定位。这些比修好 glibc 碎片更重要。

九、参数总表:/proc/sys/vm 的核心旋钮与它们的交易

墨:这一章的参数最密,汇总一张表。注意每一行的”拿什么换什么”——那才是你该记的东西,具体数值随时能查。

参数控制什么拿什么换什么
vm.overcommit_memory超发策略(0启发/1放行/2严格)1:换 fork/大申请可行性 ↔ OOM 风险。2:换故障可预测性(malloc 失败而非被杀) ↔ 内存利用率大降
vm.overcommit_ratio / vm.overcommit_kbytes=2 时的承诺上限默认 50% 太保守;调高换可用额度 ↔ 逼近真实内存风险
vm.min_free_kbytes必须保留的空闲内存底线调高:少 direct reclaim、延迟平滑、原子分配有保障 ↔ 损失可用内存 + page cache 被压小
vm.watermark_scale_factorlow/high 水位离 min 多远(kswapd 反应空间)调高:kswapd 提前介入、更少直接回收 ↔ 常态保留更多空闲 + 后台开销
vm.swappiness回收时倾向换出匿名页 vs 丢文件页调低:保护匿名热数据(DB 缓冲池) ↔ page cache 被牺牲。调高:反之
vm.vfs_cache_pressure回收 dentry/inode 的积极度调高:释放内存快 ↔ 文件路径查找变慢。调低:反之
vm.dirty_background_ratio/_bytes何时开始后台写回脏页调小:写回平滑、无尖刺 ↔ 失去合并机会、吞吐降
vm.dirty_ratio / vm.dirty_bytes何时阻塞进程强制写回调大:吞吐好 ↔ 一次写回海啸(大内存机上是灾难)。大内存机务必改用 _bytes
vm.dirty_expire_centisecs脏页最长存活时间调小:数据更安全(少丢) ↔ 写回更频繁
vm.dirty_writeback_centisecs写回线程唤醒间隔同上,调小更勤快更安全,开销更大
vm.max_map_count进程最多几个内存映射区调高:支持 mmap 密集程序(ES/大量线程) ↔ 更多内核内存记账
vm.zone_reclaim_modeNUMA 本地 zone 不足时是否优先本地回收非 0:保本地访问局部性 ↔ 可能无谓 swap 热数据(DB 灾难)。默认 0 通常正确
vm.panic_on_oomOOM 时是否直接 panic1:干净失败 + 完整现场 ↔ 整机重启。适合集群节点/强一致系统
vm.oom_kill_allocating_taskOOM 时是否直接杀申请者1:快、免扫描 ↔ 可能杀无辜小进程、释放不够引发连环 OOM
vm.drop_caches(写触发)立即丢缓存生产禁用。唯一正当用途:benchmark 冷启动对齐
vm.compact_memory / vm.compaction_proactiveness内存规整(整理碎片凑连续块)主动规整:大块更易分配、THP 成功率高 ↔ 后台 CPU + 延迟抖动
vm.nr_hugepages / THP 相关大页(减少 TLB miss)大页:TLB 命中率高、页表小 ↔ 内存不灵活、THP 的 khugepaged 规整可能造成卡顿(见第 23 章)
/proc/<pid>/oom_score_adj单进程被杀优先级把”该死谁”的判断从内核收回自己手中 ↔ 豁免过头可能杀到系统关键进程

十、这章的认知钉子

  1. malloc 成功是一张空头合同。 内核在超发,在赌你不会用满。这个赌局的激进程度由 overcommit_* 决定。
  2. overcommit 不是偷懒,是对”申请量远大于实际用量”这一现实的务实妥协(COW、稀疏使用、预留习惯)。禁掉它换来的是可预测性,代价是利用率。
  3. free 小不是问题,available 小才是。 空闲内存对内核来说是浪费,它会拿去做 page cache。
  4. 最隐蔽的延迟杀手是 direct reclaim —— 内核在你的调用栈上借你的 CPU 干回收和写盘。用 /proc/vmstat 的 allocstall/pgscan_direct 和 PSI(/proc/pressure/memory)确诊,用 min_free_kbytes + watermark_scale_factor 预防。
  5. swap 的价值是”腾挪冷页”和”缓冲突发”,不是”救命”。 关 swap 换来的是故障模式的确定性(快速 OOM 而非慢性 thrashing)。看 si/so 速率,不是看用量。
  6. 双 LRU(active/inactive)的设计意图是防止一次大扫描冲刷掉热缓存。 同样的思路遍布数据库缓冲池设计。
  7. 大内存机器上,所有”按比例”的参数都要重新审视(dirty_ratio 20% 在 256G 机器上意味着 51G 脏页,写回海啸)。改用 *_bytes。
  8. OOM Killer 杀最胖的,不是最该死的。 查 OOM 要看那份按 rss 排序的进程表,并且警惕”被杀者≠肇事者”。用 oom_score_adj 和 cgroup limit 把判断权和故障边界收回自己手里。
  9. RSS 与应用自认用量的差额是个独立调查方向(glibc arena 碎片、JVM 非堆、Go 未归还页)。

**


实战补遗一:伙伴系统与 slab 着色——内核态”内存分配”为什么也有碎片

墨:老哥,你调 vm 参数管的是用户态回收,那内核自己要内存时怎么拿?它也用 malloc 吗?

你:内核不是用户态,应该是专门的分配器吧?

墨:对,内核有两层分配器——伙伴系统(buddy)管页、slab 管小对象。理解它们,你才看得懂 /proc/meminfo 里 SReclaimable/SUnreclaim 那两行,以及”内核内存泄露”长啥样。

现象:一台机器 free 看空闲内存还有 8G,但新起的进程频繁申请内存失败、dmesg 报 page allocation failure。cat /proc/meminfo 里 SUnreclaim 高达 6G,且一直在涨。

推断:是用户态把内存吃光了。

可能推错:空闲内存 8G 说明用户态没吃光。问题是 SUnreclaim(不可回收的内核 slab 内存)涨到 6G——这是内核态对象(如 socket 的 skbuff、dentry、inode、各种 kmalloc 的结构体)占的,它们不算”用户内存”,但占着物理页。伙伴系统要分配连续空闲页时,剩余页被这些内核对象切碎成碎片,凑不出大块,于是报分配失败。

调整:看 /proc/slabinfo(slabtop 更直观),按 OBJS×OBJ_SIZE 排序,找哪个 cache 暴涨。常见凶手:

  • skbuff_head_cache:网络包头,连接数/pps 高时涨(见第 22 章 skbuff 实战补遗)。
  • dentry/inode_cache:文件访问多、且 vfs_cache_pressure 设太低不回收时涨。
  • 某内核模块 kmalloc-* 泄露:驱动 bug,OBJ 数只涨不跌。

再观察:还有个有趣细节——slab 着色(slab coloring)。slab 分配器把对象塞进页时,故意错开对象在页内的偏移(着色),让不同对象落在不同的硬件缓存行(cache line),避免多个频繁访问的对象互相踩同一个 cache line(伪共享)。这是性能优化,不是浪费;但代价是页内留了点”着色间隙”,轻微内部碎片。理解它你就明白为啥 slab 占用看着比”对象数×大小”略大。

改进:

  • SUnreclaim 只涨不跌 → 查 slabtop 定位是哪个 cache,多半是连接/socket 类或某驱动泄露,靠关连接池泄漏 / 升级驱动解决,drop_caches 救不了(SUnreclaim 不可回收)。
  • SReclaimable(page cache 的 dentry/inode 部分)涨 → echo 2 > /proc/sys/vm/drop_caches 可回收。
  • 伙伴系统外部碎片:cat /proc/buddyinfo 看各 order 空闲页,低 order 多、高 order 少 = 碎片严重,靠 echo 1 > /proc/sys/vm/compact_memory 手动规整(或开 vm.compaction_proactiveness)。

🔧 思考题:SUnreclaim 和 SReclaimable 凭什么区分”可回收”和”不可回收”?为什么 echo 3 > drop_caches 对 SUnreclaim 无效、对 SReclaimable 有效?

【参考答案】

内核对象分两类:

  • 可回收(SReclaimable):page cache 里的 dentry/inode 元数据、某些可重建的缓存。它们背后有”源”(磁盘上的文件),内存紧张时内核能直接丢、下次访问再读回来,不丢数据。所以 drop_caches 能回收它们。
  • 不可回收(SUnreclaim):内核对象的”活状态”——socket 的 skbuff、进程的内核栈、打开文件的 struct file、VFS 里的”正在用的” dentry、各子系统的 kmalloc 结构体。这些没有”外部源”可重建,丢了系统就崩,所以永远不能回收。

echo 3 > /proc/sys/vm/drop_caches 的 3 = 1(页缓存)+2(dentry/inode),只动可回收的那部分元数据,对 SUnreclaim 里”正在使用”的对象无能为力(你也不能强制回收正在用的 socket)。所以 SUnreclaim 只涨不跌 = 真有对象一直被持有没释放 = 泄露(或连接池没关)。定位用 slabtop 看哪个 cache 的 OBJS 一直涨;若是 skbuff/dentry,多半是应用层”连接/文件没 close”的连锁反应;若是某个 kmalloc-* 专属 cache,怀疑内核模块/驱动 bug。

buddyinfo 补充:/proc/buddyinfo 按 order(2^order 页)列出空闲块,order 越大块越大。SUnreclaim 碎片严重时,页被切成大量 0 阶(4K)小块,高 order 稀缺,大块连续分配(如 2M 大页、网卡 DMA 缓冲)失败——此时 compact_memory 或 THP 的 compaction 才能合并出大块。min_free_kbytes 高一点能保住高 order 余量,避免 DMA/原子分配失败(见第 12 章)。


实战补遗二:vmalloc 区耗尽与大块连续内存——为什么”空闲内存很多却分配失败”

墨:老哥,你听过 vmalloc 吗?除了一开始讲的伙伴系统/slab,内核还有一条”虚拟连续、物理可不连续”的分配路。它也会撑爆。

你:vmalloc 是内核自己用的吧,和应用无关?

墨:对,它是内核专用(驱动、模块、某些内核结构用)。但驱动/内核模块分配失败会拖垮整个机器,且报错和”内存不够”长得一样。这节把 vmalloc 区的讲清,补第 6 章伙伴系统那节没覆盖的”虚拟地址连续”分配。

现象:一台机器加载某个内核模块(或某网络驱动)时报 vmalloc: allocation failure, allocated ... of ...,但 free 看空闲内存还有 10G。模块加载失败,对应网卡/功能不可用。

推断:内存不够,加内存。

可能推错:空闲物理内存 10G 说明不是物理内存耗尽。vmalloc 分配的是”虚拟地址连续、物理页可不连续”的大块——它从内核的 vmalloc 虚拟地址区(32 位上有限、64 位上很大但非无限)要一段连续的虚拟地址空间。报错是”虚拟区不够”或”找不到足够连续的虚拟窗口”,不是物理页不够。32 位内核 vmalloc 区默认才几百 MB(受 /proc/sys/vm/vmalloc_min/内存布局限制),驱动一次要几十 MB 连续虚拟空间,区满了就失败。

调整:看 cat /proc/vmallocinfo 列出当前所有 vmalloc 分配及大小,找谁占了大块;看 grep Vmalloc /proc/meminfo 的 VmallocUsed/VmallocChunk(最大连续空闲块)。如果 VmallocChunk 小、VmallocUsed 大,说明虚拟区碎片化/接近耗尽。

再观察:另一常见 vmalloc 失败是大块连续物理内存(不是 vmalloc 虚拟连续,而是 alloc_pages 要连续物理页)失败——某些老驱动/设备 DMA 要连续物理页,伙伴系统碎片严重时高 order 凑不出,报 page allocation failure。这跟 vmalloc 不同(vmalloc 不要物理连续),但报错相似。解法各异:vmalloc 失败要调 vmalloc 区大小/减少驱动大块分配;连续物理页失败要靠 min_free_kbytes 留高 order 余量或 compaction。

改进:

  • 内核模块/驱动加载失败先查 VmallocUsed/VmallocChunk,别急着加物理内存。
  • 32 位内核 vmalloc 区小,加载大模块易失败(升级 64 位内核根治)。
  • 驱动要连续物理页失败:提 vm.min_free_kbytes 保高 order、或 echo 1 > compact_memory 手动规整(见第 6 章 buddy 实战补遗)。
  • 应用层遇 “page allocation failure”:看 dmesg 是 vmalloc 还是 buddy(连续物理),决定调参方向;多半是内核对象泄露(slab)挤占,回 slabtop 定位。

🔧 思考题:vmalloc”虚拟连续、物理不连续”相比 buddy”物理也连续”,各自解决什么场景?为什么应用层 mmap 通常不需要物理连续,而某些设备 DMA 必须要?

【参考答案】

两条分配路径解决不同约束:

  • buddy(alloc_pages):分配物理地址连续的页。优势是”硬件直接能 DMA”——网卡/磁盘控制器做 DMA 时,多数老设备只能认一段物理连续的缓冲区地址(DMA 描述符里填的是物理地址,设备不会帮你做散聚)。代价:连续物理页难凑,高 order 在碎片多时失败(“page allocation failure”)。所以 buddy 给”需要物理连续”的场景(DMA 缓冲、内核某些结构)。
  • vmalloc:分配虚拟地址连续、物理页可不连续的大块。优势是”虚拟窗口好要、物理页散着也行”——内核要一段大虚拟地址(如加载模块、映射大缓冲区)但不要求物理连续时,vmalloc 从虚拟区切一段连续虚拟地址,背后用离散物理页填,靠页表把虚拟连续映射到物理离散。代价:访问要经过页表(有 TLB 开销),且虚拟区本身有限(尤其 32 位)。适合”只要虚拟连续、不在乎物理布局”的内核大分配。

为什么应用层 mmap 通常不需要物理连续:应用的虚拟地址空间由 MMU 页表管理,CPU 访问走虚拟→物理映射,物理散着完全没问题(CPU 每访问一个虚拟页都查页表得到物理页,不在乎它们是否连续)。所以用户态要”大块内存”用 mmap/malloc 拿到的就是”虚拟连续、物理离散”,足够。

为什么某些设备 DMA 必须要物理连续:设备做 DMA 时绕过了 CPU 和 MMU,设备自己按”物理地址”读写内存(它读的 DMA 描述符里是物理地址,没有页表翻译)。如果缓冲物理不连续,设备得支持”散聚 DMA(scatter-gather)“才能一次 DMA 多个离散段;老设备或简单设备不支持 scatter-gather,就只能要一段物理连续缓冲,于是 buddy 高 order 分配成为瓶颈。现代设备多支持 SG,连续物理页需求弱化,但内核里仍有不少路径(以及 dma_alloc_coherent)要连续,故 min_free_kbytes 留高 order 余量对”设备 DMA 偶尔丢包/分配失败”是常用解(见第 12 章 min_free_kbytes 实战补遗)。

一句话:CPU 侧(应用/mmap)爱虚拟连续物理离散(好分配);设备 DMA 侧(老设备)爱物理连续(好 DMA);vmalloc 和 buddy 分别服务这两条线,报错相似但根因和解决完全不同——看 dmesg 是 vmalloc 区满还是 buddy 高 order 凑不出,才能调对。

下一章预告(第 07 章 文件系统与 Page Cache)**:你 write() 完就返回了,数据其实还在内存里躺着。这一章我们跟着一次 read/write 走进 VFS——看内核怎么用一套抽象同时伺候 ext4、XFS、NFS 和 /proc;看 page cache 怎么成为”整个系统最重要的一块内存”;看 fsync 为什么那么慢,以及为什么数据库都要和它死磕。这一章会解释”为什么你的日志写入偶尔卡 200ms”。


🔧 思考题(带参考答案)

思考题 1(基础): 一台 32G 机器,free -h 显示 free 只有 800M,但 available 有 20G。同事说”内存快满了,该扩容了”。你怎么回应?

【参考答案】

不该扩容,这是健康状态。

free 那 800M 只是”完全没被使用的内存”。剩下的绝大部分在 buff/cache(page cache)里——那是内核用空闲内存缓存文件内容,这是好事,不是占用。这些缓存页大部分是干净的(和磁盘一致),内存紧张时可以立即丢弃,不需要任何 IO。

available 才是关键指标:它是内核估算的”在不引起明显 swap 的前提下,新应用大概能拿到多少内存”。20G available 说明还有充裕空间。

核心认知:空闲内存对操作系统来说是浪费。 一个健康的、跑了一段时间的 Linux 系统,free 就应该很小——因为内核把能拿的都拿去做缓存了。如果你重启后 free 一直很大,反而说明缓存没建立起来、IO 性能没被优化到。

该关注什么才是真信号:

  • available 持续走低;
  • /proc/pressure/memory 里 some/full 明显非零;
  • /proc/vmstat 里 allocstall_* 在增长(发生直接回收);
  • vmstat 1 里 si/so 持续非零(在 thrashing);
  • 有 OOM 事件。

顺带:绝对不要为了让 free 数字好看而 echo 3 > /proc/sys/vm/drop_caches。那会清空所有热缓存,导致之后一段时间性能断崖下跌,是纯粹的自我伤害。

思考题 2(参数权衡): 某公司有两类机器:① MySQL 专用机(128G 内存,InnoDB buffer pool 设了 100G);② 文件网关(64G 内存,主要工作是读取和转发大量文件)。请分别给出 vm.swappiness 的建议值和理由。

【参考答案】

① MySQL 专用机:vm.swappiness = 1

理由:

  • InnoDB buffer pool 那 100G 是匿名内存(进程堆内存),而且是最热的数据——它存在的全部意义就是”把热数据放在内存里避免磁盘 IO”;
  • 如果内核把 buffer pool 的页 swap 到磁盘,就发生了一件荒谬的事:你花大价钱买内存做的缓存,被换到了磁盘上。查询要用到那页时,还得从 swap 读回来——比直接读数据文件还多绕一圈;
  • 现象极具迷惑性:内存监控显示”充足”,但查询延迟从 2ms 变成 200ms;
  • 所以要告诉内核:宁可丢 page cache,别动我的匿名页,即 swappiness 设很低。

为什么设 1 而不是 0? swappiness=0 在较新内核里意味着”几乎只在 OOM 边缘才 swap”,这可能让系统在内存压力下更容易触发 OOM Kill(把 MySQL 直接杀掉,比 swap 几页更严重)。设 1 保留一条极窄的退路,是社区和 MySQL 官方的常见推荐。

配套措施(同等重要):

  • 处理 NUMA:numactl --interleave=all 启动 mysqld,或用 innodb_numa_interleave=ON,避免单 node 用满而其他 node 空闲导致的”局部内存不足 → swap”(经典的 MySQL swap insanity);
  • 确认 vm.zone_reclaim_mode = 0;
  • 考虑调高 vm.min_free_kbytes(如 1G),减少直接回收造成的查询延迟尖刺。

② 文件网关:vm.swappiness 可调高(80~100)

理由:

  • 这类机器的性能命脉是 page cache(文件内容缓存)——命中缓存就不用读磁盘,直接转发;
  • 它的匿名内存很少而且大多是冷的(网关进程本身的堆不大,很多是启动时初始化后再不碰的);
  • 所以内核在回收时应该优先把那些冷的匿名页 swap 出去,尽量保住 page cache;
  • 调高 swappiness 正是在表达这个偏好。

注意前提:这条建议成立的前提是机器上有 swap 且是较快的介质(SSD/NVMe)。如果这台机器关了 swap(比如是 K8s 节点),swappiness 基本不起作用,该关注的是 vfs_cache_pressure(调低以保留 dentry/inode 缓存)和 min_free_kbytes。

通用方法论:swappiness 的选择,取决于回答一个问题——“我的热数据主要在匿名内存里,还是在 page cache 里?” 匿名内存里(数据库、自管缓存、JVM 堆)→ 调低;page cache 里(文件服务、静态资源、日志处理)→ 可调高。

思考题 3(事故推演): 一台 256G 内存的机器,跑着 API 服务和一个夜间备份脚本(会把几百 G 数据写到本地磁盘)。运维反馈:每晚备份期间,API 的 P99 从 30ms 涨到 800ms,而且不只是写操作慢,读操作也慢;iowait 飙高,iostat 显示 await 有时几百毫秒。CPU 和内存使用率都不高。请分析根因并给出修复方案与权衡。

【参考答案】

根因:脏页写回海啸(dirty page writeback storm)。

推演:

  1. 默认 vm.dirty_ratio = 20、vm.dirty_background_ratio = 10。在 256G 内存的机器上,这意味着脏页可以攒到 ~51G 才触发强制写回,~25G 才开始后台写回。
  2. 备份脚本疯狂 write(),数据先进 page cache 变成脏页,write() 立刻返回,所以脚本自己跑得飞快,脏页迅速堆积。
  3. 脏页撞上 dirty_ratio(51G)时,内核开始强制阻塞写入进程并同步写回。假设磁盘 500MB/s,51G 要 ~100 秒才写完。
  4. 这期间:
    • 磁盘队列被这几十 G 的写请求塞满;
    • API 的读请求排在这些写请求后面,await 飙到几百毫秒 —— 这就解释了”为什么读也慢”(很多人以为写多只影响写);
    • iowait 高但 CPU 使用率不高(CPU 在等 IO,不在算);
    • 内存使用率也”不高”(脏页算在 page cache 里,看起来是缓存)。

验证方法(先证实再动手):

# 备份期间实时观察脏页量
watch -n1 'grep -E "Dirty|Writeback" /proc/meminfo'
# 如果 Dirty 一路涨到几十 G 然后剧烈下降,就确诊了
 
iostat -x 1        # 看 await、%util、w/s 的形态
cat /proc/pressure/io   # PSI 的 IO 压力

修复方案与权衡:

① 核心修复:把脏页阈值从”比例”改成”绝对字节数”

vm.dirty_background_bytes = 268435456    # 256MB 开始后台写回
vm.dirty_bytes = 1073741824              # 1GB 触发阻塞
  • 原理:让写回持续、平缓地进行,而不是攒够 51G 来一次海啸。
  • 权衡:写回更频繁,失去部分”合并小写为大写”的机会,备份脚本的总吞吐可能略降(可能从 30 分钟变 35 分钟)。但换来 API 延迟稳定——这笔交易明显划算。
  • 定量法则:dirty_bytes ≈ 磁盘带宽 × 你能接受的最长卡顿时间。500MB/s × 2s = 1GB。
  • 注意:设了 *_bytes 后 *_ratio 失效(二者互斥),别两边都配。

② 备份脚本侧:主动限速 + 分批 fsync

  • 用 ionice -c 3(idle 级)运行备份,让它的 IO 优先级最低(需配合 bfq/cfq 调度器才充分生效);
  • 或用 rsync --bwlimit / pv -L 限速;
  • 或脚本里每写 N MB 主动 fsync() 一次,避免脏页堆积;
  • 权衡:备份变慢,换 API 稳定。同样划算。

③ 更彻底:IO 隔离

  • 用 cgroup v2 的 io.max 给备份进程限定 IOPS/带宽上限;
  • 或用 io.weight(需 bfq)给 API 更高权重;
  • 权衡:实现复杂度上升,但这是唯一提供”隔离保证”而非”善意配合”的方案——脚本改坏了、换了新脚本,cgroup 限制依然生效。
  • 若条件允许,把备份数据写到独立磁盘/独立块设备,物理隔离最彻底。

④ 架构层:把备份挪走

  • 用存储层快照、从库备份、或专门的备份节点。
  • 这是治本:线上服务机器不该同时承担大批量 IO 任务。

⑤ 加监控

  • 脏页量(/proc/meminfo 的 Dirty)、/proc/pressure/io、iostat 的 await;
  • 理由:这类问题在监控上”CPU 内存都正常”,只有 IO 维度和 PSI 才能暴露。

这道题的通用教训:任何”按比例”设计的参数,在规模放大 100 倍后都要重新审视。 dirty_ratio=20% 这个默认值诞生于内存以 MB/GB 计的年代;到了 256G 内存,20% 是个荒谬的绝对量。内核后来加 *_bytes 版本就是为了这个。遇到大内存机器,先检查一遍所有”ratio 类”参数的实际绝对值。

思考题 4(进阶·总账): 你要为一个 K8s 节点(64G 内存,跑着若干业务 Pod,关了 swap)做一套内存层面的”防御性配置”。请列出至少 5 项决策,每项说明它防的是什么问题、以及代价是什么。

【参考答案】

这题要把本章的机制、参数和”故障边界”思想综合起来。参考方案:

1. 调高 vm.min_free_kbytes(默认约 90MB → 1GB)+ vm.watermark_scale_factor(10 → 100)

  • 防什么:direct reclaim 造成的延迟尖刺。关了 swap 之后,内存压力下内核只能回收 page cache 和 slab,回收压力更集中;如果 kswapd 跟不上,分配请求会掉进直接回收,在应用调用栈上同步扫内存/写脏页,造成几十毫秒级的莫名卡顿。抬高底线和预警线,让 kswapd 有充足的提前反应空间。
  • 代价:约 1GB 内存被”锁”为储备,应用可用内存减少;水位线抬高会让 page cache 被压得更小一些,后台 kswapd 更活跃(少量 CPU/IO 开销)。
  • 验证:/proc/vmstat 的 allocstall_* 应该显著减少。

2. 所有 Pod 强制设置 memory requests 和 limits,且优先用 Guaranteed(requests==limits)

  • 防什么:系统级 OOM(整机内存耗尽,随机杀进程)。没有 limit 的 Pod 会裸吃内存,吃穿节点后触发系统级 OOM,可能杀掉别的容器、甚至 kubelet 或 containerd,造成节点 NotReady 的连锁灾难。
  • 机理:设了 limit 后,超限触发的是 cgroup OOM——只杀这个容器内的进程,有明确日志(memory cgroup out of memory),K8s 标记 OOMKilled 并自动重启。这是把随机的全局灾难,收窄成可预期的局部失败。
  • 代价:资源利用率下降(要为峰值预留);运维成本上升(要为每个服务确定合理的 limit)。
  • 这一条是所有措施里最重要的。

3. 显式管理 oom_score_adj 的层级(依赖 kubelet 的 QoS 机制 + 保护系统组件)

  • 防什么:OOM 杀错人。内核只会杀”最胖的”,而最胖的往往是最重要的(缓存、数据库、JVM 服务)。
  • 做法:确认 kubelet 的 QoS→oom_score_adj 映射生效(Guaranteed 受保护、BestEffort 优先牺牲);并给 kubelet/containerd/sshd/监控 agent 设很低的 oom_score_adj(通过 systemd 的 OOMScoreAdjust=),确保出事时你还能登得上机器、监控还活着。
  • 代价:豁免要节制——如果把所有东西都豁免,内核会去杀那些没豁免的,可能是你没预料到的进程。必须留明确的”可牺牲者”。

4. 给应用容器配置正确的”容器感知”(JVM UseContainerSupport / Go automaxprocs / 各语言的 cgroup 感知)

  • 防什么:应用不知道自己被关在多大的笼子里。JVM 若按宿主机 64G 算默认堆(1/4 = 16G),而容器 limit 是 2G,则 JVM 会毫不犹豫地把堆涨到远超 limit → 被 cgroup OOMKill,而且 JVM 自己毫无察觉(它以为内存还很宽裕,不触发 Full GC)。
  • 代价:几乎没有代价,这是纯粹的修正。
  • 这与第 05 章的 CFS throttling 是同一个病根:应用对自己的资源边界无知。

5. 为节点预留系统资源:kubelet 的 --system-reserved / --kube-reserved / --eviction-hard

  • 防什么:Pod 把节点内存吃到系统组件活不下去。预留出给 OS、kubelet、containerd 的份额,并设置驱逐阈值(如 memory.available<1Gi),让 kubelet 在真正 OOM 之前主动、有序地驱逐 Pod。
  • 机理:kubelet 驱逐是”优雅的”(按 QoS 和超用程度排序、发事件、重新调度),而内核 OOM 是”暴力的”(随机、无秩序)。用可控的主动驱逐,替代不可控的被动屠杀。
  • 代价:节点可分配容量下降(要留出预留量)。

6. 调整 vm.vfs_cache_pressure(可选,视负载)

  • 若节点上跑大量小文件操作(日志、CI),dentry/inode 缓存可能膨胀到十几 G。适度调高(如 200)让内核更积极回收这部分。
  • 代价:文件路径查找变慢。要先用 slabtop 确认 dentry/inode 真的是大头,别盲调。

7. 监控与告警(不是配置,但同等必要)

  • /proc/pressure/memory(PSI,尤其 full)、/proc/vmstat 的 allocstall_*、节点 MemAvailable、各容器的 container_memory_working_set_bytes 趋势、OOM 事件(区分 cgroup OOM 和系统 OOM)、kubelet 驱逐事件。
  • 理由:上面所有配置都是权衡,权衡是否合适只能靠数据验证。而且内存类故障(尤其 direct reclaim、glibc 碎片)在传统指标上”一切正常”,只有 PSI 和 vmstat 能暴露。

串起来的一句话:节点内存防御的核心思想不是”让内存永远够用”(做不到),而是——

① 把不可避免的失败,从”随机的全局灾难”收窄为”可预期的局部失败”(cgroup limit + 驱逐);② 保住关键的”抢救通道”(min_free_kbytes、系统预留、sshd/监控的豁免);③ 让应用知道自己的边界(容器感知);④ 保证出事时有现场可查(PSI + vmstat + OOM 事件)。