ARTICLE DETAIL

资讯详情

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

Linux内存管理实战:虚拟内存、页表、TLB、缺页与OOM调优

Linux内存管理实战:虚拟内存、页表、TLB、缺页与OOM调优 1. 虚拟内存不是高级特性而是被逼出来的工程妥协很多人第一次学操作系统内存管理是从虚拟地址页表缺页中断这些名词开始的背了一堆概念做题也能对但一旦线上服务出现内存相关问题脑子里那套知识完全用不上。我自己也经历过这个阶段——考试能拿分排查问题时却连free命令的输出都读不明白。后来带团队做服务端性能优化被内存问题反复毒打之后才慢慢意识到内存管理这一整套机制本质上不是设计出来的优雅方案而是在一个个真实约束下被逼出来的妥协产物。理解了这个为什么再去记那些数据结构才真正立得住。这一篇我打算把操作系统内存管理从底层硬件一路讲到 Linux 的实际实现和线上排查手法尽量把每个设计决策背后的取舍讲清楚。适合正在学操作系统、准备面试或考试的同学也适合已经工作但想补齐底层认知的后端、嵌入式、系统开发工程师。内容会偏实操和原理结合不会只停留在定义层面。1.1 没有虚拟内存的世界长什么样先回到最原始的状态。假设我们现在只有一个程序要跑物理内存就是一根连续的地址线CPU 发出的地址直接打到内存条上。这其实没什么问题早期的嵌入式系统、单片机就是这么干的程序被烧到固定地址跑起来干净利落。问题出在多任务出现之后。第一个麻烦是链接和加载的地址必须提前定死。程序编译时得知道自己要住在物理内存的哪一块一旦初始地址确定所有跳转、所有全局变量的地址都被写进了指令里。换个环境跑地址一变程序全废。当年解决这个问题靠的是重定位——加载时把程序里的地址统一加一个偏移量这种静态重定位听起来可行但它要求程序整块装入连续内存而且装入后基本不能动。第二个麻烦更致命程序可以随便改别人的内存。A 进程里一个野指针写飞了直接就把 B 进程的数据覆盖了甚至可能踩到内核。没有任何保护机制一个程序崩溃能拖垮整台机器。第三个麻烦是内存利用率极低——程序装在连续空间里中间留的空洞也用不了而且程序实际用到的内存往往远小于它申请的总量但物理上必须实打实占着。1.2 一次重定位引发的连锁问题有人可能会想那我给每个进程分配一块独立区域加上越界检查不就行了这就是所谓基址界限寄存器的方案也确实在某些系统里用过。但它有个硬伤——界限检查管的是整块区域管不到区域内部的空洞。进程内部如果有一大段没用到的地址空间物理内存照样被占着因为这块区域是连续分配的。另外程序运行时需要的空间是会变的。栈会往下长堆会往上长动态库要映射进来mmap的文件要挂进来。如果这些都要求在连续物理内存里扩展那就需要提前预留大量空间浪费得更厉害。更别说多个进程共享同一份代码、同一份只读数据的需求在纯物理寻址下几乎没法实现——只能各自复制一份。这些矛盾堆在一起逼着设计者换一个思路给每个进程一套独立的、连续的、从零开始的地址空间至于它实际映射到哪块物理内存由系统在背后悄悄翻译。这就是虚拟内存的起点。它不是为了让程序更快而是为了让多个程序能安全、高效、灵活地共存。1.3 虚拟内存真正解决的四个问题把上面的分析收拢一下虚拟内存解决的核心是四件事。第一是隔离与保护。每个进程活在自己的地址空间里A 进程的指针玩飞了MMU 在翻译时发现页表项没有映射或者权限不允许直接触发异常进程被杀掉的是它自己别的进程毫发无伤。这是现代系统稳定性的基石。第二是地址的连续幻觉。程序看到的是从 0 开始、连续可用的空间物理上却可以碎片化地散落在任意页框里。程序写起来简单物理内存的利用率却高了。第三是按需分配与延迟加载。程序启动时并不需要把所有页面都读进内存用到哪页才加载哪页。这直接让程序占用内存可以超过物理内存总和成为可能也就是我们常说的虚拟内存可以比物理内存大。第四是共享。多个进程运行同一个可执行文件时代码段在物理内存里只有一份各个进程的页表都指向它。fork之后父子进程共享只读页、写时复制也是靠虚拟内存机制实现的。理解了这四点后面所有的页表、缺页、置换算法你都能找到它们在解决哪个具体问题。2. 地址翻译全链路一条 mov 指令背后发生了什么讲清楚了动机接下来该看机制了。很多人对虚拟地址翻译的理解停留在查页表三个字但真正的链路比想象中长CPU 发出虚拟地址MMU 先查 TLB没命中就按页表层级一级一级地内存访问每一级访问本身又可能触发缓存缺失最后才拿到物理地址再去访问数据。这条链路上任何一环出问题都会体现为程序性能下降或者诡异的行为。理解它是后面所有调优的基础。2.1 MMU 与页表遍历的四个层级以常见的 64 位 x86-64 平台为例采用的是四级页表结构。一个 48 位的虚拟地址被切成了五段最高 9 位用来索引 PML4第四级页表接着 9 位索引 PDPT再 9 位索引 PD再 9 位索引 PT最后 12 位是页内偏移。999912 正好是 48。这五段的分工非常清晰前四段负责定位到具体的物理页框最后一段负责在 4KB 的页内定位到具体字节。翻译过程是这样的CR3 寄存器里保存着当前进程 PML4 的物理基址MMU 取虚拟地址的最高 9 位乘以 8每个页表项 8 字节作为偏移去 PML4 里取出 PDPT 的基址再用接下来 9 位在 PDPT 里找到 PD 的基址依次往下最终在 PT 里拿到页框号拼上低 12 位的偏移得到物理地址。这里有个很关键的细节这四次访问全部是内存访问。也就是说一次数据访问理论上要额外做四次内存读取性能直接崩塌。所以硬件必须想办法把中间结果缓存起来这就是 TLB 存在的意义后面细说。顺带提一句后来因为物理地址空间和虚拟地址空间都不够用了又扩展出了五级页表在 PML4 之上加了一层 PML5把虚拟地址扩展到 57 位。每一级还是 9 位索引但多了一层遍历。至于是几级,可以通过 CPU 的cpuid指令或者/proc/cpuinfo里的la57标志来判断。2.2 页表项里每一个比特的含义页表项PTE不是简单存一个物理页框号它是一组标志位的组合每个位都对应一个具体的语义。我们一个进程的页表里绝大部分信息都藏在这些位里。位名称作用0Present (P)为 1 表示该页当前在物理内存中为 0 则访问时触发缺页1R/W为 0 只读为 1 可读写2User/Supervisor控制用户态能否访问是内核隔离的关键3PWT页级写透控制缓存策略4PCD禁止缓存该页5Accessed (A)被访问过时由硬件置位供置换算法参考6Dirty (D)被写过时置位决定换出时是否需要写回7PS为 1 时表示这是一个大页当前级就是叶子节点8Global (G)全局页切换进程时不清 TLB 对应项63NX禁止执行是防溢出攻击的重要机制这张表里最值得琢磨的是 A 位和 D 位。硬件在访问页面时自动置 A 位写入时自动置 D 位这两个位是内核实现近似 LRU的数据来源——因为硬件只给这么粗糙的信息访问过/没访问过内核只能靠定期采样 A 位来推断页面的冷热。这也解释了为什么 Linux 做不到真正的 LRU只能用 Clock 这类近似算法。还有一个经常被忽略的是User/Supervisor 位。内核态访问用户页是可以的用户态访问内核页会直接触发页错误。SMAP、SMEP 这些安全特性就是在这一层做文章的防止内核被诱导去执行或写用户空间的数据。2.3 大页把四级遍历砍成两级的办法既然四级遍历代价高一个自然的优化就是让某些页表项直接指向大块内存。这就是大页机制。在 x86-64 上PD 级的 PS 位置 1 表示这是一个 2MB 大页PDPT 级的 PS 位置 1 表示这是一个 1GB 大页。大页的收益很直接一是遍历层数减少1GB 大页两次遍历就能定位2MB 大页三次二是 TLB 覆盖范围暴涨——同样的 TLB 条目数用 4KB 页只能覆盖几 MB用 2MB 页能覆盖几百 MB。对于内存访问密集、访问模式有局部性的应用数据库、大内存缓存服务大页带来的 TLB 命中率提升非常明显。但大页也有代价而且这个代价经常被低估。分配大页需要连续的物理内存系统跑久了内存碎片化2MB 连续块可能找不到分配就会失败或者触发内存规整compaction带来卡顿。另外大页是整块加载的如果程序只是随机访问其中一小部分就会浪费大量内存和 I/O。所以 Linux 才提供了透明大页THP的几种模式always、madvise、never让应用自己决定。生产环境里因为 THP 引发的延迟毛刺我后面会专门讲。3. 页表自身的存储难题多级页表、TLB 与缺页异常学到这里有个很容易被忽略的问题页表本身也是要占内存的而且占得可能比你想的多得多。一个进程如果用了单级页表按 48 位地址空间、4KB 页来算需要 2 的 36 次方个页表项每项 8 字节那就是 512GB——光页表就把整个物理内存吃光了。这个数字不是危言耸听它正是多级页表存在的根本原因。理解了这一点你才能明白为什么操作系统要在页表结构、TLB 和缺页处理上做那么多精细设计。3.1 单级页表为什么会撑爆内存上面那个 512GB 的计算假设了进程真的用了全部地址空间。现实中绝大多数进程的地址空间是稀疏的——代码段、数据段、栈、堆、共享库各占几块中间大片区域根本没映射。单级页表的问题就在于它必须为整个虚拟地址空间预留连续的页表项不管用不用。多级页表就是来解决稀疏性的。以四级为例只有当某一级页表项存在时下一级页表才需要实际分配。进程如果只用了几十 MB 的地址空间只需要分配几条链路上的页表内存开销从 512GB 直接降到几十 KB 级别。这是典型的用时间换空间——代价是访问层级变多而这又靠 TLB 来补。这也解释了一个反直觉的现象进程的虚拟地址空间很大页表占用却可能很小。你在smaps里看到某个进程映射了几十 GB 的地址空间但它的 RSS 可能只有几百 MB页表开销也就几 MB。反过来如果进程真的频繁、稀疏地访问大范围地址页表开销就会迅速膨胀。3.2 TLB 的命中率决定了程序性能上限TLB 是 MMU 内部的一个小型高速缓存缓存的是虚拟页号 → 物理页框号的映射。典型的一级 TLB 只有几十个到几百个条目命中时一次访问就能完成翻译未命中就要走完整的页表遍历。TLB 命中率对性能的影响大到什么程度假设 TLB 命中时地址翻译几乎零开销未命中时要做四级内存访问那么在同样的数据访问次数下命中率从 99% 掉到 95%多出来的翻译开销就可能让程序慢一截。对于内存密集型应用这个差距能到百分之几十。所以有一类优化专门针对 TLB减少 TLB 缺失通过提高访问的局部性让程序集中访问少数几个页。数组按行遍历还是按列遍历性能差好几倍本质上就是 TLB 和缓存的局部性问题。增大页大小用大页让一个 TLB 条目覆盖更多内存等价于放大了 TLB 的容量。减少上下文切换进程切换时要处理 TLB现代 CPU 用 PCID进程上下文标识避免全量刷新只刷新属于旧进程的条目。还有一个细节上下文切换时 TLB 的处理方式直接影响性能。早期没有 PCID 时切换进程必须清空整个 TLB新进程起来后所有翻译都要重来一遍这就是为什么高并发场景下频繁切换进程代价很大。PCID 出现后可以为不同进程的 TLB 条目标记标签切换时不清空只是切标签性能提升明显。这也是为什么某些云环境下绑定 CPU、减少切换能显著改善延迟。3.3 缺页异常的三种类型与处理路径访问一个页面时如果 PTE 的 Present 位是 0硬件就触发缺页异常陷入内核处理。这个处理过程是区分软缺页和硬缺页的关键也是线上排查内存性能问题的核心抓手。次缺页minor fault页已经在物理内存里了只是当前进程的页表还没建立映射。典型场景是fork之后的写时复制——父子共享同一份物理页子进程写的时候触发缺页内核复制一份新页并更新页表。又比如文件页已经在 page cache 里进程访问时只需建立映射不用读磁盘。次缺页只涉及内存操作很快。主缺页major fault页不在内存里需要从磁盘或 swap读取。这个代价是毫秒级的比次缺页高好几个数量级。频繁的主缺页意味着程序在反复换入换出或者访问的磁盘数据没有预读好性能会非常差。非法访问invalid faultPTE 里根本没有映射或者权限不符。这种情况内核会向进程发送 SIGSEGV也就是我们熟悉的段错误。排查思路其实很直接用ps -o min_flt,maj_flt或者在/proc/pid/stat里看minflt和majflt两个字段。如果是主缺页高要想是不是 swap 用多了、是不是文件随机读、是不是内存不够在抖动。如果是次缺页高通常是fork多、或者内存映射频繁。我遇到过的一个典型案例是某个服务在批量处理时主缺页飙高最后查出来是mmap了一个大文件但没有MAP_POPULATE访问时一页页触发磁盘读加上用的是机械盘直接卡成幻灯片。4. 页面置换算法从 Belady 最优到 Clock 的工程落地内存不够用的时候必须把一些页面换出去。换哪一页这是个决策问题也是操作系统里少数几个能拿数学上最优解来对比的地方。教科书上通常按 OPT、FIFO、LRU、Clock 的顺序讲但很少讲清楚为什么工业界选的是看起来最笨的 Clock。这一节我想把这几个算法的取舍讲透顺带聊聊抖动thrashing这事怎么判断。4.1 OPT 只存在于教科书里的原因OPT最优置换的思路是换出未来最长时间不会被访问的那一页。它在理论上能保证缺页率最低Belady 证明了这一点。但问题也很明显——它需要预知未来。操作系统不可能知道程序下一步要访问哪个地址所以 OPT 永远只能作为衡量其他算法好坏的基准。不过 OPT 有个很实用的副产品它可以用来做离线分析。如果你在优化一个内存敏感的算法可以把它的访问序列 trace 出来用 OPT 跑一遍看看理论上最少需要多少内存、实际缺页率和理论最优差多少。这个差值能告诉你是算法本身访问模式不好还是置换策略拖了后腿。我做过类似的实验用perf采样页访问序列再模拟不同算法最后发现真正的瓶颈不在置换而在数据结构的内存布局。4.2 FIFO 的异常与 LRU 的实现代价FIFO 最好实现一个队列就够了但它是所有算法里最容易被访问模式调戏的。除了我们熟悉的 Belady 异常增加内存反而缺页更多它在实际负载下表现也差因为它完全不考虑访问频率——一个被频繁访问的页只要进得早照样被换出去。LRU 的思路符合直觉换出最久没被访问的页。它在局部性好的负载下表现接近 OPT但实现代价很高。教科书上的 LRU 有链表法和计数器法两种本质上都要在每次内存访问时更新状态。链表法要在访问时把页移到表头涉及指针操作计数器法要在每次访问时更新一个时间戳。这两件事都必须由硬件或内核高频完成而每次访问都陷入内核显然不现实。更现实的障碍是硬件只提供 Accessed 位。内核看不到精确的访问时刻只能看到这一段时间内有没有被访问过。所以精确 LRU 在真实系统里根本实现不了。这也是一个很典型的工程思维当精确解代价过高时退而求其次找一个近似解只要近似得足够好就够了。4.3 Clock 与二次机会Linux 实际的近似 LRUClock 算法就是那个足够好的近似。它把所有页面组织成一个环形缓冲区每个页面配一个引用位对应硬件的 Accessed 位。需要换出时指针从当前位置开始扫描如果当前页的引用位是 1清零它指针前移给这页一次二次机会如果引用位是 0说明它在这轮扫描期间没被访问过直接换出。这个设计的巧妙之处在于它用一次扫描就近似出了最久未使用的效果而且每次扫描只做很少的工作。代价是它不是精确 LRU扫描过程中可能换出一些其实还会被访问的页。但对绝大多数负载来说这个误差可以接受。Linux 的实现是对 Clock 的进一步改造。它维护两条 LRU 链表active 和 inactive每条又分匿名页和文件页合起来四类。页面刚进内存时先进 inactive 链表尾部被访问两次之后提升到 active 链表长时间不访问再被降回 inactive。这样做的目的是区分冷热把冷页放在 inactive 里优先回收同时避免频繁访问的页被误伤。这里有个容易被忽略的设计意图文件页和匿名页的回收代价不同。文件页干净的话可以直接丢弃需要时再从文件读脏文件页要写回。匿名页没有后备存储只能写 swap代价更高。所以 Linux 在回收时会根据swappiness参数决定倾向回收哪一类这个参数后面讲调优时细说。4.4 工作集与抖动判断光有置换算法还不够还需要一个判断内存到底够不够的模型这就是工作集working set。工作集的定义是进程在某个时间窗口内实际访问的页面集合如果物理内存能装下这个集合程序就能平稳运行装不下就会出现频繁缺页——也就是抖动thrashing。抖动是个正反馈的恶性循环内存不够 → 换出一些页 → 程序访问被换出的页 → 缺页 → 换入一页又得换出另一页 → 缺页率更高 → CPU 大量时间耗在换页上。表现出来就是系统负载很高但吞吐极低磁盘 I/O 拉满CPU 利用率却不高。判断抖动最直接的是看主缺页率。也可以看 swap 的换入换出速率vmstat的si/so以及 CPU 的waiowait百分比。如果si/so持续很高、wa也很高基本可以确定是内存压力太大。这时候的解法不是调参数而是要么加内存要么减少并发要么优化程序的内存占用——调参数只能缓解症状。5. 物理内存从哪儿来伙伴系统、slab 与 per-CPU 缓存前面讲的是虚拟地址怎么翻译成物理地址和内存不够时换出谁。还有一个同样重要但经常被跳过的话题物理内存本身是怎么管理的。内核需要分配页框给自己用、给进程用、给各种内核对象用这些需求有大有小、有长期有短期用一套分配策略很难兼顾。Linux 的答案是分层底层用伙伴系统管理页框上层用 slab 管理小对象再加上 per-CPU 缓存提升并发性能。5.1 伙伴系统怎么保证大块连续内存伙伴系统要解决的问题是外部碎片——反复分配释放之后内存里全是小块空闲想要一块大的连续内存却找不到。它的做法是把所有空闲页框按 2 的幂次组织成链表从 order 01 页一直到 order 101024 页4MB甚至更高。分配时从目标 order 开始找。如果这个 order 没有空闲块就往上一级找找到之后把大块劈成两个伙伴一个返回一个放回下一级链表。释放时反过来检查自己的伙伴是不是也空闲如果空闲就合并成更大的一块继续往上尝试合并。这套机制保证了只要有足够的总空闲内存就能通过合并得到大块连续内存。它的代价在于分裂和合并的开销以及在碎片严重时的规整成本。当需要分配 2MB 大页而没有 order 9 的空闲块时内核会尝试触发内存规整compaction把可移动的页搬到一起腾出连续空间。这个过程可能耗时较长对延迟敏感的服务是个隐患。可以用/proc/buddyinfo看各个 order 的空闲块数量如果高 order比如 order 9、10长期是 0说明碎片化严重。5.2 slab/SLUB 解决小对象分配碎片内核里有大量小对象要分配比如task_struct、inode、各种缓存结构。如果每个都走伙伴系统分配整页那一个几十字节的对象占一页内存浪费十几倍而且频繁分配释放页代价太高。slab 分配器的思路是为每种对象类型建一个缓存缓存里存放若干个 slab通常是一个或多个连续页每个 slab 再切成若干个大小相同的对象槽位。分配对象时直接从空闲槽位里取释放时还回去。因为对象大小固定、槽位对齐不会有外部碎片而不同类型对象分开缓存也不会互相干扰。Linux 现在的实现叫 SLUB相比早期的 SLAB 简化了不少减少了管理元数据的开销。它还加了per-CPU 缓存每个 CPU 有自己的空闲对象链分配释放时优先走本地缓存避免多核同时操作同一个 slab 而加锁。这个优化在高并发场景下提升巨大因为内核对象分配是非常高频的操作。排查内核内存泄漏时/proc/slabinfo和slabtop是关键工具。如果某个 slab 的active_objs持续增长且不回落基本可以定位到对应的内核模块有泄漏。我遇到过一次dentry缓存疯涨导致内存被吃光的情况——那是某个程序在大量遍历目录把 dentry 缓存撑爆了最后靠限制遍历频率解决。5.3 内存回收LRU 链表、kswapd 与直接回收有了分配还得有回收。Linux 的回收机制分两条路径。后台回收kswapd是每个内存节点NUMA node一个的内核线程它是被动的。当空闲内存低于某个水位low watermark时kswapd被唤醒开始扫描 LRU 链表回收页面直到空闲内存回到高水位以上。这条路径不阻塞用户进程属于提前动手。直接回收如果内存消耗太快kswapd来不及回收空闲内存降到最低水位min watermark以下那么申请内存的进程会被迫自己动手回收。这就是直接回收它会阻塞当前进程延迟直接体现在业务上。生产环境里如果观察到请求延迟出现规律性的尖刺很多时候就是直接回收导致的。回收的顺序是先扫描 inactive 链表把干净的、可以直接丢弃的文件页先回收掉然后是脏文件页写回最后才动匿名页走 swap。这个顺序体现了代价小的先来的原则。理解这个顺序你就能明白为什么调整swappiness会影响性能——它本质上是在改回收匿名页和文件页之间的平衡。6. 落到 Linux 上的观测与调优实操原理讲完了该上讲台做实验了。这一节我按先看什么、再看什么、最后怎么调的顺序来组织都是我在实际排查里用到的命令和判断逻辑。需要说明的是不同发行版、不同内核版本的工具输出可能略有差异思路是通用的。6.1 free、meminfo、buddyinfo 该怎么读先看最常用的free -h。它输出里最容易引起误解的就是buff/cache那一列很多人一看这个数字很大就以为内存不够了其实那大部分是可以随时回收的文件缓存。真正该关注的是available这一列它表示在不触发 swap 的前提下系统能给新应用用的内存估计值。判断内存是否紧张看的应该是available和 swap 的使用情况而不是简单的free。/proc/meminfo提供的信息更细。几个关键字段MemTotal物理内存总量比标称值小一点因为内核保留了一部分。MemAvailable上面说的可用估计值。Cachedpage cache 大小包含 tmpfs。Dirty等待写回的脏页数量。这个值过高说明写回跟不上。Slab/SReclaimable/SUnreclaimslab 总大小、可回收、不可回收。如果SUnreclaim持续增长要警惕内核对象泄漏。PageTables页表占用的内存。这个值异常大说明进程地址空间映射过于稀疏。/proc/buddyinfo看碎片每一行对应一个内存节点的一个 zone后面的数字是各个 order 的空闲块数量。如果高 order 长期为 0需要大页或大块连续内存的应用就可能分配失败。/proc/pagetypeinfo更细它按页的迁移类型movable、unmovable、reclaimable分类统计是分析碎片问题成因的重要依据。6.2 定位进程内存smaps、pmap 与 malloc 行为系统级没问题但某个进程内存异常时就要下沉到进程层面。/proc/pid/smaps会列出该进程每一个映射区域的详细信息包括大小、RSS、PSS、共享/私有、脏页等。PSS比例共享内存这个指标特别有用因为它把共享部分按比例摊分到各进程多个进程共享同一个库时不会重复计算。关于malloc有几个必须理解的机制。glibc 的分配器对不同大小的请求走不同路径小对象走brk扩展的堆区大对象默认超过 128KB走mmap直接映射。这解释了为什么有些进程的堆看起来不大但 RSS 很高——大块内存都在独立的 mmap 区域里。还有arena机制。为了减少多线程竞争glibc 会为每个线程创建独立的分配区arena数量上限默认和 CPU 核数相关。这会导致高线程数程序的虚拟内存占用大幅上升。可以通过MALLOC_ARENA_MAX环境变量限制但要注意限制过严会造成锁竞争反而变慢。我在一个 64 核机器上跑的服务就吃过这个亏——默认 arena 太多每个 arena 都保留了自己的空闲内存RSS 虚高最后用MALLOC_ARENA_MAX4控制住了。pmap -x pid能给一个更友好的视图按映射归类。如果要找泄漏valgrind和 AddressSanitizer 仍然是主力工具各有适用范围valgrind不需要重新编译但很慢ASan 快但需要编译期插桩。线上环境更适合用定期采样smaps的方式做趋势观察。6.3 cgroup v2 下的内存限制与 OOM 判定容器环境下内存管理还要叠加 cgroup 这一层。cgroup v2 的memory.max设置了该组的内存上限memory.current是当前用量memory.stat里能看到细分的 file/anon/slab 等。有一个很容易踩的坑cgroup 的内存统计口径和宿主机的free不一样容器里看到的可用内存往往是宿主机的总量而不是组的限额导致应用误判。OOM 判定也容易出问题。当组内内存超过上限且回收不动时内核会触发 OOM killer在组内挑一个进程杀掉选择依据是oom_score而oom_score_adj可以调整权重。生产环境里通常会给关键进程调低oom_score_adj避免它被杀。但更根本的做法是把memory.max设合理并观察memory.events里的oom和oom_kill计数提前发现压力。6.4 几个真实调优参数与影响参数位置作用调优建议vm.swappiness/proc/sys/vm/控制回收匿名页的倾向0-100数据库类服务可降低到 10 以下减少换出但设 0 仍有回收vm.dirty_ratio同上脏页占总内存比例上限超过则写回阻塞高写入场景适当降低避免集中写回造成卡顿vm.overcommit_memory同上内存超售策略默认 0 启发式1 总是允许2 严格限制vm.min_free_kbytes同上最低空闲水位影响 kswapd 唤醒时机大内存机器可适当调大避免直接回收MALLOC_ARENA_MAX环境变量限制 glibc 分配区数量多线程服务建议 4-8关于overcommit有个常见误区设成 1总是允许超售看似灵活但会让malloc几乎不会失败实际写入时才可能 OOM问题暴露得晚且难定位。设成 2 严格模式则容易导致fork失败因为fork时会按最坏情况预留内存。我一般保持默认 0靠 cgroup 做资源隔离。7. 我踩过的几个坑与常见误判学完原理、会用工具之后剩下最有价值的部分就是踩坑记录了。这一节里的每一条都是我在真实环境里遇到并解决的有的是自己误判有的是机制本身的特性导致的反直觉现象。希望能帮你少走点弯路。7.1 buff/cache 高不等于内存不足这是最常见也最经典的误判。很多运维一看到free里buff/cache占了大半立刻判定内存快满了然后去重启服务甚至加内存。实际上这些缓存是内核主动用来提升 I/O 性能的完全可以随时回收。判断内存是否真的紧张正确的姿势是看available够不够、swap 有没有被大量使用、主缺页率高不高、kswapd和直接回收是否频繁。可以看/proc/vmstat里的pgscan_kswapd、pgscan_direct、pgsteal_*这些计数器。如果pgscan_direct增长很快说明直接回收频繁那才是真紧张。只看free的那一列数字容易误伤。另外要区分Cached和SReclaimable。tmpfs 占用的内存也计入Cached但它不可回收——tmpfs 的数据要么在内存里要么就没了。这点非常容易坑人比如把日志目录挂到 tmpfs 上写得多了会实打实地吃内存且不释放。7.2 THP 带来的延迟毛刺透明大页THP默认在不少发行版上是开启的它能让内核在合适的时候自动把 4KB 页合并成 2MB 大页减少 TLB 压力听起来很美好。但它带来过一个困扰我很久的问题规律性的延迟毛刺。原因在于 THP 的合并是异步的内核线程khugepaged会扫描内存找连续的 4KB 页合并。合并时需要暂停相关操作而且大页一旦分配就会整块驻留遇到内存压力时回收也更麻烦。对于延迟敏感的服务这就表现为每隔一段时间出现一个几十毫秒的尖刺。解法是把 THP 改成madvise模式只对显式调用madvise(MADV_HUGEPAGE)的区域启用或者干脆关掉然后让真正需要的应用自己用hugetlbfs显式分配大页。这个改动在某次优化里直接把 P99 延迟从几十毫秒降到了个位数代价是 TLB 命中率略降但净收益是正的。当然这个结论强依赖于具体负载不能一概而论。7.3 内存碎片导致大页分配失败有一次上线一个新服务启动时报错说无法分配大内存段。检查发现是应用申请了一大块连续内存而机器已经跑了很久物理内存碎片化严重虽然free显示空闲内存充足但连续的 2MB 块几乎没有/proc/buddyinfo里 order 9 以上全是 0。这类问题的根本原因是长期运行的系统必然碎片化标准的伙伴系统只能缓解不能根治。几个可行的方向一是尽早分配在服务启动时就申请好大块内存避免后期碎片化二是用hugetlbfs预留大页在系统启动时就把大页固定下来绕开运行时分配三是触发内存规整调整/proc/sys/vm/compact_memory但这会带来一次性卡顿只适合低峰期做。7.4 内核 slab 泄漏比用户态泄漏更隐蔽用户态内存泄漏有valgrind、ASan 这样的工具兜底但内核态的内存泄漏要隐蔽得多而且排查手段有限。我遇到的一次是某个第三方内核模块在处理大量连接时为每个连接创建的缓存对象没有正确释放导致SUnreclaim持续增长最终把内存吃满触发 OOM。定位过程大概是这样的先用slabtop排序看哪个缓存增长最快找到可疑的缓存名再用cat /proc/slabinfo看它的对象数量和大小结合slabtop -o的差异对比确认增长趋势最后定位到对应的内核模块。这个过程没有捷径靠的是耐心对比和二分排查。教训是上生产的内核模块一定要做长时间的压力测试和内存泄漏检测很多泄漏在短时间测试里看不出来。7.5 关于 swap 的两个反直觉结论最后聊两个关于 swap 的结论都和直觉相悖。第一有 swap 不一定是坏事。很多高性能调优指南建议直接关掉 swap理由是避免换出导致延迟。但对于某些场景swap 是内存压力下的最后一道缓冲——没有 swap 的话内存一紧张直接触发 OOM killer 杀进程有 swap 至少还能撑一撑让回收有时间完成。当然前提是 swap 放在 SSD 上机械盘上的 swap 会让延迟高到无法接受。第二swappiness0不等于不用 swap。这个参数控制的是内核在回收时对匿名页和文件页的相对倾向设为 0 只是尽量不换匿名页但在文件页已经回收完的情况下仍然会换出匿名页。真正要禁用 swap 得用swapoff。我见过不少配置了swappiness0却仍然观察到 swap 使用的人以为是参数没生效其实是理解错了语义。这些坑的共同点是机制本身是合理的设计问题出在我们对它的预期和实际行为不一致。把内存管理这套机制的工作原理吃透很多诡异现象都会变得有迹可循。排查内存问题最有价值的能力不是记住多少命令而是能从现象反推回机制知道该看哪个指标、该往哪个方向查。
返回列表