虚拟内存
#系统 · #虚拟内存 · #页表 · #MMU · #缺页 · #mmap
深入理解虚拟内存的地址翻译、页表、MMU、缺页处理、内存映射等核心机制。
1. 虚拟内存的概念
1.1 为什么需要虚拟内存
| 问题 | 虚拟内存如何解决 |
|---|---|
| 物理内存不够用 | 将磁盘当作内存的扩展(交换/分页) |
| 程序间互相干扰 | 每个进程拥有独立的虚拟地址空间 |
| 内存碎片 | 物理上不连续的内存,虚拟地址上看起来连续 |
| 安全隔离 | 页表权限位阻止非法访问 |
| 简化编程模型 | 每个程序似乎拥有完整的地址空间 |
1.2 虚拟地址 vs 物理地址
mermaid
graph LR
CPU -->|"虚拟地址 (VA)"| MMU["MMU<br/>内存管理单元"]
MMU -->|"物理地址 (PA)"| MEM["物理内存"]
MMU -.->|"缺失时"| DISK["磁盘(交换空间)"]2. 地址翻译
2.1 页(Page)与页框(Page Frame)
- 虚拟内存被划分为固定大小的页(通常 4KB)
- 物理内存被划分为同样大小的页框
- 页是虚拟内存管理的基本单位
2.2 页表(Page Table)
页表是虚拟页号(VPN)到物理页号(PPN)的映射表。每个进程拥有独立的页表。
虚拟地址: [ 虚拟页号 VPN | 页内偏移 VPO ]
│
┌────┘
▼
[页表基址寄存器 (CR3)]
│
┌────┘
▼
物理地址: [ 物理页号 PPN | 页内偏移 PPO ]2.3 页表条目(PTE)
PTE 结构 (x86-64, 64位):
┌────────────────────────────────────────────────────────────┐
│ PPN(物理页号) │ ... │ D │ A │ PCD│PWT│U/S│R/W│ P │
└────────────────────────────────────────────────────────────┘
位63-12 位11-6 位5 位4 位3 位2 位1 位0
P (Present): 页面是否在物理内存中
R/W: 0=只读, 1=可读写
U/S: 0=内核, 1=用户
A (Accessed): 是否被访问过(由MMU自动设置)
D (Dirty): 是否被写过(由MMU自动设置)2.4 多级页表
为什么需要多级页表?
如果使用单级页表,48 位虚拟地址空间(4KB 页)需要
四级页表方案(x86-64):
48位虚拟地址划分:
┌────────┬────────┬────────┬────────┬────────────┐
│ PML4 │ PDPT │ PD │ PT │ Offset │
│ 9位 │ 9位 │ 9位 │ 9位 │ 12位 │
└────────┴────────┴────────┴────────┴────────────┘
四级页表遍历:
CR3 → PML4[PML4_idx] → PDPT[PDPT_idx] → PD[PD_idx] → PT[PT_idx] → 物理页
其中 PD 条目可以映射 2MB 大页(跳过最后一级)
PDPT 条目可以映射 1GB 大页(跳过最后两级)为什么恰好是 9+9+9+9+12 位?——完整数学推导:
Step 1: 确定页大小和 offset 位数
页大小 = 4KB = 2¹² 字节 → offset 需要 12 位
Step 2: 确定需要几级
48 位虚拟地址 - 12 位 offset = 36 位 VPN (Virtual Page Number)
需要把 36 位 VPN 切分成若干级,每级查一个页表
Step 3: 确定每级多少位
每个 PTE 的大小 = 8 字节 (64 位 / 8 字节)
每张页表大小 = 4KB (一页),与物理页大小对齐 → 便于分配和换出
每张页表能存多少个 PTE = 4KB / 8B = 512 个 → 需要 9 位索引 (2⁹ = 512)
Step 4: 计算需要的级数
36 位 VPN ÷ 9 位/级 = 4 级
→ 恰好是 4 级页表!
如果页大小是 2MB (大页),offset 需要 21 位:
48 - 21 = 27 位 VPN → 需要 3 级 (9+9+9)
如果页大小是 1GB (巨页),offset 需要 30 位:
48 - 30 = 18 位 VPN → 需要 2 级 (9+9)
所以四级页表不是随意设计的,而是 "48位地址空间 + 4KB页 + 8B PTE + 每级页表占用1页"
这四个约束共同决定的必然结果。
一张页表只占 4KB 而不是 512GB 的原因:
多级页表的核心思想是"按需创建"——进程实际使用的地址空间通常只占 48 位空间的一小部分
不需要的中间级页表根本不会分配,大幅节省内存mermaid
graph TD
CR3["CR3 寄存器"] --> PML4["PML4 页表<br/>(每进程1个, 512条目)"]
PML4 --> PDPT["PDPT 页表<br/>(512条目)"]
PDPT --> PD["PD 页表<br/>(512条目)"]
PD --> PT["PT 页表<br/>(512条目)"]
PT --> PAGE["物理页 (4KB)"]2.5 TLB
TLB(Translation Lookaside Buffer)是 MMU 内部的缓存,缓存最近使用的虚拟地址→物理地址翻译。详情请参阅 CPU 微架构与 TLB/Cache。
3. 缺页(Page Fault)
3.1 缺页处理流程
mermaid
sequenceDiagram
participant CPU
participant MMU
participant Kernel
participant Disk
CPU->>MMU: 请求虚拟地址 VA
MMU->>MMU: 查 TLB
alt TLB 命中
MMU-->>CPU: 返回 PA
else TLB 未命中
MMU->>MMU: 遍历页表
alt PTE P=1 (在内存中)
MMU->>MMU: 填充 TLB
MMU-->>CPU: 返回 PA
else PTE P=0 (不在内存中)
MMU->>CPU: 触发缺页异常
CPU->>Kernel: 进入缺页处理程序
Kernel->>Kernel: 选择牺牲页
alt 牺牲页是脏页
Kernel->>Disk: 写回牺牲页
end
Kernel->>Disk: 读入目标页
Kernel->>Kernel: 更新页表
Kernel-->>CPU: 返回,重新执行指令
end
end3.2 缺页类型
| 类型 | 原因 | 处理方式 |
|---|---|---|
| Demand Paging | 首次访问未分配物理页的页面 | 从磁盘读取或分配零页 |
| Swap Fault | 页面被换出到交换分区 | 从交换分区读回 |
| COW Fault | 写入共享页面(fork 后的写时复制) | 复制页面 |
| Protection Fault | 违反页权限(写入只读页) | 发送 SIGSEGV 信号 |
| Invalid Fault | 访问非法地址 | 发送 SIGSEGV 信号 |
4. 内存映射(mmap)
4.1 mmap 原理
mmap() 将文件或匿名内存映射到进程的虚拟地址空间。
c
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);4.2 两种映射类型
| 类型 | 描述 | 数据来源 |
|---|---|---|
| 文件映射 | 文件内容映射到虚拟内存 | 磁盘文件 |
| 匿名映射 | 没有对应文件 | 全零页面(demand-zero) |
4.3 共享 vs 私有映射
| 组合 | 行为 | 示例 |
|---|---|---|
| 私有文件映射 | 写时复制,修改不写回文件 | 加载 .so、可执行文件 |
| 共享文件映射 | 修改会写回文件,其他进程可见 | 进程间共享内存 |
| 私有匿名映射 | 写时复制,不共享 | malloc(大块)、进程栈 |
| 共享匿名映射 | 父子进程共享同一物理页 | fork 后共享内存 |
4.4 mmap 与 read 的对比
| 维度 | mmap | read/write |
|---|---|---|
| 数据拷贝次数 | 1次(磁盘→页缓存) | 2次(磁盘→页缓存→用户缓冲区) |
| 适合场景 | 随机访问、大文件 | 顺序读写、小文件 |
| 缺页开销 | 每次访问新页可能触发 | 无 |
| 内存占用 | 占用虚拟地址空间 | 不占用 |
5. 交换(Swapping)
5.1 交换机制
当物理内存不足时,内核将部分页面换出到磁盘(交换分区或交换文件)。
Linux 使用 swap cache 来协调并发访问被换出的页面。
5.2 Linux 页面回收
LRU(最近最少使用)链表:
活跃链表 (Active LRU) ← 经常被访问的页面
非活跃链表 (Inactive LRU) ← 不太被访问的页面(换出候选)kswapd(内核交换守护进程):在内存水位低于阈值时唤醒,将非活跃链表中的页面换出。
5.3 内存水位线
高水位 (high) ───────── 空闲内存充足
低水位 (low) ───────── kswapd 开始回收
最小水位 (min) ──────── 触发直接回收 (Direct Reclaim)
分配者阻塞,等待回收5.4 OOM Killer
当内存彻底耗尽无法回收时,OOM Killer 根据 oom_score 选择并杀死进程。
5.5 页面置换算法 — 完整对比
当物理内存不足需要将页面换出到磁盘时,选择哪个页面换出直接影响系统性能。越接近 OPT(理论最优),缺页次数越少。
五大经典算法
text
OPT (Optimal, 理论最优) — 无法实现,仅作为理论下界:
策略: 淘汰"将来最长时间不再被访问"的页面
实现: 不可能(需要预知未来)
用途: 衡量其他算法的效率差距
FIFO (First In First Out):
策略: 淘汰最早进入内存的页面(队列)
实现: 一个 FIFO 队列
优点: 实现极简单
缺点: 可能淘汰频繁访问的旧页面
Belady 异常: FIFO 独有的反直觉现象
内存帧数增加 → 缺页次数反而增多!
原因: 增加帧数改变了淘汰顺序,反而淘汰了更多将要访问的页面
LRU、Clock、OPT 不存在 Belady 异常(属于栈算法)
LRU (Least Recently Used):
策略: 淘汰最久未被访问的页面
实现: 链表 (每次访问移到头部,淘汰尾部) 或矩阵计数器
优点: 接近 OPT,利用时间局部性
缺点:
- 每次访问都要更新链表 → 硬件开销大
- 需要额外的访问位记录时间戳
Clock (近似 LRU, Second Chance):
策略: 近似 LRU,给每个页面一个"访问位"
实现:
页面排成环形链表,指针循环扫描:
1. 访问位 = 0 → 淘汰该页面
2. 访问位 = 1 → 清零,给第二次机会,指针前进
优点: 硬件只需 1 bit 访问位,MMU 自动设置
现实: Linux 和大多数 OS 的实际选择
Enhanced Clock (改进型 Clock):
策略: 同时考虑"访问位"和"修改位"
优先级 (越小越先淘汰):
(0,0) 未访问未修改 → (0,1) 未访问已修改 → (1,0) 已访问未修改 → (1,1) 已访问已修改
优点: 优先淘汰干净页(无需写回磁盘),性能更好算法对比
| 算法 | 缺页率 | 实现复杂度 | 硬件支持 | Belady异常 | 实际使用 |
|---|---|---|---|---|---|
| OPT | 最低 | — | — | ❌ | 理论基准 |
| FIFO | 中-高 | 极简单 | 不需要 | ✅ 存在 | 极少 |
| LRU | 低 | 高 | 需要时间戳 | ❌ | 早期系统 |
| Clock | 接近LRU | 低 | 1 bit 访问位 | ❌ | Linux 内核 |
| Enhanced Clock | 接近LRU | 中 | 访问+修改位 | ❌ | Linux 变体 |
Linux 实际实现
text
Linux 不使用页级 Clock,而是基于 LRU 链表的两级回收:
活跃链表 (Active LRU):
- 频繁被访问的页面
- 匿名页 (anon): 进程堆/栈
- 文件页 (file): 文件映射页
非活跃链表 (Inactive LRU):
- 不太被访问的页面(换出候选)
- kswapd 优先回收此链表
页面迁移:
首次访问 → Inactive (不确定是否热)
再次访问 → Active (确认是热的)
长时间未访问 → 降级回 Inactive
内存压力 → kswapd 从 Inactive 尾部回收
为什么是两链表而非严格 LRU?
- 严格 LRU 每次访问都需更新链表位置 → 锁争用严重
- 两链表"懒惰"升级/降级 → 锁成本低,适合多核
- 实际效果接近 LRU,实现却简单得多7. NUMA 架构与内存分配
7.1 为什么需要 NUMA
在 SMP(对称多处理)架构中,所有 CPU 共享一条内存总线。当 CPU 数量增加时,总线成为瓶颈:
SMP(对称多处理): NUMA(非一致性内存访问):
┌───┬───┬───┬───┐ ┌───────┐ ┌───────┐
│CPU│CPU│CPU│CPU│ │CPU CPU│ │CPU CPU│
└─┬─┴─┬─┴─┬─┴─┬─┘ │ Node0 │ │ Node1 │
│ │ │ │ │本地内存│ │本地内存│
└───┴───┴───┘ └───┬───┘ └───┬───┘
═══ 共享总线 ═══ └─── 互联总线 ───┘
(瓶颈!)7.2 NUMA 的内存访问延迟
同一 NUMA Node(本地访问):~100ns
跨 NUMA Node(远端访问): ~150-200ns(1.5-2x 延迟)
分配策略:
--localalloc: 默认,在发起访问的 CPU 所在 Node 上分配
--membind: 强制绑定到指定 Node
--interleave: 轮询分配到所有 Node(避免单 Node 内存耗尽)7.3 诊断与调优
bash
# 查看 NUMA 拓扑
numactl --hardware
lscpu | grep NUMA
# 查看进程的 NUMA 内存分布
numastat -p <pid>
# 绑定进程到指定 NUMA Node
numactl --cpunodebind=0 --membind=0 ./my_program
# 交错分配(适用于内存分配 > 单 Node 容量的场景)
numactl --interleave=all ./my_program7.4 Go 中的 NUMA 意识
Go runtime 默认不完全 NUMA-aware,但有一些优化:
- Go 1.10+:
GOMAXPROCS按 Node 分配 P(Processor),尽量不跨 Node - 可以通过
taskset/numactl从外部绑定 - 数据库等场景建议用
numactl --interleave=all避免单 Node 内存瓶颈
bash
# 把 Go 程序绑定到 Node 0
taskset -c 0-15 numactl --membind=0 ./my_go_server
# 数据库(如 TiKV/RocksDB)推荐交错
numactl --interleave=all ./tikv-server8. 大页(Huge Pages)
6.1 大页的优势
| 指标 | 4KB 页 | 2MB 大页 |
|---|---|---|
| 映射 1GB 需要的 PTE 数 | 262,144 | 512 |
| TLB 覆盖(400条 L2 TLB) | 1.6MB | 800MB |
| 缺页处理开销(单页) | 相同 | 相同 |
| 内部碎片风险 | 低 | 高 |
6.2 透明大页(THP)
Linux THP 自动将连续的 4KB 页合并为 2MB 大页,对应用透明。
THP 为什么会引发延迟毛刺?
虽然 THP 减少了 TLB miss,但它的后台维护操作会引入不可预测的延迟抖动——这对 Redis、JVM、Go 运行时等延迟敏感的应用是致命的。
THP 的后台维护操作有两个阶段:
1. khugepaged(内核线程)扫描进程内存,寻找可以合并的 4KB 连续页:
- 扫描频率: 每 10 秒一次(可配置)
- 合并条件: 2MB 对齐 + 512 个连续 4KB 页全部存在
- 合并操作: 分配 2MB 新页 → 拷贝 512 个 4KB 页 → 更新页表
2. Compaction Stall(压缩停顿)—— 真正的延迟杀手:
当应用申请大页但找不到连续的 2MB 空间时:
- 内核暂停应用的分配请求(stall!)
- 启动内存压缩(compaction):移动 4KB 页来腾出连续的 2MB 空间
- 这个过程可能耗时数毫秒到数十毫秒
- 应用线程在此期间完全被阻塞mermaid
sequenceDiagram
participant App as 应用线程
participant Kernel as 内核
participant Memory as 物理内存
Note over Memory: 4KB 页碎片化<br/>无法找到连续 2MB
App->>Kernel: malloc() / mmap() 请求内存
Kernel->>Memory: 尝试分配 2MB 连续页
Memory-->>Kernel: ❌ 没有连续 2MB
rect rgb(255, 200, 200)
Note over App: ⚠️ COMPACTION STALL
Kernel->>Memory: 启动内存压缩<br/>移动 4KB 页 → 腾出连续空间
Note over Memory: 耗时: 1ms ~ 100ms+
Note over App: 应用线程被暂停!
end
Memory-->>Kernel: ✅ 连续 2MB 已就绪
Kernel->>Memory: 分配 2MB 大页
Kernel-->>App: 返回内存
Note over App: 应用继续执行<br/>但 P99 延迟已经飙高为什么 Redis 官方强烈建议关闭 THP?
text
Redis 的特征:
- 内存分配频繁(写操作不断产生新 key / 修改 value)
- fork() 产生子进程写 RDB / AOF rewrite
- 延迟极度敏感(P99 通常要求 < 1ms)
THP 在 Redis 场景下的问题:
1. fork() 时 COW 触发大量 4KB 页写入 → 内存碎片化
2. Redis 后续 malloc() → 内核尝试分配 2MB 大页
3. compaction stall → Redis 主线程暂停
4. P99 延迟从 <1ms → 数十甚至数百毫秒
这就是 Redis 官方日志中那句经典的警告:
"WARNING you have Transparent Huge Pages (THP) support enabled in your kernel.
This will create latency and memory usage issues with Redis."bash
# Redis 官方建议:关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 查看当前 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never ← 当前配置不同场景的 THP 建议
| 场景 | 建议 | 原因 |
|---|---|---|
| Redis | ❌ 关闭 (never) | 延迟敏感,fork + COW 触发频繁碎片 |
| Memcached | ❌ 关闭 | slab 分配器,与 THP 不兼容 |
| MongoDB | ❌ 关闭 (官方建议) | 内部使用 mmap,THP 干扰内存管理 |
| JVM (大堆) | ⚠️ madvise | 应用自己控制大页分配时机 |
| Go 服务 (大堆 10GB+) | ⚠️ madvise | Go runtime 用 madvise 建议内核使用大页 |
| 数据库 buffer pool | ⚠️ madvise | 稳定运行后手动触发,而非自动 |
| 批处理 / 离线计算 | ✅ always | 延迟不敏感,TLB 命中率收益大 |
THP 延迟毛刺的监控
bash
# 1. 检查 compaction stall 事件
perf stat -e compaction:migration:mm_compaction_migratepages \
-e compaction:begin:mm_compaction_begin \
-e compaction:end:mm_compaction_end \
-p $PID sleep 30
# 2. 查看 khugepaged 活动
cat /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs
# 3. 查看 THP 使用情况
grep -E "AnonHugePages|ShmemHugePages" /proc/meminfo
cat /proc/vmstat | grep thp
# thp_fault_alloc: 通过缺页分配的大页数
# thp_collapse_alloc: 通过压缩合并的大页数(这个值高 = 频繁 compaction)
# thp_split_page: 大页被分割的次数madvise 模式:应用自主控制
madvise 模式下,只有应用通过 madvise() 系统调用明确建议的地址范围,内核才会尝试分配大页。这给了应用精细控制的能力:
go
// Go runtime 在初始化 heap arena 时使用 madvise 建议大页
import "golang.org/x/sys/unix"
func allocWithHugePage(size int) ([]byte, error) {
// 分配内存
data, err := unix.Mmap(-1, 0, size,
unix.PROT_READ|unix.PROT_WRITE,
unix.MAP_ANONYMOUS|unix.MAP_PRIVATE)
if err != nil { return nil, err }
// 建议内核:这个区域适合使用大页
unix.Madvise(data, unix.MADV_HUGEPAGE)
return data, nil
}一句话总结:THP 是"空间换时间"的策略——用内存碎片和 compaction stall 换取更少的 TLB miss。对延迟敏感的应用(Redis/JVM/Go 服务),这个代价通常不划算,建议使用
madvise模式或关闭。
7. 虚拟内存实战
7.1 TLB — 地址翻译的"CPU 缓存"
每次内存访问都要查页表(多级查找,4 次内存访问),CPU 无法承受。TLB (Translation Lookaside Buffer) 是 MMU 内部的专门缓存,缓存最近用过的"虚拟页→物理页"映射:
mermaid
flowchart TB
VA["虚拟地址"] --> TLB{"TLB 命中?"}
TLB -->|"✅ 命中 (99%+)"| PA1["直接得到物理地址<br/>延迟: ~0.5ns (L1 TLB)"]
TLB -->|"❌ 未命中"| PM["页表遍历 (Page Walk)"]
PM -->|"4 级页表 (4 次内存访问)"| PA2["得到物理地址<br/>延迟: ~100ns<br/>并缓存到 TLB"]| TLB 层级 | 条目数 | 延迟 | 覆盖范围 (4KB页) |
|---|---|---|---|
| L1 iTLB (指令) | 64-128 | ~0.5 ns | 256-512 KB |
| L1 dTLB (数据) | 64-128 | ~0.5 ns | 256-512 KB |
| L2 STLB (共享) | 1536 | ~5 ns | 6 MB |
TLB miss 的代价:一次 L1 TLB miss + L2 STLB 命中约 5ns;一次全 miss + 4 级页表遍历约 100ns——是 L1 命中的 200 倍。这也是为什么大页 (2MB) 能把 TLB 覆盖率提升 512 倍(一个 TLB 条目覆盖 2MB 而非 4KB)。
7.2 大页(HugePages)在 Go/Java 中的应用
| 大页类型 | 页大小 | 特点 | 使用方式 |
|---|---|---|---|
| HugeTLB | 2MB / 1GB | 需显式预留,不会换出 | echo 512 > /proc/sys/vm/nr_hugepages |
| THP (透明大页) | 2MB | 内核自动合并,对应用透明 | echo always > /sys/kernel/mm/transparent_hugepage/enabled |
Go 中的大页:Go runtime 在初始化时通过 madvise(MADV_HUGEPAGE) 建议内核使用大页,但实际是否分配大页取决于 THP 配置。对于 1GB+ 堆的 Go 服务,启用 THP 可减少 10-20% 的 TLB miss,但会增加内存碎片(内部碎片 2MB-实际使用)。
Java 中的大页:JVM 通过 -XX:+UseLargePages 启用,G1GC/ZGC 等使用大堆时提升明显。
7.3 mmap — 文件到内存的映射
mmap 将文件直接映射到进程虚拟地址空间,访问文件像访问内存一样:
go
import "golang.org/x/sys/unix"
func mmapRead(path string) ([]byte, error) {
f, _ := os.Open(path)
defer f.Close()
fi, _ := f.Stat()
size := int(fi.Size())
data, err := unix.Mmap(int(f.Fd()), 0, size,
unix.PROT_READ, unix.MAP_SHARED)
return data, err
}| 维度 | mmap | read/write |
|---|---|---|
| 数据拷贝 | 0 次(直接映射 Page Cache) | 2 次(内核→用户) |
| 系统调用 | 0(映射后直接访问内存) | 每次 read/write 都调用 |
| 页面调入 | 按需(缺页时加载,惰性) | 立即读入 |
| 内存占用 | 占用虚拟地址空间 | 仅读入时占用物理内存 |
| 适合 | 大文件随机读、持久化队列 | 小文件、流式顺序访问 |
| 不适合 | 网络 IO、管道、频繁写入 | — |
Kafka/RocketMQ 为何用 mmap:消息队列的日志段文件需要频繁随机读取(consumer 可能在任意 offset),mmap 避免了 read() 的 syscall 和数据拷贝,同时利用操作系统 Page Cache 的 LRU 淘汰策略。写入时结合
write()+flush(mmap 不适合直接写入,因为缺页处理会导致写停滞)。
bash
# /proc/PID/maps: 进程的虚拟地址空间布局
cat /proc/self/maps
# pmap: 更友好的展示
pmap -x <PID>7.2 常见问题诊断
| 现象 | 可能原因 |
|---|---|
| RSS 远小于 VSZ | 大量内存被分配但未实际使用 |
| 频繁 swap in/out(用 vmstat 查看) | 内存不足,需扩容或优化 |
| SIGSEGV | 访问非法地址或违反保护 |
| OOM | 内存泄漏或配置不足 |
9. 生产系统中的虚拟内存故障
9.1 Minor Fault 和 Major Fault 差别为什么这么大
缺页不等于一定要读磁盘。
| 类型 | 含义 | 代价 |
|---|---|---|
| Minor Fault | 页不在当前进程页表里,但物理页已在内存中 | 相对可控 |
| Major Fault | 需要真正从磁盘或 swap 读入页面 | 代价极高,可能拖垮 RT |
所以线上看到 page fault 不能直接恐慌,关键是分清:到底只是页表层面的“补登记”,还是已经进入磁盘 I/O 级别的慢路径。
9.2 为什么 swap 抖动会把应用拖成“看起来 CPU 不高但特别慢”
mermaid
flowchart LR
A["工作集超过物理内存"] --> B["kswapd / direct reclaim"]
B --> C["匿名页或页缓存被回收"]
C --> D["再次访问时发生缺页"]
D --> E["需要从磁盘或 swap 读回"]
E --> F["请求线程阻塞"]
F --> G["RT 飙高,CPU 不一定高"]这类问题的典型误判是:
- 应用 CPU 不高,以为不是本机问题
- 网络看起来也正常
- 但请求就是越来越慢
实际根因可能是:
- 内存打满,进入 direct reclaim
- 匿名页被 swap out
- 热数据工作集已经放不进内存
9.3 页缓存、匿名内存、mmap 为什么要放在一起看
在 Linux 里,内存不是“只有应用堆”这么简单。
| 类型 | 常见来源 | 关注点 |
|---|---|---|
| 匿名内存 | Go heap、线程栈、malloc、大对象 | GC、栈膨胀、对象保活 |
| 页缓存(Page Cache) | 文件读写、数据库、日志、mmap 文件 | 是否挤压匿名内存 |
| 文件映射 | mmap、共享库、数据库数据文件 | 缺页行为、回收策略 |
很多线上问题的本质是三者互相争抢:
- 应用堆太大,挤压 page cache
- page cache 太激进,导致匿名页回收压力上升
- mmap 文件访问稀疏,导致 major fault 多
9.4 一个常见链路:应用变慢不一定是 GC,也可能是虚拟内存
以 Go 服务为例,用户看到的可能只是 P99 抖动,但背后的链路可能是:
text
流量上涨
→ goroutine / heap / page cache 一起涨
→ 内存接近上限
→ reclaim / swap / major fault 增多
→ handler 卡在缺页和磁盘等待
→ P99 升高,超时增多这类场景特别容易和 GC、数据库慢查询、网络抖动混淆,所以必须联合看:
heap/ RSSpgfault/pgmajfault- swap in/out
- 磁盘延迟
9.5 排障 Runbook
先看全局是否在抖
bash
vmstat 1
sar -B 1
sar -W 1
free -h
cat /proc/meminfo重点观察:
si/so:swap in/outpgmajfault:major faultwa:I/O waitfree/available:可用内存是否过低
再看进程维度
bash
pidstat -r -p <pid> 1
pmap -x <pid>
cat /proc/<pid>/smaps_rollup
numastat -p <pid>如果怀疑缺页影响性能
bash
perf stat -e page-faults,minor-faults,major-faults -p <pid> sleep 109.6 调优原则
| 问题 | 更合理的方向 |
|---|---|
| major fault 多 | 降低工作集、减少稀疏访问、给足内存 |
| swap 抖动 | 控制内存峰值,必要时调低 swappiness |
| page cache 挤压匿名内存 | 分析文件 I/O 模式、限流、冷热分层 |
| NUMA 远端访问多 | 调整绑核绑内存策略 |
一个重要原则是:不要一看到内存问题就先调内核参数。更高优先级通常是先搞清楚:到底是匿名内存过大、page cache 过大、还是 mmap/访问模式导致工作集根本装不下。
10. COW(Copy-on-Write)的工程影响
10.1 COW 的基本机制
mermaid
flowchart LR
subgraph BEFORE["fork() 之前"]
P["父进程页表"] --> PHY["物理页 (共享)"]
end
subgraph AFTER["fork() 之后"]
PA["父进程页表"] --> PHY2["物理页 (共享, 只读标记)"]
CH["子进程页表"] --> PHY2
end
subgraph WRITE["任一方写入时"]
PA2["父进程页表"] --> PHY3["原物理页"]
CH2["子进程页表"] --> PHY4["新复制的物理页"]
endfork() 不会立即复制所有内存,而是让父子进程共享同一组物理页,并把这些页标记为只读。当任一方尝试写入时,触发 COW 缺页,内核才真正复制那一页。
10.2 Redis BGSAVE 时 COW 导致内存翻倍
这是线上最常见的 COW 问题之一。
mermaid
sequenceDiagram
participant R as Redis 主进程
participant C as 子进程 (BGSAVE)
participant K as 内核
R->>K: fork()
K->>K: 父子共享所有物理页 (COW 标记)
K-->>C: 子进程开始遍历数据写 RDB
rect rgb(255, 240, 240)
Note over R: 主进程继续处理写请求
R->>K: 写入某个 key
K->>K: COW 触发: 复制该页
Note over K: 每写一页就多占一页物理内存
end
Note over K: 如果写入量大 → 几乎所有页都被复制<br/>→ 内存接近 2×为什么 Redis 的 COW 特别严重?
| 因素 | 说明 |
|---|---|
| Redis 数据全在内存 | 堆很大,COW 的潜在复制量 = 整个数据集 |
| 写入频繁 | 每秒大量 SET/INCR/LPUSH → 大量页被修改 |
| jemalloc 的页布局 | 一个 key 的修改可能触发整个 4KB 页的复制 |
| 子进程存活时间长 | RDB 写盘慢 → COW 窗口长 → 累积复制量大 |
实际数值:
text
Redis 使用 10GB 内存
BGSAVE 期间写入 QPS = 50000
每次写入平均触发 1 个新页的 COW
50000 × 4KB = 200MB/s 的额外内存增长
如果 BGSAVE 持续 30s → 额外 6GB
极端情况: 内存从 10GB → 接近 20GB → OOM应对策略:
| 策略 | 做法 |
|---|---|
| 控制写入量 | BGSAVE 期间降低写入 QPS |
| 预留内存 | 容器 memory limit 至少留 1.5× 数据量 |
| 用 AOF 替代 RDB | appendonly yes + aof-use-rdb-preamble yes |
| 调整 BGSAVE 频率 | 减少 fork 次数 |
| 监控 COW 页数 | info persistence 中的 latest_fork_usec 和 rdb_last_cow_size |
10.3 Go runtime 与 fork/COW
Go 程序很少直接 fork()(因为 goroutine 和多线程不兼容 fork),但以下场景仍然涉及 COW:
| 场景 | 说明 |
|---|---|
os/exec.Command | 内部 fork+exec,fork 瞬间 COW 标记整个堆 |
| 容器内 Go 服务 | 容器 memory limit 需要考虑 fork 瞬间的 COW 峰值 |
| cgo 调用 fork | 如果 cgo 库内部 fork,可能触发 COW |
Go 1.20+ 在 Linux 上使用 CLONE_VM + CLONE_VFORK 来避免完整 fork 的 COW 开销(os/exec 路径)。
10.4 容器 memory limit 与 COW 的关系
text
容器 memory limit = 4GiB
Redis 数据 = 3GiB
BGSAVE 触发 fork
fork 瞬间:
父子共享 3GiB 物理页
RSS 报告 ≈ 3GiB(共享页只计一次)
BGSAVE 期间写入:
每写一页 → COW → 物理内存 +4KB
如果写了 1GiB 的页 → 物理内存 = 3GiB + 1GiB = 4GiB
→ 触发 OOM → 容器被杀所以容器里跑 Redis/MySQL 等会 fork 的服务,memory limit 必须留足 COW 余量。
经验公式:
text
memory_limit >= 数据量 × (1 + 预期COW比例) + 其他开销
Redis: memory_limit >= used_memory × 1.5 ~ 2.0
MySQL (InnoDB): fork 较少,但 buffer pool 大时也要注意10.5 COW 与 MADV_FREE / MADV_DONTNEED
Go runtime 归还内存给 OS 时,有两种方式:
| 方式 | 行为 | COW 影响 |
|---|---|---|
MADV_DONTNEED | 立即释放物理页,下次访问触发缺页 | 无 COW 问题 |
MADV_FREE | 标记为"可回收",但不立即释放 | fork 时这些页仍然参与 COW |
Go 1.12-1.15 默认使用 MADV_FREE(RSS 不降),Go 1.16+ 改回 MADV_DONTNEED(RSS 能降)。
这个选择直接影响:
- 容器内 RSS 监控的准确性
- fork 时的 COW 内存峰值
- OOM 判断的时机
10.6 排障:怎么判断是不是 COW 导致的内存问题
bash
# 1. 看 fork 时的 COW 页数(Redis)
redis-cli info persistence | grep cow
# 2. 看进程的共享/私有内存
cat /proc/<pid>/smaps_rollup
# Shared_Clean / Shared_Dirty / Private_Clean / Private_Dirty
# 3. 看 fork 前后的内存变化
watch -n 1 'cat /proc/<pid>/status | grep -E "VmRSS|VmSize"'
# 4. 看容器内存事件
dmesg | grep -i oom
cat /sys/fs/cgroup/memory/memory.failcnt
登录后即可发表评论 👇