引言:为什么 OOM 总发生在"内存还够"的时候
你可能遇到过这样的场景:监控系统报告节点可用内存只剩 300MB,但应用程序突然被 OOM Kill 了;或者一个 Java 服务在堆配置充裕的情况下,GC 越来越频繁、STW 越来越长,最后直接被系统杀死。这些问题的根源往往不是"内存不够",而是页面回收机制遇到了不可回收的页面或反向映射(rmap)的开销拖慢了回收速度。
Linux 内核的内存管理子系统有两个核心难题:第一,找到一个物理页面被哪些虚拟地址映射了(反向映射);第二,在内存紧张时决定回收哪些页面(LRU 算法)。本文将深入拆解这两套机制的数据结构、算法流程、锁设计,以及在生产环境中的典型故障模式与调优方法。
一、核心数据结构:从 struct page 到 anon_vma
1.1 struct page 的映射信息
Linux 内核中每个物理页面都有一个 struct page 描述符(约 64 字节)。与反向映射相关的关键字段:
// include/linux/mm_types.h (简化)
struct page {
atomic_t _mapcount; // 映射计数(PTE引用数), <0 表示未映射
atomic_t _refcount; // 引用计数, =0 时可释放
union {
struct address_space *mapping; // 文件页: 指向 address_space
struct anon_vma *anon_vma; // 匿名页面: 指向 anon_vma (CONFIG_RMAP)
};
unsigned long private; // 私有数据: 对于匿名页指向 anon_vma
pgoff_t index; // 在映射对象中的偏移(page offset)
struct list_head lru; // 挂载到 LRU 链表的节点
// ...
} ____cacheline_aligned;
关键字段说明:
- _mapcount:当值为 -1 时表示页面未映射(孤单页面),≥0 表示有 PTE 映射。这是判断页面是否可以快速回收的快速路径。
- mapping:对于文件页指向
address_space,对于匿名页指向anon_vma(需要结合 PAGE_MAPPING_ANON 标志位判断)。 - lru:双链表节点,页面根据其类型(active/inactive, file/anon)挂载到不同的 LRU 链表。
1.2 anon_vma:匿名页面的反向映射红黑树
匿名页面(Anonymous Page,即进程堆/栈分配的内存、mmap(MAP_ANONYMOUS) 等)反向映射的核心数据结构是 struct anon_vma:
// include/linux/rmap.h (简化)
struct anon_vma {
struct rw_semaphore rwsem; // 读写锁, 保护红黑树
atomic_t refcount;
unsigned degree; // 子节点层级数(用于 anon_vma 链表)
struct anon_vma *root; // 根 anon_vma (fork 时共享)
struct rb_root_cached rb_root; // 红黑树根: 按 VMA 地址排序
// ...
};
struct anon_vma_chain {
struct vm_area_struct *vma; // 指向的 VMA
struct anon_vma *anon_vma; // 所属的 anon_vma
struct list_head same_vma; // 同一 VMA 的所有 chain
struct rb_node rb; // 红黑树节点 (挂在 anon_vma 下)
struct list_head same_anon_vma; // 同一 anon_vma 的所有 VMA
};
它们之间的关系可以用下图理解:
PID 1 (mm_struct)
└─ anon_vma_chain{A} ─────────────────┐
└─ anon_vma_chain{B} ──────┐ │
│ │
▼ ▼
struct anon_vma (root)
┌─ rb_root (红黑树)
│ ├─ anon_vma_chain{VMA @0x7f00..}
│ └─ anon_vma_chain{VMA @0x7f10..}
└─ 被 page->anon_vma 引用
PID 2 (fork 的子进程)
└─ anon_vma_chain{C} ────── 挂在同一个 anon_vma 上
(COW 后会产生新的 anon_vma)
当进程 fork 时,子进程最初共享父进程的 anon_vma;一旦子进程写了某个匿名页面触发 COW,内核会为该页面分配一个新的 anon_vma,并重新建立映射关系。这就是为什么 fork 出的多个子进程(比如 nginx worker) 共享同一份 anon_vma 树,能加速页面回收时的 rmap 遍历。
1.3 文件页的正向映射 vs 匿名页的反向映射
文件页(映射自文件,如代码段、mmap 文件)使用正向映射:address_space 中维护了一棵 radix tree/xarray,可以通过页面偏移迅速找到所有映射该页面 PTE。匿名页则需要 从页面反查 VMA,因为它没有"文件偏移"这个天然索引,只能遍历 anon_vma 红黑树中的每个 VMA,再查询其页表找到对应的 PTE。
二、页面回收链路:从 kswapd 到 direct reclaim
2.1 页面回收的两条路径
Linux 内核有两条页面回收路径,它们的触发条件和执行上下文完全不同:
| 维度 | kswapd (后台回收) | direct reclaim (直接回收) |
|---|---|---|
| 触发条件 | 内存低于 watermark[low] 时唤醒 | 进程分配内存时内存不足 |
| 执行上下文 | 内核线程 (kswapd%s) | 进程上下文 (执行 alloc 的进程) |
| 回收粒度 | 渐进式, 每次约 32 页 (SWAP_CLUSTER_MAX) | 一次性回收满足 ORDER 需求的页数 |
| 延迟影响 | 低 (后台运行) | 高 (业务进程被阻塞做回收) |
| 触发直接回收后 | 同时启动 | direct reclaim 优先, kswapd 补位 |
2.2 watermark 与 kswapd 的唤醒机制
每个 NUMA node 的每个 zone 都有三个水位线:
(high) ─── kswapd 停止工作的阈值, 开始放松
(low) ─── kswapd 被唤醒的阈值
(min) ─── 分配器必须做 direct reclaim 的阈值
|------- reserve ------|--- low ---|--- high ---|
min low high
◄──── kswapd 全力回收 ────►
◄──────── direct reclaim 必须 ──────────────►
水位线由 vm.min_free_kbytes 和 zone 大小的百分比共同决定。查看当前水位:
$ cat /proc/zoneinfo | grep -E "Node|zone|min|low|high"
Node 0, zone DMA
pages free 3968
min 268
low 335
high 403
...
Node 0, zone Normal
pages free 186822
min 13064
low 16330
high 19596
当 free pages < low 时,kswapd 醒来并以 HUGEPAGE_ANON_SWAP_INTERVAL 为间隔做渐进式回收;当 free pages < min 时,任何请求内存的进程都必须自己参与回收(direct reclaim)。
2.3 kswapd 的回收循环
kswapd 每次醒来执行:
// mm/vmscan.c: balance_pgdat() 核心逻辑
while (true) {
// 1. 计算目标: 平衡到 high watermark
for_each_zone(zone)
target = zone->watermark[WMARK_HIGH] + balance_gap;
// 2. 尝试回收 (从高階 order 到 0)
for (order = pgdat->kswapd_order; order >= 0; order--) {
shrink_node(pgdat, sc); // 核心入口
if (all_zones_ok) goto out;
}
// 3. 如果仍然不够, 降低碎片化目标, 开 compaction
if (remaining_need)
try_to_compact_pages(...);
// 4. 全都不足, 触发 OOM (最后手段)
if (need_oom_kill)
out_of_memory(...);
}
shrink_node()
└─ shrink_node_memcg() // 遍历 memcg
└─ shrink_lruvec() // 真正回收
└─ shrink_list()
└─ shrink_active_list() // 迁移: active → inactive
└─ shrink_inactive_list() // 回收: inactive → 释放/回写
shrink_lruvec() 是页面回收的"心脏",它根据 LRU 上的页面类型和扫描优先级决定回收哪些、回收多少。
2.4 LRU 双链算法:Active vs Inactive
每个 zone 维护多个 LRU 链表(Redis 类似的"二次机会"算法):
enum lru_list {
LRU_INACTIVE_ANON, // 匿名页面(冷)
LRU_ACTIVE_ANON, // 匿名页面(热)
LRU_INACTIVE_FILE, // 文件页面(冷)
LRU_ACTIVE_FILE, // 文件页面(热)
LRU_UNEVICTABLE, // 不可回收(mlock/VMSWAP 锁定)
NR_LRU_LISTS // = 5
};
页面生命周期状态机:
新分配 ──► Active LRU (初始)
|
| 第二次访问 (page_check_references)
▼
┌─── 已引用 ───► 保持 Active
│
└─── 未引用 ───► Inactive LRU
|
| 再一次访问
▼
┌─── 已引用 ───► 晋升 Active (page_vma_marked)
│
└─── 未引用 ───► shrink_inactive_list() 回收
├─ 匿名页 → swap 写回
└─ 文件页 → 丢弃或回写
判断页面是否被"引用"的标志是 PTE 的 Young 位(硬件 PTE 的 Accessed 位)。内核在扫描 LRU 时通过 ptep_test_and_clear_young() 清除该位并返回之前值,如果在清除后到页面被回收前 PTE 又被设置了,说明页面是热的。
2.5 direct reclaim 的 "回收中付出" 问题
最危险的场景:一个高速内存分配(如网络包处理、内核 burst 分配)触发了 direct reclaim。典型链路:
进程P发包 → kmalloc(GFP_KERNEL) → __alloc_pages() ├─ __alloc_pages_slowpath() │ ├─ gfp_to_alloc_flags() // 允许回收? │ ├─ get_page_from_freelist() // 快路径: 有空闲就分配 │ ├─ __alloc_pages_direct_reclaim() // 慢路径: 尝试回收 │ │ └─ shrink_node() │ │ └─ rmap 遍历 + PTE 修改 │ ├─ wait_for_kswapd() // 必要时睡眠等 kswapd │ └─ retry or fail └─ 返回页面 (但 P 已被阻塞 ~ms)这就是为什么 "direct reclaim" 是性能杀手 — 同一时刻一个 CPU 在做直接回收就意味着在该 zone 的其他 CPU 的分配也被拖慢了(因为 zone lock 争用、回收期间 kswapd 不一定进得来)。
三、rmap 遍历:匿名页面的回收瓶颈
3.1 try_to_unmap:反向遍历的核心
当一个匿名页面被标记为可回收时,内核必须修改所有映射该页面 PTE(将 Present 位清除,将页号改为 swap_entry)。入口是
try_to_unmap():// mm/rmap.c int try_to_unmap(struct page *page, enum ttu_flags flags) { struct rmap_walk_control rwc = { .rmap_one = try_to_unmap_one, // 回调: 处理单个 PTE .arg = (void *)flags, .done = page_not_mapped, // 回调: 判断是否完成 .anon_lock = page_lock_anon_vma_read, // 锁 anon_vma .invalid_vma = invalid_vma, // 跳过特殊 VMA }; if (IS_ENABLED(CONFIG_TRANSPARENT_HUGEPAGE)) rmc.try_to_unmap_one = try_to_unmap_one; return rmap_walk(page, &rwc); } // rmap_walk() 遍历 anon_vma → 每个 VMA → 每个 PTE rmap_walk(page, rwc) ├─ anon_vma = page_get_anon_vma(page) // 获取 anon_vma ├─ anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root, 0, ULONG_MAX) { │ vma = avc->vma │ address = vma->vm_start + page->index * PAGE_SIZE │ // 在该 VMA 的页表中查找 PTE │ ret = rwc->rmap_one(page, vma, address, rwc->arg) │ } └─ put_anon_vma(anon_vma)
try_to_unmap_one()的核心动作是调用ptep_clear_flush_young_notify()清除 PTE 并记录 swap_entry。这意味着对于 1000 个进程共享的 4MB 匿名页面,rmap 遍历可能要修改 1000 × (4MB/4KB) = 100 万个 PTE。3.2 透明大页(THP)对 rmap 的冲击
当透明大页(Transparent Huge Pages,2MB)启用后,一个 THP 被拆分时会产生 512 个 4KB 子页面,每个子页面都有自己的 rmap 关系。这就是著名的 "THP 分裂风暴":
// 场景: MySQL 配置了 THP=always, 产生大量 2MB THP // 当内存紧张, 内核选择拆分 THP 回收其中的 4KB 子页 khugepaged ──── split_huge_page() └─ 遍历 512 个子页面: ├─ subpage->anon_vma = parent->anon_vma ├─ 重建每个子页面的 rmap 关系 (512 × 红黑树插入) └─ 修改所有映射 PTE (如果 1000 进程共享, 就是 512000 次 PTE 修改) // 这就是为什么 THP=always 在数据库场景经常导致 CPU st/throttle查看 THP 配置:
$ cat /sys/kernel/mm/transparent_hugepage/enabled always [madvise] never # 在数据库(PostgreSQL/Redis/JVM)环境推荐: echo madvise > /sys/kernel/mm/transparent_hugepage/enabled3.3 rmap 锁竞争的量化分析
anon_vma->rwsem是 rmap 遍历的关键锁。多个进程/线程同时回收共享的匿名页面时会产生争用。perf 中可以观察:$ perf top -e cycles:p --pid $(pidof java) # 典型 rmap 锁争用热点: # 35.2% [kernel] shrink_lruvec # 18.7% [kernel] try_to_unmap # 9.3% [kernel] page_lock_anon_vma_read ← rmap lock # 6.1% [kernel] down_read ← rwsem 等待 # 4.0% [kernel] anon_vma_interval_tree_foreach四、swap 与 zswap:多层交换的兴起
4.1 swap 分区的回收路径
当匿名页面被
shrink_page_list()判定回收时:shrink_page_list() ├─ PageSwapCache(page): 已换出 → 直接丢弃 ├─ add_to_swap() // 首次换出: 分配 swap slot │ └─ get_swap_pages() // 从 swap 空闲位图中取 ├─ try_to_unmap() // 清除所有 PTE 指向 swap_entry ├─ pageout() // 将脏页写入 swap 设备 │ └─ swap_writepage() // 提交 bio 给块层 └─ __delete_from_swap_cache() // 从 swapcache 移除swap 写入是同步的(在 direct reclaim 路径上)。即使 kswapd 也是异步等待写完成。如果 swap 设备(nvme/sata)本身负载很高,回收延迟会急剧升高。
4.2 zswap:压缩换出避免 IO
Linux 5.x+ 引入了 zswap — 在将匿名页面写入 swap 设备之前先通过 zstd/lzo/z3fold 压缩到内存的 zpool 中:
shrink_page_list() → __frontswap_store() (zswap 钩子) ├─ zswap_comp_op(ZPOOL_COMP_MEMCOMPRESS, page) → zstd(2→1) ├─ zs_map_object() → 写入 zpool (zsmalloc 分配器) └─ try_to_unmap() → PTE → swap_entry (zswap_entry) // 当页面再次被访问: #PF → do_swap_page() → zswap_writeback()? 如果 zswap 无法命中: ├─ zswap_duplicate_entry检查 └─ 最终走 disk swap 读回zswap 用 CPU 换 I/O,在读多写少(cache, 推荐模型)场景效果显著。查看 zswap 命中率:
$ cat /sys/kernel/debug/zswap/stored_pages # 当前存储的压缩页数 $ cat /sys/kernel/debug/zswap/pool_total_size # zpool 当前大小 $ cat /sys/kernel/debug/zswap/reject_compress_poor # 压缩率太低放弃次数 $ cat /sys/kernel/debug/zswap/writeback_count # 回写到磁盘的次数 # 开启 zswap (systemd) $ modprobe zswap enabled=1 zstd zsmalloc $ echo zstd > /sys/module/zswap/parameters/compressor $ echo zsmalloc > /sys/module/zswap/parameters/zpool五、内存碎片整理(compaction):解决无法分配的高阶页面
5.1 外碎片的来源
Linux 长时间运行后,物理页面会被拆得七零八落 — 即使总空闲内存足够,也无法找到连续的物理页面(order ≥ 3):
zone_DMA32: [空][满][空][空][满][空][空][空][满][空] 0 1 2 3 4 5 6 7 8 9 # 空闲: 4+2+3+1=10 页, 但最大连续只有 3 页(5-7) # 无法满足 order=3 (8 页) 的透明大页需求5.2 compact_zone 的工作原理
compact_zone()使用扫描器分离可移动页面和空闲页面:compact_zone() ├─ isolate_migratepages() // 正向扫描: 标记"要搬走" │ └─ 跳过不可移动页(内核页/PAGE_MOVABLE 以外的) ├─ migrate_pages() // 实际迁移 │ └─ unmap_and_move() // rmap 反向清除 PTE, 再正向重建 │ ├─ try_to_unmap(TMU_MIGRATION) │ ├─ move_to_new_page() // 复制内容到新页面 │ └─ 更新所有映射 PTE 指向新物理地址 └─ 结果: [满][满][满][空][空][空][空][空][空][空]compaction 的开销全部在 rmap 上 — 每个被移动的页面都需要遍历
anon_vma并修改 PTE。因此 compaction 默认是异步(khugepaged/kcompactd),仅在 order 分配失败或主动触发(/proc/sys/vm/compact_memory)时同步执行。5.3 碎片化指数与观测
# 查看碎片化程度 $ cat /sys/kernel/debug/extfrag/extfrag_index -o # 1.0 = 完全碎片化, 0.0 = 无碎片 # 简单估算 (bash): $ awk '/Node/ {node=$2} /free/ {free[$NF]++} END { for(k in free) print "order "k": "free[k]" of "total[k] }' /proc/buddyinfo还有更细粒度的监控:
$ cat /sys/buddyinfo Node 0, zone DMA 1 1 0 0 2 1 1 0 1 1 3 Node 0, zone Normal 2341 987 456 234 123 45 23 12 5 2 0 # order: 0 1 2 3 4 5 6 7 8 9 10(1MB连续)六、NUMA 感知回收:让页面留在本地节点
在 NUMA 架构下,
shrink_node()需要考虑节点的局部性。Linux 5.x+ 引入了 NUMA 感知回收:// mm/vmscan.c: get_scan_count() // 根据 NUMA 拓扑决定 file/anon 的比例 if (reclaim_state->reclaim_stamp != current->reclaim_scan_count) { // 检查当前节点是否是进程的 "home node" if (node_id != preferred_node) { // 跨节点访问: 更激进回收匿名页(重建成本低) sc->may_deactivate = DEACTIVATE_ANON; } }AutoNUMA balancing (Linux 5.x+) 更进一步:内核周期性地扫描进程的页表,发现跨 NUMA node 访问的页面时,主动迁移到本地节点:
// 启用 AutoNUMA (RHEL 9+ 默认开启) echo 1 > /proc/sys/kernel/numa_balancing # 查看迁移统计 $ cat /proc/$(pidof myapp)/numa_maps | awk '/N0=/ {print \$0}' | head 7f0ac0000000 default file=/usr/java/jre/lib/rt.jar mapped=456 N0=228 N1=228七、容器环境下的 rmap 与回收挑战
7.1 cgroup v2 memory.high 触发的回收风暴
如前一篇文章(Cgroup v2)提到的,
memory.high触发软回收。但在容器内触发的回收需要在该 cgroup 的 LRU 链表上扫描 — 如果容器内有大量匿名页面(如 Redis 的常驻数据集),reredemption 的 rmap 遍历本身就会产生大量 CPU 开销。观测方法:
# 监控 direct reclaim 频率 (容器内) $ nr_reclaims=$(cat /proc/vmstat | grep -w "pgsteal_kswapd\|pgsteal_direct" | awk '{s+=\$2} END {print s}') # 快速两次采样, 差值大说明 reclaim 频繁 # 更细: per-cgroup reclaim 计数 $ cat /sys/fs/cgroup/kubepods.slice/*/memory.stat | grep -E "pgsteal|pgscan" pgsteal_kswapd 123456 # kswapd 回收的页数 pgsteal_direct 5678 # direct reclaim 回收的页数 (高 = 问题) pgscan_kswapd 234567 pgscan_direct 12345 # direct scan 页数7.2 容器 rmap 风暴:共享库 + fork 的叠加效应
在 K8s 中一个 Pod 若有多个多线程容器共享挂载的代码库(idle 时不换出但会被 rmap 扫描),当一个容器的 cgroup 触发 direct reclaim 扫描与之共享相同页面的所有 VMA 时,rmap 遍历的开销是与全局共享的页面数量成正比的,而非仅本容器的。
诊断:
$ perf record -g -a sleep 30 $ perf report --sort=symbol # 典型问题: # 25-40% time in shrink_lruvec # 15-25% time in try_to_unmap_one # 5-10% time in anon_vma_chain_descendant # 5-10% time in page_remove_rmap八、生产故障案例
8.1 Redis 集群的周期性延迟飙升
某电商 Redis 集群(64GB 内存,5GB 数据集,开启 THP=always)在负载高峰时每隔 5 分钟出现一次 200ms 的 P99 延迟峰值。诊断过程:
现象分析:
$ redis-cli --latency-history -i 1 min: 0, max: 243, avg: 1.34 (samples: 60) # 峰值与 khugepaged 活动完全同步根因:THP 开启时,khugepaged 周期性尝试将冷页面拼成 2MB 大页,但此时内存已紧张,反而触发了大量 rmap 遍历 + PTE 修改。Redis 的主线程随即被阻塞在锁竞争上(redis 维护 dict/rdb 的 glibc arena 锁 与 rmap 共享)。
修复:
# 关闭 THP (Redis 官方推荐) echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag # 结果: P99 从 200ms 降至 0.8ms, TP4 个 9 从 99.2% 提升至 99.92%8.2 Kafka Broker 的 Direct Reclaim 雪崩
某金融系统在 Kafka Broker 上观察到每秒数十次 direct reclaim,导致消息复制延迟骤升。根因:Kafka 在
socket.receive.buffer.bytes过大时,网络线程处理大包时分配 GFP_ATOMIC 内存,但 order 0 紧张触发 direct reclaim。关键观测:
$ grep -E " Direct" /proc/vmstat | awk '{print \$2}' pgscan_direct 8923471 # 每秒数千次扫描 pgsteal_direct 234567 # 每扫描 38 页才回收 1 页(说明大多是不可回收的脏页)修复三件套:
# 1. 提升 watermark 提前回收, 减少 direct reclaim sysctl -w vm.min_free_kbytes=2097152 # 2GB (64GB 机器) # 2. 降低 swappiness, 避免匿名页被过早换出 sysctl -w vm.swappiness=1 # 趋向于回收 page cache # 3. 扩大 socket buffer 上限, 让 GFP_ATOMIC 有更多可用内存 sysctl -w net.core.rmem_max=16777216 sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216"九、核心参数调优清单
| 参数 | 默认值 | 推荐调整 (64GB+ 服务器) | 适用场景 |
|---|---|---|---|
| vm.min_free_kbytes | 自动计算 | 1-2GB | 减少 direct reclaim |
| vm.swappiness | 60 | 1(Kafka/消息)-10(DB)-30(Java) | 控制匿名vs文件页回收比 |
| vm.vfs_cache_pressure | 100 | 50(文件服务器)-1000(数据库) | 控制 dentry/inode cache 回收 |
| vm.zone_reclaim_mode | 0 | 0(默认),1(NUMA数据库) | 控制 NUMA 本地回收 |
| vm.page-cluster | 3 | 0(SSD swap)-3(HDD swap) | 控制 swap 预读页数 |
| transparent_hugepage | always | madvise(数据库/mixed) | 避免 THP 分裂风暴 |
| kernel.numa_balancing | 1 | 0(已手动绑NUMA的Pod) | AutoNUMA 迁移开销 |
| vm.extfrag_threshold | 500 | 默认即可 | 控制 compaction 激进度 |
十、监控体系与排错工具箱
10.1 一键诊断脚本
#!/bin/bash
# mem_reclaim_diag.sh: 10 秒采集 + 快照诊断
echo "=== 1. Reclaim 活动 ==="
grep -E "pgscan|pgsteal|compact_|numa_" /proc/vmstat
echo "=== 2. swap 活动 ==="
grep -E "pswpin|pswpout|pswpwm" /proc/vmstat
echo "=== 3. slab 占用 ==="
slabtop -o -s c | head -8
echo "=== 4. buddy 碎片 ==="
cat /proc/buddyinfo | awk '{print \$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8,\$9,\$10,\$11,\$12}'
echo "=== 5. Top RSS 进程 ==="
ps aux --sort=-%mem | head -6
echo "=== 6. direct reclaim 频率 (两秒采样) ==="
BEFORE=$(awk '/pgscan_direct/ {print \$2}' /proc/vmstat)
sleep 2
AFTER=$(awk '/pgscan_direct/ {print \$2}' /proc/vmstat)
echo "direct reclaim scan/s: $(( (AFTER-BEFORE)/2 ))"
echo "=== 7. PSI 压力 (如果支持) ==="
cat /proc/pressure/memory 2>/dev/null | head -2
10.2 Prometheus 关键告警规则
# Node 层:
- alert: HighDirectReclaimRate
expr: rate(node_vmstat_pgscan_direct[5m]) > 100
for: 2m
labels: severity: warning
annotations:
summary: "Node {{ \$labels.instance }} direct reclaim 过高"
- alert: SwapStorm
expr: rate(node_vmstat_pswpout[5m]) + rate(node_vmstat_pswpin[5m]) > 50
for: 3m
labels: severity: warning
# cgroup 层:
- alert: ContainerMemoryPressure
expr: rate(container_memory_failcnt[1m]) > 0
for: 1m
labels: severity: critical
# PSI 层:
- alert: MemoryStallPressure
expr: rate(node_pressure_memory_stalled_seconds_total[1m]) > 0.2
for: 3m
labels: severity: warning
结语:从被动救火到主动治理
页面回收与反向映射是 Linux 内存管理中最复杂且最动态的子系统。它们的性能表现取决于工作负载的内存访问模式、NUMA 拓扑、设备速度、容器内存限制等多维因素交织。理解这套机制,意味着你能在以下场景中做出精准判断:
- 为什么 Java 服务的 P99 延迟周期性抖动?— 大概率与 THP/kswapd 争用 rmaps
- 为什么 Kafka 在峰值期延迟雪崩?— 大概率是 direct reclaim + swappiness 配比
- 为什么 Redis 集群 P99.9 偶现>200ms?— 大概率是 khugepaged 拼 THP 的锁竞争
- K8s OOM Kill 的元凶是什么?— 大概率是 cgroup 内的无法回收页面(munlock/hugetlb)
建议的治理优先级:监控先行(PSI + pgscan) → THP 策略 → watermark → swappiness → 碎片整理 → zswap。在容器化时代,让页面回收从偶发的 OOM 恐慌,转变为可预测、可度量、可治理的 SRE 体系的一部分。

发表评论 取消回复