目录

Linux-25 内核如何管理物理内存:buddy、slab、页回收与 OOM

24 篇讲的是「虚拟地址如何映射到物理页」。这一篇讲那些物理页本身是怎么被管理的 —— 从分配到回收到杀进程。

先看五个问题:

  1. buddy 分配器怎么对抗外部碎片?为什么它的分配单位必须是 2 的幂
  2. 既然有了 buddy,为什么还要 slab
  3. free 显示 free 只剩几百 MB,是不是内存快满了?为什么 available 才是关键?
  4. 内核怎么决定换出哪些页?refault 检测解决什么问题?
  5. OOM Killer 的评分函数为什么这么算?为什么它有时候「杀错」?

1. 物理内存的分层管理

   +--------------------------------------------------------------+
   |  各子系统的需求                                                |
   |  page cache | 匿名页 | 页表 | dentry/inode | task_struct | ... |
   +--------------------------------------------------------------+
        |                                    |
        | 需要【整页】(4KB 的倍数)             | 需要【小对象】(几十~几百字节)
        v                                    v
   +-------------------+            +----------------------------+
   |   buddy 分配器     | <--------- |   slab / slub 分配器        |
   |   (页级,2^n 页) |   向它批发  |   (对象级,按大小分池)      |
   +-------------------+            +----------------------------+
        |
        v
   +--------------------------------------------------------------+
   |  zone(内存区域):DMA / DMA32 / Normal / Movable              |
   +--------------------------------------------------------------+
        |
        v
   +--------------------------------------------------------------+
   |  NUMA node(物理内存节点)                                     |
   +--------------------------------------------------------------+

1.1 zone:为什么要给内存分区

因为不是所有物理内存都等价 —— 某些硬件只能访问低地址:

cat /proc/zoneinfo | grep -E '^Node|^  pages free' | head -12
# Node 0, zone      DMA
#   pages free     3968
# Node 0, zone    DMA32
#   pages free     512340
# Node 0, zone   Normal
#   pages free     1234567
zone 范围 存在理由
ZONE_DMA < 16MB 老式 ISA 设备只有 24 位地址线,只能 DMA 到低 16MB
ZONE_DMA32 < 4GB 32 位 DMA 设备(很多 PCI 设备)只能访问低 4GB
ZONE_NORMAL 其余 常规内存
ZONE_MOVABLE 可选 只放「可迁移」的页(为内存热插拔与大页规整服务
ZONE_HIGHMEM 仅 32 位系统 内核虚拟地址空间不够时的补丁(64 位系统已不存在

这个划分是硬件约束的直接产物(又一个 18 篇的例子)。它的实际后果是:内存不是一个整体,「还有 10GB 空闲」不代表「能满足一个 DMA32 的分配请求」

# 分配失败时的日志会明确指出是哪个 zone 不够
dmesg | grep -A 20 'page allocation failure'
# order:3, mode:0x2d20(GFP_ATOMIC|__GFP_COMP), nodemask=(null)
# Node 0 DMA32 free:12345kB min:16000kB low:20000kB high:24000kB
#                  ^^^^^^^^^^^^ free 低于 min 水位 -> 分配失败

1.2 NUMA:内存也有远近

numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 ... 
# node 0 size: 32768 MB
# node distances:
# node   0   1
#   0:  10  21          <- 访问本节点内存的"距离"是 10,跨节点是 21(约 2 倍延迟)
#   1:  21  10

numastat                              # 每个节点的分配统计
numastat -p $(pgrep -n myapp)         # 某进程的内存分布在哪些节点
# NUMA 的实际影响:跨节点访存慢约一倍
# 所以内核默认策略是「优先从本地节点分配」
cat /proc/sys/vm/zone_reclaim_mode     # 0 = 本地不够时去别的节点借(而不是先回收本地)

# 绑定策略(延迟敏感服务的常见调优)
numactl --cpunodebind=0 --membind=0 ./myapp     # CPU 和内存都绑在节点 0
numactl --interleave=all ./myapp                 # 交叉分配(吞吐型负载,避免单节点热点)

# ⚠️ Go 服务通常不需要手动绑,但要知道两个现象:
#   ① 大内存机器上,跨 NUMA 访问会让延迟分布变宽
#   ② 容器如果被调度到跨 NUMA 的 CPU 集合上,性能会有波动
#      (K8s 的 topologyManager 就是解决这个的)

2. buddy 分配器

2.1 开篇第一问:它要解决什么

buddy(伙伴)分配器负责「按页分配物理内存」,它的目标有两个而且互相冲突:

   目标 A:快速满足【连续多页】的请求
     内核经常需要连续物理页:
       - DMA 缓冲区(硬件按物理地址访问,必须连续)
       - 大页(2MB = 512 个连续页)
       - slab 的底层批发(一次要几页)
       - 内核栈(每个线程 16KB = 4 个连续页)

   目标 B:对抗【外部碎片】
     24 篇讲过分页消灭了「虚拟地址的外部碎片」,
     但【物理内存的连续性】仍然是稀缺资源 ——
     一台运行几个月的机器,可能有 10GB 空闲内存,
     却找不出 4 个连续的物理页。

2.2 算法:分裂与合并

   内核维护 11 个链表(order 0 ~ 10),order n 的链表挂着「2^n 个连续页」的块

   order 0: 4KB      order 1: 8KB      order 2: 16KB   ...   order 10: 4MB

   ── 分配(要 order 2 = 16KB)──
   ① 看 order 2 链表有没有空闲块 -> 有就直接给,结束
   ② 没有 -> 看 order 3(32KB)
   ③ order 3 有一块 -> 【分裂】成两个 order 2 的块
        一块给申请者,另一块挂到 order 2 链表
   ④ order 3 也没有 -> 继续向上找,逐级分裂

   ── 释放 ──
   ① 把块放回对应 order 的链表
   ② 检查它的【伙伴】(buddy)是否也空闲
   ③ 如果是 -> 【合并】成一个更大的块,升到上一级
   ④ 递归向上合并

2.3 为什么必须是 2 的幂

这是 buddy 算法的精髓:2 的幂让「找伙伴」变成一次位运算。

   两个块是「伙伴」的条件:
     ① 大小相同(同一 order)
     ② 物理地址连续
     ③ 【对齐】到 2 倍大小的边界

   于是给定一个块的起始页帧号 pfn 和 order,它的伙伴是:
     buddy_pfn = pfn ^ (1 << order)
                     ^^^ 一次 XOR 就算出来了!O(1)

   例:order 1(2 页)的块,起始 pfn = 4
       buddy_pfn = 4 ^ 2 = 6      -> 伙伴是 pfn 6 开始的那 2 页
       合并后:pfn 4 开始的 4 页(order 2),且【天然对齐到 4 的边界】✅

   如果不用 2 的幂:
     ✗ 找伙伴要遍历或查表(不再是 O(1))
     ✗ 合并后的块可能不对齐 -> 无法继续向上合并
     ✗ 分裂的结果大小不一致 -> 链表管理复杂化
# 观察每个 order 的空闲块数量
cat /proc/buddyinfo
# Node 0, zone   Normal  1234  567  234  89  34  12  5  2  1  0  0
#                        ^^^^  ^^^  ^^^ ...                    ^^ ^^
#                      order 0        ...                    order 9,10
# ⚠️ 高 order 全是 0 = 【内存碎片化严重】,无法分配大块连续内存
#    此时即使 free 显示还有很多内存,大页分配和某些驱动的 DMA 分配也会失败

# 换个更直观的算法
awk 'NR>1 {printf "%s %s: ", $2, $4; for(i=5;i<=NF;i++) printf "%s ", $i; print ""}' /proc/buddyinfo

# 碎片化指数(0~1000,越高越碎)
sudo cat /sys/kernel/debug/extfrag/extfrag_index 2>/dev/null
sudo cat /sys/kernel/debug/extfrag/unusable_index 2>/dev/null

2.4 碎片化与内存规整

# 手动触发内存规整(compaction):移动页来腾出连续空间
echo 1 | sudo tee /proc/sys/vm/compact_memory
cat /proc/vmstat | grep -E 'compact_'
# compact_migrate_scanned 1234567     扫描了多少页
# compact_free_scanned    2345678
# compact_isolated        123456
# compact_stall           89          ✅ 有多少次分配【被阻塞】等待规整
# compact_fail            12          规整失败次数

# ⚠️ compact_stall 就是 24 篇 7.2 讲的 THP 延迟尖刺的来源:
#    分配大页时若找不到连续空间,会同步触发规整 -> 进程阻塞几百毫秒

# 内核如何减少碎片:按「可迁移性」分组
cat /proc/pagetypeinfo | head -15
# Free pages count per migrate type at order  0  1  2 ...
# Node 0, zone Normal, type    Unmovable  ...        <- 内核数据(不能移动)
# Node 0, zone Normal, type      Movable  ...        <- 用户页、page cache(可移动)
# Node 0, zone Normal, type  Reclaimable  ...        <- 可回收(如 dentry 缓存)
# 思路:把「不能移动的页」聚在一起,避免它们零散地打断大块连续区域
#      -> 这样规整时才有腾挪空间

2.5 分配失败的表现

# order 越高越容易失败
dmesg | grep -B2 -A20 'page allocation failure'
# myapp: page allocation failure: order:4, mode:0x40cc0(GFP_KERNEL|__GFP_COMP)
#                                        ^^^^^^^ order 4 = 64KB 连续
# 常见受害者:
#   - 网卡驱动分配大的接收缓冲(jumbo frame)
#   - 某些文件系统的元数据操作
#   - THP 分配(但它会静默回退到 4KB 页)

# ✅ 这就是为什么内核代码里高 order 分配要么用 kvmalloc(失败时回退到 vmalloc),
#    要么必须能容忍失败
# vmalloc 用【虚拟地址连续但物理地址不连续】的方式绕开碎片问题
grep VmallocUsed /proc/meminfo

3. slab/slub:小对象分配

3.1 开篇第二问:为什么 buddy 不够

   buddy 的最小分配单位是【一整页】(4KB)。
   但内核里绝大多数对象小得多:

     struct task_struct     ~ 8KB      (每个进程一个)
     struct file            ~ 256 字节  (每个打开的文件一个,20 篇)
     struct dentry          ~ 192 字节  (每个路径名一个,20 篇 4.1)
     struct inode           ~ 600 字节
     struct sk_buff         ~ 240 字节  (每个网络包一个!33 篇)
     struct kmalloc-32/64/128/...       (通用小块)

   如果每个 dentry 都用一整页:
     ✗ 浪费 95% 的内存(192 字节 vs 4096 字节)
     ✗ 而 dcache 里可能有几百万个 dentry -> 完全不可行

slab 的三个设计目标

   ① 减少内部碎片
      一页里塞满同类对象(4096 / 192 = 21 个 dentry 挤在一页里)

   ② 缓存已初始化的对象
      「slab」这个名字来自 Solaris 的原始论文:
      对象释放后不立即归还内核,而是【保留在池子里】,
      下次分配时直接复用 —— 省掉了初始化开销(构造函数只跑一次)

   ③ 缓存友好
      同类对象紧凑排列 -> 遍历时 cache 命中率高
      早期的 slab 还做「着色」(cache coloring):给每个 slab 加不同的偏移,
      避免同一 cache set 被反复冲突

3.2 slab / slub / slob

   slab(1994,源自 Solaris)
     功能完整但【元数据开销大】、每 CPU 队列复杂
        |
   slub(2007,✅ 现在的默认)
     大幅简化:去掉了大部分队列,用 page 结构体本身存元数据
     更好的多核扩展性、更少的内存开销
        |
   slob(极简,用于嵌入式)
     只有几百行,牺牲性能换体积
grep -o 'slub_debug\|CONFIG_SLUB' /boot/config-$(uname -r) | head -2

3.3 观察与排查

# ✅ slabtop:按占用排序(内核内存排查的第一工具)
sudo slabtop -o | head -15
#  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
# 891234 891234 100%    0.19K  42440       21    169760K dentry
# 456789 456789 100%    0.58K  16314       28    261024K ext4_inode_cache
# 234567 234567 100%    0.10K   6015       39     24060K buffer_head
#                             ^^^^^^^^^^^ 每个对象的大小  ^^^^^^^^^^^ 总占用

# 原始数据
sudo head -3 /proc/slabinfo
awk 'NR>2 {print $1, $2, $3, $4}' /proc/slabinfo | sort -k4 -rn | head

# 系统级的 slab 总量
grep -E 'Slab|SReclaimable|SUnreclaim' /proc/meminfo
# Slab:            1234567 kB
# SReclaimable:    1000000 kB      <- ✅ 可回收(dentry/inode 缓存,20 篇 4.1)
# SUnreclaim:       234567 kB      <- ⚠️ 不可回收(task_struct、页表等)
#                                     这部分持续增长 = 内核内存泄漏

内核内存泄漏的排查(应用侧看不到,ps 的 RSS 里也没有):

# 现象:free 显示内存不断减少,但所有进程的 RSS 加起来对不上
# ① 先确认是不是内核内存
grep -E 'MemTotal|MemAvailable|Slab|SUnreclaim|KernelStack|PageTables|VmallocUsed' /proc/meminfo
ps -eo rss --no-headers | awk '{s+=$1} END {print "所有进程 RSS 合计: " s/1024 " MB"}'
# 两者差距很大 -> 内核内存(slab / 页表 / vmalloc / 网络缓冲)

# ② 找出增长的 slab(对比两个时间点)
sudo slabtop -o | head -20 > /tmp/slab1; sleep 300
sudo slabtop -o | head -20 > /tmp/slab2; diff /tmp/slab1 /tmp/slab2

# ③ 常见的「不是泄漏」的增长
#   dentry / inode_cache 大 -> 正常(20 篇 4.1,可回收,内存压力下自动释放)
#   验证:echo 2 > /proc/sys/vm/drop_caches 之后应该明显下降

# ④ 真正的内核泄漏(需要 CONFIG_DEBUG_KMEMLEAK)
echo scan | sudo tee /sys/kernel/debug/kmemleak
sudo cat /sys/kernel/debug/kmemleak | head -30

4. page cache 与内存回收

4.1 开篇第三问:free 少不是问题

free -h
#               total        used        free      shared  buff/cache   available
# Mem:           15Gi       4.2Gi       231Mi       128Mi        11Gi        10Gi
#                                       ^^^^^                   ^^^^^        ^^^^
#                                     只剩 231MB?          11GB 缓存    实际可用 10GB

「free 很少」是健康的表现,不是问题。 因为空闲内存是浪费的内存 —— 内核会把所有暂时不用的内存拿去做 page cache(20 篇讲过它在 read() 路径里的作用),需要时再回收。

   available 的计算(内核在 /proc/meminfo 里直接给出):
     ≈ free
     + 【可回收的】page cache(不含脏页与被 mmap 锁住的)
     + 可回收的 slab(dentry/inode 缓存)
     - 各 zone 的 min 水位(必须保留的应急储备)

   -> 所以 available 才是「应用还能拿到多少内存而不触发换页」的答案
# ✅ 正确的内存健康判断
awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END {printf "available: %.1f%% (%.1f GB / %.1f GB)\n", a/t*100, a/1048576, t/1048576}' /proc/meminfo
# available: 65.3%      <- 健康

# ⚠️ 更灵敏的指标不是「剩多少」,而是「有没有在换页」
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io----
#  r  b   swpd   free   buff  cache   si   so    bi    bo
#  1  0      0 236892  12345 1234567   0    0  4096   512
#                                      ^^   ^^ ✅ si/so 都是 0 = 健康
#  2  3 524288  98765   1234  456789  45  120  8192  2048
#                                      ^^  ^^^ ❌ 正在换页 = 内存真的不够了

4.2 双 LRU:为什么需要两个链表

内核为每个 zone 维护四条 LRU 链表(匿名页和文件页各有 active/inactive 两条):

grep -E 'Active|Inactive' /proc/meminfo
# Active:          6234567 kB
# Inactive:        4567890 kB
# Active(anon):    3456789 kB      <- 活跃的匿名页(堆栈)
# Inactive(anon):   234567 kB
# Active(file):    2777778 kB      <- 活跃的文件页(page cache)
# Inactive(file):  4333323 kB

为什么不用单一 LRU? 因为一次大扫描会冲掉所有热数据

   场景:数据库正在服务请求(热数据都在 cache 里),
        此时有人执行 `tar -czf backup.tar.gz /data`(顺序读 100GB)

   单 LRU 的后果:
     那 100GB 的数据依次进入 LRU 头部,把数据库的热数据全部挤到尾部并淘汰
     -> 备份跑完后,数据库的缓存命中率归零,性能断崖式下跌
     -> 而那 100GB 数据【只被读了一次】,留在缓存里毫无价值
     这个现象叫「cache 污染」或「scan resistance 缺失」

   双 LRU 的解法:
     ① 新读入的页先进【inactive】链表
     ② 只有【被再次访问】才提升到 active 链表
     ③ 回收时【优先从 inactive 尾部】拿
     -> 顺序扫描的数据只会在 inactive 里进出,碰不到 active 里的热数据 ✅
   ┌─────────────── active list ───────────────┐
   │  热数据(被访问过 ≥2 次)                    │  <- 回收时最后才动
   └───────────────────────────────────────────┘
              ↑ 第二次访问时提升         ↓ 长期不访问时降级
   ┌─────────────── inactive list ─────────────┐
   │  新读入的页 / 冷数据                        │  <- ✅ 回收从这里的尾部开始
   └───────────────────────────────────────────┘
                                     ↓ 淘汰

4.3 开篇第四问:refault 检测

双 LRU 还有一个缺陷:怎么知道 inactive 链表的长度是否合适?

   问题:如果 inactive 太小,真正的热数据可能在「第二次访问之前」就被淘汰了
        -> 这个页很快又被读回来(refault)
        -> 说明它其实是热的,只是没来得及被提升

   这种「刚淘汰就又被读回来」的现象叫 refault(重新缺页),
   它是【缓存容量不足】的直接信号 —— 但内核在 4.20 之前无法区分:
     「这是第一次读这个页」 vs 「这个页刚被我淘汰又回来了」

Linux 4.20 引入的 refault 检测(workingset):淘汰一个页时,记录当时的「LRU 时钟」(一个全局的淘汰计数器),存在 page cache 的 radix tree 里(用一个特殊的 shadow entry)。

   页被再次读入时:
     ① 发现有 shadow entry -> 这是一次 refault
     ② 计算「距离」= 当前 LRU 时钟 - 记录的时钟
        = 这个页被淘汰后,又有多少页被淘汰了
     ③ 如果距离 < active 链表的长度
        -> 说明「如果 inactive 再大一点,这个页本来不会被淘汰」
        -> ✅ 直接把它放进 active 链表(承认它是热的)
        -> 并且【缩小 active 的目标大小】,给 inactive 更多空间
# 观察 refault
grep -E 'workingset_refault|workingset_activate|workingset_restore' /proc/vmstat
# workingset_refault_file   1234567      <- 文件页的 refault 次数
# workingset_activate_file   234567      <- 其中被判定为「本该是热的」而直接激活的
# workingset_refault_anon     12345
# ⚠️ refault 持续高增长 = 【内存不足导致缓存反复失效】
#    这比「free 少」灵敏得多,是真正的内存压力信号

# 每个 cgroup 也有(容器场景)
cat /sys/fs/cgroup/myapp/memory.stat | grep workingset

这个机制也是 PSI 指标(第 5 章)的数据基础之一,它让「内存压力」第一次变得可量化。

4.4 回收的触发:水位线与两条路径

cat /proc/zoneinfo | grep -A 4 'Node 0, zone   Normal' | head -6
#   pages free     1234567
#         min      16000        <- ❌ 低于它:分配会阻塞/失败,触发【直接回收】
#         low      20000        <- ⚠️ 低于它:唤醒 kswapd【后台回收】
#         high     24000        <- ✅ kswapd 回收到这里就停
   两条回收路径,性能差异巨大:

   ① kswapd(后台回收)—— ✅ 好
        free 降到 low 水位时被唤醒,在后台异步回收,回到 high 水位才停
        对应用【无感知】

   ② 直接回收(direct reclaim)—— ❌ 坏
        free 降到 min 水位时,【申请内存的那个进程自己去做回收】
        -> 进程在 alloc_pages() 里【同步阻塞】,可能要几十到几百毫秒
        -> 表现为「应用突然卡一下」,而且极难定位
        -> 这是内存类长尾延迟的头号原因
# 观察直接回收(这个指标非常重要)
grep -E 'pgscan_direct|pgsteal_direct|allocstall' /proc/vmstat
# allocstall_normal   1234      <- ✅ 有多少次分配【被迫等待直接回收】
#                                   持续增长 = 内存压力大,应用会有延迟尖刺
grep -E 'pgscan_kswapd|pgsteal_kswapd' /proc/vmstat      # 后台回收的量

# 调优:提高水位线,让 kswapd 更早开始工作(用空间换延迟稳定性)
sysctl vm.min_free_kbytes                 # 默认按内存大小计算
sudo sysctl -w vm.min_free_kbytes=1048576  # 提到 1GB,给 kswapd 更多缓冲
# ⚠️ 设太大会浪费内存,设太小会频繁触发直接回收

# 观察 kswapd 是否忙不过来
ps -eo pid,comm,pcpu | grep kswapd
# kswapd 持续占用 CPU = 回收压力大

4.5 swappiness 的真实含义

cat /proc/sys/vm/swappiness       # 60(默认)

它不是「多久开始用 swap」,而是「回收时在『换出匿名页』和『丢弃文件页』之间的权重」(11 篇 7.2 提过):

   内核回收内存时有两个选择:
     A. 换出匿名页(堆、栈)-> 必须写到 swap(有 I/O 开销,且换回来也要 I/O)
     B. 丢弃文件页(page cache)-> 干净页直接丢(零成本),脏页要先写回

   swappiness 就是这两者的权重:
     0    -> 几乎只丢文件页(但【不等于禁用 swap】,内存极度紧张时仍会换)
     60   -> 默认平衡
     100  -> 两者同等对待
     200  -> 更倾向换出匿名页(内核 5.8+ 允许超过 100)

   实践:
     数据库/缓存服务(数据在匿名内存里,且延迟敏感)-> 1~10
     文件服务器(page cache 就是价值所在)-> 保持默认或调高
     K8s 节点 -> 直接关 swap(11 篇 7.3 讲过三个理由)

5. PSI:内存压力的量化

5.1 load average 的不足

13 篇讲过 load average 混合了「等 CPU」和「等 IO」两种瓶颈。它还有一个更大的问题:完全看不到内存压力。

   一台机器内存紧张时会发生:
     - 大量直接回收(进程同步阻塞)
     - 频繁的 refault(缓存反复失效)
     - swap 换入换出
   但这些可能【完全不体现在 load average 上】:
     进程只是在 alloc_pages() 里等了 50ms,既不算 R 也不算 D

5.2 PSI 的设计

PSI(Pressure Stall Information,内核 4.20+)直接测量「因为资源不足而损失的时间比例」:

cat /proc/pressure/memory
# some avg10=0.15 avg60=0.08 avg300=0.02 total=12345678
# full avg10=0.00 avg60=0.00 avg300=0.00 total=234567
cat /proc/pressure/cpu
cat /proc/pressure/io
指标 含义
some 至少有一个任务因为该资源而停滞的时间占比
full 所有任务都在因该资源停滞的时间占比(= 完全无法推进工作)
avg10/60/300 10 秒 / 60 秒 / 300 秒的移动平均(百分比)
total 累计微秒数(自己算速率更精确)
   为什么分 some 和 full?
     some 高但 full 低  -> 部分任务受影响,系统整体还在推进(可接受)
     full 高            -> ❌ 系统在【空转】,所有任务都在等内存
                            这是「内存不足」最明确的信号

   PSI 相对 load average 的优势:
     ✅ 直接量化「损失了多少工作时间」,语义明确
     ✅ 区分 CPU / 内存 / IO 三种资源
     ✅ 支持 cgroup 级别(能定位到具体容器)
     ✅ 支持 poll(可以做事件驱动的告警)
# cgroup 级别(容器场景的关键)
cat /sys/fs/cgroup/myapp.service/memory.pressure
cat /sys/fs/cgroup/myapp.service/io.pressure
systemctl status myapp | grep -i pressure           # systemd 也会展示

# 实用的告警阈值(经验值)
# memory full avg60 > 1%   -> 值得关注
# memory full avg60 > 10%  -> 严重,应用已经在明显受损
awk '/^full/{split($2,a,"="); if (a[2]+0 > 1) print "内存压力告警: " $0}' /proc/pressure/memory

# 事件驱动监听(Facebook 的 oomd、systemd-oomd 就基于它)
systemctl status systemd-oomd
cat /etc/systemd/oomd.conf
# ✅ systemd-oomd 的价值:在【内核 OOM Killer 出手之前】,
#    根据 PSI 主动杀掉压力最大的 cgroup —— 比内核 OOM 更可控、更早介入

6. OOM Killer

6.1 它为什么必须存在

OOM Killer 是 overcommit 的必然结果。

   内核允许 overcommit(承诺的虚拟内存 > 物理内存 + swap),因为:
     - 大部分程序申请的内存远多于实际使用(24 篇的按需分页)
     - fork 的 COW 意味着「承诺了双份但实际共享」(21 篇)
     - 如果严格禁止 overcommit,内存利用率会极低

   但 overcommit 是一张【可能兑付不了的空头支票】:
     当所有进程真的开始写入它们申请的内存时,物理内存会耗尽。
     
   此时内核只有三个选择:
     ① 让分配失败 -> 但很多分配路径【无法处理失败】(比如缺页时已经无法回退)
     ② 无限阻塞等待 -> 系统完全僵死(比崩溃更糟,连 ssh 都进不去)
     ③ 【杀掉一个进程】腾出内存 -> OOM Killer

6.2 开篇第五问:评分函数

// mm/oom_kill.c(简化)
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;
    // 核心:按【占用内存的比例】计分
    points = get_mm_rss(p->mm)                    // RSS(13 篇 2.3)
           + get_mm_counter(p->mm, MM_SWAPENTS)   // 被换出的部分
           + mm_pgtables_bytes(p->mm) / PAGE_SIZE;// ✅ 连页表也算进去(24 篇 3.5)

    // oom_score_adj 的影响:按 totalpages 的千分比调整
    points += p->signal->oom_score_adj * totalpages / 1000;

    return points;
}
// 最终的 oom_score 是把它归一化到 0~1000

为什么按「占用比例」而不是「绝对值」或别的标准?

   ① 目标是「杀一个就能解决问题」
      杀掉占用最多的进程,最有可能一次性缓解内存压力。
      如果杀小进程,可能杀了十个还不够 —— 那就制造了十次故障而不是一次。

   ② 为什么不按「运行时间」或「优先级」?
      因为 OOM 是【紧急情况】,评分必须简单快速且可预测。
      复杂的启发式(比如「这个进程更重要」)内核无法判断 ——
      「重要性」是策略,属于用户态(18 篇的机制与策略分离)
      -> 所以内核只提供机制(按占用打分)+ 一个策略旋钮(oom_score_adj)

   ③ 为什么把【页表】也算进去?
      因为一个进程可能 RSS 不大但页表巨大(大量稀疏映射),
      那些页表同样是不可回收的内核内存(24 篇 3.5)

   ④ 为什么不杀内核线程和 init?
      杀了它们系统直接完蛋(19 篇 6.1 的 PID 1 规则)
# 查看每个进程的 OOM 评分
for p in /proc/[0-9]*; do
  s=$(cat $p/oom_score 2>/dev/null)
  [[ -n $s && $s -gt 0 ]] && echo "$s ${p##*/} $(cat $p/comm 2>/dev/null)"
done | sort -rn | head -8
#  ^^ 分数最高的就是下一个受害者

cat /proc/$(pgrep -n myapp)/oom_score       # 0~1000
cat /proc/$(pgrep -n myapp)/oom_score_adj   # -1000 ~ 1000(可调)

# 保护关键进程
echo -1000 | sudo tee /proc/$(pgrep -n myapp)/oom_score_adj    # 几乎永不被选中
# systemd 里:OOMScoreAdjust=-500(10 篇 8 章的加固项之一)

# 让非关键的批处理优先被杀
echo 500 | sudo tee /proc/$(pgrep -n batchjob)/oom_score_adj

6.3 两层 OOM:cgroup vs 全局

   ① cgroup OOM(容器超限)
        触发:cgroup 的内存用量达到 memory.max
        范围:【只杀这个 cgroup 内】的进程
        观察:/sys/fs/cgroup/<path>/memory.events 的 oom_kill 计数

   ② 全局 OOM(整机内存耗尽)
        触发:所有 zone 都无法满足分配且回收无效
        范围:可能杀【任何】进程
        观察:dmesg / journalctl -k
# ── 容器场景的完整排查 ──
# ① 容器退出码 137 = 128 + 9(SIGKILL)(01 篇 6.3 讲过)
docker inspect mycontainer --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
# 137 true
kubectl describe pod mypod | grep -A3 'Last State'
#   Reason: OOMKilled
#   Exit Code: 137

# ② cgroup 的 OOM 计数
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
# low 0
# high 234              <- 超过 memory.high 的次数(触发了激进回收)
# max 12                <- 达到 memory.max 的次数
# oom 3                 <- 发生过 3 次 OOM
# oom_kill 3            <- ✅ 杀了 3 个进程

# ③ 看内存构成,判断是谁在占(这一步最关键)
cat /sys/fs/cgroup/system.slice/myapp.service/memory.stat | grep -E '^(anon|file|slab|sock|kernel)'
# anon 3221225472        <- ✅ 匿名页(堆栈),【不可回收】,主要嫌疑
# file 838860800         <- page cache,可回收
# slab 104857600         <- 内核对象
# sock 8388608           <- socket 缓冲区(连接多时会很大)

# ④ 内核日志里的完整现场
sudo dmesg -T | grep -A 25 'Out of memory'
# Out of memory: Killed process 12345 (java) total-vm:8388608kB,
#   anon-rss:7340032kB, file-rss:0kB, shmem-rss:0kB, UID:1000
#   pgtables:14848kB oom_score_adj:0
#            ^^^^^^^^^^ 注意:页表 14MB 也被计入了评分
# 前面还会 dump 当时【所有进程】的内存占用 —— 这是判断「是否杀对了」的依据
sudo journalctl -k | grep -i -A 25 'out of memory'

6.4 为什么 OOM Killer 有时「杀错」

   典型抱怨:「我的数据库被杀了,而那个吃内存的批处理还在跑」

   原因分析:
     ① 评分只看【当前占用】—— 数据库的 buffer pool 就是它的核心,
        自然占用最多,于是它得分最高
        -> ✅ 解法:给它设 oom_score_adj=-500,同时给批处理设正值

     ② cgroup OOM 只在【该 cgroup 内】选择
        -> 如果数据库和批处理在同一个 cgroup,评分仍按占用比

     ③ 内核无法知道「业务重要性」(这是策略,18 篇)
        -> ✅ 正确的解法不是抱怨内核,而是:
             - 用 cgroup 给不同服务【硬隔离】内存额度(memory.max)
             - 用 memory.high 让某些服务在超限时先被限速而不是被杀
             - 用 systemd-oomd / oomd 在内核出手前按 PSI 做更聪明的选择
             - 给关键服务设 OOMScoreAdjust
# 更好的做法:用 memory.high 做「软限制」
# [Service]
# MemoryHigh=1500M          <- 超过就激进回收 + 限速(进程变慢但不死)
# MemoryMax=2000M           <- 硬上限(超过才 OOM)
# ✅ 这给了应用一个「减速带」,而不是直接的悬崖
systemctl show myapp -p MemoryHigh -p MemoryMax

7. overcommit 策略

cat /proc/sys/vm/overcommit_memory
名称 行为
0 启发式(默认) 内核估算,明显不合理的大额申请会被拒绝(比如一次申请超过总内存)
1 总是允许 不做任何检查,来者不拒
2 严格 总承诺量不得超过 swap + 物理内存 × overcommit_ratio
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
# CommitLimit:     9876543 kB      <- 模式 2 下的上限
# Committed_AS:   12345678 kB      <- ✅ 当前已承诺的总量(可以超过 CommitLimit!)
sysctl vm.overcommit_ratio          # 50(默认)

为什么 Redis 要求 overcommit_memory=1(21 篇 3.3 提过):

   Redis 的 BGSAVE 用 fork 做快照。fork 时内核在【启发式模式(0)】下会估算:
     「子进程可能需要与父进程等量的内存」
     -> 如果 Redis 占了 60% 的内存,这个估算会认为「不够」-> fork 失败(ENOMEM)
     -> BGSAVE 无法执行,持久化中断(Redis 会在日志里报错)

   但实际上因为 COW,子进程几乎不会真的用那么多内存。
   -> 所以要设 overcommit_memory=1,让 fork 无条件成功

   代价:真的内存不够时,问题会推迟到「写入内存的那一刻」才暴露(触发 OOM)
        -> 这是一个有意识的取舍:用「可能被 OOM」换「fork 一定成功」
# Redis 的官方要求
sudo sysctl -w vm.overcommit_memory=1
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-redis.conf
redis-cli info persistence | grep -E 'rdb_last_bgsave_status|rdb_bgsave_in_progress'
# rdb_last_bgsave_status:err     <- 如果是 err,检查 overcommit 与内存

8. Go 视角

# Go 服务的内存问题排查顺序(把本篇的工具串起来)

# ① 先确认是应用内存还是内核内存
grep -E 'MemAvailable|Slab|SUnreclaim|PageTables' /proc/meminfo
ps -eo rss --no-headers | awk '{s+=$1} END {print s/1024 " MB"}'

# ② 确认是 Go 堆内还是堆外(13 篇 10.2 的对照表)
curl -s localhost:6060/debug/pprof/heap > heap.prof
go tool pprof -top heap.prof | head
# 对比 runtime.MemStats 的 Sys 与进程 RSS:
#   Sys ≈ RSS 且 HeapAlloc 小 -> 堆外(cgo/mmap/goroutine 栈)
curl -s 'localhost:6060/debug/pprof/heap?debug=1' | grep -E '^# (Sys|HeapAlloc|HeapSys|HeapReleased|StackSys) '

# ③ 确认有没有内存压力(本篇的核心指标)
cat /proc/pressure/memory                       # PSI
grep -E 'workingset_refault|allocstall' /proc/vmstat    # refault 与直接回收
vmstat 1 3                                       # si/so

# ④ 容器场景
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events | grep -E 'high|max|oom'
cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file|slab|sock)'
// 三个必须设的(12/24 篇讲过)
// GOMEMLIMIT=1800MiB       让 GC 感知容器上限(否则必然 OOMKilled)
// GOGC=100 或按需调整
// automaxprocs             让 GOMAXPROCS 感知 cgroup CPU 限额

// 主动降低内存占用的手段
debug.SetMemoryLimit(1800 << 20)     // 等价于 GOMEMLIMIT,可运行时调整
debug.SetGCPercent(50)                // 更激进的 GC(用 CPU 换内存)
debug.FreeOSMemory()                  // 立即归还(24 篇 8.2)

一条重要的经验:容器里 MemoryHighMemoryMax 更有价值。只设 MemoryMax 时,应用是「一路正常然后突然被杀」;配上 MemoryHigh 后,超限时先触发激进回收和限速,Go 的 GC 有机会响应压力 —— 加上 GOMEMLIMIT,通常能避免真正的 OOM。

9. 面试题

Q:buddy 分配器为什么必须用「2 的幂」作为分配单位?

因为2 的幂让「找伙伴」变成一次位运算。两个块是伙伴的条件是「大小相同 + 地址连续 + 对齐到 2 倍大小的边界」,于是给定起始页帧号和 order,伙伴的位置就是 buddy_pfn = pfn ^ (1 << order) —— 一次 XOR,O(1)

如果不用 2 的幂:找伙伴要遍历或查表(不再是 O(1))、合并后的块可能不对齐(无法继续向上合并)、分裂结果大小不一致导致链表管理复杂化。

buddy 要同时满足两个冲突的目标:快速满足连续多页的请求(DMA 缓冲、大页、内核栈都需要物理连续)和对抗外部碎片。它维护 order 0~10 共 11 个链表,分配时逐级向上找并分裂,释放时递归向上合并。

关键认知是:物理内存的连续性是稀缺资源 —— 一台运行几个月的机器可能有 10GB 空闲内存却找不出 4 个连续物理页。cat /proc/buddyinfo 里高 order 全是 0 就是这个状态,此时大页分配和某些驱动的 DMA 分配会失败。

Q:有了 buddy 为什么还要 slab?

因为 buddy 的最小单位是一整页(4KB),而内核对象小得多dentry 约 192 字节、file 约 256 字节、sk_buff 约 240 字节。dcache 里可能有几百万个 dentry,如果每个占一页就是 95% 的浪费。

slab 的三个设计目标:减少内部碎片(一页塞 21 个 dentry)、缓存已初始化的对象(释放后留在池里,下次直接复用,省掉初始化 —— 这是 “slab” 这个名字的来源)、缓存友好(同类对象紧凑排列,早期还做 cache coloring 避免冲突)。

现在的默认实现是 slub(2007),它相对原始 slab 大幅简化了元数据和队列,多核扩展性更好。

排查用 slabtop -o(按占用排序)和 /proc/meminfoSReclaimable(dentry/inode 缓存,可回收,大是正常的)vs SUnreclaim(不可回收,持续增长才是内核内存泄漏)。

Q:free 显示 free 只剩 231MB,内存是不是快满了?

不是,这是健康状态。 空闲内存是浪费的内存 —— 内核会把暂时不用的内存全部拿去做 page cache,需要时再回收。

available 才是答案:它约等于 free + 可回收的 page cache + 可回收的 slab - 各 zone 的 min 水位,表示「应用还能拿到多少内存而不触发换页」。

更灵敏的指标不是「剩多少」,而是「有没有在换页和回收」

  • vmstat 1si/so(换入换出)—— 非 0 就是内存真的不够
  • /proc/vmstatallocstall_* —— 有多少次分配被迫等待直接回收
  • /proc/vmstatworkingset_refault_* —— 缓存反复失效
  • /proc/pressure/memoryPSI

Q:为什么页回收要用「双 LRU」而不是单一 LRU?

为了抵抗一次性大扫描造成的 cache 污染。

场景:数据库热数据都在 page cache 里,此时有人 tar -czf backup.tar.gz /data(顺序读 100GB)。单 LRU 下这 100GB 会依次进入链表头部,把数据库的热数据全部挤出去淘汰 —— 而那 100GB 只被读了一次,留在缓存里毫无价值。备份跑完后数据库缓存命中率归零。

双 LRU 的解法:新读入的页先进 inactive 链表,只有被再次访问才提升到 active;回收时优先从 inactive 尾部拿。于是顺序扫描的数据只在 inactive 里进出,碰不到 active 里的热数据。

内核实际维护四条链表(匿名页和文件页各有 active/inactive),因为这两类页的回收成本完全不同(匿名页必须写 swap,干净的文件页可以直接丢)。

Q:refault 检测解决什么问题?

它补上了双 LRU 的一个缺陷:怎么知道 inactive 链表的长度够不够

如果 inactive 太小,真正的热数据可能在「第二次访问之前」就被淘汰,然后很快又被读回来。内核在 4.20 之前无法区分「第一次读这个页」和「这个页刚被我淘汰又回来了」。

Linux 4.20 的做法:淘汰页时把当时的全局 LRU 时钟记在 page cache 的 shadow entry 里。页被再次读入时,计算「距离 = 当前时钟 - 记录的时钟」(= 它被淘汰后又有多少页被淘汰)。如果距离小于 active 链表的长度,说明「inactive 再大一点它本来不会被淘汰」→ 直接把它放进 active,并缩小 active 的目标大小给 inactive 让空间。

观察 grep workingset /proc/vmstat —— workingset_refault 持续高增长是「内存不足导致缓存反复失效」的明确信号,比「free 少」灵敏得多。它也是 PSI 的数据基础之一。

Q:kswapd 回收和「直接回收」有什么区别?为什么后者是延迟尖刺的来源?

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

  • free < low → 唤醒 kswapd 后台异步回收,回到 high 才停 —— 应用无感知
  • free < min直接回收(direct reclaim):申请内存的那个进程自己去做回收,在 alloc_pages()同步阻塞,可能几十到几百毫秒 ❌

直接回收是内存类长尾延迟的头号原因,而且极难定位(应用侧只看到「某次请求突然慢了」)。观察指标是 /proc/vmstatallocstall_*

缓解手段是提高 vm.min_free_kbytes,让 kswapd 更早开始工作 —— 用一点内存换延迟稳定性。同时检查 kswapd 是否持续占 CPU(说明回收压力已经超出它的处理能力)。

Q:PSI 相比 load average 好在哪?

load average 有两个问题:混合了「等 CPU」和「等 IO」(13 篇讲过),以及完全看不到内存压力 —— 进程在 alloc_pages() 里等 50ms 既不算 R 也不算 D。

PSI 直接测量「因为资源不足而损失的时间比例」,分 CPU/内存/IO 三种资源,每种给两个数:

  • some = 至少有一个任务因该资源停滞的时间占比
  • full = 所有任务都在停滞的占比(系统在空转)

some 高但 full 低表示部分任务受影响、系统整体还在推进;full 高就是明确的资源不足信号。经验阈值是 memory full avg60 > 1% 值得关注、> 10% 已经严重受损。

它还支持 cgroup 级别(能定位到具体容器)和 poll(事件驱动告警)—— systemd-oomd/Facebook 的 oomd 就基于它,在内核 OOM Killer 出手之前主动杀掉压力最大的 cgroup,比内核 OOM 更早介入也更可控。

Q:OOM Killer 的评分函数为什么按「内存占用比例」算?

因为目标是「杀一个就能解决问题」 —— 杀掉占用最多的进程最有可能一次性缓解压力。如果杀小进程,可能杀十个还不够,那就制造了十次故障而不是一次。

评分是 RSS + swap 占用 + 页表页数连页表也算进去,因为一个进程可能 RSS 不大但页表巨大,那同样是不可回收的内核内存),再加上 oom_score_adj × totalpages / 1000

为什么不用更聪明的启发式(比如「哪个进程更重要」)? 因为 OOM 是紧急情况,评分必须简单快速可预测;而且「业务重要性」是策略,内核无法判断 —— 所以它只提供机制(按占用打分)+ 一个策略旋钮(oom_score_adj),这又是 18 篇的机制与策略分离。

Q:为什么 OOM Killer 有时「杀错」(杀了数据库而不是批处理)?怎么办?

因为评分只看当前占用 —— 数据库的 buffer pool 就是它的核心,自然得分最高。内核无法知道业务重要性。

正确的应对不是抱怨内核,而是在用户态表达策略

  1. 给关键服务设 OOMScoreAdjust=-500,给批处理设正值
  2. 用 cgroup 给不同服务硬隔离内存额度(MemoryMax
  3. 更重要的是配 MemoryHigh —— 只设 MemoryMax 时应用是「一路正常然后突然被杀」,配上 MemoryHigh 后超限时先触发激进回收和限速,给了应用一个减速带而不是悬崖(配合 Go 的 GOMEMLIMIT 通常能避免真正的 OOM)
  4. systemd-oomd 按 PSI 做更早、更聪明的选择

容器场景的排查链:退出码 137(=128+9)→ memory.eventsoom_kill 计数 → memory.stat 看内存构成anon 不可回收是主要嫌疑,file 可回收,sock 在连接多时会很大)→ dmesg 里的完整现场(会 dump 当时所有进程的占用)。

Q:为什么 Redis 要求 vm.overcommit_memory=1

Redis 的 BGSAVE 用 fork 做快照。在**默认的启发式模式(0)**下,内核 fork 时会估算「子进程可能需要与父进程等量的内存」—— 如果 Redis 已占 60% 内存,这个估算会判定「不够」并让 fork 失败(ENOMEM),导致持久化中断。

但实际上因为 COW(21 篇),子进程几乎不会真用那么多内存。所以设 overcommit_memory=1(不做任何检查)让 fork 无条件成功。

代价是有意识的取舍:真的内存不够时,问题会推迟到「写入内存的那一刻」才暴露(触发 OOM)—— 用「可能被 OOM」换「fork 一定成功」。

更本质地说,OOM Killer 本身就是 overcommit 的必然结果:内核允许承诺超过物理内存(因为按需分页和 COW 让大部分承诺不会兑付),当承诺真的被兑付时,内核只有三个选择 —— 让分配失败(但很多路径无法处理失败)、无限阻塞(系统僵死,比崩溃更糟)、或者杀一个进程。


小结

  • zone 的划分是硬件约束的产物(DMA 设备的地址线限制)→ 「还有 10GB 空闲」不代表能满足一个 DMA32 分配
  • buddy 用 2 的幂是为了让找伙伴变成一次 XOR/proc/buddyinfo 高 order 全 0 = 碎片化严重
  • slab 存在是因为 buddy 最小 4KB 而内核对象只有几百字节SReclaimable 大是正常的,SUnreclaim 持续涨才是泄漏
  • free 少是健康的,available 才是答案;但最灵敏的是 si/soallocstallworkingset_refault、PSI
  • 双 LRU 是为了抗 cache 污染(一次 tar 不该冲掉数据库的热数据);refault 检测让「inactive 是否太小」变得可测量
  • 直接回收(allocstall)是内存类长尾延迟的头号原因,靠提高 min_free_kbytes 让 kswapd 早点干活
  • PSI 的 full 才是明确的资源不足信号systemd-oomd 靠它在内核 OOM 之前介入
  • OOM 评分按占用比例是为了「杀一个就够」;内核不判断业务重要性(那是策略)→ 用 OOMScoreAdjust + MemoryHigh 减速带
  • OOM Killer 是 overcommit 的必然结果;Redis 要 overcommit_memory=1 是用「可能被 OOM」换「fork 一定成功」

下一篇讲 用户态分配器:从 brk 到 Go 的 mspan —— malloc 为什么不直接用系统调用、glibc 的 arena 如何应对多线程竞争、free 之后内存为什么不还给操作系统、以及 Go 的 mcache/mcentral/mheap 三级结构如何完全绕开 libc