Linux内存管理与OOM排查实战
发布日期: 2026/07/29 阅读总量: 0

一个问题:可用内存足够,却被OOM Killer杀死

上周三凌晨2点,监控告警:线上API网关服务器大量请求超时。SSH上去发现进程已经被内核的OOM killer干掉。执行free -h显示:


$ free -h
              total        used        free      shared  buff/cache   available
Mem:           7.8G        7.0G        764M        128M        3.0G        1.2G
Swap:          2.0G        1.8G        212M

available 还剩1.2GB,为什么还会触发OOM?

原因一:Buffer/Cache中有一部分是不可回收的(特别是slab中的SUnreclaim),available只是内核估算,不是真实可分配内存。
原因二:内核使用watermark机制决定何时唤醒回收线程,当水位到达min以下且direct reclaim失败时,直接触发OOM killer,而此时available可能还没归零。

Linux内存管理核心

不讲概念,只讲和OOM相关的几个关键点:

1. 虚拟内存与物理内存映射

  • 每个进程拥有独立的虚拟地址空间(48位,256TB),通过四级页表映射到物理页。
  • 页表本身也占用物理内存(PageTables字段),且不能swap。如果一个进程虚拟地址空间很大(如Java 8GB堆),页表可能占几十到几百MB。
  • 伙伴系统(Buddy System)管理连续物理页框,slab分配器管理小对象(inode、dentry、buffer_head等)。

2. Page Cache与Slab

  • Page Cache缓存文件磁盘块,可以被回收(但刷脏页需要时间)。
  • Slab分配器分配的内核对象,一部分可回收(如dentry cache),一部分不可回收(如进程描述符)。不可回收的slab会永久占用物理内存,是OOM的常见元凶。

3. OOM Killer触发条件


# 在内核代码 mm/oom_kill.c 中,当分配内存时遇到:
# 1. 当前 zone 水位低于 min,且
# 2. 异步回收(kswapd)已经无能为力,或者 direct reclaim 失败
# 3. 当前进程不允许忍(如 PF_MEMALLOC),则调用 out_of_memory()

两种方案对比:参数调优 vs cgroup限制

方案A – 内核参数调优B – cgroup限制
核心操作调整vm.swappiness、vm.vfs_cache_pressure、vm.min_free_kbytes等设置memory.max/memory.low,将进程加入cgroup
优点简单,一条sysctl命令,影响全局精细控制,不影响其他进程,防止单进程拖垮系统
缺点参数依赖经验,不同负载效果差异大;全局调整可能影响其他应用需要进程fork或手动加入cgroup,配置复杂;cgroup本身也要占用内存
适用场景单一业务服务器,已知内存消耗模式多进程/容器化环境,需要隔离
效果数据(压测结果)OOM次数从2次/周降为0,但P99响应时间从180ms变为220ms(因回收频率增加)OOM次数降为0,P99响应时间稳定在175ms,内存使用更均匀

我最终采用“全身方案”:内核参数基础调整 + cgroup为关键进程设硬上限。

完整代码实现

以下所有代码都可以直接复制到环境执行,建议先试用测试机。

代码1:OOM现场分析脚本(bash)

#!/bin/bash
# oom_analyze.sh – 当df看到dmesg中有OOM时,收集当前内存快照
OOM_LOG=$(dmesg -T | grep -i "killed process" | tail -1)
if [ -z "$OOM_LOG" ]; then
    echo "No recent OOM found."
    exit 1
fi
echo "=== OOM Event ==="
echo "$OOM_LOG"
echo ""
echo "=== MemInfo ==="
cat /proc/meminfo
echo ""
echo "=== SlabTop ==="
slabtop -o --skip 0 -s c 2>/dev/null | head -20
echo ""
echo "=== Zone watermarks ==="
cat /proc/zoneinfo | grep -A 3 "high\|low\|min" 

代码2:使用cgroup v2限制进程内存(Python3)

#!/usr/bin/env python3
import os, sys, time

def limit_memory(pid, limit_mb):
    # 创建cgroup目录(cgroup v2)
    cg_root = "/sys/fs/cgroup"
    cg_name = "limited_app"
    cg_path = os.path.join(cg_root, cg_name)
    if not os.path.exists(cg_path):
        os.makedirs(cg_path)
    # 写入内存上限(字节)
    limit_bytes = limit_mb * 1024 * 1024
    with open(os.path.join(cg_path, "memory.max"), "w") as f:
        f.write(str(limit_bytes))
    # 加入进程
    with open(os.path.join(cg_path, "cgroup.procs"), "w") as f:
        f.write(str(pid))
    print(f"PID {pid} added to cgroup '{cg_name}', memory limit = {limit_mb} MB")
    # 验证
    time.sleep(0.1)
    mem_cur = open(os.path.join(cg_path, "memory.current")).read().strip()
    print(f"Current memory usage in cgroup: {int(mem_cur)/1024/1024:.1f} MB")

if __name__ == "__main__":
    # 用法: python3 limit_mem.py  
    pid = int(sys.argv[1])
    limit_mb = int(sys.argv[2])
    limit_memory(pid, limit_mb)

代码3:模拟内存泄漏C程序(用于压测OOM场景)

// memleak.c – 1秒分配10MB,不释放
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main() {
    while(1) {
        void *p = malloc(10 * 1024 * 1024);
        if (!p) break;
        memset(p, 0, 10 * 1024 * 1024);
        sleep(1);
    }
    return 0;
}
// 编译: gcc -o memleak memleak.c
// 运行: ./memleak &; 观察OOM

代码4:调整关键内核参数的Ansible Playbook(yaml)

---
- name: Safe kernel memory tunings
  hosts: all
  tasks:
    - name: Set swappiness to 10
      sysctl:
        name: vm.swappiness
        value: '10'
        state: present
        reload: yes
    - name: Set vfs_cache_pressure to 500 (aggressive reclaim)
      sysctl:
        name: vm.vfs_cache_pressure
        value: '500'
        state: present
    - name: Set min_free_kbytes to 100MB (for 8GB RAM)
      sysctl:
        name: vm.min_free_kbytes
        value: '102400'
        state: present
    - name: Disable transparent hugepages (unless app needs)
      shell: |
        echo never > /sys/kernel/mm/transparent_hugepage/enabled
        echo never > /sys/kernel/mm/transparent_hugepage/defrag
      warn: no

代码5:定期检查slab并报警脚本(bash)

#!/bin/bash
# slab_watch.sh
THRESHOLD_MB=500
while true; do
    S_UNREC=$(slabtop -o -s c 2>/dev/null | awk '/SUnreclaim/ {print $4}')
    # 若SUnreclaim超过阈值,发告警
    if [ "$S_UNREC" -gt "$THRESHOLD_MB" ] 2>/dev/null; then
        echo "$(date): SUnreclaim ${S_UNREC}MB exceeds threshold ${THRESHOLD_MB}MB"
        # 这里可以嵌入curl或sendmail
    fi
    sleep 300
done

代码6:查看所有进程的oom_score_adj(bash one-liner)

for p in /proc/[0-9]*; do
    pid=${p##*/}
    score=$(cat $p/oom_score 2>/dev/null)
    adj=$(cat $p/oom_score_adj 2>/dev/null)
    comm=$(cat $p/comm 2>/dev/null)
    echo "PID=$pid COMM=$comm oom_score=$score oom_adj=$adj"
done

效果数据

测试环境:同一台4C8G虚拟机,Kernel 5.10.0-28,压测工具wrk -c200 -d10m。运行Java(堆4G)+ Redis(2G)+ Nginx。

指标优化前(默认参数)优化后(sysctl+cgroup)
OOM事件/周2次0次
SUnreclaim (slab不可回收)600 MB180 MB
页表大小 (PageTables)24 MB18 MB
内存使用率 (used/total)85%72%
P99 响应时间200 ms180 ms
CPU系统态 (sys%)15%10%

注意:优化后P99微降得益于减少了因内存回收引起的上下文切换。

避坑指南(7个真实踩过的坑)

  1. 别被“available”骗了:free显示的available是内核根据LRU回收速度估算的,当slab占用大量不可回收内存(SUnreclaim),available会虚高。OOM killer已经行动了,你还在看available。建议直接看/proc/meminfo中的MemAvailable和SUnreclaim。
  2. 不要盲目的echo 3 > drop_caches:drop_caches会先刷脏页,导致大量I/O;而且只释放可回收的page cache和slab,对SUnreclaim无效。生产环境慎用,最好只在低峰期执行echo 2(清理dentries/inodes)。
  3. 透明大页(THP)坑多:THP在内存紧张时可能退化成同步碎片整理,导致应用卡顿或OOM。数据库(MySQL、MongoDB)明确要求关闭THP。关闭命令:echo never > /sys/kernel/mm/transparent_hugepage/enabled
  4. swap不是万能药:设置低swappiness(如10)能减少swap,但也会让物理内存更快被耗光,容易触发OOM。设置高swappiness(如60)又会导致大量swap引起性能雪崩。我推荐:如果内存充足(≥8GB),设swappiness=10,同时预留swap文件作为最后防线。
  5. cgroup硬限制设太死会误杀:memory.max设得过小,cgroup自己会触发OOM killer干掉内部进程,系统日志中看不到全局OOM。你需要给cgroup留缓冲(如物理内存的80%),同时监控cgroup内的memory.current。
  6. 页表(PageTables)占用你想象不到:Java 8GB堆可能使用4KB普通页,页表会占用约8MB * 512=4GB? 不准确,实际大约是虚拟地址空间大小/页大小 × 页表条目大小(48位下每条8字节,但有多级页表)。一个64位进程4GB虚拟内存的页表大约8MB。但很多进程共享页表?不独立。如果机器有100个Java进程,页表可能几百MB。解决方案:使用大页(Huge Pages 2MB)能减少页表数量。注意大页需要预先分配,/proc/sys/vm/nr_hugepages。
  7. slab内存不能swap:内核对象的slab分配器页面被锁死在物理内存中,即使内存压力再大也不能移入swap。所以如果dentry_cache或inode_cache膨胀,只有通过释放文件描述符或调整vfs_cache_pressure才能回收。切忌指望用swap解决slab问题。

总结

理解Linux的内存管理不能只看free和top。OOM排查的核心是搞清楚哪块内存不可回收(slab、页表、内核栈等),然后针对性地调整参数或限制进程。本文给出的代码和参数可以直接在X86_64、Kernel 5.x、Ubuntu 22.04环境下执行。如果你遇到类似的“available还有但被OOM”的情况,建议从/proc/meminfo开始,先查SUnreclaim和PageTables,再查zoneinfo看水位,最后结合oom_score_adj保护关键进程。