ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

内存碎片导致RSS飙升?从glibc arena到jemalloc的治理实战

内存碎片导致RSS飙升?从glibc arena到jemalloc的治理实战 线上系统跑了一段时间后内存曲线像锯齿一样不断爬升重启就好跑几天又上去加内存也没用。我去年调优一个Java服务时就撞上过这问题服务上线两周RSS内存一路爬到16G逼近容器上限可heap dump下来一看堆内存占用还不到6G。JVM常驻内存跟堆占用对不上号查到最后问题出在glibc malloc的arena碎片上。内存碎片这词在后端开发里不算陌生但真正见过它把进程拖垮的人并不多。这篇文章就围绕“内存碎片整理方案”这件事把我在实际项目里碰到的碎片问题的成因、判断方法、系统层与应用层的治理手段以及一次完整的排障复盘一次性讲透。无论你是后端开发、SRE还是做基础组件的这篇内容应该都能对号入座地解决一些问题。1. 内存碎片到底是怎么产生的1.1 先分清两种碎片内部碎片与外部碎片聊内存碎片前得先把概念对齐。所谓内部碎片是分配器给了一整块内存但实际只用了一部分剩下的部分没法分给别人用。比如你要存一个13字节的对象分配器却按16字节的规格给你一块内存那3字节就是内部碎片。这种碎片不致命因为浪费的量固定且有限。外部碎片才是真正头疼的内存中有很多空闲的小块它们散布在各处单独看每一块都不小但因为不连续遇到一个大块分配请求时哪一块都接不住。听着有点抽象打个比方你有一段120分钟的连续空闲时间但它被切成了两段各60分钟中间夹着一小时会议这时候你想连续看一部90分钟的电影就做不到。物理内存里的情况就是如此两块60分钟的空闲页被一个已分配页隔开凑不成你想要的那个连续区域。内部碎片是“量”的问题外部碎片是“形”的问题。量的问题是浪费一点可接受形的问题是明明总量充足却无法使用这才是系统假死、OOM的罪魁祸首之一。1.2 碎片的核心来源小对象频繁分配与释放的熵增从工程视角看碎片不会凭空产生它源于不同大小对象在时间维度上的混战。一个进程的堆内存就像一块反复切切的案板你切过葱花、切过肉、切过水果案板上留下的刀痕位置各不相同。内存也一样对象A释放后留出的洞形状和下一个对象B需要的空间对不上洞就保留了下来。尤其在多线程服务中每个线程都会跑来跑去地malloc和free分配的内存块大小各异间隔也不固定。时间一长堆里就布满了各种“洞”。进程原本的虚拟地址空间可能很大但在小内存设备或者有容器内存上限的场景下可用地址空间有限碎片越多分配器就越吃力。这里要展开聊一个底层细节glibc malloc到底是怎么让碎片变多的。glibc的ptmalloc设计里分配器把释放的chunk内存块按大小挂到不同的bin里并尝试合并相邻的空闲chunk。但关键在于chunk之间如果隔着一个依然被使用的chunk那两块就没法合并。这就是碎片的核心成因。每次malloc分配器都要先搜一遍合适的bin若没有完全匹配的还会把大的空闲chunk切一刀一切又制造出新的更小的chunk。长此以往堆就像反复被拆开的乐高积木零件越来越多却越来越拼不回原来的大块。1.3 触发碎片问题的三类典型场景碎片问题并不是所有进程都会遇到它有几个比较固定的诱发条件服务长期运行不重启。重启是最粗暴的碎片整理手段生产环境出于稳定考虑很多服务一跑就是几个月甚至更久碎片自然积累。对象大小跨度大生命周期错配。比如一个服务要频繁创建几百字节的请求上下文又偶尔创建几MB的缓存块。大块请求来了小块内存已经把堆搅乱难找到连续空间。高并发多线程的malloc/free竞争。glibc为了减少线程竞争每个线程默认会有自己的arenaarena数量增加后会加剧线程之间chunk迁移的复杂度而且arena内部各bin中的空闲chunk也容易因频繁切分变成碎片。判断自己中没中招可以先看两条最直观的症状内存持续上涨但业务本身没有大量新增对象加内存或者调整JVM堆参数都不见效只是重启能暂时缓解。如果你排查时见过这两条那“内存碎片整理方案”大概率就是你要找的东西。2. 怎么判断你是真的被内存碎片拖住了2.1 操作系统层面的碎片量化查看方法谈整理方案之前先得确认“碎片真的存在”。否则一上来就优化分配器可能白忙活一场。在Linux上最直接的指标在/proc/buddyinfo里。Buddy伙伴系统是Linux物理内存按2的幂次划分页块的一种管理方式。/proc/buddyinfo的每一列分别记录了当前空闲块的数量列从左到右代表order 0单页、order 12页一直到order 101024页。如果某个order列出现大量空闲但更低的order列很少说明内存整体空闲但大块连续物理内存少顺序相反才说明碎片严重。举个例子你看到order 0和order 1有大量空闲但order 4以上几乎全是0同时MemFree很大这就大概率存在外部碎片。实际运维中可以写一段几行的Shell命令汇总给监控系统awk /DMA|Normal/ {for(i4;i15;i){sum[i-4]$i}} END {for(i0;i11;i) printf(order%d:%d ,i,sum[i]); print } /proc/buddyinfo如果发现order数高的列很少还有一种工具叫pagetypeinfo在/proc/pagetypeinfo下可以更细地看各迁移类型的空闲页分布。结合Free内存和OOM事件再去判断碎片问题才坐实。2.2 应用层的碎片子glibc malloc的检查手段系统层面颗粒度太粗很多时候物理内存没碎而是进程的堆空间碎了。这种情况下C/C进程可以调用malloc_info()拿到glibc的实际分配状态。malloc_info会输出各arena的bin占用fastbin里的chunk数量以及mmap分配的情况。你可以写个小的触发函数在服务里开一个隐藏接口或者用gdb调用。输出里的关键信息是heap节点的total值还有各bin的count。如果total很大但used不多说明空闲的chunk被挂在bin里但没法合并碎片已经在堆里扎了根。对于Java服务不能直接看malloc_info得绕个弯。建议在容器或宿主机上直接看Java进程的RSS再对比jmap -heap给出的堆占用。两者差值如果持续超过几个GB且还不断膨胀那多余的往往就是Native内存而Native内存里最容易出问题的就是malloc的碎片。还有一种更细的方式是使用jemalloc自带统计接口或perf跟踪malloc/free路径不过做初步排查时可以先用粗粒度差法定位。2.3 压测过程中如何观察碎片增长曲线我排查内存问题时最喜欢用的方法就是压测观察曲线。写一段模拟线上流量特征的压力脚本持续向被测服务发送请求每5分钟记录一次RSS和堆内使用率。碎片问题的特征曲线比较典型开始阶段内存快速上涨达到某个阈值后虽然也涨但斜率明显变缓而且HeapUsed曲线平稳时RSS还在缓慢爬升。这说明RSS的增量不是业务对象导致的而是分配器在背后切切补补。如果这种曲线在重启后回到低位过段时间又重复基本可以判定是碎片堆积。与真实泄漏的区别就在于泄漏的内存是不可达的重压或GC后HeapUsed依然稳定不降而碎片情况下HeapUsed正常Native内存却涨。# 连续30次采样间隔1分钟观察曲线 for i in $(seq 1 30); do ps -o pid,rss,vsz --no-headers -p $(pgrep -f your_service) sleep 60 done一次性采样不好判断连续采样才能看出趋势。结合机器上的vmstat或sar -r观察si/so如果内存明明充足却频繁swap也可能与碎片导致的分配失败有关。3. 系统层能做哪些整理与预防3.1 内核的compact与drop_caches什么时候有用提到碎片整理运维朋友可能首先想到echo 3 /proc/sys/vm/drop_caches。这里必须提醒一句drop_caches清理的是页缓存page cache对应用堆碎片一点帮助都没有。它释放的内存是可回收缓存不是碎掉的匿名页。真正干碎片整理的是内核的memory compaction内存规整。内核把已分配的页进行移动腾出连续的大块物理内存供申请者使用。手动触发的方式是echo 1 /proc/sys/vm/compact_memory。但要注意compact_memory是全局触发的它会扫描并尝试迁移所有可移动的页可能在短时间内引发较高的CPU开销。如果你在物理机上跑着核心数据库建议谨慎使用最好在业务低峰期操作。内核的碎片整理还有一套自动触发机制在分配高阶页order大于0失败时内核会先尝试compact再决定是否OOM。内核编译参数CONFIG_COMPACTION开启后这个机制才生效。对Linux发行版来说一般都开了但虚拟化环境下的老内核有可能没开可以检查一下。3.2 透明大页THP对碎片的影响THPTransparent Huge Pages这个参数经常被人忽略但它跟碎片问题纠缠很深。THP会把应用程序的匿名页自动合并成2MB的大页。想法是减少TLB miss提升性能但副作用是应用申请2MB大页时内核需要找到连续的2MB物理内存。碎片严重时会直接分配失败然后触发更频繁的compact而compact又会导致CPU尖刺。我处理过一个数据库偶尔出现几百毫秒延迟的问题查到最后就是THP在后台做内存规整把数据库线程的执行时间挤爆了。针对内存碎片治理多数时候建议把THP关成madvise模式只让显式声明使用大页的程序受益echo madvise /sys/kernel/mm/transparent_hugepage/enabled echo madvise /sys/kernel/mm/transparent_hugepage/defrag这样做的好处是普通程序的大块分配不会被强制规整成2MB连续内存减少碎片条件下分配失败的风险。如果是物理机上跑虚拟化平台THP导致的性能抖动更突出建议直接切到never。3.3 swappiness与overcommit防患于未然系统层的内存碎片治理不只等于事后整理也要防患于未然。vm.swappiness这个参数控制内核回收匿名页的积极程度。默认60如果把它调到0意味着除非物理内存真的不够了否则不会回收匿名页。这在一定程度上可以减少页回收频率避免内存页频繁换入换出导致的页分配碎片但也会让页面回收更被动可能加剧OOM风险。更推荐的做法是根据业务场景设置到一个合理的区间比如10-30让内核在内存够用的时候不动匿名页紧张时又能积极回收。对于overcommit编译服务或者构建型任务多的机器可以保持默认但对于长时间在线服务的机器建议设置vm.overcommit_memory2避免内核过度承诺导致某次大块分配直接失败。不过要牢记一点系统层的手段始终只是外围辅助。真正影响碎片治理效果的往往还是在应用层怎么做分配策略。4. 应用层才是主战场分配器选型与内存池策略4.1 换掉glibc malloc谈jemalloc与tcmalloc的取舍如果你确认服务被碎片拖累第一步最简单也最激进的改造就是把glibc malloc换掉。jemalloc和tcmalloc这两个业界常用的分配器都针对碎片问题做了大量设计。jemalloc的核心设计之一是把不同大小的对象分到独立的size class里并且每个线程尽量只在自己的arena里分配。这种方式可以降低线程间的锁竞争也减少了交叉分配带来的碎片。最简单的接入方式就是通过LD_PRELOADLD_PRELOAD/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 your_servicetcmalloc的思路更倾向于线程本地缓存每个线程先把小块内存缓存到本地不够了再从中心堆申请。它的好处是超小对象分配极快碎片率也低但在多线程大规模迁移场景下有内存占用偏高的倾向。我的经验是如果是Rust、C写的高并发网络服务优先试jemalloc如果是传统Java服务但受Native内存困扰也可以做一轮AB对比再决定。4.2 glibc malloc的arena参数调整技巧有些服务确实不方便改分配器比如不能随便换动态库的团队那还有一条妥协路线调整glibc的参数。MALLOC_ARENA_MAX是控制线程arena数量的环境变量。默认情况下glibcarena数量是CPU核数的8倍这会导致每个arena都有自己的空闲页堆碎片场景下浪费倍数被放大。把MALLOC_ARENA_MAX设到2或者4可以显著减少内存占用但会牺牲一些线程并发分配性能。适合的对象是“创建线程频繁但线程活跃期短”的服务。另外两个变量也值得调export MALLOC_ARENA_MAX2 export MALLOC_MMAP_THRESHOLD131072 export MALLOC_TRIM_THRESHOLD262144MALLOC_MMAP_THRESHOLD决定超过多大尺寸的分配直接用mmap而不是heap。mmap的好处是一释放就直接归还给内核不会留下堆上的碎片代价是每次分配/释放有系统调用开销。MALLOC_TRIM_THRESHOLD则控制堆顶空闲空间达到多少时收缩归还内核。合理设置这两个值能把一部分会长期残留的碎片区域挡在堆外。我实测过同一个压测场景调整参数后RSS大约能少10%-20%如果碎片更极端收益还会更大。4.3 最实用的方法预分配与对象池比换分配器更可控的是业务层的对象复用。要知道碎片最怕的其实是“释放后立刻又有不同大小的对象申请”。如果能让相同大小对象的生命周期统一碎片率会大幅降低。常见的做法是给高频创建/销毁的对象建池子。比如一个网关服务每秒钟创建几万个request对象直接new/delete会让堆形状变得千疮百孔。改成对象池模式启动时预分配一批用完后归还池中下次再来时从池中取就能让请求对象的内存规格一直保持一致碎片空间根本长不出来。struct request_pool { void *slab; size_t slab_size; size_t offset; };这是很多网络框架在内部使用的slab思路。可以看到本质上就是把malloc的分配粒度变成等长的槽位让内部碎片可以容忍外部碎片直接消失。进程毕竟还有大量临时的、大小不定的分配。这时可以引入“分代分配器”的思路把新分配对象和老对象分开管理定期把老对象的引用搬走腾出连续空间。这就是JVM里GC compaction的底层逻辑只不过在C/C侧需要自己实现或引入类似Hoard这样的分配器来达到类似效果。4.4 线上服务的“运行时整理”迁移与代际晋升对于无法轻易改代码的长跑服务可以借鉴JVM分代GC的思想做运行时整理。思路是周期性遍历对象引用图把还活着的对象迁移到一块空闲区域然后把旧区域整体释放。这个方案复杂度高一般只在自研的高性能组件中使用但效果显著。实现上分三步标记阶段从根对象出发遍历一遍所有被引用的对象。迁移阶段把存活对象拷贝到新预留的连续内存里同时更新所有指针引用。清理阶段把旧区域整块归还或置入空闲链表。这里要提醒一个最容易出错的陷阱迁移时必须暂停业务线程Stop The World否则一边拷贝一边业务修改指针程序会直接崩溃。如果没法接受长时间STW就得把迁移做成增量式的每次拷贝一部分但工程复杂度和风险成倍增加。对于绝大多数业务团队我并不推荐自己造迁移轮子采用tcmalloc或jemalloc后碎片控制已经足够。5. 实战复盘一次线上服务的内存碎片清理5.1 现场症状与初步判断去年某核心业务服务频繁接近容器内存上限团队起初怀疑是JVM堆泄漏但jmap导出的堆dump里old区占用稳定在5G左右。再用jstat看GC吞吐也没有异常增长。真正让问题暴露的是容器内存监控图重启后RSS基础值约7G然后以每天300-500M的速度爬升12天左右触顶。判断逻辑很清晰JVM堆内对象没有净增长排除Java堆泄漏RSS却持续上行方向指向Native内存。再用pmap -x pid看进程地址空间分布发现很多64M大小的匿名块处于空闲状态但进程没有把它们归还给操作系统而且地址空间里散落大量小块的匿名页。这时候心中基本锁定了glibc malloc的碎片问题。5.2 定位到glibc arena与修改验证接着做了一次分步验证先测一下把JVM的-Xmx降低会有什么表现结果RSS照爬不误说明不是堆设置问题。然后用gdbattach到进程调用malloc_info()导出堆状态发现total大于实际使用量不少且多个arena各自持有分散的空闲chunk。改造方案分三步设置MALLOC_ARENA_MAX2减少arena数量。同时观察了两天RSS增速略有放缓但没根除。最后决定接入jemalloc通过LD_PRELOAD方式替换分配器。因为Java服务通过JNI调用了一些Native库所以还保留了NMTNative Memory Tracking观察变化。改造后同一压测场景跑了24小时RSS稳定在8G左右爬升曲线变得平缓容器内存余量从10%提高到35%。这个结果说明了关键结论碎片的修复不是靠调一两个参数就能魔法般消除它需要分配器策略和业务对象生命周期的整体配合。5.3 一条重要的经验不要迷信“重启解决一切”曾经有组员开玩笑说碎片问题就靠“半夜重启大法”省心又省力。但生产服务的可用性要求越来越严重启代表流量丢失和状态清空。对无状态服务还能忍对在内存中维护大量会话、缓存的服务重启代价太高。更关键的是碎片如果不从源头抑制重启只是把时钟拨回零它还会继续积累。“重启解决90%的问题”在内存碎片这个场景里恰恰是最不推荐的解法。转变思路后我们后来在发布规范里加了条硬性要求所有长时间运行的在线服务默认接入jemalloc并在验收时压测内存爬坡速度确保碎片指标合格才允许上线。6. 常见问题与避坑速查6.1 系统与分配器参数速查表参数作用推荐值备注vm.swappiness匿名页回收积极性10-30不建议直接设0transparent_hugepage/enabled透明大页开关madvise高碎片场景建议neverMALLOC_ARENA_MAXglibc arena数量上限2-4配合压测调整MALLOC_MMAP_THRESHOLDmmap分配阈值131072过大块改走mmapMALLOC_TRIM_THRESHOLD堆顶回收阈值262144控制堆膨胀compact_memory手动内存规整低峰期触发有CPU抖动风险6.2 排查内存碎片时的踩坑记录别被free命令骗了。free显示的内存剩余很多不代表应用能拿到连续大块。要用buddyinfo看连续页分布否则会误判。改分配给Java服务时要验证Native调用。JVM里的JIT、NIO、JNI都会走系统分配器单独换JDK里的malloc不一定起效需要确认LD_PRELOAD是否真正生效。可以用lsof -p pid | grep jemalloc验证库是否被加载。malloc_info输出记得关掉再上线。调试完要删除触发接口否则暴露在公网上等于把堆内部结构送出去。有一个案例就是调试接口未关内存布局被攻击者拿到后针对性堆喷。碎片治理不是一次性动作。它应该在发布流程里作为持续测试的一部分每次依赖库升级或业务对象结构调整后重新看RSS曲线避免碎片问题复发。6.3 后续还能怎么扩展如果看完这篇文章觉得碎片问题在系统上依然存在但难度更高可以考虑三个扩展方向一是用eBPF追踪malloc/free调用点精确定位碎片的业务源头二是在自研组件里实现分段GC或区域分配器用手段对抗碎片三是结合容器化编排为高碎片风险的服务配置自动化的定时内存整理任务比如在流量低峰期做一次容器滚动重启。这三件事层层递进从排查到根治再到常态化治理。无论选哪条路最终心里都要有一杆秤内存碎片不是原罪它更像系统熵增的信号。真正重要的不是一次次整理而是让内存分配的模式更整齐、生命周期更清晰。我个人的经验是看到一个服务内存曲线像锯齿一样不断爬升时别急着加内存、调堆大小先花半天时间做一轮碎片诊断。把根因找出来往往改动很小收益就能立竿见影。
返回列表