你有没有遇到过这种场景:free -g 看内存,used 冲得很高,但按 RSS 把进程排个序,从头翻到尾也找不到哪个进程是真正的"大户";问业务方说一切正常,可机器就是越来越慢,甚至开始 OOM。如果这时候顺手打开 /proc/meminfo,大概率会看到 Slab 那一栏高得离谱,动辄几个 GB,甚至十几个 GB。这就是典型的 slab 占用内存异常,再往下深挖,往往就是一个 slab leak。
这篇文章是写给内核排查和系统优化工程师的,目的很简单:当 Slab 数值异常时,怎么一步步搞清楚到底是哪些 cache 占了大头,哪个模块在分配对象后不释放,以及如何区分"真泄漏"和"假泄漏"。我会把常用工具和实测套路完整过一遍,包括 /proc/slabinfo 的字段拆解、slabtop 的快速定位、slub_debug 的调用点追踪、kmemleak 的扫描适用边界,最后用一个模拟案例把整个排查链路串起来。新手可以按部就班操作,老手也可以看看有没有遗漏的切入角度。
1. 先定位 slab 在内存账本里的角色:三个分配层与两级缓存
1.1 从进程视角看不到的那块内存
Linux 内存可以粗略分成用户态和内核态两部分。用户态内存都有进程页表对应,RSS、VSZ 这些指标能帮你快速找到"吃内存的进程"。内核态的内存则要复杂得多,一部分是内核代码段、数据段、页表本身,另一部分就是 slab 分配器管理的各种小对象。
内核里的小对象无处不在:文件系统路径解析用的 dentry、索引节点 inode、块层的 buffer_head、网络协议栈的 sk_buff、各种驱动的私有数据结构。单个对象往往只有几十到几百字节,看起来微不足道,但数量一上来就很恐怖。一台高并发的文件服务器上 dentry 数量动辄上百万,一个 dentry 对象按 192 字节算,一百万个就是接近 200MB。如果某个驱动对每个 I/O 请求都分配一个私有对象且忘记释放,SUnreclaim 就会像滚雪球一样涨上去。
1.2 slab/slub/slob:名字不同,本质都是小对象的批发市场
Linux 的历史上有三种 slab 相关分配器:传统 slab、嵌入式场景使用的 slob、以及当前主流内核默认的 slub。虽然大家在排查问题时嘴上都说"slab 涨了",但绝大多数现代服务器上实际运行的是 SLUB。
不管叫哪个名字,核心思想都一样:把伙伴系统申请来的物理页拆成多个同等大小的对象,放进对应的 kmem_cache。每种对象类型对应一个 cache,比如 dentry、inode、kmalloc-256。分配和释放都以对象为粒度,对象释放后不是立刻还给伙伴系统,而是留在 cache 的空闲链表里复用。这样做有两个明显好处:一是减少内核用零碎小对象频繁向伙伴系统申请页带来的外部碎片,二是复用对象时不需要重新做初始化,性能更好。
打个比方,伙伴系统是土地批发商,最少一亩一亩卖;而 slab 分配器是精装公寓运营方,把整层楼隔成统一的小房间出租。大量小对象频繁入住和退租,如果每次都去找批发商要整块地,成本完全不可接受。
1.3 为什么 slab 会成为排查重点
/proc/meminfo 里的 Slab 分为两块:SReclaimable 和 SUnreclaim。SReclaimable 代表可回收对象,比如文件缓存相关的 dentry、inode,在内存压力下内核可以回收;SUnreclaim 代表不可回收对象,一旦分配就很难自动释放。如果 SUnreclaim 持续增长,基本可以判定存在内核对象泄漏。
但实际排查中经常遇到 SReclaimable 也很高的情况,这会让"是不是泄漏"的结论变得模糊。所以分析 slab 占用细节的关键,不是只看总量,而是搞清楚:每一个占用大的 cache 是谁创建的、对象数量是多少、这些对象可不可回收、分配和释放是否平衡。这也是后面每一步操作的目标。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 /proc/slabinfo 和 slabtop 出发:哪些指标说明真出问题了
2.1 slabinfo 列字段逐行拆解
/proc/slabinfo 是排查 slab 问题最直接的入口。先看一段模拟输出:
code复制slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <shared> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-256 1048576 1048576 256 16 1 : tunables 64 16 0 : slabdata 65536 65536 0
dentry 1920000 2000000 192 21 1 : tunables 64 16 0 : slabdata 95239 100000 0
逐列拆解一下:
<active_objs>:当前 cache 中正在使用的对象数量。<num_objs>:当前 cache 管理的对象总数,包含空闲对象。<objsize>:单个对象的大小,单位是字节。<objperslab>:一个 slab 里能放多少个对象。<pagesperslab>:一个 slab 占用多少物理页。<active_slabs>:当前处于活动状态的 slab 数量。<num_slabs>:该 cache 当前实际持有的 slab 总数。<sharedavail>:NUMA 环境中跨节点共享对象的数量,通常很少关注。
注意 num_objs 和 active_objs 的差值代表空闲对象。如果 num_objs 很大但 active_objs 很小,说明空闲对象多,cache 里沉淀了大量暂时没用上的对象,这未必是泄漏;如果 active_objs 和 num_slabs 同时持续上涨,那就要警惕了。
另外一个容易踩坑的点是:objsize 只是对象本身的大小,并不代表 cache 的真实内存占用。SLUB 从伙伴系统拿内存是以 slab 为单位,哪怕一个 slab 里只有 1 个活跃对象,其余空间也已经被这个 cache 占住了。所以估算实际占用内存要按 slab 数来算,而不是简单拿 num_objs 乘 objsize。
2.2 快速估算某个 cache 的真实占用
我习惯用一个小 awk 命令把 slabinfo 按真实内存占用排序:
bash复制awk 'NR>2 { printf "%-40s %10lu kB\n", $1, $10*$6*4096/1024 }' /proc/slabinfo | sort -k2 -n | tail -30
这里的 $6 是 pagesperslab,$10 是 num_slabs。一个物理页按 4096 字节计算,乘积就是该 cache 占用的物理内存。输出后,排在后面的就是内存占用最大的 cache,优先看它们。
如果想更细一点,可以同时输出对象数:
bash复制awk 'NR>2 { mem=$10*$6*4096/1024; printf "%-40s %10lu kB active=%lu total=%lu\n", $1, mem, $2, $3 }' /proc/slabinfo | sort -k2 -n | tail -30
这个脚本适合在连续时间点各跑一次,把结果保存下来做 diff。如果某个 cache 的 active_objs 和 num_slabs 都在快速增长,它大概率就是问题源头。
2.3 slabtop 第一眼该看什么排序
slabtop 是交互式实时工具,本质上就是 /proc/slabinfo 的封装。打开后默认按活跃对象数排序,屏幕上会持续刷新。我一般会先按 a 看 active_objs 排序,再按 l 看 num_slabs 排序。如果两种排序下同一个 cache 都排在最前面,说明它确实是"又多又活跃"。
需要提醒的是,slabtop 适合快速定位,但单次快照不能说明趋势。我通常这样用:先 slabtop -o > slab_before.txt,过一小时或半天再执行一次 slabtop -o > slab_after.txt,再对比两个文件中同一个 cache 的活跃对象数和 slab 数有没有明显变化。这一步虽然朴素,但非常有效。
3. 用 slub_debug 让泄漏对象开口说话:alloc_calls 与 free_calls 的实战用法
3.1 先把内核开关打开:slub_debug 参数组合
/proc/slabinfo 能告诉你哪个 cache 在涨,但它回答不了"谁分配了这些对象"的问题。要拿到分配调用点,就必须靠 SLUB 的调试能力。
前提是内核编译时开启 CONFIG_SLUB_DEBUG,大多数发行版内核默认是开的,可以用下面命令确认:
bash复制grep CONFIG_SLUB_DEBUG /boot/config-$(uname -r)
如果输出是 CONFIG_SLUB_DEBUG=y,那就可以在启动参数里加 slub_debug 选项。常见的选项字符如下:
F:Sanity check,做基本一致性校验。Z:Red Zone,在对象周围设置特殊标记,检测越界写。P:Poison,对象分配和释放时填入固定模式,检测 use-after-free 和重复释放。U:Store User,记录每个对象的分配和释放调用点。T:Trace,开启调用追踪。
排错时最核心的是 U,因为只有它能在 /sys/kernel/slab/<cache>/ 下面生成 alloc_calls 和 free_calls 文件。可以全局开启:
code复制slub_debug=PZFU
如果已经大致知道问题出在哪个 cache,建议只对特定 cache 开启,降低性能开销:
code复制slub_debug=PZFU,mydriver_cache
注意 slub_debug 是启动参数,修改后必须重启内核才能生效。对于生产环境,这通常意味着你需要在测试环境或维护窗口复现问题。
3.2 alloc_calls/free_calls 怎么读
重启并复现问题后,去 /sys/kernel/slab/ 目录下找到目标 cache:
bash复制ls /sys/kernel/slab/mydriver_cache/
如果看到 alloc_calls 和 free_calls 文件,说明 U 选项生效了。分别查看:
bash复制cat /sys/kernel/slab/mydriver_cache/alloc_calls
cat /sys/kernel/slab/mydriver_cache/free_calls
输出每一行表示一个调用点,大概长这样:
code复制 10800 my_io_submit+0x2e/0x80 [mydriver] age=7200/7200/7200
字段从左到右分别是:该调用点累计分配次数、函数名加上偏移/函数大小、所属模块名、对象年龄的最小/平均/最大。free_calls 的格式类似,但记录的是释放调用点。
排查思路很简单:把两个文件里同一函数的计数做对比。正常情况下,alloc_calls 里出现的函数,在 free_calls 里也应该有对应的释放次数。如果某个函数在 alloc_calls 里持续增长,而 free_calls 里几乎没有它的身影,说明这条路径分配了对象却没有释放。再看 age,如果对象年龄非常大,说明对象被分配后长期待在系统里,进一步佐证泄漏。
3.3 不重启内核的替代手段:tracepoint 与 bpftrace 挂钩
实际上,生产系统往往没法说重启就重启。如果已经确定可疑 cache 名字,并且有权限挂载 BPF 工具,完全可以用 bpftrace 直接在运行中的内核里抓分配/释放现场。
下面这段脚本限定只跟踪 mydriver_cache,把分配和释放路径的调用栈分别计数:
bpftrace复制#!/usr/bin/env bpftrace
kprobe:kmem_cache_alloc
{
$cache = (struct kmem_cache *)arg0;
if (strncmp($cache->name, "mydriver_cache", 14) == 0) {
@alloc[comm, kstack] = count();
}
}
kprobe:kmem_cache_free
{
$cache = (struct kmem_cache *)arg0;
if (strncmp($cache->name, "mydriver_cache", 14) == 0) {
@free[comm, kstack] = count();
}
}
运行几十秒后按 Ctrl-C,bpftrace 会打印两张表。对比 @alloc 和 @free,只出现在 alloc 表里的栈基本就是泄漏路径。需要注意,kmem_cache_alloc/kmem_cache_free 在内核里的调用频率可能非常高,即使加了 cache 名过滤也要谨慎,建议在低峰期或者故障复现窗口短时间运行。
4. kmemleak 兜底扫描:适合哪些泄漏,不适合哪些泄漏
4.1 开启 kmemleak 的正确姿势
kmemleak 是内核自带的"内存泄漏探测器",原理有点类似用户态的内存泄漏检测工具:从已知的内存根节点出发,遍历所有内存引用,找出无法从根节点到达的对象,然后报告这些"不可达"的内存块。
要使用它,内核必须开启 CONFIG_DEBUG_KMEMLEAK=y,并且启动参数里加上 kmemleak=on。默认情况下,kmemleak 会周期性扫描内存。手动控制方式如下:
bash复制mount -t debugfs none /sys/kernel/debug
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
echo scan 会立即触发一轮扫描,扫描完成后,kmemleak 会把新发现的不可达对象输出到 /sys/kernel/debug/kmemleak。echo clear 可以清空已有报告,方便下一轮复现时做对比。
4.2 一条报告的完整解读
开启后等一段时间,cat /sys/kernel/debug/kmemleak 会看到类似下面的输出:
code复制unreferenced object 0xffff88811f0a9000 (size 512):
comm "myapp", pid 1234, jiffies 4294891234 (age 10800.660s)
hex dump (first 32 bytes):
10 20 30 40 50 60 70 80 90 a0 b0 c0 d0 e0 f0 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
backtrace:
[<0000000012ab>] my_alloc_func+0x12/0x20 [mydriver]
[<0000000034cd>] my_io_submit+0x2e/0x80 [mydriver]
[<0000000056ef>] do_syscall_64+0x7f/0x100
需要关注三块:
unreferenced object后面的地址和size,表示不可达对象的地址和大小。comm、pid、jiffies,表示这个对象是在哪个进程上下文里分配的,以及分配的时间。backtrace是分配时的内核调用栈,指向具体模块和函数。
如果调用栈里有明显的自定义模块函数,比如 [mydriver],那基本就是定位到了。接下来去源码里看对应分配路径即可。
4.3 kmemleak 的边界:误报与漏报都常见
kmemleak 不是万能的。先说说误报:内核里有些数据结构会保存指向对象的指针,但这些指针并不是传统意义上的"引用",或者被存放在 kmemleak 扫描不到的区域,导致 kmemleak 认为对象不可达。这类误报在小对象池里尤其常见。所以看到一条报告,不要马上断言就是泄漏,最好结合 slub_debug 的 alloc_calls 再做一次验证。
再说漏报:如果某个对象一直挂在某个全局链表里,从根集合出发"可达
