内存碎片是Linux系统运行多年后几乎必然遇到的问题。它不像CPU飙高那样立刻报警,但会悄无声息地让系统变慢、让大页分配失败、让数据库OOM(Out of Memory)杀手频繁出动。很多人看到free命令里还有大量空闲内存,却无法分配一块连续物理内存给虚拟机或巨型进程,根源就在这里。碎片本身分两种:外部碎片和内部碎片。外部碎片是不同大小的空闲页框散布在已分配页之间,总容量够但连不成一片;内部碎片是分配出去的内存比实际用得多,比如slab分配器里对象用完但页面未释放。我们真正要对付的,绝大多数场景下是外部碎片。

先看清碎片到底有多严重

别急着调参数,先拿到数据。/proc/buddyinfo是观察外部碎片最直接的文件。每一行对应一个内存节点和zone,Node 0, zone Normal后面一串数字代表不同阶(order)的空闲连续页块数量,order 0是单页4KB,order 1是8KB,order 2是16KB,以此类推到order 10即4MB。如果高阶数字全是0,而低阶堆积大量小块,说明碎片严重。再看/proc/pagetypeinfo,它能区分可移动、不可移动、可回收三种类型的页面分布。真正造成碎片无法规整的,往往是不可移动页面嵌在可移动页面中间,把连续区域切断。用cat /proc/pagetypeinfo | grep "Normal"能看到每种迁移类型的空闲块数量。如果Movable类型的高阶块很少,而Unmovable散布其中,就是典型的碎片困局。

内核自身的反碎片机制:伙伴系统和迁移

Linux伙伴系统在分配页面时尽量从同阶空闲链表取,释放时立即尝试与相邻伙伴合并成更高阶块。这个机制天然抵抗碎片,但扛不住长期运行后不可移动页面的随机分布。从内核2.6.23开始引入页面迁移框架,核心思想是把可移动页面从目标区域搬走,腾出连续空间。迁移类型分MIGRATE_UNMOVABLE、MIGRATE_RECLAIMABLE、MIGRATE_MOVABLE、MIGRATE_CMA等。内核在分配时尽量把同类型页面放在一起,减少相互穿插。但现实中,不可移动页面(比如内核栈、slab对象)一旦落定就很难搬动,所以关键策略是让可移动的尽量集中,不可移动的尽量隔离。

启动参数vm.min_free_kbytes的深层逻辑

很多人把vm.min_free_kbytes设得很大,以为空闲内存多就能减少碎片,这是误解。这个参数控制的是内核在回收内存之前保持的最低空闲内存量,它影响的是内存回收触发时机,而不是碎片整理。设得过高会浪费内存,过低则让内核没有足够缓冲来执行碎片整理。真正与碎片直接相关的内核参数是vm.extfrag_threshold,它控制外部碎片阈值,当某个zone的碎片指数超过该值时内核会尝试规整。默认值通常是500,范围0-1000,值越小越激进。如果你的系统频繁出现高阶分配失败,可以把它调低到300甚至100,让内核更早介入。但代价是规整操作本身消耗CPU和IO,需要权衡。

主动触发内存规整:compact_memory和vm.compaction

内核提供主动触发碎片整理接口。写入1到/proc/sys/vm/compact_memory会让内核立即对所有zone执行内存规整,把可移动页面迁移到一起,释放高阶连续块。这个操作是同步的,会阻塞直到完成,在内存很大的机器上可能耗时数秒甚至更久,期间系统响应下降。所以生产环境不要轻易直接echo 1,最好在业务低峰执行。更精细的控制通过vm.compaction参数实现,它有三个关键子项:vm.compaction_proactiveness决定内核是否主动在后台规整,值0-100,越高越主动;vm.compaction_watermark_scale_factor控制规整启动的水位线;vm.compact_unevictable_allowed允许规整不可驱逐页面。对于长期运行的服务器,建议将vm.compaction_proactiveness设为20,让内核在碎片刚有苗头时就后台整理,而不是等分配失败才被动应对。

透明大页与碎片的关系及调优

透明大页(THP)依赖2MB连续物理页,对碎片极度敏感。当碎片严重时,khugepaged内核线程会持续尝试扫描和规整,消耗大量CPU却成功率很低。许多DBA和运维直接禁用THP,命令是echo never > /sys/kernel/mm/transparent_hugepage/enabled。但一刀切禁用会失去TLB命中率提升的好处。更好的做法是保留THP但限制其行为:echo madvise > /sys/kernel/mm/transparent_hugepage/enabled,这样只有显式调用madvise的进程才使用大页;同时调整/sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs和scan_sleep_millisecs,增大扫描间隔降低CPU消耗。如果你的应用是数据库或Java这类自身管理内存的,用madvise模式配合应用层的大页支持是最佳实践。

Slab分配器碎片与回收

Slab是内核内部碎片的主要来源。每个slab缓存预分配一定数量的对象,即使对象释放了,slab页面也不一定归还给伙伴系统。查看slab占用用cat /proc/slabinfo,关注那些活跃对象很少但页面很多的缓存。内核提供了slab回收机制,写入2到/proc/sys/vm/drop_caches会释放可回收的slab对象,包括dentry和inode缓存。这个操作同样有性能代价,执行后短期内文件访问会变慢。更温和的方式是调整vm.vfs_cache_pressure,值大于100会让内核更积极地回收dentry和inode缓存,间接减少slab碎片。对于内存紧张的机器,设为500甚至1000能显著释放内存,但需监控IO负载。

利用CMA预留连续区域

CMA(Contiguous Memory Allocator)是从内核3.18开始广泛使用的机制。它在启动时预留一块物理连续内存,平时这块区域可被可移动页面使用,但当有连续内存分配请求时,内核把上面的可移动页面迁移走,腾出整块空间。CMA大小通过内核启动参数cma=256M指定,或者设备树中配置。对于需要频繁分配大块连续内存的场景(比如视频编解码、DPDK、虚拟机),预留CMA是最可靠的方案。缺点是预留区域在未使用时虽然能被可移动页面利用,但不可移动页面不能占用,相当于变相减少了通用内存池。所以CMA大小要根据实际连续内存需求精确计算,不宜盲目设大。

用户态工具:page-types和compaction脚本

内核源码tools/vm目录下有page-types工具,编译后能按迁移类型、引用计数等维度统计页面分布。运行page-types -b l -Nl可以列出所有不可移动页面的物理地址,结合/proc/iomem能看出哪些驱动或内核模块占用了关键位置。如果发现某个内核模块加载后碎片急剧恶化,可以考虑把它的内存分配改为vmalloc而非kmalloc,vmalloc不要求物理连续,对碎片影响小得多。另外可以编写定时脚本,在业务低峰触发规整:

#!/bin/bash
# 低峰期主动规整,先检查碎片指数
FRAG=$(cat /sys/kernel/debug/extfrag/extfrag_index | grep "Normal" | awk '{print $4}')
if [ "$FRAG" -gt 800 ]; then
    echo 1 > /proc/sys/vm/compact_memory
    logger "Memory compaction triggered, frag index was $FRAG"
fi

这个脚本监控extfrag_index(需要挂载debugfs),碎片指数超过800时自动规整,并记录日志便于追踪。

应用层配合:内存池和mmap策略

碎片问题不能全扔给内核。应用程序自身的内存分配模式对碎片影响巨大。频繁malloc和free小块内存会造成glibc堆碎片,进而反映到内核页分配。对于长期运行的服务,建议使用内存池预分配大块并自行管理,减少系统调用。对于需要大页的应用,使用mmap配合MAP_HUGETLB显式映射hugetlbfs,绕过THP的碎片依赖。hugetlbfs在启动时预留物理连续大页,运行时分配零碎片,是数据库和JVM的推荐方案。配置方法是内核参数hugepagesz=2M hugepages=1024预留2GB大页,然后挂载hugetlbfs给应用使用。这种方案虽然损失了灵活性,但彻底消除了碎片对大页的影响。

内核版本差异与长期维护策略

不同内核版本的反碎片能力差异很大;

4.x内核的规整是被动的,只在分配失败时触发;

5.x引入了proactive compaction,后台主动整理;

6.x进一步优化了规整算法,减少迁移时的停顿。如果你还在用3.x或更早内核,升级是解决碎片问题最有效的手段。对于无法升级的环境,综合策略是:合理设置vm.min_free_kbytes(通常为物理内存的0.5%-1%),启用vm.compaction_proactiveness=20,THP设为madvise,预留适量CMA,定时低峰规整,监控buddyinfo和pagetypeinfo趋势。碎片管理是持续过程,不是一次性调参。把它纳入日常巡检,观察高阶空闲块的变化曲线,才能在碎片影响业务之前就化解掉。