资讯详情

资讯详情

Linux页框回收机制深度解析:从水位线到回收路径

1. 内存明明够用系统却在悄悄回收页框从一次延迟尖峰说起1.1 现象物理内存白白闲置负载却出现换页先讲一个真实场景。前两年我维护一台容器节点物理内存 128G日常用量也就 60G 左右内存看着绰绰有余。可某个业务做压测时我发现 vmstat 里的si和so莫名其妙出现了连续换页/proc/pressure/memory里的some指标飙到 30% 以上业务 p99 延迟从 3ms 直接跳到 90ms。当时第一反应是内存没满啊系统在折腾什么后来一步步查下来才发现问题出在一个 cgroup 的memory.high上限上——某个容器被限制到 4G它一超过上限内核立刻对这个 cgroup 内的页框执行回收哪怕物理机还闲着几十个 G。这件事很好地解释了页框回收Page Frame Reclamation的一个核心反直觉点回收不一定发生在内存耗尽时它只关心“你能不能继续分配”不关心“机器还剩多少内存”。你看着内存充足但某个水位线、某个 cgroup 配额、某个 NUMA 节点的局部压力都可能触发一次实实在在的页框回收。1.2 根因回收不是等内存耗尽才动手而是看水位线页框回收的本质是内核在“内存分配”这条链路上设置了几道闸门。Linux 不会等到内存彻底见底才启动回收因为那样做会在瞬间把系统拖入不可控的 OOM 状态。它在每个内存域zone上维护三个水位线min、low、high。分配页框时内核把剩余内存与这些水位线做比较剩余内存高于high一切正常走快速路径。低于high但高于lowkswapd会被唤醒开始异步回收把水位抬回high之上再睡觉。低于low但高于minkswapd 继续加大回收力度此时系统已经处于“内存压力”状态了。低于min普通分配直接进入慢路径来不及等 kswapd 慢慢收分配线程亲自下场执行直接回收direct reclaim。如果回收不出结果再考虑向 OOM killer 求救。你可以把这三个水位理解成水库的三级警戒线high是正常运行区间low是警告线min是死线。水库管理员kswapd在警告线就开始放水保库容但如果放水太慢导致水位跌破死线那就只能直接强制泄洪direct reclaim代价是下游会被淹没一会儿——对应到业务上就是分配延迟突然拉高。1.3 三级水位 min/low/high 的计算与作用三级水位并不是拍脑袋定的核心起点是min_free_kbytes这个内核参数。它表示系统需要保留的最小空闲页框量换算成页数后作为 min 水位的基准值low 和 high 再按倍数放大。不同内核版本对 low、high 的计算比例不太一样。早期版本常见的是low min * 4、high min * 6后来的一些内核版本改成更保守的low min min / 4、high min min / 2。无论如何缩放思路都一样high 是回收的目标水位low 是回收的启动水位min 是分配允许触碰的底线。这个细节在排查时特别有用。我见过有人设置了特别大的min_free_kbytes比如 8G本意是防止分配失败结果发现系统可用内存凭空少了 8Gkswapd 频繁唤醒CPU 大量消耗在扫描 LRU 链表上。所以调这个参数之前先想清楚你的机器到底在应对什么压力场景。查看水位线不需要任何高级工具/proc/zoneinfo直接列出来cat /proc/zoneinfo | grep -A 20 Node 0, zone Normal输出里pages free、min、low、high对应的就是当前值和三级水位。如果 free 长期贴着 low 甚至 min说明系统一直处于内存紧张状态任何突发分配都会立刻触发回收。2. LRU链表与近似LRU内核凭什么决定先回收谁2.1 为什么不用教科书里的标准LRU页框回收的第一步是选择“牺牲品”。教科书式的 LRU最近最少使用很好理解维护一个全局链表每次访问把页面提到链表头回收时从链表尾淘汰。但 Linux 内核没有这样干原因是物理实现代价太大了。试想一下全局一个链表所有 CPU 访问页面都要操作同一个锁内存越大链表越长扫描一次的时间成本越高。更麻烦的是页面访问频率差异极大纯 LRU 很容易被“一遍扫描型”流量污染——比如某个任务批量读了一堆文件之后再也不碰这些页它们却因为“最近被访问过”而排在链表头部把真正热的数据挤到了尾部。所以 Linux 用的是近似 LRU每个内存域维护两条链表active活跃和inactive不活跃再配合PG_referenced和PG_active两个标志位模拟出“最近是否被使用”的语义。数据结构上牺牲了一点精度但换来了极大的并发度和扫描效率。2.2 活跃链表与不活跃链表的晋升降级机制两条链表的配合逻辑大概是这样页面第一次被加入 LRU 时默认放进inactive链表尾部。inactive链表里的页面被访问时置位PG_referenced再次被访问或者通过mark_page_accessed路径触达就晋升到active链表。active链表里的页面被访问只刷新标志位不重新排序。链表大致保持 FIFO 顺序只是定期做老化。当active链表过长时内核会把一部分页面降级回inactive交给后续扫描决定是否回收。这种设计的妙处在于hot/cold 的识别不是精确的但统计上是有效的。像网页、配置文件这种会被频繁反复读取的数据很容易晋升到 active 并长期存活而那些“读一次就再也用不上”的页会停留在 inactive 尾部回收时首先被选中。不过这里有一个经验之谈别把“活动页”和“应该保留的页”画等号。我遇到过一种情况某服务频繁扫描一个大目录结构dentry/inode 的页缓存看起来很“活跃”但业务上已经没用了结果它们霸占着 active 链表导致数据库的 buffer pool 页反而被挤到 inactive 被回收磁盘读放大。这种问题靠调 swappiness 解决不了得用vfs_cache_pressure控制 slab 缓存回收倾向后面我会专门讲。2.3 回收稳定性Refault Distance思想扫描 LRU 只解决“回收谁”的问题但还有更隐蔽的坑如果回收错了系统会反复震荡。典型场景是inactive 链表里混着一批当天高频使用的页回收线程刚把它们扔掉业务立刻又读回来内核再次分配新页框放进来接着又被回收形成来回颠簸。针对这个问题内核引入了Refault Distance的概念主要实现是页缓存影子节点机制。简单说当一个页从 inactive 链表被回收后内核不是彻底忘记它而是在 radix tree / xarray 中留下一个“影子”记录。下次这个页被重新读入时内核会计算“距离上次回收之间inactive 链表被扫描了多少页”如果距离很短说明这个页其实很热就不应该让它再次被轻易回收而是直接把新读入的页放到 active 链表如果距离很远说明这个页没那么热按正常流程走即可。用生活化的比喻书店老板把卖得不好的书撤下架但会记一个账本记录撤下架之后书架被翻动过多少次。如果刚撤下去没两天又有人问这本书老板就知道判断错了下次重新上架时直接放在显眼位置。回收本身不可怕可怕的是一遍遍回收同样的热页。Refault Distance 就是给回收机制装上的“反震荡反馈”。这个机制对理解文件型业务特别重要。很多 DBA 看到系统 si/so 很高就怀疑 swap 过度使用其实有一部分换页来自页缓存文件页的 re-read而不是匿名页。如果pswpin/pswpout很高而si/so只是偶发可以先怀疑页缓存回收策略问题而不是急着调整 swap。3. kswapd与直接回收两条回收路径的分工与代价3.1 kswapd后台线程的工作循环每个内存节点有一个或多个kswapd内核线程名字形如kswapd0、kswapd1。它的工作模式非常像一个守门员平时休眠水位跌破 low 时被唤醒开始对 zone 的 LRU 链表做扫描和回收直到水位恢复到 high 才重新入睡。kswapd 的回收是异步的进程不会因为分配内存而卡住等它。这种设计很合理水位突破 low 属于轻度压力系统还撑得住用后台线程慢慢收不会产生明显的分配抖动。实操中观察 kswapd 通常看ps -ef | grep kswapd出来的进程 CPU 占有率或者/proc/vmstat里的pgscan_kswapd和pgsteal_kswapd。pgscan_kswapd 9742014 pgscan_direct 81920 pgsteal_kswapd 9432088 pgsteal_direct 76421如果pgscan_kswapd一直在涨说明内存压力持续存在系统长期处于低水位运行状态。这种“温水煮青蛙”式压力最容易被忽视因为业务还没明显报错但整体吞吐已经在悄悄下滑。3.2 直接回收分配线程被卡住的同步路径与 kswapd 相对的是 direct reclaim。当分配线程发现剩余内存已经低于 min 水位时不能再干等 kswapd 慢慢扫它必须自己跳到回收代码里执行 shrink 操作同步回收出足够的内存然后继续本次分配。这个过程对延迟的影响是灾难性的。一次直接回收可能要遍历大量 LRU 链表、唤醒 flusher 线程写回脏页、甚至等待 swap IO 完成耗时从几十微秒到几百毫秒不等。在高并发服务里只要有少量分配线程陷入 direct reclaim整个请求链路都能感受到明显的长尾延迟。真实的踩坑经历是这样的某次线上服务压测CPU 和内存看起来都还行但延迟毛刺非常规律地每 30 秒出现一次。查了一圈发现是某个定时任务会瞬间分配大块内存把水位压到 min 以下触发 direct reclaim同时该任务产生的页大多还是脏页回收线程被迫等待回写磁盘把平均延迟拉高了一个量级。后面把定时任务的执行时间错开再把dirty_ratio调低毛刺就消失了。所以排查内存问题时/proc/vmstat里的pgscan_direct和pgsteal_direct永远值得多看几眼。这两个值只要稳定增长基本可以断定有分配线程在同步回收延迟尖峰多半从这里来。3.3 如何确认系统正在经历哪类回收我一般按三个步骤定位第一步看 vmstat 的缓存扫描和换页情况vmstat 1重点看si、so是否持续非零以及cs上下文切换有没有暴涨。第二步看/proc/vmstat的回收计数grep -E pgscan|pgsteal|pgalloc /proc/vmstat如果pgscan_direct不为 0说明系统已经在经历直接回收数值增长越快分配失败的风险越高。第三步用 PSI 的 memory 指标做持续观测cat /proc/pressure/memory输出里some avg10表示过去 10 秒内至少有一个任务因内存回收被阻塞的时间占比full avg10表示所有任务都被阻塞的时间占比。生产环境如果想做告警我经验上是full avg10 5就要关注some avg10 20基本可以断定内存回收已经成为瓶颈。值得一提的是cgroup v2 环境下每个 cgroup 也有独立的 PSI 指标。容器化的服务如果整体机器 PSI 不高、但某个 cgroup 的 PSI 内存很高那大概率是 cgroup 内存上限触发回收而不是宿主机物理内存不足。这个判断在排查“宿主机内存充裕但容器 OOM”类问题时特别好用。4. 可回收页框的三条去向干净页、脏页、匿名页4.1 干净页缓存命中即放行最廉价的回收页框回收并非所有页都能收它只针对“可回收页”。其中最理想的就是干净的页缓存clean page cache这个页的内容在磁盘上有对应文件回收时不需要写回直接释放页框即可下次访问再从磁盘读进来。整个过程无 IO 开销成本几乎为零。内核在 shrink 过程中会优先处理这类页。如果一个文件页被映射为 page cache 的一部分scan 阶段会检查它是否脏PG_dirty不脏的话直接 unmap 和释放并把页框归还给伙伴系统。这也是为什么系统内存紧张时最先牺牲的往往是大规模文件读写的缓存页而不是程序堆里的匿名页。日常看到/proc/meminfo里Cached数值很大但Available不高时不用紧张。只要这些 Cached 页是干净页它们就是一堆“随时可以免费释放的储备军”真正有分配需求时内核立刻能拿来用。Linux 对 Available 的定义已经考虑到了这一点。4.2 脏页回写线程与回收器的配合脏页就没有这么便宜了。它必须先把数据写回磁盘才能释放页框否则数据一致性就完蛋了。写回动作由 flusher 线程flush-MD或者flush-bdi异步执行但内存回收线程在压力下可能直接同步等待写回完成。这个场景最容易引发“内存回收导致 IO 尖峰”的问题。设想一下系统里短时间内写入了大量脏页dirty值飙升此时恰好内存压力增大kswapd 扫描到一批脏页只能等 flusher 线程刷盘才能收走而磁盘吞吐有限分配线程和回写线程互相等待整个系统被 IO 拖住。控制脏页水位的参数有两组dirty_background_ratio和dirty_ratio。前者表示脏页占可用内存达到多少时后台 flusher 开始刷盘后者表示达到多少时写文件的进程自己开始同步回写。默认值通常分别是 10 和 20。对写压力大的业务我建议把dirty_background_ratio调低到 5 左右让 flusher 更早介入避免脏页积累到触发进程级同步回写。永远比等到内存回收器来收脏页要从容得多。4.3 匿名页swap换出与swappiness的语义第三类可回收页是匿名页也就是进程堆、栈、写时复制产生的页。这类页没有磁盘文件对应回收时必须写到 swap 设备或 swap 文件里将来访问时再从 swap 读回代价远高于写回文件页。内核用swappiness参数/proc/sys/vm/swappiness默认 60控制回收时扫描匿名页和文件页的比例倾向。至于它具体如何换算成两个 LRU 链表的扫描比例不同内核版本的实现有差异但语义一致数值越接近 100越倾向于换出匿名页越接近 0越倾向于只回收文件页、尽量不碰匿名页。很多人以为设置vm.swappiness0就是禁用 swap这其实是个误区。它只是让内核“尽量不回收匿名页”并不是物理上禁止换出。真要禁用 swap应该swapoff -a但那样的话匿名页在内存压力下会成为不可回收页系统更危险。在容器和裸金属混部场景下我一般保持默认值不折腾除非业务形态非常明确——比如 Redis 这种缓存型服务换出任何一个匿名页都是巨大的性能损失才值得把 swappiness 调低。下面这张表总结了三类页框回收的差异排查时可以先归个大类页类型回收是否需要IO主要处理路径代价干净文件页否直接释放极低脏文件页是写回磁盘flusher shrinker 配合中等受磁盘速度限制匿名页是写入swapswap 换出高可能伴随换入换出反复5. 回收失败的兜底slab收缩器与OOM killer5.1 shrink_slab那些不是页框却占用内存的对象LRU 链表只是页框回收的第一层内核内存里还有大量对象不属于页框却同样占用物理内存dentry、inode、文件系统元数据、网络 socket buffer、各种内核缓存。它们由 slab 分配器管理回收路径依赖注册在 shrinker 上的回调函数批量释放不常用的对象。这套机制被称为shrink_slab名字延续了早期“压缩 slab”的意象。扫描 slab 与扫描 LRU 的成本差异非常大LRU 页回收主要看标志位slab 回收可能要遍历复杂的对象关系、释放引用计数、引发连锁的内存释放。所以在内存压力比较轻微时shrink_slab不会被优先调用压力大到 kswapd 和 direct reclaim 都忙不过来时slab 收缩器会作为补充路径上场。影响 slab 回收倾向的关键参数是vfs_cache_pressure默认 100。调高它内核会更积极回收 dentry/inode 缓存调低它这些元数据缓存会更长时间驻留内存。对于文件数量庞大的场景比如大量小文件的目录服务vfs_cache_pressure调低到 50 甚至 20常能明显改善文件操作的延迟。但别盲目调很低否则元数据缓存占用膨胀会挤压页缓存和匿名页的空间。5.2 OOM killer的决策逻辑与badness评分当直接回收反复执行、仍然凑不出足够内存时内核只能祭出最后手段OOM killer。它的逻辑不是“随机杀一个进程”而是按照一套评分机制挑选“最该杀”的进程。核心评分函数叫badness大致综合以下因素进程当前占用内存的大小占权重最高进程的oom_score_adj调整值可通过/proc/pid/oom_score_adj修改是否是 root 进程有轻微减分是否直接访问硬件设备有减分倾向运行时间和 nice 值等也会影响排序。算出分数后OOM killer 挑分数最高的进程下手。注意“最高分”代表“最该被牺牲”而不是“最坏”——在内存不足的系统里杀掉一个大内存进程回收的效果立竿见影代价则是业务中断。oom_score可以在/proc/pid/oom_score实时查看想保护关键进程就把它的oom_score_adj设为负数想让它容易成为目标就设为正数。有一点容易踩坑OOM killer 的触发并不等价于物理内存耗尽。cgroup 内存超限同样会触发 cgroup 级别的 OOM表现为容器内进程被杀但宿主机/proc/vmstat里可能看不到任何全局内存压力痕迹。排查容器被杀问题时优先看 cgroup 的memory.events里的oom_kill计数而不是一上来就猜是不是物理机内存不足。5.3 cgroup内的OOM与全局OOM差异全局 OOM 和 cgroup OOM 的决策范围完全不同。cgroup v2 的内存控制器在容器超过memory.max时会尝试回收回收失败后会直接在 cgroup 范围内选择进程杀掉不会动 cgroup 外的进程。这里有一个很精巧的细节cgroup 内部也有自己的 LRU 链表。同一台物理机上跑十几个容器每个容器的页是独立挂到容器对应的 LRU 上的。当某个容器超过配额时回收器只扫描这个容器自己的链表。也就是说一个容器的内存压力不会直接引发另一个容器的页被回收。这个隔离机制对混部场景至关重要也是我在排查“一个容器 OOM却把整机的 IO 打满”这类问题时首先检查的路径——通常不是跨容器抢页而是那个容器的脏页回写和全局 flusher 共用了同一块磁盘带宽。6. 页框回收参数调优与故障排查实践6.1 swappiness调优数据库与缓存场景的差异先把结论摆出来不要随便把 swappiness 调到 0 或 100先理解自己业务的“冷热内存”结构。如果是 OLTP 数据库buffer pool 里的热数据存在匿名页里换出任何一个都伴随巨大的性能回退。这类业务适合把 swappiness 调低比如 10让内核优先回收文件页缓存而不是动进程堆。人话就是数据库场景下文件系统缓存丢了还能从磁盘读回来但热数据被换到 swap 就意味着频繁的换入换出更伤。如果是文件缓存密集型业务比如大量读日志、做批量扫描文件页本来就是“读过即弃”的反而应该把 swappiness 调高一些让匿名页相对稳定驻留文件页该走就走。默认 60 其实是比较均衡的起点只有通过监控确认某一边有明显浪费时再动。有一种争议观点是“SSD 快swap 也能很快”。实测下来SSD 上的 swap 确实比机械盘好很多但再快的 SSD 也扛不住高频抖动的换入换出。遇到 si/so 持续跳动优先解决的问题应该是“为什么内存会不够”而不是“如何让 swap 更快”。6.2 min_free_kbytes调整的坑min_free_kbytes这个参数我在生产环境只调过两三次每次都有明确理由而且调完都要盯着观察一段时间。小内存机器小于 4G上如果min_free_kbytes默认值偏大会导致可用内存被无谓压缩配页频繁失败。反过来大内存机器上如果把它设得太小低水位会让内核迟迟不启动回收等真到 direct reclaim 阶段往往已经来不及了直接 OOM。调它之前先看当前值cat /proc/sys/vm/min_free_kbytes需要提醒的是调大min_free_kbytes虽然能提早回收但也会减少内存实际可用余量尤其在内存碎片化严重时保留的“安全水位”可能反而阻碍大页分配。我的经验是从默认值往上加 20%~50% 开始试观察是否存在分配失败或者 kswapd CPU 飙升。不要一步加到 8G 这种量级除非你知道自己在干什么。6.3 用PSI和tracepoint定位回收延迟调参之前先把问题定位准。我强烈建议生产环境常态化开启对 PSI 的监控/proc/pressure/memory的some avg10和full avg10能直观反映内存回收带来的任务停顿程度是判断回收压力的第一入口。定位到“正在回收”之后如果想进一步知道回收发生在哪条路径可以用内核的 vmscan 跟踪点。配置方式大致如下mount -t tracefs none /sys/kernel/tracing echo vmscan:mm_vmscan_direct_reclaim_begin /sys/kernel/tracing/set_event echo vmscan:mm_vmscan_direct_reclaim_end /sys/kernel/tracing/set_event echo 1 /sys/kernel/tracing/tracing_on跑一段时间后看/sys/kernel/tracing/trace能直接看到是哪个进程触发了 direct reclaim以及它回收了多久。生产环境不建议长期开着 trace但短时间抓一次慢路径比瞎猜参数要靠谱得多。6.4 与页框回收相关的内核参数速查表最后列一张我在实践中常用的参数表方便排查时查。注意每个参数都有适用边界单位、阈值因内核版本而异修改前先确认自己的内核版本。参数默认值作用调整建议vm.swappiness60匿名页与文件页回收倾向数据库场景调 10 左右缓存型场景调 80~100vm.min_free_kbytes动态计算保留最小空闲页框量小步调增观察 kswapd CPU 与分配失败率vm.vfs_cache_pressure100dentry/inode 缓存回收倾向文件数量极多可调 50 左右vm.dirty_background_ratio10脏页后台回写启动阈值写压力大调低到 5vm.dirty_ratio20脏页同步回写触发阈值需要控制 IO 毛刺时适当调低vm.zone_reclaim_mode0NUMA 节点本地回收行为一般保持默认值这些参数之间不是孤立的。比如你把 swappiness 调到 0但又不小心把min_free_kbytes调得很低出现内存不足时内核既不愿回收匿名页、又没留有足够安全水位结果就是频繁 OOM。调内存参数最忌讳单点优化每次只动一个变量观察至少一个完整业务周期再决定下一步。我在实际排查页框回收问题时最常用的组合拳是先看 PSI memory 指标确认有压力再看/proc/zoneinfo确认水位位置接着看/proc/vmstat区分 kswapd 和 direct reclaim 的比例最后根据业务类型决定是调 swappiness 还是调 dirty 相关参数。这套流程帮我在不少“内存充足却延迟抖动”的场景里快速找出真凶。最后再分享一个小技巧抓现场时顺手记一下dmesg -T | grep -i out of memory的时间戳很多情况下 OOM 只是结果页框回收在之前就已经悄悄运作了好几分钟把“回收启动时刻”和“业务开始劣化时刻”对上能大幅缩小排查范围。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →