第 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。
墨:记牢这个类比。这一章后面所有的参数,本质都在回答两个问题:
- 银行敢超发多少?(
vm.overcommit_*) - 挤兑时挑谁下手、金库现金留多少缓冲?(
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。 -
为什么必须留这一块?——这是设计的精髓:
- 回收本身需要内存! 内核要写回脏页,可能需要分配 IO 请求结构、网络包缓冲(NFS 场景)。要是内存彻底为 0,连回收都做不了 → 死锁。这块预留就是”救援队自己的口粮”。
- 中断上下文不能等。网卡收包时在中断里要分配 skb,它不能睡眠、不能等回收。必须有现货。
- 原子分配(
GFP_ATOMIC)的保底。内核里很多路径不允许阻塞,只能从这块紧急储备里拿。
-
权衡(重要):
- 调高(比如从 90MB 调到 1GB):
- 得到:更早触发 kswapd 后台回收(因为 low/high 水位也跟着抬高),大幅降低跌破 min 触发 direct reclaim 的概率 → 延迟更平滑;给需要连续大块/原子分配的场景(网卡驱动高吞吐、大页)留足余地。
- 代价:这部分内存被”锁”起来当储备,应用永远用不到 → 可用内存变少;而且水位线抬高意味着回收得更早、更频繁,page cache 会被压得更小 → 文件缓存命中率下降,IO 变多。
- 调低:多出可用内存,但极易撞 direct reclaim,还可能在高吞吐网络场景下出现
page allocation failure。
- 调高(比如从 90MB 调到 1GB):
-
实战经验值:
- 通用服务器:默认值通常够。
- 高吞吐网络机器 / 大内存机器(>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 真正的价值场景:
- 把长期不用的匿名页赶走,换来更多 page cache。你的程序启动时初始化了一堆数据结构,之后再也没碰过。这些页占着内存纯浪费——swap 出去,腾出的内存给 page cache 用,整体 IO 反而变快。
- 给内存回收留一条退路。没有 swap 的机器,匿名页压根无处可去(文件页可以丢掉再从磁盘读,匿名页丢了数据就没了)。所以无 swap 时,内存压力下内核只能拼命回收 page cache 和 slab,回收压力全压在文件缓存上——page cache 被反复丢弃再读回,IO 放大。
- 缓冲突发。突发内存尖峰时,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,分最高的被杀。计算的核心因素:
- 占用内存越多,分越高(基础分基本就是”你占了多少 RSS + swap + 页表”的比例);
oom_score_adj(-1000 ~ +1000):人工干预项,直接加到分上。设 -1000 = 绝对豁免(永不被杀);- root 进程有轻微优惠(老内核有此逻辑);
- 注意:不看优先级、不看运行时长、不看”是否重要” ——内核不知道什么叫”重要”。
墨:所以有个经典的悲剧: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:架构层面——加保护和边界(治本)
- 给 Redis 上
oom_score_adj:echo -900 > /proc/$(pidof redis-server)/oom_score_adj。- 理由:Redis 是”最胖但最无辜”的,它被杀的代价(缓存全空、DB 被打穿)远大于杀掉一个 worker。把”可牺牲性”显式告诉内核。
- 同时给 worker 设
oom_score_adj = 500,让它当替死鬼(worker 挂了有重试,业务无感)。
- 给每个 worker 套 cgroup 内存 limit(如 800MB)。
- 理由:把”整机 OOM”变成”单个 worker 的 cgroup OOM”。前者是随机灾难,后者是可预期的局部失败,有日志、可自动重启。这是故障边界的收敛。
- 这也正是容器化的核心价值之一。
- 调高
vm.min_free_kbytes从默认 90MB 到 512MB。- 理由:给 kswapd 更多提前反应空间,减少在内存紧张时的 direct reclaim 尖刺。
- 代价:少 400 多 MB 可用内存,值。
- 加监控:
/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
四个教训:
- OOM 杀的是最胖的,不是肇事的。 别对着尸体查,要问”是什么让内存突然变紧了”。OOM 日志里那份按 rss 排序的进程表,是唯一可靠的案发现场。
- “改完好了一阵”是最危险的假象。 只要你没解释清楚”为什么会到那个数”,就说明根因还在,只是周期被拉长。必须追问到”这个数字合理吗”。
- RSS 和”应用层认为自己用了多少”之间的差额,是一个独立的调查方向。 差额可能藏在:glibc arena 碎片、JVM 的 Metaspace/直接内存/线程栈、Go 的
MADV_FREE未归还页、内存池、mmap的文件映射……这一层很多人压根不知道存在,查三天查不出来就是因为漏了这一层。 - 最有价值的改进往往是”收窄故障边界”和”加观测”,而不是”修那个 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_factor | low/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_mode | NUMA 本地 zone 不足时是否优先本地回收 | 非 0:保本地访问局部性 ↔ 可能无谓 swap 热数据(DB 灾难)。默认 0 通常正确 |
vm.panic_on_oom | OOM 时是否直接 panic | 1:干净失败 + 完整现场 ↔ 整机重启。适合集群节点/强一致系统 |
vm.oom_kill_allocating_task | OOM 时是否直接杀申请者 | 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 | 单进程被杀优先级 | 把”该死谁”的判断从内核收回自己手中 ↔ 豁免过头可能杀到系统关键进程 |
十、这章的认知钉子
malloc成功是一张空头合同。 内核在超发,在赌你不会用满。这个赌局的激进程度由overcommit_*决定。- overcommit 不是偷懒,是对”申请量远大于实际用量”这一现实的务实妥协(COW、稀疏使用、预留习惯)。禁掉它换来的是可预测性,代价是利用率。
free小不是问题,available小才是。 空闲内存对内核来说是浪费,它会拿去做 page cache。- 最隐蔽的延迟杀手是 direct reclaim —— 内核在你的调用栈上借你的 CPU 干回收和写盘。用
/proc/vmstat的allocstall/pgscan_direct和 PSI(/proc/pressure/memory)确诊,用min_free_kbytes+watermark_scale_factor预防。 - swap 的价值是”腾挪冷页”和”缓冲突发”,不是”救命”。 关 swap 换来的是故障模式的确定性(快速 OOM 而非慢性 thrashing)。看
si/so速率,不是看用量。 - 双 LRU(active/inactive)的设计意图是防止一次大扫描冲刷掉热缓存。 同样的思路遍布数据库缓冲池设计。
- 大内存机器上,所有”按比例”的参数都要重新审视(
dirty_ratio20% 在 256G 机器上意味着 51G 脏页,写回海啸)。改用*_bytes。 - OOM Killer 杀最胖的,不是最该死的。 查 OOM 要看那份按 rss 排序的进程表,并且警惕”被杀者≠肇事者”。用
oom_score_adj和 cgroup limit 把判断权和故障边界收回自己手里。 - 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)。
推演:
- 默认
vm.dirty_ratio = 20、vm.dirty_background_ratio = 10。在 256G 内存的机器上,这意味着脏页可以攒到 ~51G 才触发强制写回,~25G 才开始后台写回。 - 备份脚本疯狂
write(),数据先进 page cache 变成脏页,write()立刻返回,所以脚本自己跑得飞快,脏页迅速堆积。 - 脏页撞上
dirty_ratio(51G)时,内核开始强制阻塞写入进程并同步写回。假设磁盘 500MB/s,51G 要 ~100 秒才写完。 - 这期间:
- 磁盘队列被这几十 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 事件)。