Linux CPU与内存调优实战:从假死到零假死的防假死指南
1. 从一次线上告警说起:为什么“防假死”比调优本身更重要
凌晨两点,监控大屏上跳出一条告警:一台跑了三年多的业务服务器,CPU 使用率从平时的 30% 一路飙到 98%,SSH 连上去敲个 ls 都要等十几秒才回显,top 命令刷出来一片红。运维同事第一反应是“哪个进程在吃 CPU”,结果 top 里排第一的进程 CPU 占用只有 12%,剩下的全是 %wa(IO 等待)和 %si(软中断)在偷偷吃掉算力。更诡异的是,内存看着还剩 2GB,但系统就是卡到几乎无法操作,最后只能硬重启。
这个场景我相信做过 Linux 运维的朋友都不陌生。它有个很形象的名字——假死。系统没真死,进程还在跑,但你已经失去了对它的有效控制能力。而“跳出网络与文件系统”这句话,恰恰点出了很多人排查时的思维盲区:一遇到卡顿就去看网络连接、看磁盘 IO、看日志文件,却忽略了 CPU 调度和内存回收这两个最底层、也最容易引发连锁反应的环节。
这篇内容我想聊的就是围绕 Linux CPU 与内存调优 的“防假死”实战。它不是什么高深的内核源码分析,而是我在实际运维中反复踩坑、反复验证后总结出的一套可落地的方法论。适合谁看?如果你是中高级运维、后端开发、SRE,或者只是自己维护几台云主机、NAS、家庭服务器的折腾党,只要你的系统出现过“CPU 不高但就是卡”“内存没满但进程被 OOM 干掉”“SSH 能连但命令不响应”这类问题,这篇内容都能给你直接抄作业的方案。
核心关键词就四个:Linux、CPU、内存调优、防假死。我会从整体设计思路讲起,拆解 CPU 调度和内存回收的底层逻辑,然后给出具体的参数配置、排查命令和实战案例,最后整理一份常见问题速查表。全程不堆砌术语,尽量用生活化的类比把原理讲透,让你不仅知道怎么调,还知道为什么这么调。
2. 整体设计思路:防假死的本质是“保命通道”优先
2.1 假死的三种典型形态与根因定位
在动手调优之前,得先搞清楚“假死”到底分几种。我把它归纳为三类,每类的根因和应对策略完全不同。
第一类是 CPU 调度饥饿型假死。表现是系统负载(load average)极高,但 CPU 使用率看起来不高,或者某个核心被单个进程 100% 占满。根因通常是进程数远超 CPU 核心数,调度器在疯狂切换上下文,或者有进程陷入不可中断睡眠(D 状态)导致调度队列堵塞。这时候你敲命令,shell 进程根本抢不到 CPU 时间片,自然就“假死”了。
第二类是 内存回收风暴型假死。表现是内存接近耗尽,系统开始频繁进行直接内存回收(direct reclaim),%sy(系统态 CPU)飙升,%wa 也高。根因是 page cache 和匿名页的回收压力太大,内核把大量时间花在扫描 LRU 链表和回写脏页上,用户态进程被拖死。这种假死最容易被误判为“磁盘 IO 问题”,因为 %wa 确实很高。
第三类是 OOM 连锁反应型假死。表现是系统突然卡顿,然后关键进程被 OOM Killer 干掉,甚至 SSH 都连不上。根因是内存超卖严重,或者某个进程内存泄漏,触发内核的 OOM 机制。更麻烦的是,OOM Killer 的选择逻辑不一定符合你的预期,它可能干掉了数据库而不是那个泄漏的脚本。
提示:这三种假死经常同时出现。CPU 饥饿会导致内存回收变慢,内存回收风暴又会加剧 CPU 系统态开销,最终一起触发 OOM。所以防假死不能只调一个参数,要成体系地设计。
2.2 为什么“跳出网络与文件系统”是关键
很多人排查卡顿的第一反应是 netstat、ss、iostat、df -h,这些工具当然有用,但它们反映的是“结果”而不是“原因”。网络连接数暴涨可能是 CPU 调度延迟导致的 accept 队列堆积;磁盘 IO 高可能是内存回收在疯狂回写脏页。如果你只盯着网络和文件系统调,就会陷入“按下葫芦浮起瓢”的循环。
我的思路是:把 CPU 和内存当作系统的“心肺功能”来调,网络和文件系统当作“四肢”来观察。 心肺出问题,四肢必然乏力;但四肢乏力不一定是四肢本身的问题。所以防假死的第一原则是——保证系统在任何压力下,都有一条“保命通道”能让管理员登录、能执行关键命令、能优雅重启服务。
这条保命通道靠什么保障?靠三样东西:CPU 调度预留、内存回收预留、IO 优先级预留。 下面我会逐一拆解。
2.3 调优方案的整体框架
我习惯把防假死调优分成四层,从下到上依次是:
| 层级 | 调优对象 | 核心目标 | 关键手段 |
|---|---|---|---|
| 第一层 | 内核调度器 | 保证交互进程能抢到 CPU | cgroup CPU 预留、调度策略调整 |
| 第二层 | 内存回收 | 避免 direct reclaim 风暴 | vm.min_free_kbytes、watermark 调整 |
| 第三层 | OOM 机制 | 让该杀的进程被杀,不该杀的活着 | oom_score_adj、cgroup 内存限制 |
| 第四层 | 监控与自愈 | 提前发现假死前兆 | PSI 压力指标、告警阈值 |
这个框架的好处是,每一层都有明确的“为什么”。比如第一层为什么要用 cgroup 而不是 nice 值?因为 nice 值只能调整相对优先级,在极端压力下,高 nice 的进程依然可能被饿死;而 cgroup 的 CPU 带宽控制可以硬性预留一个核心给管理通道。这就是“知其所以然”。
3. 核心细节解析:CPU 调度与内存回收的底层逻辑
3.1 CPU 调度:为什么你的 SSH 会卡住
Linux 的 CFS(完全公平调度器)核心思想是“按权重分配 CPU 时间”。每个进程有一个 vruntime(虚拟运行时间),调度器总是选择 vruntime 最小的进程运行。这个设计在正常情况下很公平,但在极端压力下有个致命问题:如果就绪队列里有几百个进程,每个进程的 vruntime 都会增长得很慢,但切换频率会急剧上升,导致上下文切换开销吃掉大量 CPU。
更麻烦的是,CFS 对“交互式进程”的识别并不完美。SSH 登录后的 shell 进程,在调度器眼里和后台的批处理脚本没有本质区别。当系统负载达到几百时,你的 shell 进程可能排在就绪队列的末尾,等上几秒才能拿到一个时间片。这就是“SSH 能连但命令不响应”的根本原因。
那怎么解决?有两个方向。一是减少就绪队列长度,把非关键进程用 cgroup 限制住,不让它们无限抢占 CPU。二是给关键进程更高的调度优先级,比如用 chrt 把 SSH 服务设成实时调度策略,或者用 cgroup 的 cpu.weight 给管理通道更高的权重。
我实测下来,最稳的方案是 cgroup v2 的 CPU 带宽控制 + 权重分配。具体来说,创建两个 cgroup:一个叫 critical,给 SSH、监控 agent、日志采集器用,设置 cpu.weight=10000;另一个叫 batch,给业务进程用,设置 cpu.weight=100。这样即使业务进程把 CPU 跑满,critical 组里的进程依然能拿到足够的 CPU 时间。
注意:cgroup v2 的
cpu.weight范围是 1 到 10000,默认值是 100。权重是相对值,不是绝对值。如果你给critical设 10000,给batch设 100,那么 CPU 时间的分配比例大约是 100:1。但实际效果还取决于两个组里有多少进程在跑。
3.2 内存回收:watermark 与 direct reclaim 的博弈
Linux 内存管理里有个关键概念叫 watermark(水位线)。每个内存 zone 有三个水位线:min、low、high。当空闲内存高于 high 时,内核觉得内存充足;低于 low 时,唤醒 kswapd 后台回收;低于 min 时,触发 direct reclaim,也就是让分配内存的进程自己去做回收。
direct reclaim 是假死的罪魁祸首之一。因为它是同步的,进程在申请内存时被强制去扫描 LRU 链表、回写脏页,这个过程中进程处于 D 状态(不可中断睡眠),既不能响应信号,也不能被调度。如果多个进程同时触发 direct reclaim,系统就会陷入“回收风暴”,CPU 系统态飙升,用户态进程全部卡住。
那怎么避免?核心是让 kswapd 有足够的时间提前回收,而不是等到 min 水位才被动触发 direct reclaim。 具体做法是调大 vm.min_free_kbytes,让 min 水位更高,给 kswapd 留出更多缓冲空间。
vm.min_free_kbytes 的默认值是根据内存大小自动计算的,公式大致是:
其中 lowmem_kbytes 是低端内存的大小(KB)。对于一台 16GB 内存的机器,默认值可能在 64MB 左右。这个值在内存压力大时明显不够。我的经验是,对于 16GB 到 64GB 的服务器,把 vm.min_free_kbytes 设成 1GB 到 2GB 比较稳妥;对于 128GB 以上的大内存机器,可以设成 2GB 到 4GB。
但也不能设太大,否则会浪费内存,而且可能触发更频繁的回收。我一般会结合 vm.watermark_scale_factor 一起调。这个参数控制 low 和 high 水位相对于 min 的间距,默认是 10(表示 0.1%)。把它调大到 100 到 200,可以让 kswapd 更早启动,进一步降低 direct reclaim 的概率。
3.3 OOM Killer:如何让该活的活着
OOM Killer 的选择逻辑基于 oom_score,这个分数由进程的内存占用、运行时间、nice 值等因素综合计算。但默认逻辑有个问题:它倾向于杀掉占用内存最多的进程,而这个进程往往是你的核心业务(比如数据库),而不是那个内存泄漏的脚本。
解决方法是手动调整 oom_score_adj。这个值的范围是 -1000 到 1000。设为 -1000 表示“永远不杀”,设为 1000 表示“优先杀”。我通常会把 SSH、systemd、监控 agent 的 oom_score_adj 设成 -1000,把批处理脚本、临时任务设成 500 到 1000。
但更优雅的方案是用 cgroup v2 的 memory.max 和 memory.oom.group。给每个业务组设置内存上限,当组内内存超限时,OOM 只会在组内选择牺牲者,不会波及全局。memory.oom.group 设为 1 时,整个 cgroup 会被作为一个整体杀掉,适合那些“要么全活要么全死”的服务。
提示:调整
oom_score_adj需要 root 权限,而且对已经在运行的进程,可以通过echo写入/proc/<pid>/oom_score_adj来动态调整。但要注意,这个值会被子进程继承,所以设之前想清楚。
4. 实操过程:从参数配置到验证的完整流程
4.1 环境准备与基线采集
在动手调之前,先采集基线数据。没有基线,你根本不知道调优有没有效果。我常用的命令组合如下:
这里重点看几个指标:%wa 是否长期高于 5%,%sy 是否高于 20%,kbmemfree 和 kbmemused 的比例,以及 pgscan_direct 和 pgsteal_direct 的增长速度。如果 pgscan_direct 在短时间内快速增长,说明 direct reclaim 很频繁,假死风险很高。
4.2 CPU 调优:cgroup v2 实战配置
假设你的系统已经挂载了 cgroup v2(现代发行版默认都是),挂载点通常是 /sys/fs/cgroup。下面是我常用的配置流程:
这里的关键是 cpu.max 的配置。80000 100000 表示在每 100ms 的周期内,batch 组最多使用 80ms 的 CPU 时间。这样即使业务进程想跑满 CPU,也会被硬性限制在 80% 以内,剩下的 20% 留给 critical 组和其他系统进程。
但要注意,cpu.max 是每个 CPU 核心的限制。如果你有 8 个核心,batch 组最多可以用 8 * 80% = 6.4 个核心的时间。如果你想让 batch 组只能用 4 个核心,应该设成 400000 100000。
注意:cgroup v2 的配置是分层的。如果你在根 cgroup 下创建了子 cgroup,子 cgroup 的权重是相对于兄弟节点计算的。所以不要把
critical和batch直接放在根下,最好再建一个父 cgroup,把两者放进去,这样权重计算更清晰。
4.3 内存调优:水位线与回收参数配置
内存调优的核心是让 kswapd 提前工作,避免 direct reclaim。下面是我常用的参数配置:
这里解释几个关键参数的选择理由。vm.min_free_kbytes=1048576 表示保留 1GB 的空闲内存作为缓冲。对于 16GB 内存的机器,这相当于保留 6.25% 的内存,不算多但足够让 kswapd 提前启动。vm.watermark_scale_factor=150 表示 low 水位比 min 高 1.5%,high 水位比 min 高 3%。这样 kswapd 会在空闲内存降到 min + 1.5% 时就开始回收,而不是等到 min 才被动触发。
vm.dirty_ratio=10 和 vm.dirty_background_ratio=5 是为了控制脏页回写的节奏。默认的 dirty_ratio=20 意味着系统可以积累 20% 内存的脏页,一旦触发回写,就是一次大规模的 IO 风暴,很容易导致假死。调小到 10% 和 5%,可以让回写更平滑。
4.4 OOM 保护:给关键进程上保险
OOM 保护的配置分两步。第一步是给关键进程设置 oom_score_adj:
第二步是用 cgroup v2 的 memory.max 限制业务组的内存使用:
memory.events 里的 oom 和 oom_kill 计数可以帮你判断这个组是否发生过 OOM。如果 oom_kill 持续增长,说明内存上限设得太低,或者业务有内存泄漏,需要进一步排查。
4.5 验证与压测:用 stress-ng 模拟假死场景
配置完了不能就这么算了,得验证效果。我常用 stress-ng 来模拟 CPU 和内存压力:
在压测过程中,另开一个终端,反复执行 ssh localhost "uptime" 或者 time ls /tmp,观察响应时间。如果配置得当,即使压力很大,SSH 命令的响应时间也应该在 1 秒以内。如果超过 3 秒,说明 CPU 预留不够,需要调大 critical 组的权重或者降低 batch 组的 cpu.max。
同时用 sar -r 1 观察 pgscan_direct 的增长。如果压测期间 pgscan_direct 基本不增长,说明 kswapd 提前回收生效了,direct reclaim 被有效抑制。
5. 常见问题与排查技巧实录
5.1 假死前兆的快速识别
假死不是一瞬间发生的,通常有前兆。我整理了一份“前兆指标速查表”,你可以对照监控数据快速判断:
| 指标 | 正常范围 | 前兆范围 | 危险范围 | 含义 |
|---|---|---|---|---|
| load average (1min) | < 核心数 | 核心数 ~ 2倍 | > 2倍核心数 | 调度队列积压 |
| %sy | < 10% | 10% ~ 30% | > 30% | 系统态开销过大 |
| %wa | < 5% | 5% ~ 20% | > 20% | IO 等待严重 |
| pgscan_direct | 缓慢增长 | 快速增长 | 爆发式增长 | direct reclaim 频繁 |
| kswapd 唤醒次数 | 偶尔 | 频繁 | 持续 | 内存回收压力大 |
| D 状态进程数 | 0 ~ 1 | 2 ~ 5 | > 5 | 不可中断睡眠堆积 |
如果同时出现 load 高、%sy 高、pgscan_direct 快速增长,基本可以判定系统正在走向假死,需要立即干预。
5.2 排查工具与命令组合
排查假死时,我常用的命令组合如下:
这里重点说 ps -eo pid,stat,comm,wchan 的用法。wchan 显示进程在内核中的等待函数,如果大量进程的 wchan 是 direct_reclaim 或 balance_dirty_pages,说明内存回收是瓶颈;如果是 rwsem_down_read 或 mutex_lock,说明有锁竞争。
5.3 独家避坑技巧
第一个坑:不要盲目调大 vm.min_free_kbytes。 我见过有人把它设成内存的 20%,结果系统可用内存大幅减少,反而更容易触发 OOM。正确的做法是根据内存大小和业务特点,设成 1% 到 5% 之间,再结合 watermark_scale_factor 微调。
第二个坑:cgroup 的 cpu.max 不要设得太死。 如果你把 batch 组的 cpu.max 设成 10000 100000(只能用 10% 的 CPU),业务可能会因为 CPU 不足而超时。我一般会先设一个宽松的值,比如 80%,然后根据压测结果逐步收紧。
第三个坑:OOM 保护不是万能的。 如果你把太多进程的 oom_score_adj 设成 -1000,当内存真的耗尽时,内核可能找不到合适的牺牲者,最终触发 panic 或者全局 OOM。我的建议是只保护最核心的 3 到 5 个进程,其他进程该杀就杀。
第四个坑:别忘了监控 PSI。 PSI(Pressure Stall Information)是内核提供的压力指标,比 load average 更直观。/proc/pressure/cpu、/proc/pressure/memory、/proc/pressure/io 分别反映 CPU、内存、IO 的压力。如果 memory 的 full 值持续高于 0,说明有进程因为内存不足而完全停滞,这是假死的明确信号。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| SSH 能连但命令卡 | CPU 调度饥饿 | vmstat 1、ps -eo stat |
调大 critical 组权重 |
| 系统卡顿但 CPU 不高 | direct reclaim 风暴 | grep pgscan_direct /proc/vmstat |
调大 min_free_kbytes |
| 关键进程被 OOM 杀掉 | oom_score 计算不利 | `dmesg | grep oom` |
| load 高但 CPU 空闲 | D 状态进程堆积 | ps -eo stat,wchan |
排查 IO 或锁竞争 |
| 内存没满但进程被杀 | cgroup 内存超限 | cat memory.events |
调大 memory.max |
| 压测时响应变慢 | CPU 带宽限制过严 | cat cpu.stat |
放宽 cpu.max |
6. 监控与自愈:让系统自己扛住假死
6.1 用 PSI 做假死预警
PSI 是我目前最推荐的假死预警指标。它的原理是统计“因为资源不足而完全停滞”的时间占比。比如 /proc/pressure/memory 里的 full 值,表示所有任务都因为内存不足而停滞的时间比例。如果这个值持续大于 0,说明系统已经进入假死边缘。
我通常会在监控系统里配置这样的告警规则:
memory full > 0持续 10 秒:警告memory full > 0持续 30 秒:严重cpu full > 0持续 30 秒:警告io full > 0持续 60 秒:警告
这些阈值可以根据业务特点调整,但核心思路是:PSI 的 full 值比 load average 更早、更准确地反映假死风险。
6.2 自愈脚本:压力过大时自动降级
除了监控告警,还可以写一个简单的自愈脚本,在检测到假死前兆时自动降级非关键业务。下面是一个示例:
这个脚本的逻辑很简单:每 10 秒检查一次内存 PSI,如果压力超过阈值,就把 batch 组的内存上限降低 20%。这样可以强制业务组回收内存,避免全局内存耗尽。当然,这只是一个示例,实际使用时需要根据业务特点调整阈值和降级策略。
注意:自愈脚本本身也要做好保护,避免它自己成为压力源。建议把脚本放在
critical组里,并设置合理的oom_score_adj。
6.3 长期调优:从“防假死”到“零假死”
防假死是底线,但长期来看,我们追求的是“零假死”——系统在任何压力下都能保持可响应。这需要从架构层面做优化,比如:
- 资源隔离:用 cgroup 或容器把不同业务隔离开,避免互相影响。
- 容量规划:根据业务增长趋势提前扩容,不要等到资源耗尽才动手。
- 混沌工程:定期注入 CPU、内存、IO 压力,验证系统的抗压能力。
- 根因分析:每次假死事件后都要做复盘,找到根本原因并修复。
我在实际运维中最大的体会是:假死往往不是单一原因造成的,而是多个小问题叠加的结果。 比如一个内存泄漏的脚本 + 一个没有限制的 cgroup + 一个过低的 min_free_kbytes,单独看每个问题都不致命,但组合起来就会让系统在某个时刻突然崩溃。所以防假死调优要有全局视角,不能头痛医头。
最后分享一个小技巧:如果你不确定某个参数该设多少,可以先设一个保守值,然后用 stress-ng 压测,观察 PSI 和 pgscan_direct 的变化,再逐步调整。这个过程可能需要反复几次,但比拍脑袋设参数靠谱得多。我自己的服务器上,vm.min_free_kbytes 从默认的 64MB 调到 1GB 后,direct reclaim 的发生频率下降了 90% 以上,SSH 在压测期间的响应时间从 5 秒降到了 0.5 秒以内。这个投入产出比,值得每个运维花半小时去调。