1. 项目概述
1.1 核心需求解析
做Linux性能调优这些年,我遇到过不少棘手的场景:数据库节点内存莫名其妙被吃光、高并发服务在运行几天后出现卡顿、容器平台频繁报出内存不足但业务进程的内存占用看起来一切正常。这种时候,大部分人第一反应是看free、top,然后陷入迷茫——用户态内存明明没怎么涨,内存到底去哪了?
答案往往藏在内核态的内存分配里。而说到内核内存分配,Slab是绕不开的话题。我在排查实际故障时,多次靠分析Slab信息找到了内存增长的真凶,比如某个驱动版本存在对象泄漏、某个文件系统缓存的dentry对象数量异常膨胀。可以这么说,不懂Slab,你对Linux内存管理的理解就停留在用户态的皮毛。
这篇文章不是教科书式的源码逐行分析,而是把我实际踩坑、定位问题、做性能分析的经验整理出来。核心围绕三件事:内核内存分配的整体链路是什么、Slab分配器的设计原理和数据结构、以及拿到一台内存异常的机器后,如何用Slab相关的工具和指标快速缩小排查范围。另外会穿插介绍实际项目中遇到过的问题case,包括如何通过调整Slab相关的内核参数来优化特定场景的性能。
适合这样几类读者:已经在用Linux但遇到内存问题无从下手的运维和SRE、刚接触内核开发的工程师、以及做云原生基础设施希望深入理解容器内存隔离底层原理的同学。如果你对内存管理的理解还停留在free和top的层面,这篇文章能帮你打开新的一扇门。
2. 内核内存分配的整体链路
2.1 从伙伴系统到Slab的两级分配架构
先理清一个基本框架。Linux内核管理物理内存的核心机制是伙伴系统(Buddy System),它以页(通常是4KB)为单位管理物理内存。伙伴系统的设计思想是把空闲内存按2的幂次方分成不同的链表,比如order为0的链表挂的是单页块,order为1的链表挂的是2页连续块,order为2的链表挂的是4页连续块,以此类推。分配内存时,内核从满足需求的最小order对应的链表里摘一块,如果找不到就向更大的order“借”,然后逐级拆分。
这套机制在分配大块内存时非常高效,但它有个明显的短板:内核里大量需要分配的对象远小于一个页的大小。比如一个task_struct结构体、一个dentry结构体、一个inode结构体,大小从几十字节到几KB不等。如果每次都通过伙伴系统分配一个整页,会造成两个问题:一是内存内部碎片的浪费,可能一个页只用了100字节;二是频繁的分配和释放会导致伙伴系统的链表操作开销很大,而且每次分配都要走比较重的路径。
于是Slab分配器就出现了。它建立在伙伴系统之上,充当“二级缓存”的角色。简单理解,Slab从伙伴系统申请一批连续页面,然后把这些页面划分成大小相等的对象,维护一个对象池。需要分配小对象时,直接从对象池里取一个,释放时归还对象池,这样就绕过了频繁的伙伴系统操作,分配和释放速度会快得多。
2.2 为什么需要单独一套对象缓存机制
也许你会问,既然伙伴系统之上的Slab本质是缓存分配,那为什么不把所有小于一页的分配请求统一交给一个通用内存池,而非要为每种对象建立独立的缓存?这里就有Slab设计里一个很重要的思想:对象类型的区分。
不同类型的内核对象,它们的生命周期、使用频率、初始化开销差异很大。以dentry为例,文件路径查找时需要频繁创建和释放dentry对象。如果每次分配都是“拿一块内存、手动初始化字段、用完后手动销毁”,性能会很差。Slab的思路是把“构造”和“析构”也纳入缓存体系——第一次从Slab缓存中拿到对象时做完整的构造函数初始化,后续分配和释放时只做字段的清理和复用,不再重复执行高成本的初始化操作。这就是kmem_cache_create接口中构造函数参数的用武之地。
另一个原因是便于统计和监控。每个Slab缓存都有名字,比如kmalloc-64、dentry、filp。通过/proc/slabinfo可以清晰看到每种对象的数量、占用内存,这在排查内存泄漏时极其关键。你可以直接定位到某种对象暴涨,进而反查是哪个内核子系统在疯狂创建对象,这种精确到“对象类型”的可观测性是通用内存池无法提供的。
2.3 经典Slab、SLUB与SLOB三种实现
说到这儿必须提一个容易混淆的点:平时我们说的Slab,广义上可能指代三种不同的实现。早期内核用的是经典Slab分配器,设计精巧但代码复杂,在多核大内存的机器上锁竞争问题比较突出。从内核2.6.23开始,SLUB分配器成为默认实现,它简化了经典Slab的很多复杂逻辑,把per-CPU的本地对象缓存做到更轻量,同时保留了Slab的核心思想——每对象缓存。现在的绝大多数发行版,你看到的内核里实际运行的其实是SLUB。
SLOB则面向内存极度受限的嵌入式场景,它把Slab的缓存机制退化掉,所有小对象都从连续内存段里紧凑分配,核心目的是节省内存而不是追求速度。平时做性能分析和排查,几乎遇不到SLOB,所以下文说Slab的地方,实际上指的都是SLUB实现,但在概念层面,经典Slab里的缓存着色、对象复用等设计思路依然适用,理解它们有助于看懂各种内核文档。
我建议在弄懂原理时,先按经典Slab的概念去理解对象、Slab缓存、per-CPU部分,然后再对照SLUB去理解实际代码,这样认知落差不至于太大。经典Slab在mm/slab.c,SLUB在mm/slub.c,两者在分配路径上的差异主要体现在元数据的管理方式上。
3. Slab分配器的核心设计原理
3.1 slab、cache、object三个层级
理解Slab,关键是抓住三个概念:struct kmem_cache(缓存描述符)、slab(一个或多个连续页构成的容器)、object(实际分配的对象)。
一个kmem_cache对应一种类型的对象,它有三种关键状态:kmem_cache_cpu(当前CPU上的活动slab)、kmem_cache_node(每个NUMA节点上的部分空slab链表)、以及全空和全满的链表。分配路径的核心优化策略是:优先从当前CPU的本地活动slab取对象,只有本地没有空闲对象时才去节点级链表寻找部分空slab,实在不行再从伙伴系统分配新页面。
用个生活化的类比:想象你是餐厅厨师,厨房台面上放着几种常用调料(当前CPU的本地slab),用完了就去后厨储物柜补货(节点级链表),储物柜也空了就打电话让供应商送货(伙伴系统)。你当然不想每次用盐都跑去供应商那里进货,Slab的per-CPU缓存就是这份“台面调料”的功劳。
对象是Slab管理的原子单位。每个对象在slab中占据固定大小的空间,SLUB在对象之间嵌入了一些管理数据(freelist指针),用来串起空闲对象链表。分配时从freelist头部弹出一个对象,释放时把对象重新挂回freelist。这个过程在无竞争的情况下也就是几十纳秒级别的操作,非常快。
3.2 内存着色与cacheline伪共享问题
在经典Slab设计中,有个有意思的技术叫缓存着色(cache coloring),SLUB里也有相关考虑。它的目的是解决什么问题呢?
硬件CPU的一级和二级缓存(cacheline)大小通常是64字节。如果所有slab里的对象都从同一个内存对齐偏移开始排列,那么不同slab中相同位置的对象会映射到CPU缓存的同一组(cache set)上。当某个内核路径频繁访问多个slab中相同偏移位置的对象时,就会造成缓存行冲突,导致cache miss率上升。着色就是让不同的slab里的对象起始地址在页内偏移上做一个错开,让它们散布到不同的缓存集合里,降低冲突。
SLUB里虽然没有完整保留经典Slab的着色机制,但它在对象布局上做了一些对齐和redzone处理,加之SLUB默认把freelist信息放在对象之间而不是单独管理,实际效果上对象地址在页内的分布是比较分散的。现代CPU缓存容量大了很多,着色带来的收益不如当年显著,但这个设计思路在理解硬件和内存分配器交互时仍然很有价值。
还有个相关问题值得注意:per-CPU缓存其实也顺便规避了多核竞争下的伪共享问题。每个CPU有自己独立的活动slab,不同CPU不会同时操作同一个对象的freelist,因此避免了内核对象在多核间乒乓迁移导致的cacheline失效。这一点在高吞吐场景下非常关键,也是SLUB能成为默认实现的重要原因之一。
3.3 kmalloc系列接口如何走Slab路径
Slab不只是给内核内部各类对象使用,它还实现了通用的kmalloc接口。你在写驱动或内核模块时常用kmalloc(size, GFP_KERNEL)分配内存,这个调用最终走的也是Slab/SLUB的分配路径。
内核里预创建了一系列固定大小的kmalloc缓存,覆盖从8字节到8MB的范围,比如kmalloc-8、kmalloc-16、kmalloc-32、kmalloc-64、kmalloc-128、kmalloc-256、kmalloc-512、kmalloc-1k一直到kmalloc-8k等。kmalloc调用时,内核根据请求大小找到不小于该大小的缓存,从里面对应分配一个对象。
所以分析/proc/slabinfo时,你会看到大量以kmalloc-为前缀的缓存。这些缓存里的对象来源很杂,可能是某个驱动分配的临时缓冲区,也可能是网络层分配的sk_buff数据区,无法直接看出是谁分配的。这也是Slab分析相对棘手的地方——kmalloc缓存对象的归属不好追踪。相比之下,像dentry、inode、filp这样有明确名字的专用缓存就好排查得多。
当然,SLUB提供了追踪分配的机制,编译内核时开启CONFIG_SLUB_DEBUG后,通过slub_debug参数可以记录分配调用栈。不过这在生产环境里开销较大,通常是出了问题后专门开启进行复现调查,不适合常驻。
3.4 GFP标志位与内存回收的关系
讨论内核内存分配,不能只看分配器本身,还得看分配时的GFP标志。GFP_KERNEL和GFP_ATOMIC代表两种截然不同的语义:GFP_KERNEL允许分配时睡眠等待内存回收,这意味着可以触发页换出、收缩缓存等操作;GFP_ATOMIC则用于中断上下文或持有自旋锁的路径,不允许睡眠,一旦在内存紧张时分配失败就立即返回错误。
这跟Slab有什么关系?关系很大。Slab分配器需要向伙伴系统申请页面作为新的slab容器时,携带的就是调用者的GFP标志。休眠允许与否决定了在内存水位线不足时,分配器是等待回收完毕再分配,还是直接返回NULL。理解这一点,才能在排查莫名分配失败时判断是内存真的耗尽,还是因为上下文限制了分配能力。
另外一个容易被忽略的点是__GFP_RECLAIMABLE标志。有些Slab缓存创建时明确标注了可回收性,例如dentry和inode缓存,它们的内存页在内存压力下可以被内核主动收缩释放。这些可回收的页会在内存管理器的LRU列表中有专门归属。因此即使一个系统free显示内存占用很高,只要可回收Slab占了大头,系统未必真的处于OOM风险中,关键要区分“可回收”和“不可回收”两类。
提示:排查内存问题时,先分清楚内存是被可回收缓存占用,还是被不可回收对象占用,很多时候能帮你快速判断要不要介入处理。如果全是可回收的dentry/page cache,而业务本身没有明显的分配失败,系统可能还在安全范围内。
4. 性能分析与排查实战
4.1 分析Slab状态的三个必知命令
在真实环境里分析Slab,最常用的三个工具是/proc/slabinfo、slabtop和perf。它们各有侧重,配合使用效果最好。
cat /proc/slabinfo会输出所有Slab缓存的详细信息,包含对象数量、活动对象、每对象大小、每个slab的页数等。这是最底层的信息源。格式大致是:
slabinfo - version: 2.1 # name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>关键看active_objs和num_objs,两者差异越大说明空闲对象越多。如果一个缓存的active_objs持续增长且从不回落,大概率存在对象泄漏。
slabtop是top风格的交互式工具,按对象数量或内存占用排序展示Slab缓存。执行slabtop -o可以按活动对象数排序,一眼看到哪类对象涨幅最猛,很多内核调优工作中的第一步就是把slabtop的输出记录下来作为基线。
perf能派上用场的地方在于定位分配热点。通过perf record采样内核态调用栈,再看分配路径上的函数占比,可以确认是哪个子系统在频繁分配对象。举个例子,如果perf采样里__kmalloc调用占比高,且调用栈中频繁出现tcp_alloc_md5sig_pool之类的函数,那你就找到了网络栈的分配热点。
4.2 /proc/slabinfo字段逐个拆解
很多人看到/proc/slabinfo密密麻麻的输出就发怵,其实核心字段并不复杂。我挑几个关键的解释一下。
objsize是每个对象的大小。objperslab是每个slab能放多少个对象,它由slab所占页数和对象大小共同决定。pagesperslab就是这个slab占用的页数。tunables里的limit表示per-CPU队列中最多缓存多少个空闲对象,batchcount是一次从节点链表搬运到per-CPU队列的对象数量。这两个参数在经典Slab里影响明显,SLUB中逻辑有些变化,但概念仍可参考。
slabdata部分的active_slabs和num_slabs分别表示正在使用的slab数量和总slab数量,两者差值代表完全空闲的slab数量。如果某个缓存中空闲slab很多,说明对象被释放后slab容器本身没有归还给伙伴系统,处于“留而不放”的状态。这是设计使然,目的是应对突发分配,但如果这种缓存多且持续不释放,也会造成实际内存占用虚高。
看一个实际案例。我在一次内存告警中执行slabtop发现kmalloc-192缓存有近200万个对象,但活动对象只有几十万,也就是好几GB内存几乎全是空闲对象占着。结合业务场景判断,这个分配模式来自某个网络中间件在高峰期创建的大量小对象,峰值过后对象释放了,但SLUB的per-CPU缓存和节点缓存没有及时把空slab归还伙伴系统。这种场景下的处理手法是“等待+观察”,只要没有持续的对象创建,内存会在系统内存压力增大时自动回收,不建议手动干预。
4.3 使用perf定位Slab分配热点
当你想进一步了解到底是哪个模块在频繁分配Slab对象时,perf是最直接的武器。假设我在一台压测机器上发现kmalloc-128缓存的对象数量增长异常,我可以这样定位:
perf record -g -a -- sleep 5 perf report --sort=sym,callchain在内核调用栈中查找__kmalloc相关的路径,然后逐级展开,看最频繁的调用来源。我自己实际遇到过一个典型案例:某分布式存储节点内存持续上涨,slabtop显示bio相关缓存数量非常大,用perf采样后发现都来自块设备层的bio_alloc路径,再往下关联到文件系统用到了direct IO。最终定位是上层存储服务在做4KB对齐的直写IO时,bio创建频率过高,而每次bio的释放没有及时归还给Slab缓存,导致对象池膨胀。
找到热点后,有两种优化路线:一是优化应用层,合并小IO,减少bio创建次数;二是调整内核参数,比如增大/sys/module/block/parameters/...下的某些队列深度参数。路线选择取决于业务形态,但定位到具体分配路径是前提。
4.4 观察对象数量变化趋势的正确姿势
分析Slab状态时,单次快照意义有限,比较有价值的是看对象数量的变化曲线。我通常在排查内存问题时做这样一组操作:
- 记录当前
/proc/slabinfo的快照,保存一份baseline。 - 等30到60秒后再记录一次,对比不同缓存
active_objs的变化。 - 重点关注持续增长且增长速率稳定的缓存,这类对象大概率存在泄漏或异常膨胀。
- 对疑似缓存,通过
slabtop -s按缓存名排序,重点盯一段时间。
这个思路和排查用户态内存泄漏用的是同一套方法论——找增长点,区分“该涨”还是“不该涨”。比如文件服务器上dentry对象增长,可能是业务在大量遍历目录,如果是合理操作就可以接受;但如果是绝不应该频繁创建dentry的场景,那就要怀疑是不是某些路径没有正确释放dentry引用。
注意:Slab对象数量短暂上升是正常的,尤其是网络高并发和文件密集访问场景。判断是否异常,核心看“回落”——正常情况下高峰期过后对象数量会下降,不回落就要警惕。
5. 常见问题定位与调优笔记
5.1 内存泄漏排查:对象只增不减怎么办
Slab层面的内存泄漏,特征非常明显:某个缓存的活动对象数量线性增长,且业务上找不到对应的正常增长逻辑。排查Skab内存泄漏,常见的手法有这么几种。
先确认是不是真的泄漏。连续采样几个小时甚至几天,看增长趋势是否持续。如果业务量平稳,但dentry、kmalloc-*缓存却不断攀升,就有问题了。工具方面,kmemleak是专门检测内核内存泄漏的利器,它通过扫描内存中的指针来发现无人引用的已分配内存块。启用kmemleak需要在编译内核时开启CONFIG_DEBUG_KMEMLEAK,使用echo scan > /sys/kernel/debug/kmemleak触发扫描,然后查看报告。
另一种手法是配合slub_debug。通过内核启动参数slub_debug=P启用页面完整性检查,释放对象时会校验是否有越界写入;通过slub_debug=F则会在分配和释放时记录调用栈,泄漏后可以通过/sys/kernel/slab/<cache-name>/trace追踪最近的分配历史。这个方法开销大,适合临时复现问题。
我处理过一次vmalloc相关的Slab泄漏,现象是kmalloc-4k缓存对象数持续增长,但kmemleak没有报告明显的未被引用内存。后来是通过slub_debug=F重新复现并排查调用栈,发现是一个第三方驱动在某条错误处理路径中,分配了对象却因为异常分支提前返回忘记释放。这属于驱动资源管理的经典错误,代码审查时的教训是:每个错误分支都要检查资源释放路径是否完整。
5.2 内存碎片化对Slab的影响
内核内存碎片化是个隐蔽问题。虽然Slab向伙伴系统申请的通常是连续页面,但申请较大order的页面(比如order 3,即连续的8页)可能因为伙伴系统里没有足够大的连续块而失败,即使系统总空闲内存还很多。
这种场景在长期运行且频繁分配释放大内存的服务器上并不少见。伙伴系统的高阶连续内存被大量配置成低阶块,而Slab这种“细碎”分配模式恰恰容易加深碎片化程度——因为它会把大页面拆散成小对象,长时间运行后,想把内存重新聚合回高阶块就很困难了。
应对思路有三个层面。第一,避免不必要的Slab大对象分配,能用vmalloc的场景就用vmalloc,它不需要连续的物理内存;第二,通过drop_caches或compact_memory触发内存规整,把分散的页页迁移并聚合起来;第三,通过代码层面合并分配请求,减少对高阶order的需求。
# 查看内存碎片化指数 cat /proc/buddyinfo # 手动触发内存规整(谨慎操作,生产环境建议先在测试机验证) echo 1 > /proc/sys/vm/compact_memory/proc/buddyinfo每个order对应列的数字代表该大小的连续块数量。如果你发现低order列数字巨大、高order列好几个都是0,并且系统还会出现“分配大块连续内存失败”的告警,碎片化就相当严重了。
5.3 回收策略与shadow memory开销控制
Slab缓存也不是只进不出。内核在内存水位低时会触发内存回收,可回收的对象(比如文件系统的dentry/inode缓存)会被自动收缩。/proc/sys/vm/vfs_cache_pressure控制着内核回收目录项和索引节点缓存的积极程度,默认值100,值越大回收越积极。把这个值调高,可以让内核在内存紧张时更努力回收文件系统缓存,释放更多内存。
还有一类和Slab相关的开销容易被忽视,就是SLUB自身的调试设施带来的额外内存。slub_debug开启后,每个对象会附加redzone、track、padding等元数据,对象实际占用的内存会增大不少,同时速度和启动时间也会受影响。生产环境默认不要开启这些调试选项,只有在内核开发或定位疑难问题时才考虑临时开启。
另外,cgroup的内存限制也会影响Slab。容器场景下,memory.kmem.slabinfo可以单独查看某个cgroup的Slab使用量。如果容器频繁触发OOM,但业务本身内存不高,就要考虑是不是内核对象在cgroup维度占用过多,比如容器内频繁的文件操作导致dcache在cgroup里累积。这种情况下适当设置memory.kmem.limit_in_bytes可能有帮助。
5.4 与容器内存统计相关的Slab问题
容器化环境下,Slab问题有个比较坑的地方:内核对象的内存属于哪个cgroup,统计起来并不总是直观。dentry、inode这类内核缓存在较新的内核中能归属到发起操作的cgroup,但也不是百分百精确。
我在排查一个Kubernetes节点内存异常时遇到过这样的情况:节点上所有pod的RSS加起来离节点总内存占用差了一大截,后来查下来发现是宿主机上的文件系统缓存和Slab对象占用了大量内存,而这些Slab对象根本没计入任何pod。不同存储插件、节点级Agent都可能导致宿主机级别的Slab增长。这时候要结合/proc/meminfo里的Slab字段和page cache一起判断,不要只看pod维度。
在容器环境下定位Slab增长,我推荐这样的流程:先确认节点级Slab占用趋势,再用slabtop找出上涨最快的缓存,然后结合节点上运行的存储、网络、监控组件判断哪些组件会创建大量此类对象,最后再针对组件做配置优化或升级修复。
6. 我的实操体会与调优经验
6.1 调优前先分清业务形态
给Slab做调优,不能脱离业务形态空谈。拿文件服务器和数据库服务器举例,两者的Slab特征完全不同。
文件服务器上dentry和inode缓存的膨胀是常态,这些确实是文件访问带来的合理对象。如果vfs_cache_pressure设定过低,内核倾向于保留更多dentry缓存,高密集的小文件访问场景可能会提速;但代价是内存占用会更大。反之数据库服务器通常不太依赖文件缓存,适当调高vfs_cache_pressure更合理,能避免文件缓存挤占太多内存,间接减少换页。
我实际调过一台跑MySQL的机器,vfs_cache_pressure从默认100调到200后,文件缓存回收更积极,内存页换出压力降低,查询延迟的毛刺少了。这里要反复测试,因为代价是文件访问的缓存命中率可能下降。
6.2 常用SLUB内核参数的调整指南
针对SLUB分配器本身,有几个参数值得了解,但不建议轻易动:
slab_min_objects:每个slab最少对象数,影响slab整体大小的选择,对性能影响较小。slab_max_order:限制Slab向伙伴系统申请的最大order,避免单个slab容器过大导致内存碎片。如果系统碎片化严重,适当减小这个值可以降低申请连续大块内存的失败概率。slub_min_order/slub_max_order:SLUB中控制每个slab最小/最大order的参数,默认为3(即8页),一般不需要改。slub_cpu_partial:控制每个CPU上partial slab缓存的数量上限,增大它可以提升分配性能,但会占用更多per-CPU内存。
在我自己的服务器上,默认参数下绝大多数场景表现良好。除非你通过/proc/buddyinfo看到高阶内存块严重不足,或者perf明确显示Slab分配路径是瓶颈,否则不建议调整这些参数。盲目调参往往得不偿失。
6.3 从内核版本和体系结构看Slab演进
最后聊聊趋势。经典Slab到SLUB的演进解决了多核扩展性问题,而最近内核版本的更新还引入了更多性能和可观测性方面的优化。比如SLUB的slab_strict_numa参数控制严格的NUMA本地分配策略,在NUMA架构下减少跨节点访问延迟。
另外一个值得关注的方向是struct slab和struct page的拆分。较新的内核把Slab元数据从页描述符中抽取出来成为独立的struct slab结构,这样做的好处是减少内存占用,并且让内存管理的语义更清晰。这部分对普通排查影响不大,但看内核邮件列表和设计文档时,理解这个背景会更容易跟上社区讨论。
对我个人而言,掌握Slab分析的意义不只是解决几个内存问题,更在于建立一种“从分配器视角看系统”的思维习惯。内核开发、驱动调试、底层性能优化,很多时候问题表象五花八门,但最终都落在“内存到底怎么被使用的”这个根本问题上。搞懂Slab,等于多了一把钥匙,能从更底层去理解系统的运行逻辑。
最后再分享一个小技巧:排查内存问题时,一定要把/proc/meminfo、/proc/slabinfo、/proc/buddyinfo以及perf的数据串起来看,单看任何一个都容易误判。先看总内存水位和回收状态,再看Slab对象结构,然后用perf定位动态行为,最后回到代码里确认根因。这套组合拳打下来,绝大多数内核内存相关的问题都能有一个清晰的结论。