首页 / 资讯中心 / 文章详情

从一次线上内存暴涨事故,读懂存储器管理的核心机制与排查思路

从一次线上内存暴涨事故,读懂存储器管理的核心机制与排查思路 ★ FEATURED ARTICLE
从一次线上内存暴涨事故说起存储器管理到底管了什么前阵子帮人排查一个后台服务的内存问题服务本身逻辑并不复杂不过是频繁创建和销毁对象、加载一批配置数据结果压测时内存占用一路飙升最后直接触发系统OOM。当时排查的时候翻到系统日志发现进程的虚拟内存空间明明还有很大余量物理内存却撑不住了。顺着这个问题往下挖才意识到很多开发对存储器管理这一块的理解停留在“内存不够就加条子”的层面根本不知道操作系统在背后做了多少事。如果你也在学操作系统或者正被内存相关的线上问题折磨这篇内容值得认真读一遍。我尽量不按教科书的方式逐条念定义而是用实际问题的链路把存储器管理的核心逻辑串起来物理内存怎么划分、进程地址空间怎么组织、虚拟内存怎么运作、页面置换怎么选、段页式到底解决什么问题以及日常开发里最常见的存储器相关“坑”该怎么躲。适合正在复习操作系统的学生也适合写服务端代码想补底层认知的开发者。1. 为什么物理内存明明没满程序却常说“内存不够”把时间拨回计算机还很简单的年代。早期的操作系统一次只跑一个程序内存里放一份程序镜像、一份数据区跑完换下一个。那个阶段所谓的存储器管理基本就是“分个区装进去跑完清掉”。后来多道程序设计出现内存里同时驻留多个进程问题立刻来了谁占哪块内存怎么保证进程之间不互相踩踏谁来做地址翻译1.1 地址空间隔离进程不能直接碰物理地址一个最容易忽略的事实是普通应用程序根本不应该直接操作物理地址。如果每个进程都以为自己“拥有”整台机器的全部内存那任何一个野指针都能把另一个进程的数据改得面目全非。操作系统在这里引入了一个关键抽象——逻辑地址空间。每个进程看到的是从0开始的一段连续地址这段地址由操作系统和硬件配合映射到真实的物理内存位置。这个映射关系落在现代x86平台上就是CPU里的MMU内存管理单元加页表。MMU每次收到CPU发来的逻辑地址就查页表得到物理页框号拼上页内偏移形成真实物理地址。整个翻译过程对进程完全透明——进程只管访问自己的逻辑地址至于这段地址到底落在物理内存的哪个页框甚至是否在物理内存里都由操作系统决定。1.2 连续分配的尴尬碎片能把可用内存“切成豆腐”早期实现里内存分配采用连续分配方式——给每个进程一整块连续的物理内存区域。管理方式从固定分区演进到动态分区。动态分区听上去很灵活进程需要多少就找一块不小于它需求的内存块分给它。但问题在于释放。进程退出后释放的内存块如果太小没法分配给新进程就会积累一堆无法利用的“外部碎片”。打个比方你有一条128MB的内存条跑完几个小程序后释放出许多小块每块都不超过1MB却散布在全内存的不同位置。这时候来了个需要10MB连续空间的新进程就算剩余总空间远超10MB依然分配失败。这就是外部碎片。动态分区里常用的首次适应算法、最佳适应算法本质上是选择空闲块的策略问题。首次适应速度快但容易在大块内存前段累积碎片最佳适应看起来“精打细算”却可能留下大量难以使用的小残片。无论哪种策略都绕不开碎片带来的分配失败风险。正因为连续分配这个“必须整块连续”的死穴才有了分页机制——从根上消灭“必须连续”这个约束。1.3 分区维护的代价往往被低估很多教学材料只告诉你首次适应算法“从头找第一个合适的块”但真正做系统设计时空闲块的维护本身就有成本。经典做法是维护空闲分区链表按地址排序或按大小排序每次分配时遍历链表。我在模拟实验里测过一组数据当内存块数量超过几千个、分配释放频率很高时链表遍历的时间开销和链表节点本身占用的内存开销都很可观。这也是为什么现代操作系统的主流方案里连续分配只出现在启动早期的极简环境或嵌入式裸机场景。通用操作系统早就全面转向分页——页表把物理内存切成等大小的页框常见4KB进程的逻辑地址也切成等大小的页面任何一页都可以放在任意一个空闲页框里。碎片问题从“找连续大块”变成“找足够多的页框”外部碎片基本消失只剩下页内碎片这种最多一页的浪费。2. 分页机制里最容易被忽略的两个“隐藏级”环节分页的核心并不复杂把地址拆成页号和页内偏移两段查页表完成映射。但落到工程实现上有几个细节非常关键也是面试和实际调优中最常踩的点。2.1 页表到底存在哪里每个进程都有一张“地址翻译表”页表不是全局一张而是每个进程一个。进程切换时操作系统要切换页表基址寄存器x86-64上是CR3把MMU的翻译上下文切换到新进程的映射关系。这也是“进程切换开销大”的原因之一——不只是保存几个寄存器整个地址映射环境都要换。一张页表需要覆盖多少项在32位系统里逻辑地址空间是4GB4KB一页就需要约100万个页表项。每个页表项通常4字节意味着光页表就要占4MB。如果进程实际只用了一小部分地址空间为全部地址空间建立完整页表显然是浪费。所以现代系统引入多级页表。以x86-64的四级页表为例逻辑地址被拆成好几段索引每一级查一个表只有在前一级表项有效时才继续往下一级走。这样进程如果只用低地址空间大部分高层的页目录项直接为空根本不需要为那些区间建立中间表和叶子表内存开销大幅下降。2.2 TLB错过一次可能要“多付几百次访问的代价”页表在内存里于是每次访存都得先查页表——一次逻辑地址访问就变成两次物理内存访问。如果页表分了好几级那代价更大。解决办法是快表TLB把最近用过的页号到页框号的映射缓存到CPU内部的高速存储里。TLB命中时地址翻译几乎不增加时间TLB未命中时CPU得真去内存里走一遍多级页表查表流程通常几十个时钟周期起步。我的一个经验是很多所谓“性能突然变差”的诡异问题最后都指向TLB抖动——比如程序访问内存的模式极端分散或者用了特别大的内存区域且访问局部性差导致TLB条目被频繁换出。优化上能做的事主要有几件尽量保证关键数据结构的内存局部性减少跳跃式访问对大块数据考虑使用大页内存如2MB/1GB大页让一个TLB条目覆盖更大范围在多核场景下注意vCPU和物理核的亲和性绑定减少因迁移导致的TLB刷新。2.3 地址变换的完整落点一个具体换算例子很多教材给出了逻辑地址1024页大小256B的场景但我觉得不如用一个更接近真实系统的例子页面大小4KB逻辑地址0x12345678页表基址放在CR3寄存器指定的物理地址。地址0x12345678的二进制高20位是页号0x12345低12位是偏移0x678。MMU用CR3里的页表基址加上页号乘以4每个页表项4字节找到对应页表项取出其中的页框号假设为0xABCDE于是物理地址就是0xABCDE678。整个过程中进程只认0x12345678映射关系全由MMU和操作系统维护。理解这个换算过程很重要。因为后面讲虚拟内存、页面置换、内存映射文件时本质都是同一套“查页表—找页框—拼地址”机制在不同场景下的应用。3. 虚拟存储的底层逻辑怎么用一块硬盘“骗过”程序的胃口分页解决了碎片问题但没解决另一个更尖锐的问题物理内存总量有限装不下所有进程同时需要的全部数据。早期操作系统干脆让进程只能跑到物理内存范围内内存不够就等。后来虚拟存储技术改变了这个格局。3.1 局部性原理程序不是均匀地访问所有地址虚拟存储的理论基础是局部性原理。一个进程在任意短时间内访问的指令和数据的地址往往集中在一个较小范围内。典型程序都有循环和顺序执行的特征——循环体里的代码会被反复访问数组、栈这类结构也倾向于在附近的地址上操作。既然如此就没必要把整个进程全部塞进物理内存只要把正在活跃的那部分页面驻留内存其余的留在磁盘交换分区或页面文件上缺了再调入。这就像你在家里做饭并不需要把超市里所有食材都搬进冰箱只需要备齐今天要用的那几样。操作系统用“局部性”作为判断依据猜测哪些页面“今天会用”。3.2 缺页中断程序以为自己一直在跑其实中间有硬盘的“暂停”当进程访问一个不在物理内存中的页面时MMU查页表发现该页表项的有效位没置位就触发缺页异常。操作系统接管现场找到磁盘上对应的页分配一个空闲页框把内容读入更新页表然后重新执行触发异常的指令。这个过程对进程来讲完全透明进程的感受只是“这次访问稍微慢了一点”——但这个“一点”可能是几十微秒甚至毫秒级因为磁盘I/O和内存访问差了不止一个数量级。如果系统频繁触发缺页CPU就会大量时间花在等待I/O上表现为系统整体吞吐骤降、load飙升这就是所谓的“颠簸/抖动”thrashing。我在排查性能问题时第一个习惯就是看缺页异常计数如果长时间持续高位运行且system态CPU占比高多半是内存不足导致持续换页加内存比调代码更优先。3.3 页面置换算法淘汰谁的决定影响全局既然物理内存有限就必然要面临“新页面进来旧页面让位”的问题。页面置换算法决定淘汰哪个页面其质量直接影响缺页率。教科书上主要的几个候选各有权衡算法核心思想优点缺点最佳置换OPT淘汰未来最长时间不会被访问的页缺页率理论最低需要未来知识只作教学基准先进先出FIFO淘汰最先进入内存的页实现简单可能存在Belady异常增加页框反而缺页更多最近最久未使用LRU淘汰最久未被访问的页性能接近OPT硬件实现成本高需要时间戳等机制时钟算法Clock用访问位近似LRU循环扫描开销低性能可接受近似精度不如严格LRU实际系统几乎不用严格LRU因为要为每次访问记录时间戳并维护有序结构代价太高。Linux内核主要采用一种改进型时钟算法配合访问位和脏位做多级判断优先淘汰“未访问且未修改”的页面避免写回I/O。这个设计非常务实——淘汰一个干净的页面不需要写磁盘淘汰一个脏页面要把内容写回成本差别明显。3.4 工作集模型预防颠簸比事后换页更重要说到颠簸最有效的解决办法不是算法层面的置换优化而是控制每个进程的工作集。工作集指进程在一段时间内实际活跃访问的页面集合。当物理内存规模小于所有进程工作集总和时无论置换算法多聪明系统都会反复缺页。操作系统的应对策略包括限制并发进程数量、必要时在进程级别做交换出/换入、动态调整分配给每个进程的页框数。这对我来说有一个非常实际的经验提醒那种“只要置换算法够好内存就不会爆”的想法是错的。线上服务如果剩余物理内存已经小到工作集都装不下正确做法是扩容或降载而不是继续调参数。4. 从分段到段页式为什么纯分页也还不够“像人话”分页解决碎片和虚拟存储问题但存在一个别扭的地方页面的大小是硬性规定的4KB或更大的2MB/1MB与程序的逻辑结构没有任何关系。一个函数可能跨两个页一个数组可能拆散到多个页里。对程序员而言更自然的视图是“代码段”“数据段”“栈段”——每个段是一个有意义的逻辑单位大小可以各不相同。4.1 分段管理让“段”成为分配和共享的基本单位分段机制里逻辑地址由段号段内偏移组成。每个进程有一个段表每项记录了该段在物理内存中的起始地址和段长。段长可变所以段内访问要检查是否越界。分段的好处很直接模块化与共享。两个进程可以共享同一个代码段不必各自复制一份编译器和链接器对“段”的处理也更自然可执行文件本身就有代码段、数据段、BSS段等概念。但分段又回到了“大块连续分配”的老路会产生外部碎片。如果按段全部载入内存也失去了虚拟存储的灵活度。4.2 段页式两层寻址解决“逻辑清晰”和“物理整块”的矛盾段页式把两者的优点结合起来先把进程按逻辑切成段再把每个段按固定页面大小切成页。逻辑地址需要三段解释——段号、段内页号、页内偏移。访问时第一步查段表得到该段的页表起始地址第二步查页表得到页框号第三步拼上偏移得到物理地址。这个过程多了一次访问时间成本更高但换来的是程序员看到的地址空间是逻辑清晰的段结构操作系统管理内存仍然用等大小页框碎片问题不存在段也可以部分驻留内存实现虚拟存储。4.3 现代系统的实际取向它们在用哪种方案现代通用操作系统Linux、Windows、macOS在物理内存管理层面实际上是纯分页方案。但进程的地址空间里仍然有很清晰的区域划分代码段、数据段、堆、栈、内存映射区。这些区域在页表里对应一段段连续的虚拟地址范围只是没有直接用硬件分段机制去实现。x86-64的分段机制更多是历史遗留现代系统基本靠分页完成隔离。真正更重要的是“虚拟地址空间布局”这个实践层面的东西。例如Linux在x86-64下把用户空间和内核空间用地址高位标志区分用户态地址通常在0x0000000000000000到0x00007fffffffffff内核映射在0xffff800000000000以上。栈通常在高位向下增长堆由低向高增长中间有巨大空洞留给内存映射区域。理解这个布局对调试崩溃、排查内存膨胀非常有用——看到地址落在哪个区间基本能判断是栈上的问题、堆的问题还是映射区的问题。5. 实际开发中与存储器管理强相关的几个典型场景前面讲了很多操作系统内部的机制可能有人觉得“我又不写操作系统了解这些有什么用”。下面结合真实开发中常见问题说说存储器管理知识怎么落地。5.1 内存泄漏排查先看RSS还是先看VMS进程的内存占用有两个指标经常混在一起用VMS虚拟内存大小和RSS常驻物理内存大小。VMS是进程的逻辑地址空间大小包含了映射但从未触碰的内存、共享库、文件映射等RSS是实际驻留的物理内存页数。真实故障场景里如果你发现一个进程的VMS特别大但RSS很小不用慌很可能是加载了很多共享库或映射了超大文件物理内存压力不大。反过来RSS持续爬升且不回落才更像真正在消耗物理内存——比如C/C程序malloc了但没free或者Java堆外内存使用失控。排查工具方面在Linux上我常用/proc/进程ID/status里的VmRSS字段做快速判断配合smem按比例统计共享内存的影响进一步看页表统计也可以用/proc/进程ID/smaps能看清每一段地址映射的驻留情况。需要特别强调一点如果服务用了mmap映射大文件VMS会大幅膨胀但RSS只反映实际读写过的页面。SSD上随机读一个4GB映射文件的一部分RSS只对应那一部分这恰恰体现了虚拟存储“按需调页”的设计。5.2 页缓存对服务表现的影响很多开发不知道文件读写的内存管理器介入程度远比想象中深。Linux的页面缓存会把读过的文件页保留在物理内存里下次命中直接走内存不再走磁盘。这带来一个现象一个服务明明只需要512MB逻辑内存结果系统RSS总占用达到十几个GB其中很大一部分是页缓存。这不是问题反而是好事。页缓存被回收的优先级低于进程匿名页真实压力到来时内核会主动收缩页缓存。因此你看到free命令里cache数值很高不一定代表内存不足。真正的红线是available这一列——它已经考虑到了页缓存可回收的量看它比看used靠谱得多。5.3 swap到底该不该开怎么调swap的争议一直存在。关闭swap的好处是进程不会因为换页导致大量延迟抖动坏处是在物理内存用尽时直接OOM kill。大多数线上服务器建议保留一定量的swap或者至少用较保守的swappiness值如10而不是默认的60让内核只在内存压力显著时才启用换页。有一次我们系统出现周期性卡顿查下来就是swap上积累了热点页面程序每次访问这些页面都要先换入。把涉及的核心服务进程内存锁定mlock或者扩大物理内存并调低swappiness之后问题立刻缓解。这种问题如果只靠业务代码层排查很容易绕远路。5.4 大页内存HugePages实战中的取舍数据库、搜索引擎这类内存大户经常用大页。TLB条目数是有限的默认4KB页模式下一个需要几十GB共享内存的实例会产生千万级页表条目和大量TLB未命中用2MB甚至1GB的大页TLB覆盖范围大幅提升。但大页不是无脑开。它有几个麻烦预先分配后内存在操作系统看来被“固定”了不能被页缓存复用部分早期版本的分配与释放逻辑不灵活容易造成内存“钉子户”。实践中我的建议是对明确的大块固定内存区域如数据库缓冲池用大页对普通业务进程保持默认页大小。分配时用echo写入/sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages这类接口就可以不用装额外工具。6. 把存储器管理知识串成一条排查链路最后梳理一套我个人在线上问题中反复用到的思路。无论问题表象是OOM、卡顿、性能下降还是进程被杀顺着这条链路排查通常能快速定位是哪个环节出了问题。6.1 确定是物理内存不足还是逻辑地址空间膨胀第一步看可用物理内存free -h重点看available。如果available长期接近0说明物理内存确实吃紧。如果available很充足但进程被杀那要么是配置了cgroup内存限制看/sys/fs/cgroup下的memory.limit_in_bytes要么是进程自身触发了语言运行时的堆限制。很多Java进程在容器里被OOM Kill其实不是宿主机内存耗尽而是容器内存上限被击穿。6.2 确定是哪类页面在“吃掉”内存cat /proc/meminfo里可以区分匿名页AnonPages和文件页Cached。匿名页一般是进程堆、栈、私有数据文件页是页缓存、映射文件。如果AnonPages快速攀升多半是业务进程在疯狂分配堆内存或者有内存泄漏如果Cached很大多半是文件读写命中大量页缓存这是常态。再进一步用top里的SHR字段分清共享内存或者ps -eo pid,rss,vsz,comm按RSS排序找出真正的内存大户进程。6.3 确定是不是频繁换页引发的连锁反应vmstat 1里siswap in和soswap out如果持续非零说明内存压力已经很严重。另一个指标是pgfault和pgmajfault后者每秒如果很高说明进程频繁触发主缺页中断也就是大量页面要从磁盘换入。这种情况不解决内存缺口只在应用层面“优化代码”一般收效甚微。6.4 一个综合排查的实际过程我排查过一个模拟项目X现象是压测到一定并发后请求延迟突然飙到原来的十倍。最初怀疑是锁竞争或网络问题但查互斥锁等待和网络重传都没有异常。看vmstat时发现pi和po周期性跳高cs上下文切换也异常高再看/proc/meminfoSwapTotal设置了一部分SwapUsed涨到了五成以上。进一步看/proc/进程ID/status里的VmSwap发现服务进程本身有大量页面被换出。根因是实例所在宿主机同时跑了多个依赖内存的服务物理内存总量不够而该服务代码的一次数据批量加载又占用了大量内存频繁触发换入换出。处理方案分两层短期把swappiness调低、给该服务加内存上限保护不让它超过一定RSS长期调整批量加载的批次大小避免一次性把大量数据读入内存。最终延迟恢复正常。这个案例里如果只盯着应用代码找问题可能很久都摸不到原因。但一旦用存储器管理的“页表—缺页—换页—工作集”框架去拆解思路就非常清晰判断是否有物理内存压力是否有大量换页再定位到哪个进程、哪段操作引起的最后针对性处理。7. 学习存储器管理时我建议你做过的几个小实验原理看再多不如亲手验证几个现象来得扎实。如果你有条件我建议在Linux虚拟机里做这几个实验会帮助你把存储管理的抽象概念钉死第一个写一个小程序按1MB步进malloc一块内存并依次touch每个页写入一个字节观察VMS和RSS的变化。你会发现malloc只把VMS推高touch之后RSS才涨起来这就是按需调页的直观体现。第二个用mmap映射一个几百MB的文件只随机读其中几页观察RSS远小于文件大小然后顺序读整个文件RSS先涨上去再回落。这能帮你理解页缓存和页面回收。第三个同时跑多个吃内存的程序把物理内存压爆观察系统怎么在进程间回收页面、是否出现swap、谁被OOM Kill。这个实验建议在虚拟机里做别拿生产环境练手。这些实验做下来教材里那些“虚拟存储”“页面置换”“工作集”的概念就不再是抽象名词了你会真实看到操作系统是怎么“编”出足够内存给所有程序用的。8. 结尾一个多年下来最实在的体会我个人做了这么多和内存相关的排查后最深的体会是存储器管理的核心不是某一个算法多精巧而是“假装内存够用”这套机制的整体配合。分页解决了碎片虚拟存储把容量天花板从物理内存扩展到了磁盘置换算法在效率和成本之间做折中段页式试图同时满足人类逻辑和机器物理需要。每层都有自己的取舍也有自己的坑。对普通开发者来说不必记住所有页表项的格式但一定要有“地址映射、缺页中断、换页开销、页缓存”这几个思维模型。遇到内存类问题先判断是物理内存短缺还是虚拟空间虚高再判断是页缓存还是匿名页再判断有没有持续换页最后落到具体的进程和操作上。这套链路我用了很多年几乎没有落空过。最后再分享一个小技巧排查内存问题别怕用/proc底层的文件它们的信息密度远高于各种图形化监控的汇总值。真正把/proc/meminfo、/proc/进程ID/status、/proc/进程ID/smaps这几个文件读明白你对系统内存状态的理解会超过大多数只盯Dashboard的人。
阅读完成 · 觉得有帮助?
咨询建站