如何在香港服务器的CentOS 8中优化内存回收机制(slab reclaim),避免高负载时的OOM Killer问题?

凌晨 2:07,我在葵涌机房的冷风口蹲着看串口日志。值班电话里同事说:“又被 OOM 杀了,这次是 Nginx 的一个 worker。”
那台机器的配置其实不差:
- 位置:香港机房(单路供电,A/B 两路网络)
- 服务器:Dell R740xd,CPU 2×Xeon Silver 4210R,内存 256 GB,系统盘 RAID1(SAS 480 GB),数据盘 2×NVMe(1.6 TB,软 RAID0),网口 2×10 GbE
- OS:CentOS 8 (kernel 4.18.x,SLUB allocator)
业务:Nginx + 两个 Go 服务(大量小文件元数据访问,目录遍历密集),旁边还有 rsync/备份任务扫盘
从 dmesg 和 journalctl -k 里能看出典型的 OOM 现场:Out of memory: Killed process ... (nginx) total-vm:..., anon-rss:...。
但奇怪的是,free -h 显示的“已用内存”很高,buff/cache 也不低;/proc/meminfo 的 SReclaimable 出奇地大。这意味着大量内存卡在 slab(尤其是 dentry/inode)里,回收不够积极,高峰期就把业务顶进了 OOM。
一、问题画像:怎么确认是 slab/目录项缓存顶爆的?
先把现场证据采全:
# 1) 总览
free -h
cat /proc/meminfo | egrep 'MemTotal|MemFree|MemAvailable|Slab|SReclaimable|SUnreclaim'
# 2) 哪些 slab 在长大
slabtop -o -s c | head -40 # 按 cache 名称排序,常见 dentry、inode_cache、kmalloc-* 等
# 3) VFS 回收倾向
sysctl vm.vfs_cache_pressure
# 4) 压力观察(PSI)
cat /proc/pressure/memory
一次高峰采样(真实机器上的量级,略做脱敏):
| 指标 | 数值(Before) | 备注 |
|---|---|---|
| MemTotal | 256 GB | 物理内存 |
| MemAvailable | 6.1 GB | 危险地偏低 |
| Slab | 34.8 GB | slab 总量 |
| SReclaimable | 27.9 GB | 可回收的 slab(重点) |
| SUnreclaim | 6.9 GB | 不可回收 slab |
vm.vfs_cache_pressure |
100 | 默认值 |
PSI memory some avg10 |
21.3% | 10 分钟内“有人等内存”的时间占比 |
| slabtop 前 3 | dentry, inode_cache, kmalloc-64 | dentry/inode 远高于平日 |
结论:SReclaimable 巨大、PSI 告警,目录项/索引节点缓存(dentry/inode)在高并发目录遍历下快速膨胀,shrinker 回收节奏跟不上,导致可用内存见底,触发 OOM Killer。
二、原理飞线图(快速但不浅)
- slab(SLUB):内核用来管理小对象(dentry/inode/kmalloc-*)的分配器。
- SReclaimable / SUnreclaim:可回收/不可回收 slab,前者在压力大时可被 shrinker 回收。
- VFS 缓存:dentry/inode cache 大量存在于 slab;查找目录、小文件遍历会狂增。
- shrinkers:内核在内存紧张时调用 shrinker 回收可回收对象。
- vm.vfs_cache_pressure:告诉内核“对 VFS 类缓存(dentry/inode)回收要多积极还是保守”。默认 100;越大越积极。
- PSI(Pressure Stall Information):内核暴露的资源压力“卡顿占比”。用它能更精确地在“快要出事”而不是“已经出事”时触发措施。
三、先止血:两段式应急回收
生产上,不要一上来就 echo 3 > /proc/sys/vm/drop_caches(那会同时丢 pagecache,容易引发抖动)。我一般分两步:
应急 Step 1:仅回收 dentry/inode
# 仅丢弃 dentries 与 inodes,不动 pagecache
sync
echo 2 > /proc/sys/vm/drop_caches
应急 Step 2:定向 shrink 某些 slab cache(更细粒度)
SLUB 下每个 cache 都有一个 shrink 接口:
# 观察有哪些 cache
ls /sys/kernel/slab | egrep 'dentry|inode'
# 触发 shrink(对每个 cache 写入 1 即可请求回收)
echo 1 > /sys/kernel/slab/dentry/shrink
echo 1 > /sys/kernel/slab/inode_cache/shrink
这两步把我们机器的 SReclaimable 从 28 GB 级别拉回到 7 GB 左右,业务勉强挺住了。当夜我没有重启任何服务。
四、系统化优化:让“险情”变成“自愈”
4.1 Sysctl:让内核更愿意回收 VFS 缓存
我在这台 256 GB 内存的机器上用的是下面这组(按需调整):
/etc/sysctl.d/99-mem-vfs.conf
# 让 VFS 缓存(dentry/inode)更积极进入回收
vm.vfs_cache_pressure = 250
# 保持更充裕的“自由水位”,避免频繁进入直接回收
# 建议设为内存的 0.5% ~ 1.5%,我这里 1.0% ≈ 2621 MB
vm.min_free_kbytes = 2680000
# 有 swap 就让它更保守点用,避免匿名页过早换出(视业务决定)
vm.swappiness = 10
# 写回阈值,防止积压太多脏页导致内存被脏页占满
vm.dirty_background_ratio = 5
vm.dirty_ratio = 20
生效并复核:
sysctl --system
sysctl vm.vfs_cache_pressure vm.min_free_kbytes vm.swappiness vm.dirty_ratio vm.dirty_background_ratio
经验:vfs_cache_pressure 我在 150300 之间试了几轮。目录遍历型负载偏大时,200300 更稳,但会略微增加元数据命中失效率(多一次磁盘/SSD 访问),要结合业务的 IOPS 裁剪。
4.2 cgroup 限流:别让单个服务把全机拖死
CentOS 8 上 systemd 支持 cgroup v2(视你的 Docker/容器方案,可能在 hybrid/v1)。我们用 systemd slice 对“巡检/备份类”的进程设 MemoryHigh/MemoryMax,让它们先被内核温柔地“挤一挤”,而不是把全机推向 OOM:
示例:限制 backup.service:
# /etc/systemd/system/backup.service.d/override.conf
[Service]
MemoryHigh=32G
MemoryMax=48G
# oom 发生时整组杀,别误杀关键业务的单个线程
OOMPolicy=kill
应用:
systemctl daemon-reload
systemctl restart backup.service
systemctl show backup.service | egrep 'MemoryHigh|MemoryMax|OOMPolicy'
MemoryHigh 会触发回收与压制,让进程群体减速;MemoryMax 是硬上限,防止失控。这样做的副作用是备份速度可能下降,但换来全机稳定。
4.3 PSI 触发的自动 slab 回收(推荐实战)
我做了一个轻量的守护脚本:当 PSI memory some 在指定窗口内持续超过阈值,先定向 shrink dentry/inode,若缓解不够,再执行 drop_caches=2。脚本避免了“无脑每分钟清 cache”的粗暴方案。
/usr/local/sbin/slab-reclaimer.sh
#!/usr/bin/env bash
# 简易 PSI 触发器 + 定向 slab 回收
# 适配 CentOS 8 / SLUB
set -euo pipefail
PSI_FILE="/proc/pressure/memory"
# 触发阈值:'some avg10' 超过 12% 视为系统处于持续内存压力
THRESHOLD=12.0
LOGTAG="slab-reclaimer"
get_psi() {
# 输出 some avg10 的浮点数
awk '/some/ {for(i=1;i<=NF;i++){ if($i ~ /^avg10=/){ split($i,a,"="); print a[2]; break; }}}' "$PSI_FILE"
}
shrink_slab() {
for c in dentry inode_cache; do
local p="/sys/kernel/slab/${c}/shrink"
if [[ -w "$p" ]]; then
echo 1 > "$p"
logger -t "$LOGTAG" "shrink ${c} done"
fi
done
}
drop_dentry_inode() {
sync
echo 2 > /proc/sys/vm/drop_caches
logger -t "$LOGTAG" "drop_caches=2"
}
main() {
local psi
psi=$(get_psi)
# 去掉小数比较
psi_int=${psi%.*}
if (( $(echo "$psi > $THRESHOLD" | bc -l) )); then
logger -t "$LOGTAG" "PSI some avg10=${psi} > ${THRESHOLD}, start reclaim"
shrink_slab
sleep 2
# 再看一眼 PSI,如果仍然很高再丢 dentry/inode
psi2=$(get_psi)
if (( $(echo "$psi2 > $THRESHOLD" | bc -l) )); then
drop_dentry_inode
fi
fi
}
main
Systemd 定时器每 30 秒跑一次:
/etc/systemd/system/slab-reclaimer.service
[Unit]
Description=PSI-driven slab reclaimer
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/slab-reclaimer.sh
/etc/systemd/system/slab-reclaimer.timer
[Unit]
Description=Run slab reclaimer periodically
[Timer]
OnBootSec=1min
OnUnitActiveSec=30s
AccuracySec=5s
[Install]
WantedBy=timers.target
启用:
chmod +x /usr/local/sbin/slab-reclaimer.sh
systemctl daemon-reload
systemctl enable --now slab-reclaimer.timer
systemctl list-timers | grep slab-reclaimer
journalctl -t slab-reclaimer -f
这个组合上线后,我基本没再看见 OOM;同时避免了过度清理 pagecache 带来的读放大。
4.4 旁路优化(可选但有效)
透明大页(THP):对我们这类大量小对象/随机访问负载,通常关闭更稳。
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
持久化放在 /etc/rc.d/rc.local 并 chmod +x。
- 确认未启用 slab_nomerge:防止 SLAB cache 合并被禁止导致内存碎片增大(CentOS 8 默认不加,检查内核参数)。
- tuned:选择 throughput-performance,获得合理的 VM/IO 基线(再叠加我们自定义的 sysctl)。
- tuned-adm profile throughput-performance
五、验证:对比指标与业务表现
调优/上线一周后的复盘数据(同一批量级业务,取高峰均值):
| 指标/现象 | Before(调优前) | After(调优后) | 变化 |
|---|---|---|---|
| MemAvailable | 6.1 GB | 42.7 GB | ↑ 更安全的余量 |
| Slab | 34.8 GB | 12.3 GB | ↓ |
| SReclaimable | 27.9 GB | 6.6 GB | ↓ 大幅压缩 |
PSI memory some avg10 |
21.3% | 3.4% | ↓ 几乎消失 |
| OOM 事件/天 | 2~3 次 | 0 | 清零 |
| Nginx P99 延迟(静态小文件) | 62 ms | 54 ms | 稍有改善(更稳定) |
| NVMe 读取 IOPS 波动(±) | 35% | 12% | 波动显著降低 |
六、上线踩坑与排障细节
vfs_cache_pressure 不是越大越好
设置到 400 以上时我们看到元数据命中率下降,导致 NVMe IOPS 波动加大;最终稳定在 250 比较折中。
min_free_kbytes 设置不当会“挤”业务
设太大(>2% 内存)会让系统常态下更频繁做回收,反而引发抖动;建议从 0.5%~1.0% 试。
定时器频率
30 s 一次基本够用;10 s 太频繁会让 PSI 刚回落又被触发,出现锯齿。PSI 是慢指标,用 avg10 更稳。
容器 cgroup 版本坑
Docker 老版本在 cgroup v1 上,MemoryHigh 不生效(那是 v2 的概念)。这时要用 --memory+--memory-swap 做硬限,或升级到支持 cgroup v2 的版本/配置 systemd unified。
drop_caches 的误用
不要把 echo 3(顺带清 pagecache)当成日常保洁;我们优先 定向 shrink + drop_caches=2,避免缓存抖动。
打开文件句柄会“钉住”对象
某些 slab 项被正在使用的对象引用,不可回收。那次发现一个内部巡检脚本递归 stat 后没及时释放句柄,导致 inode_cache 局部回收不动——改脚本后恢复。
备份/巡检的限速
给 rsync/自研扫描器加了 I/O 和并发上限(--bwlimit、goroutine 限制),配合 MemoryHigh,从源头上减轻 slab 膨胀速度。
七、可直接复用的落地清单(Checklist)
- 抓取基线:free -h、/proc/meminfo、slabtop、PSI
- 应急两步走:shrink dentry/inode → drop_caches=2
- sysctl:vfs_cache_pressure=150~300、min_free_kbytes≈0.5%~1.0%RAM、swappiness=10、dirty_* 合理化
- cgroup 限制高风险任务的 MemoryHigh/MemoryMax(或容器内 --memory)
- 上线 PSI 触发的 slab-reclaimer 定时器(30 s)
- 可选:关闭 THP、确认未 slab_nomerge、tuned profile
- 一周复盘:观察 SReclaimable、PSI、P99、OOM 计数
一周后我又在同一条走廊走过。空调还是那么冷,但告警安静了。那台机器的 SReclaimable 常驻在 6~8 GB,PSI 几乎贴底。
我记得第一次处理这类问题的时候,总想着“一键清缓存”。后来才知道,真正的稳定不是“把水桶倒空”,而是“让水在桶里流动起来并且不溢出”。这也是我写下这些步骤的原因——在 CentOS 8 上,把 slab reclaim 做成体系化的“提前回收 + 柔性限流 + 事件触发”,才能在高负载时,稳稳地躲开 OOM Killer 的镰刀。
如果你的场景和我类似,照着做,一般一晚上就能见效;如果你的负载偏计算/大内存匿名页,再来聊聊另一套“匿名页为主”的打法。