Linux OOM Killer 排查与解决:从内存耗尽到 cgroup 限流实战

发布时间:2026/10/21 15:05:22
Linux OOM Killer 排查与解决:从内存耗尽到 cgroup 限流实战 1. OOM 到底是怎么发生的先搞懂内核在干什么很多人一看到服务器日志里出现Out of memory或者Killed process第一反应就是内存不够了赶紧加内存条。这个理解没错但不完整。我见过太多案例8G 内存的机器跑个 Java 应用都能 OOM而隔壁 4G 内存的机器跑同样的业务却稳如老狗——问题根本不在于物理内存总量而在于 Linux 内核的OOM Killer内存溢出杀手机制是怎么工作的。先简单过一遍原理。Linux 内核有个叫做Out Of Memory Manager的组件它的职责是当系统物理内存 swap 交换空间全部耗尽并且内核无法通过正常回收机制拿到可用内存时触发一个强制清理流程。这个流程核心是选出一个或几个倒霉进程直接杀掉释放内存保住系统整体不崩溃。这里的杀谁可不是随机选的。内核有一套默认评分机制主要看这几个维度进程当前占用的物理内存大小RSS进程的oom_score_adj调整值默认是 0可以人为调高调低进程的父子关系、运行时长、是否 root 权限等实际表现就是谁占内存最多、谁对系统最不重要谁就最容易被 OOM Killer 盯上。所以有时候你发现是 MySQL 数据库进程被杀了或者是 Java 服务被杀了并不一定是它们真的最该杀而是它们的 RSS 足够大。再补充一个超级容易忽略的知识点Linux 的内存耗尽指的是物理内存和 swap 全部用完。也就是说如果你 swap 配置得很大OOM 可能迟迟不出现但一旦 swap 本身太小哪怕物理内存还有空闲 Linux 也可能提前进入内存紧张状态虽然这时并不一定会触发 OOM Killer但会让系统响应变得极其卡顿。我见过不少新手在 OOM 之后第一件事是重启机器这个操作不能说错但对于定位问题基本没有帮助。重启之后内存确实清理了但下次业务高峰来临时同样的进程照样被杀。正确的做法是先看系统日志确认是不是 OOM再逐个排查是谁触发的、为什么触发。1.1 怎么确认系统确实发生了 OOM不确定是不是 OOM就盲目调参数是最常见的弯路。命令很简单dmesg | grep -i out of memory dmesg | grep -i killed process如果日志里出现了类似这样的输出[12345.678901] Out of memory: Killed process 12345 (java), score 987, or a child of an oom killer那就实锤了确实是 OOM。还有一个路径是看系统日志journalctl -k | grep -i oom需要特别说明的是dmesg的输出可能被刷掉尤其是系统运行了很久、日志环形缓冲区一直在覆盖的情况下。所以我建议在排查时同时查这两个来源只要有一个能匹配到oom关键词就可以确认。另外如果你用的是 systemd 管理的服务被杀掉之后systemctl status 服务名会显示failed状态并且日志里会有Memory cgroup out of memory这类的记录这种往往是 cgroup 内存限制导致的后文我会单独讲它和物理内存耗尽的 OOM 在表现上很像但处理方式完全不同。1.2 swap 和 OOM 之间的暧昧关系在讲对策之前先花一小段把 swap 讲清楚。swap 是磁盘上划出一块区域充当假内存当物理内存不足时内核会把不活跃的内存页换到磁盘上。很多人有一个误区swap 越大越安全。其实不对。swap 太大、物理内存又偏少时系统会出现一种叫频繁换页的状态——进程在内存和磁盘之间来回倒腾CPU 使用率飙升到 100%但业务响应速度却慢得离谱。这种情况下 OOM 不一定会马上触发但服务器体验比 OOM 还差。而 swap 太小甚至为 0 时物理内存一旦用完就直接触发 OOM Killer杀进程非常果断。所以合理的做法是根据业务场景设置一个合适的 swap 值而不是一刀切地追求越大越好或越小越好。关于多少算合适我通常建议物理内存 8G 以下的机器swap 设置为物理内存的 1~2 倍16G 以上的大内存机器swap 设个 4~8G 就差不多了。如果磁盘是 SSD可以稍微激进一点SSD 的随机读写性能不错swap 换页的体验损失没那么大。2. 第一步排查找到谁吃掉了内存确认是 OOM 之后不要急着改配置。你得先知道内存是被谁吃掉的。这个排查动作可以拆成两步看当前状态和看历史趋势。当前状态用free和top基本就能搞定free -h看输出里的available这一列它才是真正可以随时拿走的可用内存比free列的数值更有参考价值因为available已经算上了可回收的缓存和 buffer。如果available长期低于总内存的 20%说明这台机器内存确实吃紧。接着看进程top -o %MEM这个命令会把进程按内存占用从高到低排序十有八九你会看到某些 Java/Python/Node 进程名列前茅。这时候要留个心眼top 里的 RES 列只代表物理内存占用不代表这个进程的真实内存需求上限。因为很多语言运行时特别是 JVM会预先申请一大块虚拟内存地址空间但不会马上全部用满。从看当前状态切换到看历史趋势可以用sar -r查看历史内存使用数据不过需要确保sysstat已经安装并且采集过一段时间的数据。如果你之前没有开启采集那就只能靠atop这类工具或者自己在 OOM 发生前部署监控脚本了。在实际定位中我遇到最多的情况是以下三类进程内存泄漏——代码里某个集合无限增长、缓存没有淘汰策略、连接池越开越大导致进程 RSS 一路爬升最终吃光内存。瞬时内存尖刺——业务高峰期并发请求暴增每个请求都创建大量临时对象内存峰值瞬间击穿。并发线程数过高——每一路线程都自带独立栈空间默认的线程栈大小是 8MBJVM 里常见500 个线程加起来就是 4G 内存非常容易被低估。这三类问题对应的处理方向完全不同所以是谁吃的和怎么吃的必须分开看。如果进程数量众多还可以用ps aux --sort-%mem | head -20快速抓前 20 名内存大户这个命令在脚本里非常好用可以自动化采集。2.1 内存泄漏怎么快速判断内存泄漏是最隐蔽的一类它不像瞬时尖峰那样轰地一下爆发而是每天涨一点用个两三周才到 OOM 边缘。快速判断方法是把一个关键指标做成趋势图进程 RSS 在负载不变的情况下是否持续增长。如果业务量平稳、无发版、无配置变更但 RSS 每日从 1G 涨到 1.2G 再到 1.4G基本可以确定是泄漏。在没接监控平台的情况下可以手工连续采样几天ps -o pid,rss,vsz,etime,cmd -p 12345 /tmp/mem.log配合 crontab 每 10 分钟采样一次48 小时后拉出来看趋势。如果rss稳步上行要么是代码层泄漏要么是某些框架的缓存策略设置不当。Java 应用可以用jmap -heap、jstat -gc看堆内和堆外的情况Python 应用可以用tracemalloc定位对象分配点Golang 应用则用pprof拉堆快照。2.2 瞬时尖峰怎么判断瞬时尖峰的判断稍微复杂因为 OOM 发生的那一刻进程已经被杀了你去top里看不到原来的肇事者。这时最可靠的方式是看cgroup 的峰值记录。如果你用的是 systemd 启动的服务直接看服务的MemoryPeak属性systemctl show 服务名 -p MemoryPeak没有 systemd 的场景下可以看全局的cat /sys/fs/cgroup/memory/memory.peak_usage_in_bytes这条路径在 cgroup v1 的机器上是有效的v2 版本的路径不同后面讲 cgroup 时我再展开。现实里我排查过一个每天凌晨 3 点必 OOM的案例最终定位是定时任务在凌晨统一做报表聚合数据一次性加载到内存峰值瞬间冲高。这种尖峰型 OOM加内存是最直接的缓解方式但更稳的做法是给任务分片、限流或者调整定时任务的执行错峰。3. 临时止血调大 swap 和限制关键进程的 OOM 等级定位完谁吃的接下来聊怎么临时止血。这里的思路分两条线扩大可用内存池和保护关键进程不被误杀。这两招都不能根治问题但能在你写出修复代码之前让业务先撑住。3.1 给系统增加 swap 空间增加 swap 不需要重启全程在线操作。最常见的做法是使用 swapfile交换文件而不是 swap 分区文件方式更灵活且无需重新分区磁盘。步骤如下在磁盘上创建一个指定大小的空文件比如要加 4Gfallocate -l 4G /swapfile注意有些文件系统如部分老旧的 XFS 配置不支持fallocate会报错。这时可以用dd if/dev/zero of/swapfile bs1M count4096替代但速度慢一些。把权限收紧避免普通用户读取交换文件内容造成安全隐患chmod 600 /swapfile格式化为 swap 格式并启用mkswap /swapfile swapon /swapfile查看是否生效free -h swapon --show把这条配置写入/etc/fstab否则重启后会失效/swapfile none swap sw 0 0我个人的建议是除非业务线上没有条件立即新增物理内存否则 swap 只作为短期缓冲。因为 swap 的读写性能再快也远不如物理内存特别是对延迟敏感的业务大量的换页操作会让性能雪崩。还有一个关于swappiness的参数值得提一下。它是内核调节多久倾向于使用 swap的开关范围 0~100默认通常是 60。你可以在/etc/sysctl.conf里调低它vm.swappiness10调整后执行sysctl -p生效。设成 10 意味着内核更倾向于保留物理内存中的 page cache而不是急于把内存页换到磁盘上。对很多内存不是特别宽裕但又不希望频繁 swap 的业务场景这个值是个不错的平衡点。3.2 告诉 OOM Killer别杀这个进程Linux 的 OOM Killer 并不是随机杀人进程可以通过一个文件来调整自己的被杀优先级/proc/pid/oom_score_adj。取值范围是 -1000 到 1000数值越高越容易被杀越低越安全。如果你想保护某个关键服务比如数据库可以把它的 oom_score_adj 调得很低echo -1000 /proc/$(pgrep mysqld)/oom_score_adj注意-1000 表示该进程完全不被 OOM Killer 选中但也要小心如果这个进程确实有问题系统宁可卡死也不杀它反而更危险。所以一般建议设成 -500 到 -800 之间既保住它又留有余地。对于 systemd 管理的服务更规范的做法是 Edit 服务的 unit 文件systemctl edit 服务名写入[Service] OOMScoreAdjust-500然后重启服务生效。这种方式的好处是重启后配置依然保留手工 echo 的方式重启一次就失效了。反过来如果某些占用内存高但又不是那么重要的任务比如大数据分析作业你可以主动调高它的 oom_score_adj让它在内存紧张时第一个被清理保护核心业务。这也是预置止损策略的一部分。4. 根治方向一给进程加内存上限别再让它无限扩张内存泄漏也好、尖峰也好背后的实质是进程的内存使用量没有边界。所以一个非常重要的根治思路是用 cgroup 给进程设置内存上限超了直接被 OOM 杀掉而不是把整台机器拖垮。这样做最直接的好处是故障隔离。一个服务的内存在 2G 的配额内走到极限只会导致它自己被重启不会波及同机上的其他应用。这在混部多个应用的服务器上简直是救命稻草。用 systemd 给单个服务设置内存上限只需在 unit 文件里加[Service] MemoryMax2G MemoryHigh1.5GMemoryMax是硬上限超过就会被 OOM Killer 处理MemoryHigh是软水位超过后内核会尝试回收该服务的内存页但不会立刻杀进程。这个组合非常适合正常时稳、峰值时冲高的服务。如果是跑在日常手动启动的进程上可以借助systemd-run临时启动systemd-run --scope -p MemoryMax2G ./my_server这样就让整个进程处在 cgroup 约束之下。原理上这相当于在一个受限的容器里跑进程虽然我们没安装 Docker但隔离效果是类似的。设置完内存上限后服务如果频繁被杀你会比较容易观察到每次死掉的时间点是不是对应业务高峰这个规律对定位问题反而有帮助。不像之前整个系统混乱一团连肇事进程是谁都要猜。4.1 cgroup v1 和 v2 的区别要心里有数不少发行版已经从 cgroup v1 切到 v2两者的控制接口不太一样。比如老路径/sys/fs/cgroup/memory/memory.limit_in_bytes在 v2 里变成了/sys/fs/cgroup/memory.max查看当前使用的是哪个版本stat -fc %T /sys/fs/cgroup输出是cgroup2fs就是 v2是tmpfs大概率是 v1。systemd 的MemoryMax配置在不同版本下能自动适配底层所以我建议能用 systemd 就用 systemd别自己直接操作 cgroup 文件减少踩坑。另外v2 还有一个重要行为向memory.max写入强制回收时如果进程内存占用超过阈值内核会异步触发回收并不一定立刻杀掉进程。这种软性表现经常让新手误以为设置没生效。实际上只要你看到服务的内存在高位被压制住就说明 cgroup 已经在起作用了。5. 根治方向二针对具体技术栈的减内存实操说完通用的 cgroup 方案再逐个拆一下常见技术栈的常见内存大户这样你可以直接对号入座。5.1 Java 服务JVM 堆与堆外内存Java 是 OOM 重灾区。一套 Java 服务的整体内存占用 堆内内存Heap 堆外内存Metaspace、DirectBuffer、线程栈等。许多人只盯着-Xmx设置堆大小却忘了堆外导致实际 RSS 远高于预期。一个比较常见的保守设置思路是java -Xms2G -Xmx2G -XX:MaxMetaspaceSize256m -XX:MaxDirectMemorySize512m \ -Xss512k -jar app.jar解释一下-Xms和-Xmx相等避免堆动态伸缩带来的性能抖动-XX:MaxDirectMemorySize限制 NIO 直接内存-Xss把线程栈从默认 1M 降到 512k线程多的时候收益非常明显。一套 4G 内存的服务器如果只部署一个 Java 应用这个配置可以让整体占用控制在 3G 左右剩余给系统和其他进程。这里有一个极易踩的坑容器环境下识别的内存。默认情况 JVM 只识别宿主机的物理内存如果不加-XX:MaxRAMPercentage或-XX:MaxRAM参数JVM 会按照宿主机全部内存来计算默认堆大小。明明 cgroup 限制只有 2GJVM 却按宿主机 32G 的 1/4约 8G来分配堆直接超过限制被杀。我建议容器内启动 Java 服务一律追加-XX:MaxRAMPercentage75.0 -XX:InitialRAMPercentage75.0这表示按 cgroup 配额内的 75% 来分配堆留足堆外和系统的余量。5.2 Python / Node 服务关掉不必要的缓存Python 服务最常见的内存爆炸点在数据处理。比如 Pandas 一次性读入几个 GB 的 CSVOOM 是分分钟的事。有用的做法是按需分块读取而不是一次性pd.read_csv全量加载。chunksize参数就能做到for chunk in pd.read_csv(big.csv, chunksize100000): process(chunk)Node 服务则更常见的是全局缓存越来越多和生产环境没关调试模式导致的内存持有。排查时用process.memoryUsage()打印快照观察heapUsed是否会持续上升。5.3 数据库类服务缓冲区调优MySQL、Redis、PostgreSQL 这类数据库的内存大头通常是缓存/缓冲池。比如 MySQL 的innodb_buffer_pool_size如果设置过大再加上一堆连接会话内存很容易爆。但如果你把它调太小查询又变慢。这里没有万能参数我的建议是为数据库单独预留内存配额然后按整机预算的 50%~60% 设置缓冲池大小。例如整机 16G数据库最多可用 10G那么缓冲池设到 6G 左右比较合适其余留给连接、排序和临时表。6. 最后一个保命技巧监控预警比事后补救重要得多走到这一步你会发现所有补救手段都难免被动。真正的最优解是在 OOM 发生之前就收到预警并且有足够的时间窗口去处理。推荐的做法是给系统加一层简单的监控脚本每隔一分钟检查一次available内存和 swap 的使用率当可用内存低于阈值比如 20%时发出告警#!/bin/bash threshold20 available_percent$(free | awk NR2{print $7/$2*100}) if (( $(echo $available_percent $threshold | bc -l) )); then echo 可用内存低于 ${threshold}% | mail -s 内存预警 opsexample.com fi把这个脚本丢进 crontab* * * * * /opt/scripts/mem_alert.sh你就能在 OOM 之前先收到邮件或通知。进阶一点的团队还可以接入现成的监控体系配置内存使用率的告警规则阈值可以设到 70% / 80% / 90% 三档分别对应注意、警告、紧急三个级别三级告警策略比一味等 OOM 再处理要可靠得多。另外很多云主机厂商的控制台自带内存使用率监控图这个图虽然有时延但作为长期趋势参考完全够用。你要是一觉醒来看到昨天凌晨内存使用率有个尖峰并且服务刚好重置过基本上就能把问题窗口锁定到凌晨那个时段。我自己的习惯是遇到 OOM先看日志确认现象再查进程定位肇事者然后用 cgroup 限制止损最后才改代码/配置做根治。这套流程走下来大部分 OOM 问题都能在几小时内稳定住。至于到底要不要加物理内存我建议放到最后一步再判断——很多时候加了内存只是把问题推迟而不是消灭问题。