
1. 先弄明白内存回收到底在防什么Linux 内核的内存管理子系统里内存回收机制是最容易被看一眼就以为懂了、真出事时又完全接不上手的一块。绝大多数人对它的第一印象是内存不够了就把不用的页丢掉这个理解不能算错但它漏掉了回收机制真正的价值它不是在内存告急时才开始工作的消防队而是一套常态运行、持续把冷数据挪走腾出空间的调度系统。理解这一点你才会明白为什么有时候机器看起来还剩几百兆空闲内存系统却已经开始发抖。我印象比较深的一次是在一块只有 512MB 内存的嵌入式板子上跑业务程序。free -m显示 MemFree 还有小两百兆按直觉完全够用但业务侧的响应延迟每隔几十秒就出现一次尖刺长的能到两三百毫秒。当时第一反应是猜测调度问题折腾了半天才发现日志里allocstall这个计数在持续往上爬——系统正在频繁进入直接内存回收。问题根源是那块板子上跑的是 eMMC脏页回写慢而内核默认的脏页比例阈值对这个存储介质来说太宽松了回收器扫一圈发现大部分文件页是脏的、不能立刻动手只能反复扫、反复等回写延迟尖刺就是这么来的。所以这篇文章想讲清楚的事情有三层内存回收的触发条件到底是怎么判定的回收器在内核源码层面按什么顺序挑页以及在实际工程里怎么观测、怎么调参、怎么避免踩坑。它适合正在做内核方向学习的人也适合被线上内存问题折磨过的后端、运维和嵌入式开发者。文章里的代码以 Linux 5.10 之后的 mainline 为主线涉及 folio 的部分会单独说明版本差异。提示下面涉及的所有 sysctl 参数、tracepoint 名称、/proc 字段名都可以直接在你自己维护的测试机上验证。不要在生产机器上照抄参数尤其是 min_free_kbytes 和 dirty_ratio 这两个。1.1 回收不是删除而是转移所有权很多新手会把内存回收理解成释放内存这个概念偏差会导致后面所有判断都变形。物理页框只有一个它要么被某个进程或内核结构占用要么空闲。回收做的事情是把一个当前没人需要的内容从物理页框里挪出去或者直接作废然后把页框交还给伙伴系统。具体就两条出口。匿名页进程堆栈、malloc 出来的、匿名 mmap 的没有后备文件只能把内容写到交换空间也就是 swap然后页框才能空出来。文件页page cache、代码段、mmap 的文件背后有真实文件如果内容是干净的和磁盘一致直接把页框丢掉就行如果是脏的得先回写落盘再丢。这个区分非常关键因为它直接决定了回收的成本丢一个干净文件页几乎零成本换出一个匿名页要经历压缩、排队、IO 三步代价高出一个量级。内核把它们分别挂在不同的 LRU 链表上管理回收器扫的时候也是分开核算配额的这部分后面会详细展开。1.2 三类看起来像内存的东西排查内存问题时free命令的第二行经常让人困惑buff/cache 占了几个 G但 MemAvailable 又显示大部分可用。原因是内核把很多可回收的东西都算进了缓存统计里大致分三类。第一类是page cache也就是普通文件的页缓存包括读过的数据和还没落盘的写数据这是可回收主体。第二类是slab 中可回收的部分主要是 dentry 和 inode 缓存即SReclaimable内核通过 shrinker 回调来回收它们。第三类是匿名页它只有在配置了 swap 的情况下才是可回收的没有 swap 的机器上匿名页属于不可回收资产——这一点非常多人忽略一台没配 swap 的服务器内存回收能做的事情其实相当有限。注意判断内存够不够要看MemAvailable不要看MemFree。后者只代表伙伴系统里完全空闲的页框前者已经把可回收的 cache 和 slab 折算进去了。1.3 回收压力分三条路径进来回收不是只有一个入口它有三条压力路径优先级从低到高。第一条是后台回收由 kswapd 承担。每个 NUMA 节点上有若干个 kswapd 内核线程当某个管理区的空闲页低于低水位线时被唤醒它异步地在后台扫页直到空闲页回升到高水位线才自己睡回去。这条路径对业务基本无感是最理想的情况。第二条是直接回收由发起分配的进程自己动手。当分配请求在低水位线以上找不到空闲页时内存分配会走慢速路径此时如果 kswapd 来不及补上分配者会被迫自己进入回收流程。这条路径是造成业务抖动的主因因为它把一个内存分配动作变成了扫描 可能触发 IO的重活延迟完全不可控。第三条是 OOM Killer。当前两条路径都失败回收器在把 priority 从 12 一路降到 0 之后仍然拿不到页内核只能杀进程。这是最后的兜底也是所有内存调优想要避免的结局。三条路径是递进的理解这个递进关系你在看/proc/vmstat时就知道该盯哪些计数pgscan_kswapd涨说明后台在忙但还没失控allocstall和pgscan_direct一起涨就说明已经在拖业务下水了。2. 回收的账本LRU 链表与页描述符回收器能按顺序挑页靠的是一套非常朴素的记账方式把所有可回收的页按最近有没有被访问过挂到链表上新的挂前面回收从后面捞。这套机制几十年没大变过理解它之后很多看起来奇怪的现象都能解释。2.1 五条 LRU 链表的分工在传统实现里先不看 MGLRU每个 NUMA 节点的每个内存 cgroup 都维护五条链表。前四条是主力第五条是收容所。链表存放内容回收动作inactive_file文件页近期没被访问干净页直接丢脏页先回写active_file文件页近期被访问过降级到 inactive 后再考虑inactive_anon匿名页近期没被访问写入 swap 后释放active_anon匿名页近期被访问过降级到 inactive 后再考虑unevictable被 mlock 锁定或不可回收的页不回收扫到就跳过这里的设计逻辑值得多说一句为什么要拆成 active 和 inactive 两级而不是维护一个带时间戳的堆因为内核要在极低的常数代价下做近似 LRU维护精确时间戳的成本太高。两级链表用极简的方式实现了给每个页第二次机会一个页从 inactive 被访问到时升级到 active回收器不会立刻动它只有落到 inactive 尾部的页才是真正的候选。这种近似必然带来误差。一个曾经很热、现在早已冷掉的页只要还挂在 active 链表上就得先经历一次降级才能被回收。所以回收器实际上是在回收不活跃候选区命中率依赖 active/inactive 的比例控制。2.2 struct page 到 folio 的演进页的元信息都在struct page里其中和回收直接相关的是flags字段里的一组位以及_refcount和_mapcount两个计数器。早期版本的 struct page 是 64 字节一张 16GB 内存的机器光页描述符就要占 256MB非常可观。从 5.16 开始内核引入了folio的概念用于表示一个连续的、可作为整体操作的页组通常是 THP 大页或基页本身。它本身并不是新分配的内存结构而是对struct page的一层封装folio 总是指向页组的首个 pagefolio_page(folio, n)拿到组内第 n 个页。这个改动让回收路径上大量遍历页组的代码从名字到语义都变清楚了也顺带修掉了一批长期存在的边界 bug。如果你读的代码里出现了folio_test_lru()、folio_isolate_lru()这类函数别慌把它们理解成对一个页组整体操作就行。提示判断一个页是否可回收核心看三个条件——PageLRU已置位、引用计数为预期值、不在 unevictable 链表上。三个条件缺一个回收器就会跳过它跳过太多就会导致扫描空转这也是pgrefill高的原因之一。2.3 页的活跃度是怎么判定的页的活跃由两个地方共同决定一是访问时硬件置的 Accessed 位PTE 里的 A 位二是回收器扫描时的采样结果。回收器在扫描 inactive 链表时会检查这个位如果发现被访问过就把页升级到 active 链表同时清掉该位。这个采样周期直接决定了内存回收的手感。系统空闲时 active/inactive 比例大致稳定内存紧张时回收器扫得更频繁采样粒度变细会有更多页被降级回收。反过来说如果你的负载是周期性的比如每 5 分钟一批任务采样窗口和工作周期的错配就可能让本该留在内存里的热数据被错误地踢出去下一次访问又要从磁盘读回来。这种缓存抖动在数据库和搜索引擎场景里非常常见。Linux 5.18 之后逐步默认启用的 MGLRUMulti-Gen LRU就是针对这个问题的改进把两级链表演进成多代默认四代每代的晋升和降级有更细的规则对周期性负载和突然的内存压力都有更好的适应性。它的开关在/sys/kernel/mm/lru_gen/enabled用 0/1/2 三个值控制作用范围。3. 反向映射回收器怎么找到所有引用者前面说把页释放掉但有个问题必须先回答一个物理页可能被好几个进程映射你怎么知道要改谁的页表如果释放了一个还被映射着的页进程下次访问就会拿到垃圾数据或者直接崩。这就轮到反向映射rmap登场了。3.1 匿名页和文件页的映射结构完全不同两者的反向查找索引走的是两套结构。匿名页用anon_vma和anon_vma_chain。每个进程的每段匿名 VMA 都对应一个 anon_vma当子进程 fork 时父子共享同一批物理页但各自有 VMA内核用 anon_vma_chain 把这些关联串起来。回收一个匿名页时顺着 anon_vma 里的区间树找出所有映射了它的 VMA逐个改页表。文件页走的是address_space也就是struct inode里的 i_mmap 区间树。每个映射了同一个文件的对象进程 mmap、内核的 page cache 读都能通过这棵树找到。这棵树上还挂着 xarray用来快速定位页缓存里的具体页。两套结构的存在意味着同一个物理页在生命周期里可能换过归属一个文件页被 mmap 到进程地址空间后就是文件页但它被换出到 swap 再读回来就变成匿名页了。这个转换过程内核处理得很小心因为稍有不慎就会出现页归属僵持。3.2 遍历成本是回收的主要开销反向映射带来一个非常现实的性能问题要回收的页被映射得越多改页表的工作量越大。一个被 100 个进程共享的 4KB 页回收时就要遍历 100 个 VMA 去改页表。任谁都不希望回收器陷入这种遍历里所以内核做了一个优化页的 mapcount 越高越倾向于不回收它。具体实现里回收器通过 try_to_unmap 尝试解除映射如果发现页正被频繁引用mapcount 很高且 PTE 的 young 位还是置着的就会放弃这次回收把它重新放回 LRU 的头部标记为最近被人用过。这个动作在 vmstat 里体现为pgsteal_*和pgscan_*的比例失配——扫了很多页但偷不到几个。3.3 mapped 计数与扫描空转内核给内存 cgroup 维护了NR_FILE_MAPPED和NR_ANON_MAPPED两个统计量它们的作用之一是给回收器预估这轮扫描有多少是白干。排查内存回收效率时我有个固定做法把pgscan_kswapd和pgsteal_kswapd做比值。如果扫描量和偷取量长期保持 10:1 甚至更夸张说明回收器在大量空转扫描了很多页但一个都没回收掉。这时候一般有三个方向可查是不是有大量页被 mlock 或者被引用着不能动、是不是脏页太多在等回写、是不是 LRU 链表结构失衡导致一直在扫不活跃度很低的区域。另外mapped计数本身也是一个观测点。一台跑 Java 或者 Go 的机器堆内存对应的匿名页全部被映射着回收时遍历开销很大这也是这些运行时宁可让 GC 自己管内存、也不信任内核回收的原因之一。4. 换出与写回回收的两条出口怎么走前面讲了怎么挑页现在讲挑出来之后怎么处理。这一段是内存回收里最接地气的部分因为几乎所有线上问题的根因都在这里。4.1 匿名页换出压缩、排队、IO 三步走匿名页要换出第一步是分配 swap slot。内核会先在 swap 地址空间里找一个空闲位置然后调用try_to_unmap解除所有映射把页内容写进 slot最后把页表项改写成一个特殊的 swap PTE。下次进程访问这个地址会触发缺页异常内核再把它读回来。这个路径有三个容易被忽略的成本点。第一是 swap slot 的分配粒度。现代内核支持 swap 的簇和连续分配优化但如果你只挂了一个很小的 swap 分区比如 1GB 而物理内存 32GBswap 空间会很快被填满之后匿名页就再也换不出去了即使物理内存还有余量回收也会失败。我见过不止一次明明配了 swap 但系统还是 OOM的案例原因就是 swap 空间不够。第二是回写队列的排队。换出不是立刻写盘的它进的是块层的回写队列队列满了就得等。vmstat里的pswpin/pswpout只能告诉你总量看队列深度要看/proc/pressure/memory里的 some/full 指标。第三是读回来的缺页路径。被换出的页读回来时要走一次主缺页也就是pgmajfault。这个计数飙高基本就等于业务性能在往下掉因为主缺页涉及 IO延迟比次缺页高两个数量级。现在很多人会用zram或者zswap来替代传统 swap 分区。zram 是一块内存里的压缩块设备直接把匿名页压缩后放在内存里省了 IO 但是吃 CPUzswap 是前置压缩缓存压缩后如果还放不下才写真正的 swap 设备。两者的取舍很清楚CPU 富余、IO 贫瘠的场景选 zram两者都一般的话用 zswap 配合一块小 swap 分区。提示zram 的大小要设得克制。设成物理内存的一半以上等于把内存变成了一个压缩黑盒出问题时你很难判断到底还有多少真实可用内存。我一般按物理内存的 25%~50% 配具体看数据压缩率。4.2 干净文件页直接丢弃成本最低这是回收器最喜欢的情况。一个干净的文件页背后有真实文件兜底丢掉之后下次访问重新读一遍就行。整个过程只需要把页从 LRU 上摘下来、从 inode 的页缓存索引里删掉、归还伙伴系统没有 IO延迟微秒级。所以从系统层面看让更多的内存变成可丢弃的干净文件页本身就是一种内存优化。这也解释了为什么数据库这类应用普遍使用直接 IO 或者 O_DIRECT 绕过 page cache——它们不希望自己的数据被内核当作可丢弃的东西随便踢掉宁可自己管缓存。4.3 脏文件页最尴尬的中间态脏页既不能直接丢又不一定值得立刻换出。它必须等回写到磁盘之后才能被回收而这个等待过程就是回收延迟的主要来源。内核用两个参数控制脏页的规模vm.dirty_background_ratio和vm.dirty_ratio默认分别是 10% 和 20%。当脏页占比超过 background 阈值pdflush 类的回写线程现在是 per-bdi 的kworker开始后台回写超过 dirty_ratio 阈值进程自己写数据时会被直接阻塞去回写。这两个默认值是给机械盘时代设计的。对于 eMMC、UFS 这类随机写性能有限的介质20% 的阈值意味着可能堆积几个 G 的脏页一旦触发直接回收回收器扫到脏页只能跳过同时队列里压着大量待回写数据压力就这么叠上去了。注意vm.dirty_ratio调低会让写性能下降因为回写更频繁但在小内存或慢存储场景下这是避免直接回收拖慢业务的有效手段。调参的原则是让脏页峰值不超过存储设备能在 1~2 秒内刷完的量粗算方法是dirty_bytes ≈ 设备顺序写带宽 × 2。5. 水线与扫描控制回收器什么时候动手、动多少水线这套机制是理解为什么内存充足系统却开始回收这个经典疑问的钥匙。5.1 min/low/high 三条线的含义每个管理区有三个水线值单位是页数。min直接回收的触发线。分配请求放在 min 以下走的就不是快速路径了。lowkswapd 的唤醒线。空闲页跌破 lowkswapd 被唤醒。highkswapd 的休眠线。回收到位、空闲页超过 highkswapd 睡回去。三条线的默认关系是 min : low : high 4 : 5 : 6内核在启动时按min_free_kbytes推导出来。min_free_kbytes的默认值不是固定数字而是按内存大小算的大致是4 * sqrt(低端内存KB)16GB 内存的机器上通常在几十兆级别。这就是内存充足却回收的答案kswapd 的唤醒条件是 low 水线不是内存耗尽。一台 32GB 的机器low 水线可能在 300MB 左右只要空闲页掉到这条线以下kswapd 就开始工作而 MemFree 显示的还有几百兆甚至几个 G你自然觉得它莫名其妙在忙。5.2 扫描优先级与回收力度回收器用一个叫 priority 的变量控制扫描强度初始值是 12DEF_PRIORITY每轮扫描不达标就减 1一直到 0。每轮扫描的页数大致是LRU 链表长度 priority也就是说 priority 越接近 0扫描比例越大priority12 时只扫链表的 1/4096priority0 时扫全表。这个设计很有意思。它等价于一个指数退避策略内存压力小的时候回收器只轻轻扫一小部分不至于把有用的缓存踢掉压力持续不缓解它才会越扫越狠。所以从tracepoint上看mm_vmscan_direct_reclaim_begin事件里带的 order 和 priority 参数就能告诉你当时有多紧张。想让回收更早介入、更平滑可以调大vm.watermark_scale_factor默认 10它会把 low 和 high 相对 min 的间距拉大让 kswapd 有更充裕的回收窗口。代价是空闲页会长期保持一个更高的备用水位对小内存机器来说相当于少了一点可用内存。5.3 三个最常被误用的参数参数默认值真实作用常见误用vm.swappiness60匿名页与文件页的回收权重倾向以为设成 0 就是禁用 swapvm.vfs_cache_pressure100回收 dentry/inode 缓存的倾向无脑调到 1000导致目录遍历变慢vm.min_free_kbytes自动决定三条水线的绝对位置一键脚本设成 1GB小机器直接 OOM关于 swappiness有一个特别容易搞错的点它调到 0 并不等于完全不用 swap。内核文档里写得很明白即使 swappiness 为 0在极端内存压力下为了不让系统直接 OOM内核仍然可能换出少量匿名页。真想让匿名页完全不动唯一可靠的做法是不配 swap 设备或者用mlock/mlockall锁住关键内存。vfs_cache_pressure 的误用同样常见。把 dentry 和 inode 缓存扫得太狠表面上 MemFree 变多了但代价是文件遍历和路径查找要不停重新解析目录CPU 会明显上升。这类用 CPU 换内存的操作一定要先量清楚哪边更贵。6. 与脏页回写、memcg 的联动内存回收很少单独工作它和回写、和 cgroup 都有很深的耦合。这两块搞不清调参会一直在打转。6.1 回写线程和 kswapd 是两条独立的路很多人以为内存回收会去主动触发回写其实不是。回收器扫到脏页时的动作是把它移到 inactive 链表头部保留位置、可能唤醒回写线程、然后继续扫下一张。它自己不做回写。回写是另一条完全独立的路径由 pdflush 演化而来的 per-bdi 回写线程驱动。所以一台脏页堆积的机器你会看到一个很奇怪的现象kswapd 跑得很勤CPU 占用不低但空闲内存就是上不去。原因就是回收器一直在扫脏页、跳过脏页等到回写线程终于把页刷干净了这些页才可能被回收。判断这个僵局的办法是看/proc/meminfo里的Dirty和Writeback两个字段。如果 Dirty 长期维持在高位同时pgscan_kswapd在涨而pgsteal_kswapd不涨基本可以确定是被脏页卡住了。6.2 cgroup v2 的 memory.high 是回收的软刹车cgroup v2 的 memory controller 提供了三个关键水位。memory.min / memory.low保护线这部分内存不会被回收。memory.high软限制超过它不会杀进程但会强制该 cgroup 进入按需回收同时把分配动作节流。memory.max硬限制超过就触发 OOM。memory.high是我认为最有用的一个。它把内存回收从全局问题变成了可局部控制的动作你可以给一个主要跑批处理任务的 cgroup 设一个比较紧的 high 值让它自己的内存压力自己消化不影响同机上跑的核心服务。需要注意 5.0 之后的一个变化在 cgroup 内部发生直接回收时回收器会优先在同一个 cgroup 内做回收而不是全局乱扫。这是为了提高 NUMA 亲和性和减少跨组干扰。副作用是如果一个 cgroup 的 high 设得太紧它自己的直接回收会非常频繁业务延迟反而不稳定。这种时候正确的做法是放宽 high而不是去调 swappiness。提示cgroup v2 下的压力指标可以直接读/sys/fs/cgroup/path/memory.pressure其中full字段的增长速率比some更能反映业务是否真的被内存卡住了。7. OOM Killer最后一道闸门怎么选人OOM 是所有内存问题的终点也是讨论最多、误区最多的部分。它的核心只有一句话在所有进程里挑一个最该死的杀掉。难点在于最该死怎么定义。7.1 oom_badness 的打分逻辑内核计算每个进程的得分公式大致是score (rss swapents pgtables) * 1000 / total_pagesrss是驻留物理内存swapents是换出的页数pgtables是页表占用。得分越高越可能被杀同时每个进程还有一个oom_score_adj范围从 -1000 到 1000直接加在最终得分上。注意 swapents 也计入得分这一条。这意味着一个已经被大量换出到 swap 的进程依然有很高的被杀风险——它的内存足迹并没有因为换出而减少。这和很多人的直觉相反。7.2 oom_score_adj 的实战用法这个值最实用的用法是给关键进程一个负分让它不容易被杀。比如一个负责接收流量的接入层进程# 把指定进程的保护等级设为高越负越安全 echo -800 /proc/$(pidof ingress-worker)/oom_score_adj同时给那些崩了可以自动重启、损失可控的离线任务设正值echo 500 /proc/$(pidof batch-worker)/oom_score_adj但要清楚-1000只是让这个进程在分数上最不容易被选中内核在某些情况下比如 OOM 是它自己触发的或者系统里只剩它一个仍然会杀它。真正需要绝对保护的进程得配合 cgroup 的memory.min或者在容器场景下限制 cgroup 的内存上限让 OOM 发生在 cgroup 层面而不是全局。7.3 值得关注的 oom 相关 vmstat 计数除了常规的pgscan/pgsteal还有几个计数在排查 OOM 时很有价值。oom_kill只增不减每杀一次加一。这个值只要不是 0就说明历史上发生过 OOM值得去/var/log/messages或dmesg里翻具体场景。pgmajfault主缺页配合allocstall看能判断是不是换出后频繁换入导致的抖动。workingset_refault表示工作集里的页被回收后又被重新访问。这个指标上涨说明回收器在误伤热数据是个很强的信号。8. 观测方法论从 vmstat 到 tracepoint 的三级排查调内存问题最怕的是凭感觉。我自己的排查顺序是固定的三步从粗到细。8.1 第一级/proc/meminfo 与 /proc/vmstat 的快照/proc/meminfo里字段很多重点看这几个字段含义关注点MemAvailable内核估算的可用内存真正的还剩多少Dirty待回写的脏页长期高于千万量级要警惕Writeback正在回写的页持续不为 0 说明 IO 吃紧SReclaimable可回收 slab数值很大说明 dentry 缓存堆积Unevictable不可回收页异常大说明有大量 mlockSwapCached换出后又读回的页高说明换出策略在抖动/proc/vmstat则是看增量用两次采样做差# 采两次 vmstat间隔 5 秒关注回收相关的计数 grep -E pgscan|pgsteal|allocstall|pgmajfault|workingset /proc/vmstat /tmp/v1 sleep 5 grep -E pgscan|pgsteal|allocstall|pgmajfault|workingset /proc/vmstat /tmp/v2 diff /tmp/v1 /tmp/v28.2 第二级用 PSI 量化业务到底有多难受PSIPressure Stall Information是 4.20 之后引入的它把内存压力的严重程度量化成了时间占比。直接读/proc/pressure/memorysome avg100.32 avg600.15 avg3000.05 total1234567 full avg100.08 avg600.02 avg3000.00 total234567some表示至少有一个任务因为内存受阻的时间占比full表示所有任务都受阻的时间占比。经验上full的 avg10 长期超过 1% 就说明内存已经在实质影响业务了超过 5% 基本可以确定是需要立刻处理的级别。比 vmstat 好用的地方在于PSI 是时间维度的直接可以对应到用户感知的延迟。8.3 第三级tracepoint 看现场要看具体是哪些页在被扫描、回收到了哪一步就得开 tracepoint。以 kswapd 的扫描过程为例# 打开 vmscan 相关的 tracepoint cd /sys/kernel/debug/tracing echo 1 events/vmscan/enable echo 1 events/compaction/enable # 实时观察输出量大建议配合 grep 过滤 cat trace_pipe | grep -E kswapd|direct_reclaim几个关键的 tracepoint 事件mm_vmscan_kswapd_wakekswapd 被唤醒参数里有 zone 和 order。mm_vmscan_direct_reclaim_begin直接回收开始参数里有 order 和 gfp flags。mm_vmscan_lru_shrink_inactive回收 inactive 链表参数里有 nr_scanned、nr_reclaimed、priority。mm_vmscan_lru_isolate页被从 LRU 隔离出来能看出是匿名页还是文件页。如果用的是较新的内核用perf trace会更方便perf trace -e vmscan:mm_vmscan_direct_reclaim_begin \ -e vmscan:mm_vmscan_lru_shrink_inactive -- sleep 60我一般会盯nr_reclaimed和nr_scanned的比值这个比值能在现场直接告诉你这轮回收是不是白干。9. 踩过的坑与问题速查这一节是我这些年攒下来的都是文档里不会写、但实际会碰到的。9.1 内存明显充足却在频繁回收三种典型原因。一是脏页堆积回收器扫到脏页只能跳过表现为 pgscan 涨而 pgsteal 不涨根治办法是调低dirty_ratio/dirty_background_ratio或者给慢设备设更小的dirty_bytes。二是大量不可回收页看Unevictable字段常见于使用 GPU 驱动、RDMA 或者显式 mlock 的应用。三是 cgroup 的 local reclaim容器内存超限后触发的是组内回收全局 MemFree 再高也没用这时候要去看 cgroup 的memory.current和memory.high。9.2 配了 swap 却一直不用先确认三件事swap 是不是真挂上了swapon --show、vm.swappiness是不是被别的脚本改成了 0、以及 swap 设备是不是已经满了。还有一个很隐蔽的原因如果系统里绝大多数内存是文件页回收器会优先回收它们匿名页基本轮不到。这种情况其实是好事说明你不需要 swap别为了让 swap 用起来去调 swappiness。9.3 常见问题速查表现象可能原因排查/处理方向allocstall 持续增长直接回收频繁调大 min_free_kbytes 或 watermark_scale_factorpgscan/pgsteal 比值过高回收空转检查脏页比例、Unevictable 大小、mapcountworkingset_refault 高热数据被误回收考虑启用 MGLRU或增大 watermarkpgmajfault 高换出后频繁换入检查 swap 是否过小、内存是否真的不足Dirty 长期高位回写跟不上调低脏页阈值检查设备写带宽OOM 杀掉非预期进程oom_score_adj 未设置关键进程设负值批处理任务设正值9.4 几个不太像技巧的技巧min_free_kbytes别乱动。很多一键优化脚本会把它设成物理内存的 5% 甚至更高这在 64GB 机器上是 3GB 的空闲备用水位等于白白浪费。合理的做法是先用默认值跑一段时间观察水位是否够 kswapd 及时响应不够再小幅上调。THP 和回收的关系值得单独提一下。透明大页在回收时需要先拆分split成基页如果因为引用计数或者映射关系拆不开这次回收就会失败。在内存碎片化严重的机器上THP 可能让回收效率明显下降。如果你观察到大页相关的 vmstat 计数异常thp_split_page持续增长可以试试把 THP 切成madvise模式让应用自己决定哪里用大页。还有一个容易忽视的vm.overcommit_memory。它管的是分配时的承诺策略默认值 0 是启发式判断。改成 1 会让内核无条件答应所有分配请求等到真正需要物理页时才回收和 OOM风险是可能出现分配成功但写入时被杀的情况。改成 2 则按 oversubscribe 比例硬性限制总量。生产环境我一般保持 0除非有非常明确的理由。最后一点经验是关于观测的节奏。内存问题的特点是平时好好的压力来了才发作。所以别等出问题才去看 vmstat应该在业务低峰期先用 PSI 和 vmstat 建立一个基线记录正常情况下pgscan_direct是不是恒为 0、full压力值是不是在 0.01 以下。有了这条基线任何异常增长都会立刻跳出来排查速度能快好几倍。我自己踩过最深的一次坑是给一个跑在容器里的服务调memory.high。当时为了省内存把 high 设得很紧结果不管业务忙不忙cgroup 内的直接回收几乎从不停歇memory.pressure的 full 值长期在 2% 到 5% 之间晃业务侧表现为偶发的几十毫秒延迟尖刺。后来把 high 放宽到实际峰值的 1.2 倍压力值直接掉到 0.01 以下内存占用也没有明显上涨——因为那个服务本来就不是省内存型的负载硬压它只会让内核做无用功。这件事让我对内存调优这四个字的理解改了很多多数时候你要做的不是让数字变好看而是让内核少做那些没意义的搬运。