ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内存碎片治理实战:从内核防碎片到用户态分配器优化

内存碎片治理实战:从内核防碎片到用户态分配器优化 先说一个常见现象一台128G内存的服务器free -h看着还剩几十G进程却突然报“Cannot allocate memory”甚至容器直接被OOM Killer干掉。遇到这种情况很多人第一反应是内存泄漏但排查一圈却什么都没漏。这时候十有八九是内存碎片问题。内存碎片整理这个方案说白了就是解决“内存明明有剩余但凑不出一块连续空间”的尴尬。这篇文章我会从碎片产生的机制讲起覆盖内核层的防碎片策略、用户态分配器的治理手段再结合一次实战调优流程把“整理碎片”这件事做得明明白白。适合后端开发、SRE、内核爱好者以及所有跟服务器内存死磕过的朋友。1. 读懂内存碎片从现象到机制1.1 外部碎片与内部碎片两个不同的麻烦内存碎片分两种一种是内部碎片一种是外部碎片很多人混着说但治理思路完全不同。内部碎片是因为分配粒度大于实际需求造成的浪费。比如内核分配对象时按2次幂对齐申请30字节可能实际拿到64字节的块那34字节就浪费了。这种浪费看不见摸不着但它稳定存在。用户态malloc同理每次分配都有头部开销和字节对齐积少成多也很可观。外部碎片才是传统意义上“内存碎片”的主角。物理内存是按页通常是4KB管理的内核把连续页块做成不同order的伙伴链表。系统长时间运行后页面被各种进程和内核对象占用空闲页块被打散。你要分配一个order416个连续页64KB的块时可能有几百个零散的空闲页但找不出相邻的16页。这时候内存总量不缺却分配失败。打个比方一块空地停满了车每辆车之间都有一点空隙总和很大但不够停一辆大巴。外部碎片就是“空隙很多大块没有”。1.2 碎片如何一步步拖垮系统碎片不是一瞬间产生的而是长时间累积的结果。主要来源有三条第一短生命周期对象的反复分配释放。典型如高并发服务里的请求对象、连接缓冲、临时数组频繁malloc/free会让堆空间千疮百孔。第二不同生命周期对象的交错分配。长生命周期对象缓存、全局单例和短生命周期对象穿插分配回收后留下大量空洞。第三NUMA架构下的局部偏好。CPU优先分配本node内存导致内存在不同NUMA节点上分配不均某个节点的碎片可能反而严重跨节点访问时延迟上升。碎片严重后系统会尝试触发内存规整compaction也就是把可移动页面搬走合并成大块。但这个操作本身消耗CPU还可能导致进程卡顿。碎片到极端情况大页分配连续失败重负载进程启动直接崩掉。更隐蔽的问题是碎片会让内存回收失效——回收算法优先回收干净页、文件页但碎片太多时回收半天凑不出连续块性能反而恶化。为什么碎片问题“越来越难搞”因为内存越来越大、容器化越来越普及单个机器上跑的进程数量暴增每个进程的堆都是独立王国碎片被分散到几十个进程里靠“重启大法”重启一个解决不了整体问题。系统性治理就变得很必要。2. 系统级整理内核怎么扛住碎片2.1 伙伴系统的防碎片设计不只靠compactionLinux内核的物理内存管理以**伙伴系统buddy system**为基石空闲页按order0到MAX_ORDER挂到链表分配大块时从对应order找找不到就往高阶拆。这个算法的优点是分配释放快代价就是容易碎片化。为了对抗碎片内核做了好几层设计很多人在调优时只盯着/proc/sys/vm/drop_caches其实完全忽略了更核心的机制。第一层叫迁移类型migrate type。内核把页面按可迁移性分成不可移动Unmovable、可回收Reclaimable、可移动Movable。内核对每种迁移类型分别维护空闲页链表分配时优先用同类型的空闲页。这样做的意义在于不可移动页面被聚集在一起可移动页面可以随时被迁走给大块连续分配腾地方。这就是防碎片的第一道防线不是等碎片严重了再整理而是让碎片集中在“可移动”的页面里。第二层是**页块pageblock**机制典型大小是order10即4MB。分配器尽量把同迁移类型的页分配在同一个pageblock内避免不同类型页面互相穿插。第三层才是内存规整compaction。当高阶分配比如order3失败时内核异步或者同步扫描可移动页把它们搬运到其它地方凑出连续的大块页面。这里有几个触发条件vm.compaction_memory相关参数只控制触发阈值/proc/sys/vm/compact_memory是手动触发全局规整区分同步压缩和异步压缩同步压缩更激进也更可能导致延迟尖刺。第四层是CMAContiguous Memory Allocator它的思路是预留一块区域平时允许可移动页面占用但大块连续分配比如GPU显存、多媒体编解码时可以强制迁移腾出空间。CMA适合有稳定连续内存需求的场景但不建议全局配置太大否则日常内存使用率低的时候很浪费内存吃紧时迁移频率又太高。2.2 内核参数调整别乱调先看懂很多人以为调内核参数是“照着网上的值填”结果把系统调崩了。我实际用下来以下参数的调整思路是这样的仅供参考参数作用经验值 / 调整策略vm.min_free_kbytes保留给紧急分配的最低空闲内存默认值通常偏小内存压力大时调到物理内存的0.5%~1%左右注意不是越大越好vm.zone_reclaim_mode是否允许回收本node内存以满足本地分配默认0不回收NUMA多路服务器可以设为1但要小心CPU开销vm.compaction_proactiveness内核主动规整内存的积极性0~100默认20碎片严重的业务可以适当提高到50~70vm.page_lock_unfairness页面分配时防止不公的阈值一般不用动真出问题再关注vm.vfs_cache_pressure控制目录项/索引节点缓存的回收倾向默认100缓存类内存过多时调高但别低于50重点说下vm.min_free_kbytes。这个参数在内存碎片场景下是个隐性保护伞。它的用途是给原子分配、不可中断分配留底线。如果设置太低系统在内存水位紧张时无法及时回收容易触发OOM。过高也不行会导致可用内存缩水。我的经验是# 128G内存的机器经验做法是留1G左右 sysctl -w vm.min_free_kbytes1048576然后观察/proc/buddyinfo里的空闲页分布如果高阶orderorder8长时间没有min_free_kbytes可以继续上调配合紧凑策略。另一个容易被忽略的是触发规整的代价。内核做规整时如果大量页面是“可移动”的迁移压力小如果某些进程的页面被锁在某个zone里规整就会长时间扫描甚至同步压缩时引发CPU软锁。所以调vm.compaction_proactiveness时要逐步增我建议每次加10观察系统负载和分配失败率而不是一步到位。2.3 手动整理操作什么时候能“手动”什么时候没用网上很多帖子教人这么操作echo 1 /proc/sys/vm/compact_memory echo 3 /proc/sys/vm/drop_caches这两个命令被当成“内存整理神器”但实际效果差距很大。先说compact_memory这个命令触发的是全局内存规整把所有zone里可移动页搬移确实能让高阶空闲页数量上升。但如果系统本身没什么可移动页比如slab对象、内核数据结构占据大头跑这个命令只会白烧CPU/proc/buddyinfo基本不会变。再说drop_caches它只释放页缓存page cache和目录项/索引节点缓存对“用户进程堆内存造成的碎片”毫无帮助。它可以释放一些文件缓存页这些页属于“可回收”类型回收后会给伙伴系统增加低阶空闲页间接缓解分配压力但解决不了根因。需要明确手动整理只是临时招式。碎片是长期运行的结果如果业务代码本身反复申请释放连续内存手动整理完很快又变碎片。那什么情况下手动整理有用典型场景是内存峰值过后比如原本有大量并发请求占用了内存、释放后空闲页都是小碎片这时候可以先看/proc/pagetypeinfo确认可移动页占比再触发compact确实能缩短后续大分配等待时间。这是治标需要结合用户态治理治本。内核侧还有一个容易被忽略的选项HugeTLB和透明大页THP。大页本质上绕过了碎片问题——连续2MB/1GB的映射TLB压力小分配时直接从预留大页池拿不会因为碎片导致失败。但THP开启后khugepaged会尝试把普通页折叠成大页这个折叠过程需要迁移页面反而增加碎片迁移开销而且如果折叠失败会造成额外内存浪费。在碎片敏感的场景比如大数据组件、JVM我倾向于在应用层关闭THP改用显式HugeTLBecho never /sys/kernel/mm/transparent_hugepage/enabled # 或者针对特定进程用 madvise 模式 echo madvise /sys/kernel/mm/transparent_hugepage/enabled这样做的逻辑是让常规内存分配走普通路径大页由明确预留的HugeTLB池承担避免内核频繁做“折叠-迁移”的无用功。实际测试中部分业务开启THP后分配延迟反而升高因为折叠线程扫描全内存代价很高。3. 用户态分配器碎片治理的主战场3.1 malloc分配器的碎片化差异很多人以为内存分配是操作系统的事用户态只是调用malloc。不对用户态堆分配器才是碎片的主战场。你写的应用每次new/malloc都发生在堆上堆怎么切块、怎么合并、怎么缓存直接决定碎片率。glibc默认的ptmalloc有三个特点多线程通过arena隔离减少锁竞争、bin缓存管理空闲块、large bin按大小分类。但它对大块分配的处理不够优雅长生命周期对象和短生命周期对象混在一个arena里时很容易产生外部碎片。如果你用JavaJVM的C堆metaspace、DirectBuffer也都走glibc碎片会直接戳到JVM。jemalloc、tcmalloc、mimalloc这类现代分配器则在设计上就对抗碎片它们按**大小类size class**分级每个线程有本地缓存大块分配走独立的chunk区域避免和小的对象混杂释放时尽量合并相邻空闲块并在满足条件时把整块chunk归还给操作系统。以jemalloc为例通过dirty_decay_ms参数控制“脏页”已释放但未归还OS的保留时间。保留太久内存占用虚高归还得太快频繁madvise引起系统调用开销。线上调优时我会这样调整# 设置decay时间为10秒既不会频繁系统调用又能及时释放 export MALLOC_CONFdirty_decay_ms:10000,muzzy_decay_ms:10000如果用jemalloc的prof模式还可以打印堆内存的碎片分布图export MALLOC_CONFprof:true,prof_gdump:true,lg_prof_sample:4096然后通过jeprof分析热点分配。实测中一个线程多、短连接多的网关服务从glibc切到jemalloc后RSS峰值下降约20%这就是碎片治理的立竿见影效果。tcmalloc的线程缓存机制类似也适合高吞吐、多线程场景。3.2 从分配策略上规避碎片除了换分配器更根本的做法是设计自己的分配策略。我在消息中间件项目里做过一个简单但很有效的内存池方案按固定大小分桶16B、32B、64B…每个线程一个空闲链表跨线程释放时丢到全局回收队列。关键点是“定长块不合并”每次分配和释放都是常数时间块大小固定永远不会产生不可分配的空洞。这种方案对线程模型固定、对象生命周期短的服务天然友好。如果你开发的是Rust程序其实标准库的Vec、Box之外你可以考虑使用mimalloc或jemalloc作为全局分配器一行依赖切换就能带来碎片优化的收益。C则可以通过tcmalloc或jemalloc替换默认的malloc或用operator new拦截统一走内存池。还有一点大对象与池化要分开。比如你有一个缓存模块常驻内存500MB如果你把这块内存和小对象分配混在一个arena里那缓存区会造成大量空洞别的对象穿插进来时间一长整个进程内存变成“瑞士奶酪”。正确做法是给缓存单独开一块large arena或者用mmap直接分配避免跟常规对象混在一起。实际项目中我还踩过这种坑错误地把线程local缓存设置得过大。某个服务用tcmalloc时把TCMALLOC_TRANSFER_NUM_OBJS调大以降低锁竞争结果每个线程缓存了大量空闲对象内存膨胀到原来的1.5倍。碎片少了总内存却涨了这是“用空间换碎片”的极端。调优要盯RSS和碎片率两个指标不要只看一个。3.3 内存池实战一个简单可靠的方案如果是自研服务最省心的内存在池里是采用伙伴式内存池原理和内核类似但比通用分配器更可控把一整块内存按2次幂分级4K、8K、16K…释放时尝试合并相邻块升级到更高阶当某高阶块空闲时间超过阈值整块归还系统。代码层面不需要很高深关键是要维护好两个数组空闲块链表数组和分配状态位图。参考实现思路class BuddyPool: # 以2^max_order字节的内存作为池 def __init__(self, max_order12): self.max_order max_order self.free [[] for _ in range(max_order 1)] self.free[max_order].append(0) # 初始整块 # 分配: 找最小可用阶必要时逐级拆分留半块 def malloc(self, size): order self._size_to_order(size) # 从当前order往上找 # 如果所找块是上一级拆开的另一半挂到低一级链表 def free(self, addr, size): order self._size_to_order(size) # 入链表并尝试与buddy相邻块合并循环上推这种池的好处是没有系统调用的开销、不会出现外部碎片因为伙伴合并机制天然合并相邻块但缺点也很明显分配粒度固定内部碎片依然存在。所以它适合“对象大小范围有限”的场景比如网络协议包的缓冲区。如果是高并发、多线程的场景需要做到无锁化。简单的做法是每线程独立的池但要注意线程退出时把空闲块回收到全局池——否则慢慢泄漏。另一个注意点不要信任“释放后立刻可用”的假设你要有调试模式分配和释放时加magic number越界访问很快就能抓出来。内存池的Bug很多是数组越界踩到池元数据线上环境极难定位前期埋点比什么都重要。4. 梳理故障排查思路与实战案例4.1 一次典型的内存分配失败排查之前遇到过一次真实故障。服务是消息网关几十个连接并发收发运行一周后开始出现Could not allocate memory错误。当时free -g显示内存还有60%可用swap也没怎么用但进程就是分配不了内存。第一反应看dmesgdmesg | tail -n 80这里出现了大量“Page allocation failure: order:4”的字样。这说明内核在尝试分配order464KB的连续页时失败。这台机器不是物理内存不够而是低阶空闲页很多但高阶连续页被碎片吃掉了。接着查/proc/buddyinfo发现order4以上的空闲块数量确实偏少。再查/proc/pagetypeinfo发现Unmovable页分散在大量pageblock里没有聚集导致即便有Movable页也存在但迁移链太长compact效率很低。最终定位到两个根因一是某些线程的长生命周期缓冲对象是用malloc分配的跨过了内存池二是内核vm.compaction_proactiveness设置太低碎片已经比较严重才触发整理。我们的处理方式是# 临时措施手动规整 echo 1 /proc/sys/vm/compact_memory # 调高主动规整 sysctl -w vm.compaction_proactiveness60 # 调低NUMA回收的消极性允许回收本node的页 echo 1 /proc/sys/vm/zone_reclaim_mode # 同时给应用侧换jemalloc export LD_PRELOAD/usr/lib64/libjemalloc.so.2做了这三步compaction生效后/proc/buddyinfo里order8以上的空闲块大幅上升分配失败告警消失。但是问题本质是代码里短生命周期的大缓冲没有走内存池。后来我们把网络收发缓冲统一用一个伙伴式内存池管理再也没出现过同类故障。这个案例很典型系统参数只是兜底应用层分配策略才是核心。4.2 常用观测工具与指标解读遇到内存碎片问题建议按以下步骤收集信息/proc/buddyinfo是最直接的碎片指标输出格式为每个zone、每个order的空闲页块数量。order0是4KBorder1是8KB依次翻倍。如果order4的块长期为0说明外部碎片很严重。/proc/pagetypeinfo展示了每个zone里不同迁移类型的页面数量。Unmovable分散程度高规整难度大Movable占比高则规整效果好。cat /proc/meminfo重点看Committed_AS和MemAvailable前者是承诺给进程的内存总量后者才是真正可分配量。注意MemAvailable是估算值在碎片存在时它会偏乐观。dmesg排查page allocation failure这个行文里有order、gfp_mask、内存波动情况非常重要。看到order3的失败时基本可以确定是碎片问题。smem用来查各进程真实RSS和PSS判断谁是内存大头smem -rs pss | head -n 30另外还可以用perf分析内存分配热点但碎片治理阶段最有用的还是jemalloc的prof模式或者valgrind massif它们能画出堆内存的分配/释放历史帮你找到“长生命周期和短生命周期穿插分配”的代码位置。4.3 碎片治理速查表我把常见的问题场景和对应处理方式整理成一张表实操时可以直接对照场景现象首选处理进阶处理内核分配大块失败dmesg出现order4的allocation failure调高min_free_kbytes手动compact开启/调大CMA调整migrate type考虑HugeTLB用户进程堆碎导致RSS膨胀free总内存够单个进程RSS用了好几个G切换jemalloc/tcmalloc调decay参数业务层接内存池优化长连接缓存结构文件缓存过多触发回收存储型机器缓存页占了大头drop_caches释放页缓存调低vfs_cache_pressure限制cache上限线程局部缓存放大了内存RSS增幅明显但活跃对象不多调小线程本地缓存上限定期flush线程缓存到全局池NUMA节点碎片某node多次分配失败其他node空闲zone_reclaim_mode1绑定CPU与内存推荐numactl --interleave或--membind大页分配失败HugeTLB预留不足预留更多HugeTLB页检查kernel.shmmax限制调整nmemb这几类问题不是互斥的实际项目里经常叠加。比如一个JVM服务既用了DirectBuffer走内核分配又有大量短生命周期Java对象走堆外C堆还开启了THP内核折叠线程忙碌三者叠加时碎片治理难度成倍增加。这时候最好的策略是先逐层剥离问题关掉THP切换分配器再静态分析DirectBuffer的分配释放规律。5. 实操心得与扩展建议最后说一些我的经验。内存碎片治理和性能优化一样讲究“先量化再优化”。我一上来会先执行五分钟内的连续采样把/proc/buddyinfo的历史数据拉出来看看碎片到底是在逐步恶化还是周期性波动。如果是周期性波动说明业务负载特征导致——比如定时批处理任务集中申请大块内存这时只需错峰处理不用全局调参。另一个心得是内核参数要配合应用层策略一起改而不是单独依赖某个参数。比如你把vm.compaction_proactiveness调到很高但应用层还在不停制造不可移动页那内核再勤奋也白搭。反过来应用层做了完美的内存池但如果内核在内存压迫时疯狂回收文件页导致分配器迟迟拿不到底层页瓶颈依然在系统层。理想的局面是应用层稳定复用内存、碎片少系统层保留足够的高阶空闲页两边各自管好“大块连续内存”的供需。如果你做的是云原生或容器化场景还要考虑一层容器内存限制是cgroup维度内存碎片往往跨容器存在。某个容器频繁分配大块内存它的失败可能影响同主机的其他容器内核无法区分cgroup做内存规整。这种场景下我更倾向给关键业务容器使用HugeTLB或者预留独立内存池把干扰隔离掉。这个思路尤其适合数据库、Redis、消息队列这类延迟敏感组件。这个方案后续还可以继续延伸依靠内核的BPF能力实时追踪分配失败事件、定期输出碎片报告甚至结合K8s的节点资源管理做自动化迁移。内存碎片不会彻底消失但可以把它控制在“几乎不影响业务”的范围里。我的感受是只要把系统层和应用层两条线同时拉起来这套整理方案基本能应对绝大多数生产环境的碎片问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进