ARTICLE DETAIL

资讯详情

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

Linux内存回收:kswapd、LRU、反向映射与OOM排查

Linux内存回收:kswapd、LRU、反向映射与OOM排查 内存回收是 Linux 内核内存管理里最容易被忽略、也最容易在线上翻车的一块。大家平时聊内存管理注意力基本都在分配侧——slab、buddy、伙伴系统、每 CPU 页框缓存这些确实重要但真正决定一台机器在压力下是稳稳扛住还是整机卡成幻灯片的往往是回收侧。回收路径设计得不好、参数配得不对表现就是延迟毛刺、allocstall飙升、kswapd 跑满一个核、应用线程长时间卡在 D 状态严重一点直接触发 OOM。这篇就把回收机制从触发点、数据结构、LRU 老化、反向映射、匿名页与文件页两条路径一直讲到观测手段和调参踩坑尽量把每个为什么这么设计都说清楚。1. 一次分配失败之后内核到底走了哪条路理解回收最好的入口不是回收函数本身而是分配函数的失败分支。因为回收从来不是主动发生的它是分配不到内存这件事的副产品。你顺着__alloc_pages往下读会发现内存分配被明确切成了三段快路径、慢路径、以及慢路径里的回收与压缩。搞清这三段的分工后面所有关于水位线、kswapd、直接回收的讨论才有落脚点。1.1 快路径、慢路径与直接回收的三段式分工__alloc_pages的第一段是快路径。它做的事情极其简单从当前 CPU 的页框缓存pcp或者伙伴系统的空闲链表里摘一个页块出来不做任何全局加锁的复杂操作也不检查水位线是否健康只要拿到了就直接返回。这条路径占了绝大多数分配请求也是内核内存分配性能的底气所在——一次快路径分配通常在几百纳秒量级。只有当空闲内存掉到水位线以下__alloc_pages_slowpath才会被调用。慢路径的思路是先别急着要内存先把环境收拾一下它会先尝试唤醒 kswapd 后台回收线程然后判断这次分配是不是允许直接回收ALLOC_WMARK_MIN之外还会考虑gfp_mask里有没有__GFP_NOFAIL、是不是GFP_NOFS/GFP_NOIO允许的话就调用__alloc_pages_direct_reclaim最终走到try_to_free_pages。这就是所谓的直接回收——由发起分配的进程自己动手回收内存回收出成果了再重试一次分配。第三段是内存压缩compaction。当你申请的是高阶页比如 THP 的 order-9即使空闲内存总量足够也可能因为物理页碎片化而拼不出连续块这时候慢路径会尝试压缩把零散的空闲页搬到一起。压缩和回收是两条不同的路子回收是把有人用的页腾走压缩是把没人用的页搬家。很多人排查高阶分配失败时把这两件事搞混白白调了半天min_free_kbytes。注意GFP_ATOMIC这类不可睡眠的分配不允许走直接回收它只能靠 kswapd 提前把水位线顶上去。这也是为什么中断上下文高频分配时一旦 kswapd 跟不上就会看到大量高阶分配失败。1.2 三条水位线与 kswapd 的唤醒时机每个 zone 有三个水位WMARK_MIN、WMARK_LOW、WMARK_HIGH5.9 之后还引入了WMARK_PROMO用在部分路径上抬高唤醒阈值。三者的关系在内核__setup_per_zone_wmarks()里写得非常直白/* 简化后的逻辑 */ wmark_min max(min_free_kbytes / 4, watermark_scale_factor * managed / 10000); wmark_low wmark_min * 5 / 4; wmark_high wmark_min * 3 / 2;也就是说LOW到MIN、HIGH到LOW的两段间距是相等的都是min_free_kbytes / 4这个量级取和watermark_scale_factor换算值的较大者。这个等间距不是随便设的它决定了两个关键行为。水位低于LOW时唤醒 kswapd低于MIN时才允许直接回收并且会同步唤醒 kswapd。之所以要留出LOW到MIN这一段缓冲是想给 kswapd 一个提前量让后台线程在还有余粮的时候就开始干活尽量把直接回收挡在门外。理想情况下kswapd 回收速度跟得上消耗速度进程永远走不到直接回收那一步。而 kswapd 醒来之后的目标是把水位抬回HIGH不是抬回LOW。这也是刻意设计的如果只抬到LOW那么下一次分配稍有波动就又要唤醒一次kswapd 会疯狂在睡与醒之间横跳既浪费调度开销又形成不好看的回收节奏。一次多回收一点、睡久一点整体吞吐更平稳。1.3 直接回收为什么会把进程按在 D 状态直接回收最直接的症状就是进程卡住。原因不难理解回收要做的就是把页从别人手里拿走这件事本身需要加锁、需要遍历反向映射、需要等待回写完成。尤其是碰到脏页内核可能要先把它交给回写线程然后等 IO 结束这个等待是不可中断的睡眠进程自然就进了 D 状态。更麻烦的是直接回收还可能撞上回收风暴多个进程同时分配失败每个都自己下场回收并且都去扫描同一批 LRU 链表锁竞争激烈谁也没回收出多少东西最后大家都没进展。内核对此有一些缓解手段比如pgscan_direct_throttle计数对应的限流逻辑以及把多个并发回收者收敛到同一批扫描上的优先级调整但这块一直是调优的难点。我个人判断是不是回收导致的延迟有一个很朴素的信号看/proc/vmstat里的allocstall增量。如果它和应用延迟毛刺是同步起跳的那基本不用怀疑问题在回收侧而不在业务代码里。2. zone、lruvec 与五条 LRU 链表回收机制的骨架回收的所有动作最终都落在几条链表上。链表挂在哪、由谁管、怎么分直接决定了回收的粒度和并发行为。这块的数据结构不算多但每个都有它存在的理由值得单独拆一遍。2.1 struct zone页面回收的记账单位回收是按 zone 独立进行的不是全局一把梭。struct zone里维护着这个 zone 的水位线、空闲页计数、以及指向对应lruvec的引用。为什么按 zone 而不是按 node因为一个 NUMA 节点里可能有ZONE_DMA、ZONE_DMA32、ZONE_NORMAL这些不同用途的区域它们的稀缺程度、可回收性、能否被直接回收都不一样。DMA 区可能只有几十 MB被文件页占满之后必须优先把文件页踢出去否则声卡网卡这类设备直接分配失败而这种失败在高位内存充裕的情况下完全体现不出来。所以当你看到/proc/zoneinfo里某个 zone 的free长期贴着min摆动而整机MemFree看起来还很健康不要觉得奇怪这两个数字关注的层面根本不同。2.2 lruvec 与 memcgLRU 链表为什么不止一套struct lruvec是 LRU 链表的真正载体核心成员是lists[NR_LRU_LISTS]、lru_lock、reclaim_stat记录 recent_rotated / recent_scanned以及nonresident_age这类用于工作集探测的字段。关键在于每个 memcg 每个 node 都有一套自己的 lruvec而不是只有全局一套。这个设计是被 cgroup 逼出来的。如果只有一个全局 LRU 链表那么 cgroup A 超了内存限额内核就得在整机几百万个页里翻找属于 A 的那部分效率低到不可接受。给每个 memcg 一套独立链表之后A 超限时只需要扫 A 自己那几条链表代价和 A 的内存占用成正比跟整机规模无关。代价是内存开销和复杂度每个 memcg 每个 node 都要维护五条链表和一堆计数器。所以在容器密度很高的机器上memcg 本身的开销也不能完全忽略。想观察这套结构的效果可以直接读/sys/fs/cgroup/group/memory.stat里的pgscan、pgsteal字段它们就是这张 memcg 对应的回收账本。2.3 五条链表各自装什么页enum lru_list一共五项理解它们的分工是理解回收行为的前提链表存放内容回收方式LRU_INACTIVE_ANON最近访问较少的匿名页需要 swap 才能回收LRU_ACTIVE_ANON近期被访问过的匿名页降级到 inactive 后再回收LRU_INACTIVE_FILE最近访问较少的文件页clean 页可直接丢弃LRU_ACTIVE_FILE近期被访问过的文件页降级后回收LRU_UNEVICTABLEmlock 等锁定页不回收只记账五条而不是四条第五条的用途很明确把明确不该被回收的页单独隔离出来。早期内核是把被锁定的页留在 active 链表上结果扫描器每次都要把它们捞出来再放回去白白浪费扫描配额。拆出LRU_UNEVICTABLE之后扫描器可以完全跳过这条链表。你在/proc/meminfo里看到的Mlocked和Unevictable两个字段对应的就是这条链表的规模。2.4 folio从 page 到 folio 的粒度变化顺带说一个近几年的重要变化由于大页和 THP 的普及内核引入了struct folio用来表示一个在页缓存或 LRU 层面被当作整体处理的内存块。以前struct page既代表单个 4K 页又代表 compound page 的头页语义混在一起判断这个页是不是 LRU 上的一个条目要靠PageHead/PageTail各种辅助函数绕。改动之后folio明确就是链表上、映射里、回写单位的那个实体page退化为描述单个物理页。对排查问题的人来说这个变化主要体现在tracepoint 和打印信息里出现的大小。老内核在mm_vmscan_lru_isolate里给的是 page 数新内核给的是 folio 数和它覆盖的页数两个数字可能差 512 倍2MB THP 的情况下。看数据的时候如果不注意这一点很容易得出回收量掉了一个数量级的错误结论。3. 双 LRU 与页面老化内核怎么判断一个页该不该被收走有了链表下一个问题是链表上的页怎么排优先级内核的答案非常朴素——用两个标志位模拟一个访问频次的年龄。这套机制在纸面上看起来简陋实际跑了二十多年依然能撑住大多数场景值得认真拆。3.1 PG_referenced 与 PG_active 两个标志位怎么配合页上跟老化相关的有三个 flagPG_referenced、PG_active、PG_lru。PG_lru只是个在场证明表示这个页确实挂在某条 LRU 链表上。真正参与老化判断的是前两个。PG_active为真说明页在 ACTIVE 链表上属于最近用过的一批。PG_referenced是个软性提示位硬件 MMU 的访问位清理之后如果页在被清位之后又被访问过内核就把它置上。这套机制的核心操作是folio_referenced/folio_check_references它通过页表项的 young 位来判断自上次扫描以来这个页有没有被碰过。注意这里的用词——ptep_clear_flush_young是边读边清的读一次 young 位就把它清掉。也就是说PG_referenced表达的不是这个页曾经被访问过而是在两次回收扫描之间的这段时间窗口里被访问过。这个时间窗口的概念非常关键它意味着一个页是否被判为 active取决于它的访问密度而不是访问总量。热了半年的页只要在最近一轮扫描里没被碰照样会被降级。3.2 folio_check_references 的四条返回分支folio_check_references老内核叫page_check_references有的版本里函数名接近page_referenced_file的逻辑大致有四个结果理解这四条的差异就能理解回收的取舍页在 ACTIVE 链表且被引用过→ 返回folio_referenced正值保持 active不回收。页在 ACTIVE 链表但没被引用→ 返回folio_referenced相关的未引用信号之后会被降级到 INACTIVE本轮不回收。页在 INACTIVE 链表但被引用过→ 这是一次二次机会。内核会考虑把它重新激活回 ACTIVE 链表而不是回收。这个分支的存在让偶尔被访问的页不至于因为一次扫描时机不巧就被踢掉本质上是一层缓存命中率保护。页在 INACTIVE 且没被引用→ 走真正的回收流程尝试 unmap再决定丢弃还是回写。第 3 条分支有个前提条件通常要求这个页不是被同一个进程重复映射之类的特殊情况内核会做一层判断避免同一个进程反复访问导致页被永远保护。这个细节在做大规模共享内存应用压测时会被放大如果你发现某些共享页怎么也不被回收先看看是不是踩到了这个保护逻辑。3.3 get_scan_count活跃/非活跃比例与扫描量分配每轮回收不是均匀扫描五条链表而是由get_scan_count决定这次扫多少匿名页、多少文件页。它会综合几个输入匿名页和文件页各自在 active / inactive 上的占比。reclaim_stat里记录的recent_rotated和recent_scanned用来判断上一轮扫描的效率。swappiness下面单独讲。是否有 swap 可用。页所属 memcg 的anon_cost/file_cost较新内核引入了成本模型让稀疏 LRU 不再因为平均数而过分受益。一个很实用的推论如果 INACTIVE 链表快空了内核会主动多扫一些 ACTIVE 链表把页降级过来补充弹药。所以你看到pgdeactivate突然放大往往意味着非活跃池被抽干了接下来会有一波更激进的降级动作。提示reclaim_stat里的recent_scanned和recent_rotated是判断回收效率的关键。rotated 占 scanned 的比例高说明扫了半天全是活跃页回收效率差可能是工作集本来就大也可能是swappiness或vfs_cache_pressure配得不合适。3.4 swappiness 真正在算的是哪一项vm.swappiness默认 60但它不是60% 的情况下换出匿名页这么简单的百分比。它作用在get_scan_count里先算出理论上匿名页和文件页应得的扫描份额然后按swappiness在按需分配和只扫文件页之间做插值。swappiness 0表示优先只扫文件页swappiness 200上限表示两类页一视同仁甚至更偏向匿名页。这里有个流传很广但不准确的认知swappiness0不等于完全不使用 swap。在较新的内核里即使设为 0当内存压力足够大时仍然可能换出匿名页——这是为了避免在明明有 swap 空间的情况下直接触发 OOM。0 的真实含义是能不用就不用但不是绝对不用。如果你的场景是延迟敏感并且明确不希望有换页 IO靠设 0 是不够的得从 cgroup 层面限制memory.max或者干脆不配 swap。没有配 swap 的机器上匿名页基本就是不可回收的除了少数可以丢弃的场景get_scan_count会把扫描重心几乎全压到文件页上。这会导致一个典型现象没有 swap 的机器回收压力全靠 page cache 顶着page cache 被冲掉之后应用开始读盘IO 又上来形成连锁反应。3.5 workingset refault绕过双 LRU 的快速激活双 LRU 有个天然短板一个页被回收之后再次被访问要重新经历INACTIVE → 被访问 → ACTIVE的完整流程中间至少挨过一轮扫描窗口。如果工作集比可用内存稍大一点就会出现刚被回收就被重新读回来的抖动这就是经典的 refault 问题。内核对此的应对是工作集探测workingset detection页被回收时记下当前的nonresident_age页被重新读入时对比这两个年龄如果间隔很短说明最近刚把它踢掉属于误判那就直接把它放到 ACTIVE 链表跳过重新老化的过程。相关的计数器是workingset_refault、workingset_activate、workingset_restore。排查内存抖动时这几个数特别有用workingset_refault持续升高说明内存确实不够用工作集被反复驱逐又读回加内存或者削减工作集之外的办法基本无效。4. 反向映射要收走一个页得先把所有映射它的进程找出来回收一个页之前内核必须先做一件事把所有映射了这个物理页的页表项都拆掉。一个物理页可能被几十个进程映射比如共享库、共享内存、fork 出来的子进程不拆干净就回收进程下次访问会读到别人的数据这是不可接受的安全问题。完成这件事的机制叫反向映射。4.1 没有 rmap 会怎样正向映射是每个进程自己知道自己映射了哪些物理页页表就是反向映射则需要给一个物理页反查谁映射了它。如果没有这套结构内核想回收一个页就只能遍历全系统所有进程的所有页表成本高得离谱等于没法回收。rmap 的本质是把页 → 映射者这个反查关系预先算好并缓存起来。查找的粒度不是物理页而是VMA内核反查得到的是哪些 VMA 覆盖了这个页然后再按 VMA 去逐级遍历页表找到具体的页表项。因为 VMA 数量远小于页表项数量这个抽象层次的选择是整块设计的效率关键。4.2 anon_vma、anon_vma_chain 与 i_mmap 的组织方式匿名页和文件页的反向映射走两套完全不同的结构匿名页用anon_vma加anon_vma_chain。anon_vma挂在 VMA 上多个 VMA 通过anon_vma_chain串成链物理页的mapping字段或folio对应的字段指回一个anon_vma从而反向找到所有相关 VMA。这套链式结构主要解决 fork 之后父子进程共享匿名页的查找问题。文件页用address_space里的i_mmap结构。老内核是红黑树6.1 之后换成了 maple tree。文件页天然属于某个 inode所有映射了这个文件的 VMA 都挂在同一个address_space下反查时按文件偏移查找对应 VMA。顺带说i_mmap从 rbtree 换成 maple tree 这个改动对回收路径的影响主要体现在锁和区间查询的开销上。maple tree 的区间查询在大量映射场景下更平滑减少了持锁时间——对高并发回收是个实实在在的收益。4.3 rmap_walk 与 try_to_unmap 的执行路径实际的 unmap 过程由rmap_walk驱动分rmap_walk_anon和rmap_walk_file两条分别遍历对应的 VMA 集合对每个 VMA 调用回调try_to_unmap_one。try_to_unmap_one做的事是在 VMA 对应的页表里定位到这个页的 PTE检查哪些条件下不能动migration、device private、被 mlock 且不允许忽略等符合条件就执行ptep_clear_flush把页表项换成一个 swap entry 或者直接清空。这里有两个容易忽视的细节。第一PTE 的清理是批量的内核会用TTU_BATCH_FLUSH把多个 unmap 操作攒起来最后统一做 TLB flush减少 IPI 开销。第二一个页可能只被部分 unmap。try_to_unmap返回成功不代表所有映射都拆掉了如果还有进程映射着folio_mapcount不为零这个页就不能回收会被放回链表下一轮再来。这就是为什么在共享度很高的场景下回收效率会比较差——页不是收不动而是收一遍拆不干净得反复拆。5. 匿名页和文件页回收路径差在哪拆完映射回收的最后一步是决定这个页怎么处理。匿名页和文件页在这里彻底分道扬镳因为它们的后备存储不同文件页背后有文件匿名页背后什么都没有除非有 swap。5.1 文件页clean 直接丢dirty 先排队回写文件页的处理逻辑最干净。页的PG_dirty如果没置位说明内容和磁盘上一致不需要回写内核直接从 page cache 里摘掉这个条目__remove_mapping页框归还伙伴系统。整个操作不涉及任何 IO速度极快也是文件页成为回收首选的根本原因。如果页是脏的就没法直接丢——丢了就是数据丢失。内核会保留它在 page cache 里同时确保它已经进入回写队列PG_writeback然后把这个页从待回收里剔除放回链表等下一轮。等回写完成、脏位清掉之后后续某一轮扫描才能把它收掉。这个分两轮的设计引出一个非常典型的线上现象脏页多的机器回收一轮回收不掉多少页。你能在vmscan的 tracepoint 里看到大量页被扫描、被隔离但pgsteal实际回收数上不去。这时候加大回收力度是没用的瓶颈在回写带宽和脏页比例上。宁可从dirty_ratio、dirty_background_ratio和应用的写模式入手。5.2 匿名页add_to_swap、swap cache 与 pageout匿名页没有磁盘上的对应位置所以要回收必须先给它造一个家也就是分配一个 swap slot。这个动作由add_to_swap完成分配 swap entry把页放进 swap cache设置PG_swapcache然后try_to_unmap时把 PTE 替换成这个 swap entry。接下来是pageout把页内容写进 swap 空间写完之后这个页才真正可回收。到这一步才和文件页的处理合流——都要等 IO。这里有一连串的成本分配 swap slot 需要加锁每个 swap 设备有自己的swap_info_struct和 cluster 分配结构写 swap 产生真实 IO如果 swap 在慢设备上比如机械盘或者网络存储一次回收的延迟可能是毫秒到几十毫秒量级。这也是别把 swap 放在慢盘上这个建议的根本原因不是因为 swap 本身慢而是匿名页回收路径上的每次换出都会把延迟带进直接回收进而带到业务线程上。提示/proc/meminfo里的SwapCached表示同时存在于内存和 swap 中的页。这个值长期偏高说明有不少匿名页被换出去之后又被读了回来属于典型的抖动信号。5.3 那些根本收不动的页mlock、writeback、unevictable内核对每类页都有放弃回收的判断识别这些条件对排查内存够但回收不掉的问题非常关键页状态是否可回收说明PG_mlocked否被mlock锁定挂 UNEVICTABLE 链表PG_writeback本轮否正在回写下一轮再试PG_dirty匿名需要先换出走 swap 路径PG_dirty文件需等回写保留在 page cachePG_unevictable否包括 ramfs/tmpfs 里被锁的页等mapped 且 unmap 失败否还有其他映射者放回链表PG_reserved否保留页物理地址被特殊占用平时最容易被忽略的是PG_mlocked。数据库、实时程序、JVM 的某些模式都会大量mlock内存这些页一旦被锁定就彻底退出回收视野。如果一台机器的Mlocked占了物理内存的很大比例剩下的可回收池其实比MemFree Cached看起来的小得多OOM 会来得比预期早。排查时先看一眼/proc/meminfo的Mlocked和Unevictable。6. 把回收现场看清楚meminfo、vmstat 与 tracepoint 实操前面讲的都是机制落地到排查要靠观测工具。回收这块的观测手段其实很成熟只是很多字段的含义没被讲清楚导致读出来的数字没什么信息量。这一节把常用的几个入口逐个拆开。6.1 /proc/meminfo 里跟回收相关的字段逐个拆先给一份对照表这些字段我基本每次排查内存问题都会扫一遍字段含义关注点MemFree完全空闲的内存别只看这个MemAvailable应用视角的可用内存估算比 MemFree 更可信Active(anon)/Inactive(anon)匿名页的 active / inactive比例失衡说明老化异常Active(file)/Inactive(file)文件页的 active / inactive回收主力池Unevictable/Mlocked不可回收页偏高会显著压缩可回收池Dirty等待回写的脏页直接决定回收效率Writeback正在回写的页与 Dirty 一起看SwapCached同时在内存和 swap 的页偏高说明抖动SReclaimable/SUnreclaim可回收 / 不可回收 slab判断内核内存压力KReclaimable内核可回收内存合计5.x 之后的汇总字段PageTables页表占用大量进程/映射场景会很大一个容易误读的点Active(anon) Inactive(anon)在没配 swap 的机器上几乎是死重因为回收不了。真正能指望的是Inactive(file)这一块。所以当有人问内存明明还有很多 Cached为什么还是 OOM答案往往就在这里。6.2 /proc/vmstat 的 vmscan 计数器怎么读/proc/vmstat里跟回收相关的计数器是一整套指标体系成对读才有意义grep -E ^(pgscan|pgsteal|pgactivate|pgdeactivate|pgrefill|allocstall|pgmajfault|workingset|compact) /proc/vmstat几个关键组合的读法pgscan_kswapd与pgscan_direct分别对应后台回收和直接回收的扫描量。后者的存在本身就是水位线没有提前量的信号。pgsteal_kswapd与pgscan_kswapd比值就是回收回收率。远小于 1 说明扫的多、收到的少效率差。pgactivate与pgdeactivate反映页在 active / inactive 之间的流转。pgdeactivate突然抬升通常意味着非活跃池被抽干了。allocstall直接回收发生的次数。这个数应该是平缓的有尖峰就说明内存压力打到了分配路径上。注意这些计数器都是自开机以来的累计值。看绝对值没有意义必须两次采样做差并且结合采样间隔换算速率。我习惯用一秒钟的间隔连续采三次看趋势而不是看单点。6.3 /proc/zoneinfo 与水位线对照/proc/zoneinfo是少数能把水位线和实际空闲量放到一起看的地方cat /proc/zoneinfo | grep -A 12 Node 0, zone Normal输出里会有min、low、high、spanned、present、managed以及protection数组下面还有NR_FREE_PAGES等一系列 page state。做内存水位分析时我会把各个 zone 的free和min/low/high并排看free长期在min附近摆说明内存吃紧回收在持续工作。free在low到high之间kswapd 在正常发挥。某个 zone 的free低于min且长期不涨这个 zone 有结构性问题比如 DMA 区被占满。protection那一列是内核为每个 zone 预留的最小保护值在 NUMA 机器上特别值得看它决定了低端 zone 有多大的免死金牌。6.4 tracepoint 抓 direct reclaim 与 kswapd要对回收过程做精细分析tracepoint 是最强的手段。vmscan子系统提供了一整套事件# 查看可用的 vmscan 事件 ls /sys/kernel/tracing/events/vmscan/ # 抓直接回收的开始和结束 echo 1 /sys/kernel/tracing/events/vmscan/mm_vmscan_direct_reclaim_begin/enable echo 1 /sys/kernel/tracing/events/vmscan/mm_vmscan_direct_reclaim_end/enable cat /sys/kernel/tracing/trace_pipe几个常用的组合mm_vmscan_direct_reclaim_begin/_end一次直接回收的起止两个时间戳相减就是这个进程被回收拖住的时长。这个数字直接关联应用延迟。mm_vmscan_kswapd_wake/mm_vmscan_kswapd_sleep后台回收线程的工作窗口能看出它的繁忙程度。mm_vmscan_lru_shrink_inactive一次非活跃链表回收的明细包含回收页数、脏页数、写回页数等是分析扫了多少、收到多少最直接的数据源。mm_vmscan_lru_isolate隔离阶段的明细能看到 order 分布。如果不想用trace_pipe手搓trace-cmd record -e vmscan或者perf record -e vmscan:*都可以后处理方便得多。6.5 bpftrace 量一次直接回收的耗时想快速量化回收到底拖了多久一行 bpftrace 就够了bpftrace -e kprobe:try_to_free_pages { start[tid] nsecs; } kretprobe:try_to_free_pages /start[tid]/ { $d (nsecs - start[tid]) / 1000000; us hist($d); delete(start[tid]); }try_to_free_pages是直接回收的总入口它的执行时长基本上就是这次分配被拖住的时间。在延迟敏感的服务上如果这个直方图的长尾拖到了几十毫秒甚至上百毫秒说明回收路径已经是延迟的主要贡献者了。进一步定位可以按调用栈聚合-e kretprobe:try_to_free_pages { [kstack] ... }看看是哪类调用触发的——是普通业务分配还是某个后台线程。7. 调参与排查线上回收问题的完整排查链路机制和观测都清楚了接下来是最实际的调参和排查。这块的坑特别多因为大部分参数的作用是间接的改一个参数往往会在另一个地方冒出新问题。7.1 min_free_kbytes 与 watermark_scale_factor 怎么定vm.min_free_kbytes是最常被调的参数也是最常被调错的。它的默认值在启动时按4 * sqrt(lowmem_kbytes)算出来在大内存机器上算出的绝对值可能偏小——比如 256GB 内存的机器默认min_free_kbytes可能只有几百 MB相对于 ARP 表、页表、slab 这些不可回收但必须能分配的需求来说不够。调大的直接收益是留出更多应急内存让系统在极端情况下还有周转空间不至于立刻触发直接回收或者 OOM。代价是可用内存变少。我的经验是不要无脑设置成很大而是先看/proc/zoneinfo里各 zone 的free波动。如果 free 的谷底经常逼近 min适当上调min_free_kbytes到能让谷底留有余量即可。vm.watermark_scale_factor默认 10控制的是low到min、high到low这两段的宽度相对于 managed 页数的万分比。它的作用是调节 kswapd 的提前量值调大kswapd 更早开始工作直接回收更少但后台 CPU 占用更高、page cache 保留更少。对延迟敏感的服务把它调到 50 到 200 之间是常见做法对吞吐优先的批处理一般保持默认。7.2 脏页比例引发的回收抖动脏页对回收的影响经常被低估。回收一轮能收多少直接取决于有多少 clean 页可弃。脏页比例高的时候扫描器把页隔离出来发现是脏的只能又放回去pgsteal / pgscan比值立刻掉下来。相关参数参数默认作用vm.dirty_background_ratio10后台回写线程启动的阈值vm.dirty_ratio20进程被强制同步回写的阈值vm.dirty_expire_centisecs3000脏页最长驻留时间30 秒vm.dirty_writeback_centisecs500回写线程唤醒间隔5 秒vm.dirty_background_bytes0按绝对字节数设阈值这里最容易踩的坑是用百分比在大内存机器上。20% 在 512GB 内存机器上就是 100GB 脏页回写设备根本来不及刷脏页在内存里堆着回收路径被拖死最后在dirty_ratio处触发进程同步回写应用线程直接在 write 里阻塞。这类机器的正确做法是用dirty_background_bytes和dirty_bytes设置绝对值比如按回写带宽的 5 到 10 秒的量来定。dirty_expire_centisecs也值得关注。默认 30 秒意味着一个脏页可能在内存里待半分钟才被回写这期间它就是不可回收的。如果压缩这个时间脏页更快变干净回收效率更高代价是回写次数变多、IO 更碎。7.3 memcg 回收、memory.high 与局部 OOM容器场景下回收的第一现场往往是 memcg 而不是整机。cgroup v2 提供了两个关键接口memory.high软限制。超过这个值不会杀进程但会触发该 cgroup 的同步回收并让组内进程的分配被限流。适合做限速而不是限制。memory.max硬限制。超过且回收无果就触发该 cgroup 内的 OOM。memory.high的限流效果是通过分配路径上的延迟实现的所以调小 memory.high 会让容器内的分配变慢。这一点如果不说清楚很容易被误判为应用性能问题。我在压测里见过把memory.high设成比实际工作集略小的值结果应用 P99 延迟直接翻倍——不是回收慢是分配侧被有意放慢了。cgroup v2 还有个很实用的接口memory.reclaim可以主动触发一次该 cgroup 的回收需要 5.19 左右及以上的内核echo 1G /sys/fs/cgroup/test/memory.reclaim在发布前主动回收一下能把回收成本从运行期挪到发布期对延迟敏感的服务挺有用。7.4 slab 回收与 shrinker 的边界回收不只有页还有 slab。dcache、icache 这些内核缓存通过一个叫 shrinker 的注册机制参与回收vm.vfs_cache_pressure控制的是它们的回收积极程度。默认 100调大比如 200 到 500会让 dcache 和 icache 被更积极地回收腾出内存给页缓存调小则相反。判断要不要动这个参数看/proc/meminfo的SReclaimable和/proc/vmstat的slabs_scanned。如果SReclaimable很大而slabs_scanned长期是零说明 slab 回收基本没参与可以考虑调一调。但要注意 dcache 被大量回收之后文件查找路径会变慢这是一个明确的权衡。还有一个盲点shrinkers 是全局的不按 memcg 隔离历史上如此后来有 per-memcg shrinker 的工作在推进。这意味着内核缓存的回收在某些版本上不完全受容器限额约束容器内存统计和限制的边界会有微妙差异。做容器内存规划时如果发现容器实际占用和memory.current对不上往这个方向看一看。7.5 回收和应用延迟之间的传导路径把整条链路串起来看回收影响延迟的路径其实很清晰空闲内存掉到min以下。分配走慢路径唤醒 kswapd 并尝试直接回收。直接回收扫描 LRU隔离、unmap、丢弃或回写。遇到脏页或匿名页就要等 IO进程不可中断睡眠。回收出成果后重试分配成功则返回失败则进入更激进的回收最终可能 OOM。第 4 步是延迟的主要来源。所以优化方向也很明确要么减少走到第 2 步的概率水位线、memory.high提前限流、控制工作集要么减少第 4 步的时长脏页控制、swap 放快盘、减少不可回收页。这两条路比调大回收力度有效得多——回收力度本身不是瓶颈回收能收到什么是瓶颈。8. 复盘三次内存回收引发的线上抖动说几个我自己实际遇到过的场景比任何理论参数表都直观。第一次是没配 swap 的缓存服务。MemFree看着还行Inactive(file)也够用但每隔几分钟就有一次持续几百毫秒的延迟毛刺。抓allocstall发现尖峰和毛刺完全同步再看pgscan_direct在尖峰处暴涨。根因是文件页里脏页比例高回收一轮收不掉多少不得不连续多轮直接回收。最后的处理是压低dirty_ratio并改成按字节数设置阈值同时把min_free_kbytes从默认值拉高了一档让 kswapd 有足够的提前量。改动很小毛刺基本消失。第二次是容器密度很高的机器。某个容器偶发被 OOM但看memory.current离memory.max还有距离。查下来是Mlocked占了这个容器限额的一大块——应用用了mlock锁内存这部分页对 memcg 回收完全不可用实际可回收池比限额小很多在峰值场景下就撞线了。这种问题的解法不在内核参数上得从应用侧减少mlock或者把限额调高。第三次是匿名页抖动。应用有个大对象缓存工作集略微超过可用物理内存结果workingset_refault持续上升SwapCached也在涨。用swappiness0试过效果有限——因为问题的本质是工作集超出了物理内存不是换出策略选得不好。后来把缓存做了分层热数据控制在物理内存的 60% 以内抖动才彻底消停。这几次经验让我形成一个判断习惯看到回收相关的指标异常先问可回收池有多大再问回收效率有多高最后才考虑参数调优。顺序反了八成会在错的参数上折腾很久。还有个小技巧值得单独提一下做回收相关的实验时用echo 1 /proc/sys/vm/compact_memory或者直接写 memcg 的memory.reclaim来制造可复现的回收事件比靠压测自然触发好控制得多。观测的时候把 tracepoint 采到的数据按调用栈聚合能看到是哪个路径在触发回收——很多时候你以为的业务分配压力实际上是某个后台线程在批量读取文件把 page cache 冲掉了。
返回列表