ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

从一次线上内存暴涨事故说起:存储器管理到底管了什么

前阵子帮人排查一个后台服务的内存问题,服务本身逻辑并不复杂,不过是频繁创建和销毁对象、加载一批配置数据,结果压测时内存占用一路飙升,最后直接触发系统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位系统里,逻辑地址空间是4GB,4KB一页,就需要约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里si(swap in)和so(swap out)如果持续非零,说明内存压力已经很严重。另一个指标是pgfault和pgmajfault,后者每秒如果很高,说明进程频繁触发主缺页中断,也就是大量页面要从磁盘换入。这种情况不解决内存缺口,只在应用层面“优化代码”一般收效甚微。

6.4 一个综合排查的实际过程

我排查过一个模拟项目X,现象是压测到一定并发后请求延迟突然飙到原来的十倍。最初怀疑是锁竞争或网络问题,但查互斥锁等待和网络重传都没有异常。看vmstat时发现pi和po周期性跳高,cs上下文切换也异常高,再看/proc/meminfo,SwapTotal设置了一部分,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的人。

RELATED READING

延伸阅读

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