内存碎片这个事,平时不显山不露水,真到线上出问题时,往往让人挠头。同样一台机器,明明还有几十GB空闲内存,一个8MB的连续DMA缓冲却怎么都分配不到;MySQL想扩展buffer pool,Redis想申请一块大的虚拟内存区域,系统反而开始swap,业务延迟飙升,监控图上直接拉满。这些现象背后的共同元凶,往往就是内存碎片。
这篇文章不是教科书,更像是我这几年跟内存碎片贴身肉搏后的记录。我从内核的伙伴系统、内存规整讲到应用层的分配器选型与内存池,再到实际排查流程,尽量说人话,把能落地的东西都摆出来。适合搞Linux运维、做后台服务的同学,以及正在被高并发内存增长折磨的开发者,都能从里面捞到点对线上有用的经验。
1. 内存碎片到底是什么,真凶又是谁
1.1 外部碎片与内部碎片:两种完全不同的病
很多人在一块儿提“碎片”,其实有两种病根。最容易引起线上事故的是外部碎片:物理内存的总空闲量够,但空闲的页块被打散成一个个小段,无法凑出分配器要的连续大块。Linux伙伴系统按2的幂次管理页块,每次分配都是找order-N的连续块。如果order-3以上的块全部被用掉,空闲的全是order-0和order-1这种小碎片,就算总量还有几百GB,一次16页的连续分配照样失败。
内核页管理里,空闲块被挂在free_area[order]链表上,从/proc/buddyinfo能看到各order的数量。order-N代表2^N页连续物理内存,分配时如果目标order不足,就会往下拆更大的块。拆合并分的游戏规则决定了:只要频繁分配和释放,低order空闲块会越来越多,高order块迟迟合并不出来,碎片就这么一点点积累。、
另一种是内部碎片:分配出去的块比需求大,内部剩余空间没被利用。slab分配器按对象大小建kmem_cache,kmalloc-32的请求可能落在64字节的cache里,每对象最多浪费一半;页分配按整页走,一个只用了256字节的内核对象也占掉一页。内部碎片不影响连续性,但会推高整体内存占用,加大回收压力,间接也会让外部碎片更容易恶化。
1.2 碎片对系统有哪些实打实的伤害
最直接的是连续分配失败。并行文件系统的stripe buffer分配失败会直接报IO错误,网卡的ring buffer或scatter-gather表需要连续DMA内存,失败就是丢包。这类问题一旦出现,业务侧通常很难绕过,只能让底层不断重试,越重试越乱。
其次是性能雪崩。伙伴系统在order不足时会反复尝试向邻居pageblock借块并拆块,分配路径变长;kswapd被唤醒开始回收,甚至触发直接内存压缩,整个路径耗时可能从微秒级跳到毫秒级。对调度、存储这类延迟敏感服务,一次分配失败就能让RT超时,前端看到的是一连串超时。
还有一个容易忽略的坑:碎片会放大OOM误判。空闲统计是“总量”,如果一个节点大部分内存因为无法合并而“看起来可用、实际分不出来”,OOM killer可能把本该活下来的进程杀了,或者触发不必要的swap。做监控时不能只看free -m,要看高order可用块。
2. 怎么判断内存到底碎没碎
2.1 三张表把内存底裤看穿
先看/proc/buddyinfo,这是最直观的碎片体检报告:
cat /proc/buddyinfo Node 0, zone DMA 1 0 1 0 1 1 1 1 1 0 0 Node 0, zone DMA32 10432 8192 2048 256 32 0 0 0 0 0 0 Node 0, zone Normal 52031 20000 4096 512 128 16 4 1 0 0 0每一列代表order 0到10。拿DMA32那行来说,order-2有2048个块,但order-5以上全是0,说明这块zone里可分配的大块已经耗尽,空闲集中在4KB、8KB这种小块,典型的外部碎片特征。想要一个order-5的128KB连续分配,只能等内存回收或规整。
/proc/pagetypeinfo更进一步,能看到pageblock的迁移类型分布:Movable、Reclaimable、Unmovable、Reserve。如果Unmovable占了绝大多数pageblock,说明该zone已被不可移动的内核对象钉死,即使触发规整,可移动页比重低,能腾出来的连续块也有限。这也是为什么同一台机器,有的zone怎么compact都没有改善的原因。
/proc/zoneinfo里还有free、nr_free_pages、nr_migrate这些字段,配合/proc/meminfo里的MemAvailable可以估算:当MemFree很大但高order空闲块持续走低时,碎片就是主要矛盾。
2.2 碎片指数:用数字量化而不是靠感觉
内核里有个vm.extfrag_threshold参数,它影响内核在什么情况下决定做内存规整。具体到碎片程度,可以看这样一个概念:碎片指数越接近1,表示几乎所有空闲页都集中在低order,越难分配出连续大块;接近0,表示当前分配条件良好。实际公式比较绕,但语义就是这个。
真正实用的是运维自建指标。我个人会上线一个定时采样脚本,专门统计每个zone中order>=3的空闲块能覆盖的总内存,做成监控曲线。这里给一个粗略的awk示例:
awk '/^Node/ && $3 ~ /Normal|DMA32/ { sum=0; for(i=4;i<=NF;i++) if (i-4>=3) sum+=$i*2^(i-4); printf "%s %s order3+ free pages(KB) %d\n", $1, $3, sum*4 }' /proc/buddyinfo注意这里统计的是“单个连续块至少满足order>=3的总覆盖内存”,不是把所有order-0都算进去。高order空闲块持续为0,而free总内存很高,基本可以定性为严重外部碎片。这个脚本我没法在文章里保证适配所有发行版,但思路值得复制:把order分布变成曲线,碎片才会从“玄学”变成“指标”。
2.3 分配失败的现场取证
如果系统已经在报错,直接看dmesg里类似这样的行:
page allocation failure: order:4, mode:0x102c0a(GFP_KERNEL)order:4就是16页、64KB的连续物理分配失败;后面的调用栈能说明谁在申请、在哪条路径上。此时再回看buddyinfo,往往能看到高order空闲为零。
这里有个特别容易踩的坑:order:4未必全是碎片问题。如果页被大量匿名内存或page cache占满,kswapd连续回收也拿不出块,那就是整体内存不足加回收节奏问题,不是碎片。判断方法是看碎片指数和回收活动:如果kswapd在跑、dmesg里已经出现reclaim相关信息,优先考虑整体水位;如果kswapd不忙但分配还是失败,才往碎片方向查。
3. 内核侧的整理方案:能借力内核就借力
3.1 伙伴系统怎么“反向合并”
碎片整理的本质是把已经拆散的空闲块重新合并成大块。伙伴系统本身只会在释放时合并相邻兄弟,真要整理,则需要把已分配的可移动页搬走,腾出连续物理区间。
内核为此给每个pageblock打上了迁移类型标签:MIGRATE_MOVABLE、MIGRATE_RECLAIMABLE、MIGRATE_UNMOVABLE。常规申请尽量在对应类型的区域里圈地,这样不同特性的页面不会在pageblock里互相掺和。做compaction时,只需扫描Movable类型的页,把它们复制到zone里另一处空闲块,再释放原位置,就能腾出连续区间。这就是整个内存规整机制能“动手术”的基础。
要注意的是,不可移动页是搬不动的。如果Unmovable页面正好横在你希望合并的区域中间,compact怎么扫都绕不开,结果就是整理失败。这解释了为什么有些zone看起来碎,但compact没有效果。碰到这种局面,与其反复触发compact,不如先看是什么东西占着不可移动页,再考虑应用层改造。
3.2 Memory Compaction:手动触发和自动触发
Linux内存规整的触发点主要有三个:直接分配慢路径、khugepaged、手动请求。手动触发最典型的命令是:
echo 1 > /proc/sys/vm/compact_memory执行后内核会尝试对所有zone做一次同步压缩。我建议在低峰期先做一次,然后立刻看buddyinfo里的高order列,如果order>=3的块数量明显上升,说明是可移动页占主导,情况还有救。如果完全没变化,别急着加大药量,先查Unmovable分布和内存水位。
自动触发更常见。分配路径上,伙伴系统拿不到目标order时,会进入慢路径进行内存回收和规整判断。内核的__alloc_pages_slowpath里会尝试__alloc_pages_direct_compact,如果模式允许,就做一次同步或异步的compact。同步compact意味着当前线程原地等待迁移完成,延迟不可控,这就是很多系统出现“随机抖动”的原因之一。
还有个相关参数是/proc/sys/vm/compact_unevictable_allowed,它决定能否把unevictable LRU里的页面也搬走。一般要谨慎,因为涉及mlock区域时,强行迁移容易出幺蛾子。普通业务场景保持默认即可,别轻易调。
在容器场景千万注意:/proc/sys/vm/compact_memory是宿主机全局参数,普通容器没有权限写。如果非要在容器场景触发,只能在宿主机侧或借助cgroup对内存的限制来间接影响,没有直接的“容器内compact”入口。
3.3 CMA:给设备留的后备稻田
同步compact不够的另一个方案是CMA(Contiguous Memory Allocator)。CMA的做法是在系统启动时预留一块物理内存区域,平时这块区域可以当普通Movable内存用;一旦驱动需要连续大块,比如视频编解码、GPU buffer、DMA ring,内核就把CMA区域内可移动页迁走,腾出连续区给设备。它本质也是碎片整理,但把“整理时机”和“预留区域”绑定得更明确。
对嵌入式或音视频处理场景,CMA几乎是标配。内核编译需要CONFIG_CMA,启动参数可用cma=配置大小。值得注意的是,CMA预留内存不能随便参与不可移动的内核分配,用不好会浪费内存。建议结合业务模型评估分区面积,别用默认配置硬扛。
4. 应用层自救:分配器、内存池与业务姿势
4.1 分配器选型:glibc、jemalloc、tcmalloc的差别
很多“内存碎片”问题最终被吐槽到内核头上,其实病根在进程内分配器。glibc的malloc在长生命周期、多线程场景下会建立多个arena,每个arena有一段连续堆,频繁的小对象分配释放会在arena里留下大量空洞。调环境变量有一定缓解:
export MALLOC_ARENA_MAX=4 export MALLOC_MMAP_THRESHOLD_=131072 export MALLOC_TOP_PAD_=1048576MALLOC_ARENA_MAX限制arena数量,降低整体内存占用,但锁竞争可能增加;MALLOC_MMAP_THRESHOLD_让大于等于该阈值的请求走mmap,由内核直接映射堆外,释放时立即归还系统,不参与堆内合并,能明显降低碎片,但阈值设太高或太低都会引入性能损耗;MALLOC_TOP_PAD_保留堆顶余量,减少频繁brk导致的堆收缩和再扩张。每台机器的业务模型不同,这几个值上线前最好压测验证。
如果你的服务是Redis、大型Java Native内存使用方或者高并发C++服务,建议优先考虑jemalloc。jemalloc的arena和chunk设计就是冲着低碎片去的,还能通过stats遥测看到retained和active内存差。Redis官方报告里的mem_fragmentation_ratio就是基于jemalloc的内部allocated和used_memory算出来的,这个指标比单纯看RSS靠谱得多。
4.2 内存池与对象池,把频繁增删拉平
内核和应用层再能打,最有效的永远是源头治理。每秒几十万次的小对象malloc/free,无论哪个分配器都不可能做到零碎片,不如让对象生命周期可控。
我举一个后端网关优化的例子:网关每收到一个包就malloc一个64KB buffer,处理完再free,高峰期内存增长斜率非常吓人。改造后预分配一个buffer池,每次从池里借、用完归还,池内对象大小固定,新申请频率从每秒几十万降到个位数。上线后RSS稳定了,GC压力也小了一大截,整体水位一夜之间不再抖。
内存池本身有取舍:池子常驻,最坏情况下内存占用看起来变高,但换来的是极低的分配次数、极低的碎片率和稳定的延迟。在需要降低碎片的日子里,这种“以空间换确定性”的玩法非常值。核心是把最频繁分配的对象挑出来池化,别一上来就套一个大而全的通用池。
4.3 JVM与Go的碎片应对,别只会调Heap
JVM堆的碎片主要来自GC算法。CMS是典型的“标记-清除不压缩”,堆里留存大量气泡,对象晋升时会触发promotion failed、concurrent mode failure,最后退化成Full GC。G1用Region做分代和回收,单块Region内有局部移动,整体碎片控制比CMS好,但大对象(humongous)依然要求连续多个Region,区域空得多但连不上时一样Full GC。
我的建议是,JVM调优不要只盯着-Xmx,要看GC日志里有没有分配失败和晋升失败。选G1的话关注humongous allocation,必要时调大-XX:HeapRegionSize,或者在代码层避免超大对象反复创建。Parallel GC虽然没有碎片问题,但每次Full GC要压缩整个堆,停顿成本更高,属于另一种取舍。
Go的堆不像JVM那样在GC时搬运对象,逃逸对象留在原地,通过空闲列表管理。Mcentral按size class管理内存,设计上碎片相对可控,但大量交错的大小对象生命周期仍然会在后台产生空洞。实际调优方向是减少跨size class的频繁创建,尤其别在循环里造临时大结构体然后立即丢掉,这会让分配器的空闲链表变得越来越稀疏。
5. THP、大页与虚拟化场景的碎片暗流
5.1 THP可能是“隐形碎片炸弹”
透明大页THP经常被忽略。THP会在后台尝试把连续的普通页合并成2MB的hugepage,但合并需要连续2MB物理内存。如果系统碎片已经严重,khugepaged会不断发起compaction和内存回收,CPU被吃掉一截,业务延迟波动却看不到明显收益。典型表现是:CPU使用率不高但khugepaged线程一直活跃,/proc/meminfo里的AnonHugePages却没什么增长。
对大多数后台服务,我更推荐按需开启而不是全局:
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled这样只有调用madvise(MADV_HUGEPAGE)的区域才享受THP,避免全局合并带来的碎片压力和抖动。像MySQL这类对延迟敏感且锁竞争大的服务,不少团队干脆echo never,省掉内核在分配路径上的额外尝试。注意:修改THP参数前要确认业务进程没有在堆上依赖大页性能,一改就翻车的例子并不少见。
5.2 显式大页与预留,根本不用整理
既然碎片问题是在“已有布局中挤出连续大块”,那最粗暴的解法就是启动时就预留好。HugeTLB通过hugepages参数在早期启动阶段把连续物理内存锁出来,此后分配直接取预留池,跟碎片无关:
# 启动参数 hugepages=128 # 或者运行时设置(需要连续内存,可能失败) echo 128 > /proc/sys/vm/nr_hugepages不过动态调nr_hugepages时,新预留的大页同样需要连续物理内存,系统碎片严重就会失败或触发compaction,生产环境尽量在启动阶段预留。数据库常用1G hugepages给共享内存,配套调整vm.overcommit_memory,再用cat /proc/meminfo | grep Huge验证预取无误。
虚拟化场景也值得留神:Guest里的“空闲”内存,对宿主机而言可能是被balloon回收的页。Guest内部做碎片整理时,如果撞上balloon驱动内存热插拔,延迟和IO都容易波动。建议虚拟化部署下,优先在Guest内做主动监控,而不是依赖宿主机全局触发compact,这样故障域更清晰。
6. 现场排查与避坑经验速查表
6.1 一套完整的诊断流程
我把实际排查按顺序归纳成了四步,可以直接照着抄。
- 看压力来源:先确认是不是内存水位和回收导致的,
vmstat 1看si/so和kswapd活动,排除整体不足再说碎片。 - 看伙伴系统:
cat /proc/buddyinfo,算zone内order>=3的空闲块,如果高order长期为0,才进入碎片流程。 - 看迁移类型:
cat /proc/pagetypeinfo,确认Unmovable是否过高;如果不高,尝试低峰执行一次echo 1 > /proc/sys/vm/compact_memory并观察改善。 - 看应用行为:CPU爆高但回收不严重,查THP、GC、分配器配置;CPU正常但内存一直涨,就要从对象池和分配频率上下手。
这套流程的好处是尽量从内核到应用、从现象到根因,避免一上来就乱发compact,结果把服务抖动得更严重。
6.2 常见问题速查表
| 现象 | 常见原因 | 优先排查动作 |
|---|---|---|
| dmesg出现order:3+分配失败,内存总量还很多 | 外部碎片 | 看buddyinfo高order列,低峰尝试compact |
| compact后buddyinfo没好转 | Unmovable页块过多 | 看pagetypeinfo,考虑CMA或应用层池化 |
| khugepaged线程CPU持续高 | THP后台合并触发反复compaction | 确认THP开关,改madvise或never |
| Redis RSS远大于used_memory | 分配器碎片 | 开activedefrag、确认jemalloc版本、优化大key生命周期 |
| JVM频繁Full GC、promotion failed | CMS堆碎片或G1 humongous | 换G1或调HeapRegionSize,代码层减少大对象 |
| 容器内无法compact | 容器无全局权限 | 从宿主机触发,或调整容器内存上限让回收更积极 |
6.3 几个容易忽略的坑
第一个坑是不要在生产高峰期全局compact。同步compaction会持有zone锁并迁移页,很可能让整个内存分配路径停顿。我曾见过在晚高峰执行echo 1 > /proc/sys/vm/compact_memory后,同机多个服务的P99暴涨三倍。真想主动整理,先挑一个低峰窗口小范围验证,或者干脆通过cgroup限制和zone_reclaim_mode间接控制影响范围。
第二个坑是min_free_kbytes设置不当会加剧碎片。保留给紧急分配的pageblock数量太少,分配器更容易往低order不断拆块,碎得更快。但也不是越大越好,调大它直接减少可用内存。建议在测试环境对比几个不同值,看哪个能让高order空闲块更稳定。
第三个坑是监控维度不对。只盯free和used根本看不出碎片,碎片特征基本都在order分布里。我会把“满足order>=3的空闲页块数”作为核心监控指标,配合dmesg告警,比人工事后看日志有效得多。
第四个坑是把一切归咎于碎片。内存碎片是热门词,但不少问题其实是内存回收不及时、cgroup限额冲突或者程序的虚拟内存占用异常。有一次我们把order-4分配失败当成碎片处理了两天,最后发现是cgroup的memory.max压得只剩几十MB可用,compact再怎么触发也救不回来。所以流程里第二步一定要先放松水位判断。
说到底,我这几年跟内存碎片打交道的最大体会是:锤子别乱挥。先让监控把order分布变成常态指标,再从代码层面压平分配频率,最后才把内核compact当作一把备用手里的工具。希望这篇记录能帮你少走几个弯路。