引言:为什么 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/enabled

3.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.swappiness601(Kafka/消息)-10(DB)-30(Java)控制匿名vs文件页回收比
vm.vfs_cache_pressure10050(文件服务器)-1000(数据库)控制 dentry/inode cache 回收
vm.zone_reclaim_mode00(默认),1(NUMA数据库)控制 NUMA 本地回收
vm.page-cluster30(SSD swap)-3(HDD swap)控制 swap 预读页数
transparent_hugepagealwaysmadvise(数据库/mixed)避免 THP 分裂风暴
kernel.numa_balancing10(已手动绑NUMA的Pod)AutoNUMA 迁移开销
vm.extfrag_threshold500默认即可控制 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 体系的一部分。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部