本文基于《Systems Performance》第7章整理,面向希望深入理解Linux内存机制的读者。
目标:把复杂的内存概念用最通俗的语言讲清楚。

目录

  1. 为什么内存性能很重要
  2. 核心术语速查
  3. 关键概念详解
  4. 硬件架构
  5. 软件架构
  6. 进程虚拟地址空间
  7. 内存分析方法论
  8. 常用观测工具
  9. 调优参数
  10. 综合示例分析

1. 为什么内存性能很重要

可以把计算机的存储层级想象成一个仓库体系:

CPU寄存器    <- 柜台(纳秒级,极快)
L1/L2/L3缓存 <- 仓库前台(纳秒级,很快)
主内存(RAM)  <- 仓库(纳秒~微秒,快)
磁盘/SSD     <- 远程货仓(毫秒级,慢100~10000倍)

当主内存装满时,系统被迫把数据换到磁盘上,速度骤降。这就是内存性能的核心问题。
内存满了会发生什么?

  • 系统开始把内存数据写到磁盘(换页/交换),性能急剧下降
  • 极端情况下,内核会直接杀掉占用最多内存的进程(OOM Killer)

2. 核心术语速查


术语 通俗解释
主内存 (Main Memory) 物理RAM,掉电丢失
虚拟内存 (Virtual Memory) 给每个进程的"假地址空间",几乎无限大(无限大什么意思错了吧)
常驻内存 (Resident Memory) 真正住在RAM里的那部分内存
匿名内存 (Anonymous Memory) 没有对应文件的内存,比如堆
页 (Page) 内存管理的基本单位,通常4KB
页错误 (Page Fault) 访问了还没映射到物理内存的虚拟地址
换页 (Paging) 把页在RAM和磁盘之间搬来搬去
交换 (Swapping) Linux中特指把匿名页换到swap分区
OOM 内存耗尽,Out Of Memory
TLB 地址转换缓存,加速虚拟->物理地址查找
RSS 进程实际占用的物理内存大小
VSZ 进程虚拟地址空间总大小
WSS 工作集大小,进程频繁访问的那部分内存

3. 关键概念详解

3.1 虚拟内存——给每个进程一个"独立王国"

虚拟内存是操作系统给每个进程的一个幻觉:让每个进程都以为自己独占一大片连续内存。

进程A看到的世界:          进程B看到的世界:
  地址 0x0000...           地址 0x0000...
  [完整的地址空间]          [完整的地址空间]
        |                        |
        v                        v
     物理RAM(实际上两者共享,由OS管理映射)

好处:

  • 进程之间相互隔离,A崩溃不影响B
  • 可以超额分配(分配比RAM更多的内存)
  • 简化程序员的内存管理工作

3.2 换页(Paging)——精细化内存调度

换页是以"页"为单位(通常4KB)在RAM和磁盘之间搬运数据。
分为两种:
文件系统换页("好"的换页)
把内存映射文件的页在RAM和磁盘间移动。页若未被修改(干净页),直接丢弃即可,因为磁盘上已有副本。
匿名换页("坏"的换页,即Linux的swap)
把进程堆/栈数据写到swap分区。这很糟糕,因为:

  • 进程访问已换出的页时,必须等待磁盘读取(同步等待!)
  • 现代应用动辄几百MB堆,换页延迟极高
最优状态: 所有工作数据都在RAM中,无换页
可接受:   文件缓存被换出,应用RAM保持
危险状态: 匿名页被换出,应用感受到卡顿
崩溃状态: OOM Killer开始杀进程

3.3 按需分页(Demand Paging)——懒惰是美德

程序调用 malloc() 申请内存时,OS并不立刻分配物理内存,而是等到真正访问时才分配。

malloc(1MB)
   |
   v
OS: "好的,记录一下,但先不分配物理页"
   |
   v
程序写入这块内存
   |
   v
MMU: "这个虚拟地址没有物理映射!"-> 触发页错误(Page Fault)
   |
   v
OS处理页错误: 找一块空闲物理页,建立映射
   |
   v
程序继续执行(对程序透明)

页错误的两种类型:

  • 次要页错误 (Minor Fault):从现有内存满足映射,无需读磁盘,很快
  • 主要页错误 (Major Fault):需要从磁盘读数据,很慢(毫秒级)
    页的四种状态:
A. 未分配          (没有申请过)
B. 已分配未映射    (malloc了但还没访问)
C. 已分配映射到RAM (正在使用)
D. 已分配映射到磁盘(被换出了)

由此定义两个常见指标:
RSS = 状态C的页总大小 \text{RSS} = \text{状态C的页总大小} RSS=状态C的页总大小
VSZ = 状态B + 状态C + 状态D的页总大小 \text{VSZ} = \text{状态B} + \text{状态C} + \text{状态D的页总大小} VSZ=状态B+状态C+状态D的页总大小

3.4 过度提交(Overcommit)

Linux默认允许分配比实际RAM+Swap更多的虚拟内存,依赖"大多数程序不会真正用完申请的内存"这一假设。

场景:系统RAM=4GB,Swap=2GB
程序A申请10GB: malloc成功!(过度提交)
程序B申请10GB: malloc成功!(还是过度提交)
...
真正开始大量使用时 -> OOM Killer出动

3.5 工作集大小(WSS)

WSS是进程频繁访问的那部分内存。
性能优    ⟺    WSS ≤ CPU缓存大小 \text{性能优} \iff \text{WSS} \leq \text{CPU缓存大小} 性能优WSSCPU缓存大小
性能差    ⟺    WSS > RAM大小(需要swap) \text{性能差} \iff \text{WSS} > \text{RAM大小(需要swap)} 性能差WSS>RAM大小(需要swap

4. 硬件架构

4.1 DRAM——主内存的物理实现

DRAM(动态随机存取内存):

  • 每个比特只需一个电容+一个晶体管,密度高
  • 电容需要周期性刷新(dynamic的由来)
  • 掉电数据丢失(volatile)
  • 典型访问延迟(CAS延迟):10~20纳秒(DDR4)

4.2 UMA vs NUMA——多处理器内存架构

UMA(统一内存访问)——所有CPU访问内存的延迟相同

CPU1 ----+
         |---- 系统总线 ---- 内存控制器 ---- DRAM
CPU2 ----+

NUMA(非统一内存访问)——CPU访问"近端"内存比"远端"快

[CPU1] -- 本地总线 -- [DRAM A]
   |
CPU互联(QPI/InfinityFabric)
   |
[CPU2] -- 本地总线 -- [DRAM B]

CPU1访问DRAM A:1跳,延迟低(本地内存)
CPU1访问DRAM B:2跳,延迟高(远程内存)
Linux的NUMA感知调度器会尽量把线程和其数据安排在同一个NUMA节点上。

4.3 DDR SDRAM带宽发展


标准 数据率 峰值带宽
DDR-200 200 MT/s 1.6 GB/s
DDR3-1600 1600 MT/s 12.8 GB/s
DDR4-3200 3200 MT/s 25.6 GB/s
DDR5-6400 6400 MT/s 51.2 GB/s

4.4 MMU和TLB——地址翻译加速器

每次内存访问都需要把虚拟地址翻译成物理地址,MMU负责这个工作。

CPU发出虚拟地址
      |
      v
   TLB查找  --命中--> 直接得到物理地址(快!)
      |
     未命中
      |
      v
   查页表(在RAM中)
      |
      v
   得到物理地址,同时存入TLB
      |
      v
   访问物理内存

**TLB(Translation Lookaside Buffer)**相当于地址翻译的缓存。
TLB容量有限(典型:64个4K页条目),所以使用大页(2MB、1GB)能显著提升TLB命中率:
TLB覆盖范围 = TLB条目数 × 页大小 \text{TLB覆盖范围} = \text{TLB条目数} \times \text{页大小} TLB覆盖范围=TLB条目数×页大小
例如:64个条目 × 4KB = 256KB vs 64个条目 × 2MB = 128MB(提升512倍!)

5. 软件架构

5.1 Linux内存释放层次

当空闲内存下降时,Linux按以下顺序释放内存:

阶段1: 空闲列表(Free List)     <- 直接取用,无开销
阶段2: 页缓存(Page Cache)      <- 丢弃干净的文件缓存
阶段3: 换页(Swapping/kswapd)   <- 把匿名页写到swap
阶段4: 收割(Reaping)           <- 回收内核slab缓存
阶段5: OOM Killer              <- 杀掉进程(最后手段)

5.2 kswapd——页面换出守护进程

kswapd是Linux的后台内存管理守护进程,基于三个水位线工作:

内存使用量
    ^
    |
100%|========================================= (内存全满)
    |
高水位| - - - - - - - - - - - - - - - - - - - (kswapd停止)
    |    kswapd后台运行,慢速回收内存
低水位| - - - - - - - - - - - - - - - - - - - (kswapd唤醒)
    |
最低| - - - - - - - - - - - - - - - - - - - - (直接回收,阻塞应用)
    |
    +-------------------------------------------> 时间

kswapd维护两个LRU列表:

活跃列表(Active List):   最近被访问过的页
不活跃列表(Inactive List): 长期未访问的页(优先换出)
扫描顺序: 先扫不活跃列表 -> 若不够再扫活跃列表

swappiness参数(默认60)控制倾向:

  • 值越高 → 越倾向于换出匿名页,保留文件缓存
  • 值越低 → 越倾向于释放文件缓存,保留应用内存
  • 值为0 → 尽可能不换出应用内存(但内存不足时仍会换出)

5.3 Buddy分配器——内核页管理

Linux用Buddy算法管理物理页,维护多个按大小分级的空闲链表(2的幂次方)。

空闲链表:
1页  (4KB):  [页] [页] [页] ...
2页  (8KB):  [页页] [页页] ...
4页  (16KB): [页页页页] ...
8页  (32KB): ...
...

"Buddy"指分配时寻找相邻的空闲页合并使用,释放时也会合并相邻空闲页(减少碎片)。

5.4 Slab分配器——内核对象复用

对于频繁分配/释放的固定大小对象(如进程描述符、文件对象),slab分配器预先分配一批,缓存起来复用,避免反复初始化开销。

// 内核代码示例(带注释)
// kmem_alloc: 按大小找对应slab缓存分配
df = kmem_alloc(sizeof(l2arc_data_free_t), KM_SLEEP);
// kmem_cache_alloc: 直接从指定缓存分配(更快)
head = kmem_cache_alloc(hdr_cache, KM_PUSHPAGE);

Magazine机制(每CPU缓存):每个CPU有一个"弹夹"(M个对象),在弹夹耗尽前无需全局锁,大幅减少多核竞争。

5.5 用户空间分配器


分配器 特点
glibc (dlmalloc) 小对象用分级bin,大对象用mmap
TCMalloc Google出品,每线程缓存,低锁竞争
jemalloc Facebook优化,多arena,低碎片

切换用户空间分配器无需改代码,只需设置环境变量:

# 使用TCMalloc替换默认的glibc分配器
export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4

6. 进程虚拟地址空间

一个32位Linux进程的地址空间布局(从低到高):

高地址
+---------------------------+
|       内核空间             |  (用户程序无法直接访问)
+---------------------------+  <- 0xC0000000 (32位)
|       栈 (Stack)          |  向下增长,局部变量、函数调用帧
|          |                |
|          v                |
+---------------------------+
|      内存映射区            |  动态库、mmap文件
+---------------------------+
|          ^                |
|          |                |
|       堆 (Heap)           |  向上增长,malloc的数据
+---------------------------+
|    BSS段(未初始化数据)   |
+---------------------------+
|    数据段(初始化数据)    |  全局变量
+---------------------------+
|    代码段(Text)          |  可执行指令,只读
+---------------------------+
低地址 0x00000000

堆为何只增不减?

简单分配器的 free() 并不把内存还给OS,而是留着备用(避免下次 malloc 的系统调用开销)。

malloc(100KB) -> 向OS申请
free(100KB)   -> 还给分配器内部,不还OS
malloc(100KB) -> 直接从分配器内部复用,很快!

glibc提供 malloc_trim() 可主动将顶部空闲堆内存归还OS。

7. 内存分析方法论

7.1 USE方法(推荐首先使用)

对内存资源检查三个维度:
U - 使用率 (Utilization)
内存使用率 = 已使用内存 总内存 × 100 % \text{内存使用率} = \frac{\text{已使用内存}}{\text{总内存}} \times 100\% 内存使用率=总内存已使用内存×100%
注意:文件缓存占用的内存可随时被应用回收,不应算作"真正在用"。
S - 饱和度 (Saturation)
检查以下任何一个发生都说明内存饱和:

  • 持续的页面扫描(sar -B 的 pgscan 列非零)
  • swap活动(vmstat 的 si/so 列非零)
  • OOM Killer被触发(dmesg | grep "Out of memory"
    E - 错误 (Errors)
  • 内存分配失败(malloc 返回NULL)
  • ECC可纠正错误(预示硬件问题)
  • OOM Killer事件

7.2 性能排查流程

发现内存问题
     |
     v
1. 检查饱和度: vmstat 1 (看si/so列)
     |
有换页活动
     |
     v
2. 找大内存进程: top -o %MEM
     |
     v
3. 分析进程内存: pmap -x <PID>
     |
     v
4. 追踪分配来源: perf/bpftrace跟踪malloc
     |
     v
5. 确定是泄漏还是正常增长: memleak工具

7.3 内存泄漏 vs 内存增长

内存泄漏:已申请的内存不再使用,但程序忘记释放。需要修改代码。
内存增长:程序正常使用内存,但用量超出预期。可能是配置问题(如数据库缓存配太大)。
判断方法:看内存是否持续线性增长,且在没有工作负载时也不下降。

8. 常用观测工具

8.1 vmstat——内存健康速览

vmstat 1    # 每秒刷新一次

关键列解释:

procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu---
r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa
^  ^   ^      ^      ^     ^       ^    ^
|  |   |      |      |     |       |    |
|  |   |      |      |     |       |    +-- 换出到swap的KB/s (非零=有问题!)
|  |   |      |      |     |       +------- 从swap换入的KB/s (非零=有问题!)
|  |   |      |      |     +--------------- 页缓存大小(KB)
|  |   |      |      +--------------------- 缓冲缓存大小(KB)
|  |   |      +---------------------------- 空闲内存(KB)
|  |   +----------------------------------- 已换出内存总量(KB)
|  +--------------------------------------- 等待IO的进程数
+------------------------------------------ 运行队列长度

快速判断siso 持续非零 = 系统正在换页 = 内存不足!

8.2 PSI——内存压力统计(Linux 4.20+)

cat /proc/pressure/memory
# 输出示例:
# some avg10=2.84 avg60=1.23 avg300=0.32 total=1468344
# full avg10=1.85 avg60=0.66 avg300=0.16 total=702578

解读:

  • some:部分任务因内存等待的时间百分比
  • full:所有可运行任务都在等待内存的时间百分比
  • avg10=2.84 > avg300=0.32 → 内存压力在升高(需要关注!)

8.3 slabtop——内核slab缓存监控

slabtop -sc    # 按缓存大小排序

输出示例解读:

OBJS    ACTIVE   USE    OBJ SIZE  CACHE SIZE  NAME
45450   33712    74%    1.05K     48480K      ext4_inode_cache
                                 ^
                                 这个inode缓存占了约47MB,是否合理?

8.4 numastat——NUMA内存分配统计

numastat
                    node0           node1
numa_hit        210057224016   151287435161   <- 本地分配次数(越高越好)
numa_miss         9377491084      291611562   <- 非预期本地分配(越低越好)
numa_foreign       291611562     9377491084   <- 应该本地但去了远端(越低越好)

NUMA命中率 = numa_hit numa_hit + numa_miss × 100 % \text{NUMA命中率} = \frac{\text{numa\_hit}}{\text{numa\_hit} + \text{numa\_miss}} \times 100\% NUMA命中率=numa_hit+numa_missnuma_hit×100%
命中率低 → 考虑用 numactl --membind 绑定进程到NUMA节点。

8.5 pmap——进程内存映射详情

pmap -x <PID>
Address           Kbytes  RSS    Dirty  Mode   Mapping
000055dadb0dd000  58284   10748  0      r-x--  mysqld      <- 代码段(只读可执行)
000055dade9c8000  1316    1316   1316   r----  mysqld      <- 只读数据
000055dadeb11000  3592    816    764    rw---  mysqld      <- 读写数据段
000055dadee93000  1168    1080   1080   rw---  [anon]      <- 堆内存
...

Dirty列:被修改过的私有页,换出时必须写磁盘(影响换页开销)。

8.6 perf——页错误采样与火焰图

# 全系统跟踪页错误60秒,记录调用栈
perf record -e page-faults -a -g -- sleep 60
# 查看报告
perf report
# 生成火焰图(需要FlameGraph工具)
perf script --header > out.stacks
./stackcollapse-perf.pl < out.stacks | \
  ./flamegraph.pl --bgcolor=green --count=pages \
  --title="Page Fault Flame Graph" > out.svg

页错误火焰图用绿色背景(区别于CPU性能的黄色火焰图),每个"塔"的宽度代表该代码路径触发的页错误数量(即内存增长量)。

8.7 bpftrace——强大的内存追踪脚本

# 统计各进程的malloc请求总字节数(注意:高开销!)
bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc {
    @[ustack, comm] = sum(arg0); }'
# 按用户栈统计页错误次数
bpftrace -e 't:exceptions:page_fault_user { @[ustack, comm] = count(); }'
# 统计各进程触发的swap读入次数
bpftrace -e 'kprobe:swap_readpage { @[comm, pid] = count(); }'
# 统计堆扩展(brk系统调用)的调用路径
bpftrace -e 'tracepoint:syscalls:sys_enter_brk { @[ustack, comm] = count(); }'
# 列出所有内存相关tracepoint
bpftrace -l 't:*:mm_*'

重要警告:追踪 malloc 这类高频函数会带来极大开销(可能让目标程序慢2倍以上),生产环境谨慎使用。

8.8 drsnoop——直接内存回收延迟追踪

drsnoop -T
# 输出:
# TIME(s)       COMM    PID     LAT(ms)  PAGES
# 0.000000000   java    11266     1.72     57
# 0.004007000   java    11266     3.21     57

LAT(ms) 表示应用因内存不足被阻塞的时间。持续出现说明系统内存严重不足。

8.9 wss——工作集大小估算

./wss.pl $(pgrep -n mysqld) 1    # 每秒报告一次
Est(s)    RSS(MB)   PSS(MB)   Ref(MB)
1.014     403.66    400.59    86.00    <- 1秒内访问了86MB
5.094     403.66    400.59    100.33   <- 5秒内访问了100MB
10.194    403.66    400.59    109.14   <- 10秒内访问了109MB

此例中mysqld的RSS是400MB,但WSS约100MB。这意味着优化好100MB的缓存命中就能覆盖大部分工作负载。

9. 调优参数

9.1 关键sysctl参数

# 查看当前值
sysctl vm.swappiness
# 修改(立即生效)
sysctl -w vm.swappiness=10
# 永久生效:写入 /etc/sysctl.conf
echo "vm.swappiness=10" >> /etc/sysctl.conf

参数 默认值 说明 调优建议
vm.swappiness 60 换页倾向(0-100) 数据库服务器可设为10,减少swap
vm.min_free_kbytes 动态 最低保留空闲内存 内存压力大时适当增大
vm.overcommit_memory 0 0=启发式;1=总允许;2=禁止 对内存精确控制可设为2
vm.vfs_cache_pressure 100 回收目录/inode缓存的倾向 文件服务器可降低以保留缓存
kernel.numa_balancing 1 NUMA自动页平衡 老内核可能有bug,考虑关闭
vm.watermark_scale_factor 10 kswapd水位线间距 高分配突发负载时适当增大

第八章:文件系统 —— 深度解析

0. 本章全景图

应用程序
    |
    | 逻辑 I/O(应用眼中的读写)
    v
+---------------------------+
|       VFS 虚拟文件系统      |  <-- 统一接口层,屏蔽底层差异
+---------------------------+
    |            |
    |            | 物理 I/O(真正落到磁盘的读写)
    v            v
  文件系统缓存   磁盘设备
 (内存 DRAM)

核心思想:文件系统是应用和磁盘之间的"翻译官",
大量使用缓存、缓冲、预读来让应用感知不到磁盘的慢。

1. 关键术语速查


术语 白话解释
文件系统 把磁盘上的裸数据组织成"文件+目录"的结构,提供权限控制
文件系统缓存 用内存(DRAM)暂存常用文件数据,避免反复读磁盘
逻辑 I/O 应用发出的读写请求(应用看到的)
物理 I/O 最终真正下发到磁盘的读写(磁盘看到的)
inode 文件的"身份证":存储权限、时间戳、数据位置等元数据
VFS 虚拟文件系统,让内核可以同时支持 ext4、XFS、ZFS 等各种文件系统
吞吐量 单位时间内传输的数据量,单位 bytes/s
卷(Volume) 把多块磁盘虚拟成一块,灵活管理存储

2. 两个核心模型

2.1 文件系统接口模型

         应用程序
         |      |
    read()    write()
     open()  close()
     stat()  mkdir()
         |      |
         v      v
    +----------------+
    |   文件系统      |
    |  (软件层)     |
    +----------------+
         |      |
   逻辑I/O      物理I/O
         |      |
         v      v
        磁盘设备

2.2 缓存读取流程

应用发出 read()
        |
        v
  数据在内存缓存中?
   /           \
 是(缓存命中)   否(缓存未命中)
  |               |
  直接返回        从磁盘读取
  (极快,微秒级)  (较慢,毫秒级)
                  |
                  把数据存入缓存
                  (下次命中)

比喻:就像图书馆的常借书架(缓存),常用书放架上,不用跑仓库(磁盘)。

3. 核心概念详解

3.1 文件系统延迟

延迟 = 操作完成时间 − 操作请求时间 \text{延迟} = \text{操作完成时间} - \text{操作请求时间} 延迟=操作完成时间操作请求时间
延迟包含三部分时间:

  1. 文件系统软件处理时间
  2. 磁盘 I/O 子系统排队时间
  3. 磁盘设备实际读写时间
    关键结论:大多数情况下,应用线程会阻塞等待文件系统响应,
    所以文件系统延迟 直接等比影响 应用性能。

3.2 随机 I/O vs 顺序 I/O

顺序 I/O(高效):
磁盘位置: [0] -> [1] -> [2] -> [3] -> [4]
          连续读取,磁头不需要跳跃
随机 I/O(低效):
磁盘位置: [5] -> [1] -> [9] -> [2] -> [7]
          每次读取都要移动磁头,大量时间浪费在"寻道"

顺序 I/O 性能 ≫ 随机 I/O 性能(机械硬盘上差距可达10倍以上) \text{顺序 I/O 性能} \gg \text{随机 I/O 性能(机械硬盘上差距可达10倍以上)} 顺序 I/O 性能随机 I/O 性能(机械硬盘上差距可达10倍以上)

3.3 预读(Prefetch / Read-Ahead)

问题:顺序读大文件时,应用一次请求一块,但磁盘读完才能请求下一块,效率低。
解决方案:检测到顺序读模式后,提前把后面的数据读进内存。

应用请求流程(有预读):
时刻1: 应用请求 Block[1]
       文件系统发现是顺序读,同时预读 Block[2], Block[3], Block[4]
时刻2: Block[1] 读完返回给应用
       Block[2,3,4] 也已经在内存里了
时刻3: 应用请求 Block[2] -> 直接从内存返回!(几乎0延迟)

3.4 写回缓存(Write-Back Caching)

目标:让写操作尽快返回,不阻塞应用。

应用调用 write()
    |
    v
数据写入内存缓冲区
    |
    v
立即返回给应用(应用以为写完了)
    |
    |(异步,稍后某个时刻)
    v
内核刷新线程把"脏数据"写入磁盘

代价:如果突然断电,内存里的数据会丢失!
写回缓存 = 性能提升 + 数据丢失风险 \text{写回缓存} = \text{性能提升} + \text{数据丢失风险} 写回缓存=性能提升+数据丢失风险

3.5 同步写(Synchronous Write)

与写回缓存相反,同步写等数据真正落盘才返回。

应用调用 write(O_SYNC)
    |
    v
数据写入磁盘(等待磁盘确认)
    |
    v
返回给应用(确保数据安全)

两种方式对比

方式 速度 安全性 适用场景
写回缓存(异步) 有风险 普通文件
同步写 O_SYNC 安全 数据库日志
批量提交 fsync() 中等 安全 数据库检查点

3.6 逻辑 I/O vs 物理 I/O(最重要的概念之一)

应用写1字节,磁盘可能读写超过128KB! 下面是完整流程:

步骤1: 应用 write() 写1字节
步骤2: 文件系统发现该字节所在的128KB数据块不在缓存里
步骤3: 从磁盘读出128KB(先读再写)
步骤4: 修改那1字节
步骤5: 等待刷新时机,把128KB写回磁盘
步骤6: 更新元数据(inode、时间戳等),又是一次磁盘写

物理 I/O 和逻辑 I/O 的差异类型

逻辑 I/O 与物理 I/O 差异

无关 Unrelated

间接 Indirect

隐式 Implicit

缩小 Deflated

膨胀 Inflated

其他进程的 I/O
其他租户的 I/O
RAID 重建 I/O

预读产生的 I/O
写回延迟批量写入

内存映射文件的
读写指令触发 I/O

缓存命中不读盘
写合并减少 I/O
数据压缩

元数据 I/O
日志双写
RAID 校验写

4. 架构:文件系统的"建筑结构"

4.1 I/O 栈全景

应用程序
    |
    | 系统调用(read/write/open...)
    v
+---------------------------+
|   VFS(虚拟文件系统层)    |
+---------------------------+
    |            |
    |            | 直接 I/O(绕过缓存)
    v            |
+----------+     |
| 文件系统  |     |
| 缓存     |     |
+----------+     |
    |            |
    v            v
+---------------------------+
|   块设备 I/O 子系统        |
+---------------------------+
    |
    v
   磁盘设备(HDD/SSD)

4.2 VFS 的作用

VFS 就像一个"翻译层":

ext4 文件系统  --|
XFS  文件系统  --|-->  VFS 统一接口  -->  应用程序
ZFS  文件系统  --|
NFS  文件系统  --|

无论底层是什么文件系统,应用都用同样的 read()write() 调用。

4.3 Linux 文件系统缓存体系

+--------------------------------------------------+
|                    应用程序                        |
+--------------------------------------------------+
                        |
+--------------------------------------------------+
|                  VFS 层                           |
|  +------------+  +-------------+  +-----------+ |
|  | 目录缓存    |  | inode 缓存   |  | 页缓存    | |
|  | (dentry)   |  | (inode)     |  | (page     | |
|  |            |  |             |  |  cache)   | |
|  +------------+  +-------------+  +-----------+ |
|                                        |         |
|                                   +----------+   |
|                                   | 缓冲缓存  |   |
|                                   | (buffer  |   |
|                                   |  cache)  |   |
|                                   +----------+   |
+--------------------------------------------------+
                        |
                     磁盘设备

各缓存说明:

缓存名称 作用
页缓存(Page Cache) 缓存文件数据页,最主要的缓存
目录缓存(dentry cache / Dcache) 缓存路径名到 inode 的映射,加速路径查找
inode 缓存 缓存文件属性(权限、大小、时间等)
缓冲缓存(Buffer Cache) 缓存块设备数据,Linux 2.4 后合并入页缓存

4.4 文件系统特性

块(Block)vs 区段(Extent)
块方式(Block-based):
文件数据: [块1] [块3] [块7] [块2] ...  <- 分散存储,随机 I/O
指针:     ptr1->ptr3->ptr7->ptr2 ...   <- 需要大量指针
区段方式(Extent-based):
文件数据: [块1][块2][块3][块4][块5]    <- 连续存储,顺序 I/O
指针:     起始位置+长度 一个指针搞定   <- 元数据少
日志(Journaling)

解决问题:断电时文件系统一致性

正常写流程(有日志):
  1. 先把"我要做什么"写入日志(日志是连续写,很快)
  2. 再执行实际写操作
  3. 操作完成后标记日志条目为"已完成"
崩溃恢复:
  重放未完成的日志条目,保证一致性
  而不是扫描整个文件系统(大文件系统可能要几小时)
写时复制(Copy-on-Write, COW)
传统覆写:
旧数据位置: [A] -> 直接覆写 -> [B]
问题: 覆写过程中断电,数据损坏
COW:
旧数据位置: [A](保留不动)
新位置:      [B](写入新数据)
更新引用:    指针 -> [B]
释放:        [A] 加入空闲列表
好处: 随时崩溃都安全,旧数据还在

4.5 主流文件系统对比

Linux 文件系统家族

FFS
伯克利快速文件系统
1980s 鼻祖

ext 系列

XFS
1993 SGI

ext3
1999 加入日志

ext4
2008 加入区段

ZFS
2005 Sun
池化存储+COW

btrfs
2007 Oracle
COW B树

各文件系统核心性能特点:

文件系统 核心特点 适用场景
ext4 区段、延迟分配、日志 Linux 通用首选
XFS 并行分配组、高并发 大文件、高并发服务器(Netflix用于Cassandra)
ZFS ARC缓存、池化、数据校验 文件服务器、高可靠性
btrfs COW B树、快照、在线均衡 现代特性需求场景

5. 分析方法论

5.1 延迟分析

可以在四个层次测量延迟:

层次1: 应用层
  最接近用户感受,但每个应用实现不同
层次2: 系统调用层(Syscall)
  标准接口,工具丰富(strace)
  但 read() 不区分文件/网络/管道
层次3: VFS 层
  覆盖所有文件系统类型
  一个测量点,统一数据
层次4: 文件系统内部
  只看特定文件系统(如 ext4)
  最精确,但工具要跟版本走

事务成本公式
文件系统占用率 = 应用事务中阻塞在文件系统的总时间 应用事务总时间 × 100 % \text{文件系统占用率} = \frac{\text{应用事务中阻塞在文件系统的总时间}}{\text{应用事务总时间}} \times 100\% 文件系统占用率=应用事务总时间应用事务中阻塞在文件系统的总时间×100%
例子

  • 事务耗时 200ms,其中等待文件系统 180ms
    180 200 × 100 % = 90 % \frac{180}{200} \times 100\% = 90\% 200180×100%=90%,消除文件系统瓶颈可提速最多 10 倍
  • 事务耗时 200ms,其中等待文件系统 2ms
    2 200 × 100 % = 1 % \frac{2}{200} \times 100\% = 1\% 2002×100%=1%,优化文件系统意义不大,去找别的瓶颈

5.2 工作负载特征描述

描述一个文件系统负载需要这些指标:

指标 说明
操作率 每秒多少次 read/write/stat/open
吞吐量 每秒传输多少 bytes
I/O 大小 每次读写多少字节
读写比 读占几成,写占几成
同步写比例 需要立即落盘的写有多少
访问模式 随机还是顺序

6. 观测工具详解

6.1 常用工具速览

mount     -> 查看已挂载的文件系统及参数
free      -> 查看内存和缓存使用情况
vmstat    -> 虚拟内存统计(含缓存大小)
sar       -> 历史统计数据收集和查看
slabtop   -> 内核 slab 缓存详情(dentry/inode 缓存等)
strace    -> 系统调用跟踪(有性能开销,慎用)
fatrace   -> 文件访问事件跟踪(用 fanotify)
opensnoop -> 跟踪所有 open() 调用
filetop   -> top 命令的文件版,显示最忙的文件
cachestat -> 页缓存命中率统计
ext4dist  -> ext4 操作延迟分布(直方图)
ext4slower-> 找出 ext4 慢操作(超过阈值的)
bpftrace  -> 自定义 BPF 跟踪脚本

6.2 free 命令解读

$ free -mw
       total  used  free  shared  buffers  cache  available
Mem:    1950   568   163       0       84   1133       1187
含义:
- total:     物理内存总量 1950MB
- used:      已用内存 568MB(应用使用)
- free:      完全空闲 163MB
- buffers:   缓冲缓存 84MB(块设备缓存)
- cache:     页缓存 1133MB(文件数据缓存)
- available: 还能给应用用的内存 1187MB(含可回收的缓存)

注意:缓存占用内存是正常现象,不是"内存不够用"。
空闲内存放着浪费,不如用来缓存文件数据。

6.3 ext4dist 输出解读

operation = read
    usecs            : count
        0 -> 1       : 783   |*****  <- 0-1微秒(缓存命中)
        2 -> 3       : 88    |**
        4 -> 7       : 449   |*****
        8 -> 15      : 1306  |*****  <- 8-15微秒(也是内存缓存)
       ...
      256 -> 511     : 158   |****   <- 256微秒以上(磁盘读!)
      512 -> 1023    : 110   |***
     1024 -> 2047    : 33    |*

这是典型的双峰分布
低延迟峰 ≈ 0 - 15 μ s (缓存命中) \text{低延迟峰} \approx 0\text{-}15\mu s \quad \text{(缓存命中)} 低延迟峰0-15μs(缓存命中)
高延迟峰 ≈ 256 μ s - 2 m s (磁盘 I/O) \text{高延迟峰} \approx 256\mu s\text{-}2ms \quad \text{(磁盘 I/O)} 高延迟峰256μs-2ms(磁盘 I/O

7. 性能测试工具

7.1 dd 命令快速测试

# 写测试:写 1GB 数据,块大小 1MB
dd if=/dev/zero of=testfile bs=1024k count=1k
# 读测试:读刚才写的文件
dd if=testfile of=/dev/null bs=1024k

注意:写测试结果极快是因为写回缓存,只是写到内存,还没到磁盘。

7.2 工作集大小(WSS)的影响

WSS < 内存大小 ⇒ 近100%缓存命中 ⇒ 测的是内存速度 \text{WSS} < \text{内存大小} \Rightarrow \text{近100\%缓存命中} \Rightarrow \text{测的是内存速度} WSS<内存大小100%缓存命中测的是内存速度
WSS ≫ 内存大小 ⇒ 频繁缓存未命中 ⇒ 测的是磁盘速度 \text{WSS} \gg \text{内存大小} \Rightarrow \text{频繁缓存未命中} \Rightarrow \text{测的是磁盘速度} WSS内存大小频繁缓存未命中测的是磁盘速度

内存 文件总大小 测试类型 预期结果
128GB 10GB 随机读 几乎全部命中缓存
128GB 1000GB 随机读 约88%读磁盘
128GB 10GB 同步写 100%写磁盘

7.3 清空缓存(测试前保证公平)

# 清空页缓存
echo 1 > /proc/sys/vm/drop_caches
# 清空 slab(dentry/inode 缓存)
echo 2 > /proc/sys/vm/drop_caches
# 全部清空(测试前推荐)
echo 3 > /proc/sys/vm/drop_caches

8. 调优参数

8.1 访问时间戳(atime)

每次读文件都更新 atime 会产生额外写 I/O。

# 挂载时禁用 atime(高性能场景)
mount -o noatime /dev/sda1 /data
# 或使用 relatime(默认,只在必要时更新)
mount -o relatime /dev/sda1 /data

8.2 fsync vs O_SYNC

O_SYNC 方式:每次 write() 都同步落盘
  write() -> [磁盘写] -> 返回
  write() -> [磁盘写] -> 返回
  write() -> [磁盘写] -> 返回
  开销:N次磁盘 I/O
fsync() 方式:批量写完再同步
  write() -> [内存缓冲]
  write() -> [内存缓冲]
  write() -> [内存缓冲]
  fsync() -> [批量磁盘写] -> 返回
  开销:1次磁盘 I/O(合并了多次写)

fsync 性能 ≫ O_SYNC 性能(写密集场景下) \text{fsync 性能} \gg \text{O\_SYNC 性能(写密集场景下)} fsync 性能O_SYNC 性能(写密集场景下)

8.3 ZFS 重要调优参数


参数 默认值 说明
recordsize 128KB 文件块大小,小文件随机 I/O 应调小
compression off lzjb 轻量压缩,可减少 I/O
atime on 关掉可提升性能
primarycache all 归档文件可设 metadata,避免污染缓存
sync standard 同步写行为

9. C++ 代码演示:文件系统操作性能测量

#include <iostream>
#include <fstream>
#include <chrono>
#include <vector>
#include <string>
#include <cstring>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
// 测量单次操作的延迟(微秒)
// 返回 microseconds
double measure_write_latency(const std::string& filename, size_t block_size) {
    // 准备写入数据(全0)
    std::vector<char> buffer(block_size, 0);
    // 打开文件(O_WRONLY=只写, O_CREAT=不存在则创建, O_TRUNC=清空)
    int fd = open(filename.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        std::cerr << "无法打开文件: " << filename << std::endl;
        return -1.0;
    }
    // 记录开始时间(高精度时钟)
    auto start = std::chrono::high_resolution_clock::now();
    // 执行写操作
    ssize_t written = write(fd, buffer.data(), block_size);
    // 记录结束时间
    auto end = std::chrono::high_resolution_clock::now();
    close(fd);
    if (written != static_cast<ssize_t>(block_size)) {
        std::cerr << "写入不完整" << std::endl;
        return -1.0;
    }
    // 计算耗时(微秒)
    // duration_cast 转换时间单位
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    return static_cast<double>(duration.count());
}
// 测量读操作延迟(微秒)
double measure_read_latency(const std::string& filename, size_t block_size) {
    // 接收读取数据的缓冲区
    std::vector<char> buffer(block_size);
    // O_RDONLY = 只读
    int fd = open(filename.c_str(), O_RDONLY);
    if (fd < 0) {
        std::cerr << "无法打开文件: " << filename << std::endl;
        return -1.0;
    }
    auto start = std::chrono::high_resolution_clock::now();
    ssize_t bytes_read = read(fd, buffer.data(), block_size);
    auto end = std::chrono::high_resolution_clock::now();
    close(fd);
    if (bytes_read <= 0) {
        std::cerr << "读取失败" << std::endl;
        return -1.0;
    }
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    return static_cast<double>(duration.count());
}
// 测量 fsync 延迟(微秒)
// fsync 强制把缓冲区数据刷到磁盘
double measure_fsync_latency(const std::string& filename, size_t block_size) {
    std::vector<char> buffer(block_size, 1); // 用1填充,避免零页优化
    int fd = open(filename.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        return -1.0;
    }
    // 先写数据(异步,很快)
    write(fd, buffer.data(), block_size);
    // 计时 fsync 的延迟(这才是真正写磁盘的时刻)
    auto start = std::chrono::high_resolution_clock::now();
    fsync(fd); // 强制刷盘,等磁盘确认
    auto end = std::chrono::high_resolution_clock::now();
    close(fd);
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    return static_cast<double>(duration.count());
}
// 运行多次测量,返回平均值和最大值
struct Stats {
    double avg_us;   // 平均延迟(微秒)
    double max_us;   // 最大延迟(微秒)
    double min_us;   // 最小延迟(微秒)
};
Stats run_benchmark(const std::string& name,
                    const std::string& filename,
                    size_t block_size,
                    int rounds,
                    double (*measure_func)(const std::string&, size_t)) {
    std::vector<double> latencies;
    latencies.reserve(rounds);
    for (int i = 0; i < rounds; i++) {
        double lat = measure_func(filename, block_size);
        if (lat >= 0) {
            latencies.push_back(lat);
        }
    }
    if (latencies.empty()) {
        return {-1, -1, -1};
    }
    // 计算统计值
    double sum = 0.0;
    double min_v = latencies[0];
    double max_v = latencies[0];
    for (double v : latencies) {
        sum += v;
        if (v < min_v) min_v = v;
        if (v > max_v) max_v = v;
    }
    return {sum / latencies.size(), max_v, min_v};
}
int main() {
    const std::string test_file = "/tmp/fs_latency_test.bin";
    const int ROUNDS = 20; // 重复测量次数,越多结果越稳定
    std::cout << "=== 文件系统操作延迟测量 ===" << std::endl;
    std::cout << "测试文件: " << test_file << std::endl;
    std::cout << "每项测试轮数: " << ROUNDS << std::endl << std::endl;
    // 测试不同块大小的写延迟
    // 典型块大小:4KB(数据库常用)、128KB(文件系统默认)
    std::vector<size_t> block_sizes = {4096, 65536, 131072}; // 4KB, 64KB, 128KB
    std::cout << "--- 缓冲写延迟(write,数据进内存就返回)---" << std::endl;
    for (size_t bs : block_sizes) {
        auto stats = run_benchmark("write", test_file, bs, ROUNDS, measure_write_latency);
        std::cout << "  块大小 " << bs / 1024 << "KB: "
                  << "平均=" << stats.avg_us << "us, "
                  << "最小=" << stats.min_us << "us, "
                  << "最大=" << stats.max_us << "us" << std::endl;
    }
    std::cout << std::endl << "--- 读延迟(第二次读,大概率命中缓存)---" << std::endl;
    // 先写一次,让数据进缓存
    measure_write_latency(test_file, 131072);
    for (size_t bs : block_sizes) {
        auto stats = run_benchmark("read", test_file, bs, ROUNDS, measure_read_latency);
        std::cout << "  块大小 " << bs / 1024 << "KB: "
                  << "平均=" << stats.avg_us << "us, "
                  << "最小=" << stats.min_us << "us, "
                  << "最大=" << stats.max_us << "us" << std::endl;
    }
    std::cout << std::endl << "--- fsync 延迟(强制刷盘,反映磁盘真实速度)---" << std::endl;
    for (size_t bs : block_sizes) {
        auto stats = run_benchmark("fsync", test_file, bs, ROUNDS, measure_fsync_latency);
        std::cout << "  块大小 " << bs / 1024 << "KB: "
                  << "平均=" << stats.avg_us << "us, "
                  << "最小=" << stats.min_us << "us, "
                  << "最大=" << stats.max_us << "us" << std::endl;
    }
    // 清理测试文件
    unlink(test_file.c_str());
    std::cout << std::endl;
    std::cout << "提示:" << std::endl;
    std::cout << "  缓冲写 << fsync  -> 写回缓存生效,write 只写内存" << std::endl;
    std::cout << "  读延迟很低       -> 数据来自页缓存,未读磁盘" << std::endl;
    std::cout << "  fsync 延迟最高   -> 真正等磁盘确认写入" << std::endl;
    return 0;
}

编译和运行

g++ -O2 -std=c++17 -o fs_bench fs_bench.cpp
./fs_bench

典型输出(SSD 上)

=== 文件系统操作延迟测量 ===
--- 缓冲写延迟(几乎只是内存拷贝)---
  块大小 4KB:   平均=9us,   最小=5us,  最大=25us
  块大小 64KB:  平均=55us,  最小=40us, 最大=120us
  块大小 128KB:平均=105us, 最小=80us, 最大=210us
--- 读延迟(缓存命中)---
  块大小 4KB:   平均=3us,  最小=2us, 最大=8us
  块大小 64KB:  平均=13us, 最小=9us, 最大=25us
--- fsync 延迟(真正写磁盘)---
  块大小 4KB:   平均=500us,  最小=200us, 最大=2000us
  块大小 128KB: 平均=2000us, 最小=800us, 最大=8000us

10. 总结:性能分析的思路流程

发现性能问题
      |
      v
先测文件系统延迟(ext4dist/ext4slower)
      |
      +---> 延迟很低?-> 文件系统没问题,去看 CPU/网络/应用逻辑
      |
      +---> 延迟高?-> 继续分析
                |
                v
         查缓存命中率(cachestat)
                |
         +------+------+
         |             |
    命中率高        命中率低
    延迟仍高        延迟高
         |             |
         v             v
    可能是文件   增加内存/调整
    系统 Bug     工作集大小
    或锁竞争     或优化访问模式

黄金法则
先排查文件系统(逻辑I/O) → 再排查磁盘(物理I/O) \text{先排查文件系统(逻辑I/O)} \rightarrow \text{再排查磁盘(物理I/O)} 先排查文件系统(逻辑I/O再排查磁盘(物理I/O
不要一上来就看 iostat 磁盘指标,因为大量磁盘 I/O 可能是后台刷脏页,和应用性能毫无关系。
参考:Brendan Gregg《Systems Performance》第8章

9.2 大页(Huge Pages)配置

# 分配50个2MB大页(共100MB)
echo 50 > /proc/sys/vm/nr_hugepages
# 检查大页状态
grep Huge /proc/meminfo
# HugePages_Total:  50
# HugePages_Free:   50
# Hugepagesize:   2048 kB
# 挂载hugetlbfs,供应用使用
mkdir /mnt/hugetlbfs
mount -t hugetlbfs none /mnt/hugetlbfs -o pagesize=2048K

透明大页(THP):由内核自动管理,无需应用修改,但历史上有性能问题,建议测试后再用。

9.3 NUMA绑定

# 将进程PID 3161绑定到NUMA节点0(内存和CPU都绑定)
numactl --membind=0 --physcpubind=0 -- 3161
# 查看可用NUMA节点
numastat

9.4 cgroup内存限制

# cgroup v1示例:限制某个容器最多使用4GB内存
echo "4294967296" > /sys/fs/cgroup/memory/<group>/memory.limit_in_bytes
# 禁止该cgroup使用swap
echo "4294967296" > /sys/fs/cgroup/memory/<group>/memory.memsw.limit_in_bytes

10. 综合示例分析

分析一段vmstat输出

以下是习题中的vmstat输出,我们来逐行分析:

procs ----memory---- ---swap-- --io-- --sys-- ----cpu----
r  b  swpd   free    si   so   bi   bo   in   cs  us sy id wa
2  0  413344 62284    0    0   17   12    1    1   0  0 100  0  <- 初始状态
2  0  418036 68172    0  4692 4520 4692 1060 1939  61 38  0  1  <- 问题开始!
2  0  418232 71272    0   196 23924 196 1288 2464  51 38  0 11  <- 大量磁盘读
2  0  418308 68792    0    76 3408   96 1028 1873  58 39  0  3  
1  0  418308 67296    0    0   1060   0 1020 1843  53 47  0  0  
...
2  0  430792 65584    0  8472 4912 8472 1030 1846  54 40  0  6  <- 又一波换出

分析结论:

  1. si 列始终为0 → 没有换入(好事,说明换出的数据暂未被访问回来)
  2. so 列多次非零(4692、196、8472)→ 系统在持续换出匿名页到swap
  3. swpd 从413344增长到434252 → 共有约20MB新数据被换到swap
  4. bi(块读入)出现23924这样的高峰 → 大量磁盘读,可能是读取文件数据或换回已换出的页
  5. wa(CPU等待IO)出现11%、6% → CPU在等磁盘,说明IO是瓶颈
  6. free 在60000~70000KB之间波动 → 内存紧张但还没耗尽
    结论:系统内存不足,正在使用swap。需要找出哪个进程占用最多内存(top -o %MEM)并决定是增加内存还是优化应用配置。

系统调用与C++代码示例

以下是一个完整可运行的C++程序,演示内存分配行为,包含RSS/VSZ的查询:

#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <unistd.h>   // getpid, sleep
#include <sys/resource.h>  // getrusage
// 从 /proc/self/status 读取当前进程的内存信息
struct MemInfo {
    long vm_rss_kb;   // 常驻物理内存(Resident Set Size)单位KB
    long vm_size_kb;  // 虚拟内存大小(Virtual Memory Size)单位KB
    long vm_peak_kb;  // 虚拟内存峰值
};
MemInfo readMemInfo() {
    MemInfo info = {0, 0, 0};
    std::ifstream status("/proc/self/status");
    std::string line;
    while (std::getline(status, line)) {
        std::istringstream iss(line);
        std::string key;
        long value;
        std::string unit;
        iss >> key >> value >> unit;
        if (key == "VmRSS:")  info.vm_rss_kb  = value;
        if (key == "VmSize:") info.vm_size_kb = value;
        if (key == "VmPeak:") info.vm_peak_kb = value;
    }
    return info;
}
void printMemInfo(const std::string& label) {
    MemInfo m = readMemInfo();
    std::cout << "[" << label << "]\n"
              << "  VSZ (虚拟内存): " << m.vm_size_kb / 1024 << " MB\n"
              << "  RSS (物理内存): " << m.vm_rss_kb  / 1024 << " MB\n"
              << "  峰值虚拟内存:   " << m.vm_peak_kb / 1024 << " MB\n\n";
}
int main() {
    std::cout << "PID: " << getpid() << "\n\n";
    // 步骤1: 初始状态
    printMemInfo("初始状态");
    // 步骤2: 申请内存(按需分配,此时不触发页错误)
    const size_t SIZE = 100 * 1024 * 1024; // 100 MB
    char* buf = static_cast<char*>(malloc(SIZE));
    if (!buf) {
        std::cerr << "malloc失败\n";
        return 1;
    }
    // malloc之后:VSZ增加,但RSS还没增加(按需分配!)
    printMemInfo("malloc(100MB)之后(还未访问)");
    // 步骤3: 实际写入内存(触发页错误,建立物理映射)
    // 每4096字节写一个字节,触发每一页的页错误
    for (size_t i = 0; i < SIZE; i += 4096) {
        buf[i] = static_cast<char>(i & 0xFF);
    }
    // 写入之后:RSS也增加了(物理页已分配)
    printMemInfo("写入所有页之后");
    // 步骤4: 释放内存(free不会立刻归还OS,RSS可能不变)
    free(buf);
    buf = nullptr;
    // free之后:glibc可能保留这块内存供下次malloc使用
    printMemInfo("free()之后(注意RSS可能没有下降)");
    // 步骤5: 用mmap分配并munmap释放(会真正归还OS)
    char* mapped = static_cast<char*>(
        // 使用posix_memalign或mmap来获得按页对齐的内存
        aligned_alloc(4096, SIZE)
    );
    if (mapped) {
        // 写入触发物理映射
        memset(mapped, 0xAB, SIZE);
        printMemInfo("aligned_alloc + memset之后");
        free(mapped);
        printMemInfo("free aligned_alloc之后");
    }
    std::cout << "观察结论:\n"
              << "  1. malloc后VSZ立即增加,但RSS未必增加(按需分配)\n"
              << "  2. 实际写入后RSS才增加(页错误触发物理映射)\n"
              << "  3. free后RSS不一定立即减少(分配器保留备用)\n";
    return 0;
}

编译和运行:

g++ -O0 -o mem_demo mem_demo.cpp
./mem_demo

预期输出示意:

PID: 12345
[初始状态]
  VSZ (虚拟内存): 4 MB
  RSS (物理内存): 1 MB
[malloc(100MB)之后(还未访问)]
  VSZ (虚拟内存): 104 MB   <- VSZ增加了100MB
  RSS (物理内存): 1 MB     <- RSS几乎不变!(按需分配的体现)
[写入所有页之后]
  VSZ (虚拟内存): 104 MB
  RSS (物理内存): 101 MB   <- RSS增加了,物理页已分配
[free()之后(注意RSS可能没有下降)]
  VSZ (虚拟内存): 104 MB   <- glibc保留了这块虚拟地址
  RSS (物理内存): 101 MB   <- 物理内存可能仍被glibc持有

关键公式汇总

进程内存大小关系:
VSZ ≥ RSS \text{VSZ} \geq \text{RSS} VSZRSS
RSS = ∑ 已映射到RAM的页 页大小 \text{RSS} = \sum_{\text{已映射到RAM的页}} \text{页大小} RSS=已映射到RAM的页页大小
PSS = 私有内存 + 共享内存 共享该内存的进程数 \text{PSS} = \text{私有内存} + \frac{\text{共享内存}}{\text{共享该内存的进程数}} PSS=私有内存+共享该内存的进程数共享内存
内存利用率:
利用率(%) = 总内存 − 可用内存 总内存 × 100 \text{利用率(\%)} = \frac{\text{总内存} - \text{可用内存}}{\text{总内存}} \times 100 利用率(%)=总内存总内存可用内存×100
其中"可用内存"包含可立即回收的文件缓存。
NUMA命中率:
NUMA命中率(%) = numa_hit numa_hit + numa_miss × 100 \text{NUMA命中率(\%)} = \frac{\text{numa\_hit}}{\text{numa\_hit} + \text{numa\_miss}} \times 100 NUMA命中率(%)=numa_hit+numa_missnuma_hit×100
TLB覆盖范围:
TLB覆盖 = TLB条目数 × 页大小 \text{TLB覆盖} = \text{TLB条目数} \times \text{页大小} TLB覆盖=TLB条目数×页大小

Linux 磁盘 I/O 性能深度解析(第九章笔记)

本文基于《Systems Performance》第9章整理,目标是用最通俗的语言把磁盘I/O的核心知识讲清楚。

目录

  1. 为什么磁盘性能很重要
  2. 核心术语速查
  3. 磁盘基础模型
  4. 关键概念详解
  5. 硬件架构
  6. Linux磁盘I/O软件栈
  7. RAID原理与性能
  8. 性能分析方法论
  9. 常用观测工具
  10. 可视化分析
  11. 磁盘基准测试
  12. 调优参数
  13. 综合示例分析
  14. 完整C++代码示例

1. 为什么磁盘性能很重要

磁盘I/O是系统中最慢的操作之一。可以用一个直观比喻来理解各层存储的速度差距:

如果把"CPU访问L1缓存"比作"从桌子上拿文件"(1秒),那么:
CPU L1缓存     ~1 ns   →  1秒(从桌上拿文件)
CPU L2缓存     ~4 ns   →  4秒
主内存 RAM     ~100 ns →  100秒(约2分钟,去隔壁房间)
SSD读取        ~100 us →  28小时
机械硬盘       ~8 ms   →  3.5个月!

当大量应用程序同时需要磁盘数据,而磁盘跟不上速度时,CPU就只能干等,系统吞吐量骤降。磁盘I/O分析的目标就是找到瓶颈、消除等待

2. 核心术语速查


术语 通俗解释
IOPS 每秒完成的I/O操作次数(Input/Output Operations Per Second)
吞吐量 (Throughput) 每秒传输的数据量(MB/s),类似"水管流量"
带宽 (Bandwidth) 硬件最大数据传输速率上限,类似"水管最大直径"
I/O延迟 (Latency) 一次I/O从发起到完成的总时间
服务时间 (Service Time) 磁盘实际处理I/O的时间(不含等待队列)
等待时间 (Wait Time) I/O在队列中等候的时间
延迟异常值 (Latency Outlier) 比正常I/O慢得多的"偶发慢I/O"
扇区 (Sector) 磁盘存储的基本单位,传统512字节,现代多为4KB
虚拟磁盘 由多块物理磁盘模拟成的单一逻辑磁盘
HBA 主机总线适配器(Host Bus Adapter),即磁盘控制器卡

3. 磁盘基础模型

3.1 简单磁盘模型——像超市收银台

磁盘有一个内置队列,I/O请求到达后先排队,再被处理:

应用程序发出I/O请求
        |
        v
+------------------+
|   OS内核队列      |  <-- 先在操作系统排队
+------------------+
        |
        v
+------------------+
|   磁盘内部队列    |  <-- 再到磁盘内部队列
+------------------+
        |
        v
+------------------+
|   磁盘实际处理    |  <-- 磁盘磁头寻道、读写
+------------------+
        |
        v
     I/O完成,通知OS

这就像超市收银台:顾客(I/O请求)先排队,收银员(磁盘)逐个处理。磁盘内部的控制器还可以对队列中的I/O重排序(电梯算法),进一步优化处理效率。

3.2 带缓存的磁盘模型

磁盘通常内置少量DRAM作为缓存,用于:

  • 读缓存:缓存最近读过的数据,下次读同一块时直接从缓存返回(极快)
  • 写回缓存:数据先写入缓存就返回"完成",之后再慢慢写到磁盘介质
写I/O路径(写回模式):
  应用层写入 -> 磁盘缓存(快) -> 返回完成 | 后台 -> 磁盘介质(慢)
                                            ^
                          应用不需要等待这一步!

注意:写回缓存必须配合电池(BBU)使用,防止断电导致缓存数据丢失。

4. 关键概念详解

4.1 I/O时间的精确定义

这是最容易混淆的地方,书中给出了非常清晰的层次划分:

时间轴:
  t0          t1          t2          t3
  |           |           |           |
  +--OS等待---+---磁盘等待--+---磁盘处理--+
  |<-- 块I/O等待时间 -->|             |
                        |<-- 磁盘服务时间 -->|
  |<------------ 块I/O服务时间 ----------->|
  |<------------- 块I/O请求时间 ----------->|
                        (也叫磁盘I/O延迟)
  • 块I/O等待时间:I/O在OS内核队列中排队的时间(t0t1
  • 块I/O服务时间:I/O从发给磁盘到磁盘返回完成的时间(t1t3),这是 iostat 默认显示的 await
  • 块I/O请求时间:I/O从创建到完成的全部时间(t0t3
    平均磁盘服务时间可以用以下公式推算(但此公式在并行I/O时不精确):
    平均服务时间 = 利用率(%) IOPS × 1000  ms \text{平均服务时间} = \frac{\text{利用率(\%)}}{\text{IOPS}} \times 1000 \text{ ms} 平均服务时间=IOPS利用率(%)×1000 ms
    示例:利用率60%,IOPS=300,则平均服务时间 = 600 ms 300 = 2 ms = \frac{600\text{ms}}{300} = 2\text{ms} =300600ms=2ms

4.2 各类磁盘的延迟量级对比


操作类型 延迟 按比例放大(缓存命中=1秒)
磁盘内缓存命中 <100 微秒 1秒
SSD读取(小I/O) ~100-1000 微秒 1-10秒
机械盘顺序读 ~1 毫秒 10秒
机械盘随机读(7200rpm) ~8 毫秒 1.3分钟
机械盘随机读(队列满) >100 毫秒 17分钟
最差情况(RAID-5随机I/O) >1000 毫秒 2.8小时

这个对比说明:对机械盘来说,随机I/O和顺序I/O的性能差距可达100倍以上。

4.3 随机I/O vs 顺序I/O

机械盘的痛点:磁头需要物理移动才能到达目标位置(寻道),盘片需要旋转到目标扇区(旋转等待)。

机械盘读取扇区1和扇区2:
  盘片示意图(俯视):
         扇区1                 扇区2
           |                    |
     ------o------         ------o------
    /       \   /         /      \    /
   /    磁头  \/         /         \/
  |    当前位置|         |           |
   \         /     ->    \         /
    \       /             \       /
     ------                ------
  顺序I/O: 扇区2紧接在扇区1后面 -> 磁头几乎不用移动,很快
  随机I/O: 扇区2在磁盘另一处   -> 磁头大幅寻道 + 等待旋转,很慢

SSD没有这个问题:闪存是电子存储,没有机械移动部件,随机读和顺序读性能接近。

4.4 I/O利用率的正确理解

磁盘利用率 = 磁盘忙于处理I/O的时间 观察时间段总长 × 100 % \text{磁盘利用率} = \frac{\text{磁盘忙于处理I/O的时间}}{\text{观察时间段总长}} \times 100\% 磁盘利用率=观察时间段总长磁盘忙于处理I/O的时间×100%
几个容易踩的坑:
坑1:100%利用率不一定是问题——关键看响应时间是否可接受。
坑2:虚拟磁盘(如RAID卡提供的)的利用率含义模糊:

  • RAID卡可能同时在多块物理盘并行处理,OS报100%并不代表物理盘都满了
  • 写回缓存命中时磁盘立刻返回,OS看到利用率很低,但物理盘仍在后台忙碌
    坑3:I/O wait(CPU的iowait指标)会误导人:
场景:
  线程A在等待磁盘 -> CPU空闲 -> iowait = 高
  此时来了一个CPU密集型任务:
  线程B开始跑    -> CPU忙碌 -> iowait 下降!
  但线程A还在等磁盘!磁盘问题并没消失!

所以应该直接测量线程被磁盘阻塞的时间,而不是看iowait。

4.5 饱和度

饱和度 = 平均队列长度(aqu-sz) > 0  时,说明开始排队 \text{饱和度} = \text{平均队列长度(aqu-sz)} > 0 \text{ 时,说明开始排队} 饱和度=平均队列长度(aqu-sz>0 时,说明开始排队
饱和度是"超过磁盘处理能力、开始积压工作"的度量。100%利用率的磁盘可能没有积压(处理得刚好),也可能积压严重(每个I/O都要排队等很久)。

4.6 同步 vs 异步I/O

同步I/O(应用会阻塞):
  应用 --发出读请求--> OS --> 磁盘
  应用 <-----等待-------- (卡在这里)
  应用 <--数据返回------- OS <-- 磁盘完成
异步I/O(应用不阻塞):
  应用 --发出读请求--> OS --> 磁盘
  应用 --继续执行其他工作-->
  ...
  磁盘完成 -> OS -> 通知应用(回调/事件)

写回缓存本质上是让写I/O变成异步:应用发出写请求,数据进缓存就返回"完成",实际写盘在后台异步完成。

5. 硬件架构

5.1 机械硬盘(HDD)内部结构

机械硬盘剖面示意:
  主轴(高速旋转)
       |
  +----+----+
  |  盘片1   |  <- 铁氧化物涂层,用磁化方向存储0/1
  +----+----+
  |  盘片2   |
  +----+----+
       |
  磁臂(可左右摆动)
       |
  磁头(读写元件,每个盘面一个)
  数据组织:
  盘片 -> 磁道(同心圆) -> 扇区(磁道分成的小块)

关键性能参数

  • 转速:5400/7200/10K/15K rpm,转速越高,旋转等待越短
  • 寻道时间:磁头移动到目标磁道的时间(毫秒级)
    机械盘的理论最大吞吐量(了解即可,现代磁盘暴露的参数已不真实):
    最大吞吐量 = 每道最大扇区数 × 扇区大小 × rpm 60 s \text{最大吞吐量} = \text{每道最大扇区数} \times \text{扇区大小} \times \frac{\text{rpm}}{60\text{s}} 最大吞吐量=每道最大扇区数×扇区大小×60srpm
    短行程(Short-stroking)优化:只使用磁盘最外圈的磁道,因为:
  • 寻道距离短(磁头移动范围小)
  • 外圈磁道更长,扇区更多,顺序读吞吐量更高(区域位密度)

5.2 固态硬盘(SSD)闪存特性

闪存的本质特点(导致读写不对称):

读操作:可以按页读取(通常8KB),速度很快
写操作(麻烦在这里):
  1. 必须先擦除整个块(通常256KB~512KB)
  2. 才能写入新数据
  => 小写I/O(比如4KB写)会触发"读-修改-写":
     读出整个块 -> 修改其中4KB -> 擦除整块 -> 写回整块
     这就是写放大(Write Amplification)!

闪存类型对比(可靠性从高到低):

类型 每格存储 程序/擦除寿命 适用场景
SLC 1位 50,000-100,000次 企业级高可靠
MLC 2位 5,000-10,000次 企业/消费级
TLC 3位 ~3,000次 消费级
QLC 4位 ~1,000次 低成本归档

SSD的写性能优化机制

  • 磨损均衡(Wear Leveling):把写操作分散到所有块,延长使用寿命
  • TRIM命令:告知SSD哪些块已不需要,SSD可提前整理,减少后续写放大
  • 内存过量配置(Overprovisioning):预留额外空间用于写操作缓冲

5.3 NVMe——最快的存储接口

NVMe(Non-Volatile Memory Express)直接接PCIe总线,绕过了传统SATA/SAS控制器的瓶颈:

传统SSD(SATA接口):
  CPU -> SATA控制器 -> SATA线 -> SSD
  最大带宽:~600MB/s,队列深度:32
NVMe SSD(PCIe接口):
  CPU -> PCIe总线 -> NVMe SSD
  最大带宽:~7000MB/s(PCIe 4.0 x4)
  支持多队列:每队列最多65535个命令!
  预期延迟:<20微秒

接口带宽对比汇总:

接口 带宽 特点
SATA 3.0 6 Gbps(实际~550MB/s) 消费级常用
SAS-3 12 Gbps 企业级
SAS-4 22.5 Gbps 最新企业级
NVMe(PCIe 4.0 x4) ~7000 MB/s 目前最快

6. Linux磁盘I/O软件栈

6.1 完整I/O路径

从应用发出I/O到数据从磁盘返回,经过以下层次:

应用程序
read/write系统调用

VFS虚拟文件系统层

具体文件系统
ext4 / xfs / btrfs

页缓存 Page Cache

块设备层 Block Layer

I/O合并 I/O Merging

I/O调度器 I/O Scheduler

设备驱动 Device Driver

物理磁盘

6.2 I/O合并——减少系统调用开销

Linux会把多个相邻的小I/O合并成一个大I/O,减少每次I/O的内核开销:

前合并(Front Merge):新I/O的结束位置 = 已有I/O的开始位置
  已有: [扇区100..150]
  新来: [扇区90..100]
  合并: [扇区90..150]
后合并(Back Merge,更常见):新I/O的开始位置 = 已有I/O的结束位置
  已有: [扇区100..150]
  新来: [扇区150..200]
  合并: [扇区100..200]

iostat 中的 rqm/s(合并请求数/秒)非零说明有顺序工作负载正在被合并优化。

6.3 I/O调度器——多队列架构(Linux 5.0+)

Linux 5.0之后,经典的单队列调度器被废弃,全面切换为多队列调度器(blk-mq):

旧架构(单队列,瓶颈在锁竞争):
  CPU0 \
  CPU1  +---> [全局单一队列(有锁)] ---> 磁盘
  CPU2 /
新架构(多队列,无锁竞争):
  CPU0 ---> [CPU0专属提交队列]  \
  CPU1 ---> [CPU1专属提交队列]  +---> [多个调度队列] ---> 磁盘
  CPU2 ---> [CPU2专属提交队列]  /

现代多队列调度器选项:

调度器 适用场景 特点
none NVMe高性能SSD 不调度,直接下发
mq-deadline 通用场景 防止I/O饿死,设置超时
BFQ 桌面/交互式 按进程公平分配带宽
Kyber 云环境(Netflix默认) 按目标延迟动态调整队列

查看和修改当前I/O调度器:

# 查看当前调度器(方括号内为当前使用的)
cat /sys/block/nvme0n1/queue/scheduler
# 输出:[none] mq-deadline kyber bfq
# 修改为mq-deadline
echo mq-deadline > /sys/block/nvme0n1/queue/scheduler

7. RAID原理与性能

RAID(独立磁盘冗余阵列)把多块磁盘组合成一个逻辑磁盘,兼顾性能和可靠性。

7.1 主要RAID级别性能对比

RAID-5 带奇偶校验

磁盘1
数据

磁盘2
数据

磁盘3
奇偶校验P

RAID-1 镜像

磁盘1
块A B C

磁盘2
块A B C 副本

RAID-0 条带化

磁盘1
块A1 B1 C1

磁盘2
块A2 B2 C2


RAID级别 读性能 写性能 容错 说明
RAID-0(条带) 最好(多盘并行) 最好 一块盘坏数据全丢
RAID-1(镜像) 好(可双盘读) 受最慢盘限制 1块盘 写入两份,吞吐减半
RAID-10 类似RAID-1 类似RAID-1 每组1块 RAID-0+RAID-1
RAID-5 一般 (有惩罚) 1块盘 写惩罚严重
RAID-6 一般 更差 2块盘 比RAID-5更差

7.2 RAID-5的写惩罚——"读-改-写"问题

RAID-5每次小写I/O都需要额外的读操作来重算奇偶校验:

RAID-5写入一个小块的步骤:
  步骤1: 读出旧数据块(磁盘I/O!)
  步骤2: 读出旧奇偶校验块(磁盘I/O!)
  步骤3: 计算新奇偶校验(CPU计算)
  步骤4: 写入新数据块(磁盘I/O)
  步骤5: 写入新奇偶校验块(磁盘I/O)
  总计:2次读 + 2次写 = 4次磁盘I/O,只为了完成1次逻辑写!

这就是为什么RAID-5写性能差,尤其是随机小写场景。带写回缓存的RAID控制器可以缓解这个问题,但需要电池保护缓存数据。

8. 性能分析方法论

8.1 USE方法——快速定位瓶颈

对每块磁盘和每个控制器检查三项:
U(利用率)
磁盘利用率 = iostat中的%util字段 \text{磁盘利用率} = \text{iostat中的\%util字段} 磁盘利用率=iostat中的%util字段

  • %util < 60%:基本健康
  • %util > 60%:需要关注(可能开始排队)
  • %util = 100%:持续多秒 = 几乎肯定是瓶颈
    S(饱和度)
    饱和度 ≈ iostat中的aqu-sz(平均队列长度) \text{饱和度} \approx \text{iostat中的aqu-sz(平均队列长度)} 饱和度iostat中的aqu-sz(平均队列长度)
    aqu-sz > 1 说明I/O在排队,值越大饱和越严重。
    E(错误)
# 查看磁盘错误日志
dmesg | grep -i "error\|fail\|reset"
# 查看SMART健康状态
smartctl --all /dev/sda

8.2 工作负载特征描述

一个完整的磁盘工作负载描述应包含以下维度:

系统磁盘有较轻的随机读工作负载:
  平均 350 IOPS,吞吐量 3 MB/s,96%是读I/O。
  I/O大小约8KB。
  偶尔有顺序写突发,持续2-5秒,
  峰值可达4800 IOPS和560 MB/s,写I/O约128KB。

关键特征清单:

  • I/O速率(IOPS):读/写分别是多少?
  • 吞吐量(MB/s):读/写分别多少?
  • I/O大小分布:主要是小I/O还是大I/O?
  • 随机 vs 顺序:哪种为主?
  • 读写比例:读多还是写多?

8.3 延迟分析——定位慢I/O的来源

应用层感知的延迟
        |
        v
文件系统层延迟(是否有文件系统锁/元数据I/O?)
        |
        v
块设备层延迟(OS队列等待时间?)
        |
        v
磁盘设备延迟(物理磁盘响应时间?)

方法:在不同层分别测量延迟,哪层最大就是瓶颈所在。

9. 常用观测工具

9.1 iostat——磁盘健康状态一览

iostat 是最常用的磁盘观测命令,来自 sysstat 包。
基础使用

iostat -sxz 1    # 短格式扩展输出,跳过零活动设备,每秒刷新

关键字段解读(以示例输出为基础):

Device    tps    kB/s   rqm/s   await  aqu-sz  areq-sz  %util
nvme0n1  1642   9064    664     0.44    0.00     5.52   100.00
^         ^      ^       ^       ^       ^        ^        ^
磁盘名  IOPS  吞吐KB/s 合并数  平均响  队列长  平均大  利用率
                              应时ms          小KB

字段详解:

字段 含义 异常信号
tps 每秒I/O操作数(IOPS) 持续极高需关注
kB/s 每秒读写KB数 接近磁盘上限时警惕
rqm/s 每秒合并的请求数 非零说明有顺序访问
await 平均I/O响应时间(ms) 机械盘>10ms,SSD>1ms需关注
aqu-sz 平均队列长度 >1说明开始排队
areq-sz 平均请求大小(KB) 小值(<8KB)说明随机I/O
%util 磁盘利用率 >60%开始关注,=100%持续即瓶颈

分读写查看-x-s):

iostat -xz 1

这会把读(r/srkB/sr_await)和写(w/swkB/sw_await)分开显示。
r_await(读延迟)通常比 w_await(写延迟)更重要,因为写可以由页缓存缓冲。
包含分区统计

iostat -dmstxz -p ALL 1    # -d仅磁盘 -m用MB -s短格式 -t时间戳 -p ALL含分区

9.2 PSI——磁盘压力统计(Linux 4.20+)

cat /proc/pressure/io
# some avg10=63.11 avg60=32.18 avg300=8.62 total=667212021
# full avg10=60.76 avg60=31.13 avg300=8.35 total=622722632
  • some:部分线程因I/O等待而停滞的时间百分比
  • full:所有可运行线程都在等I/O的时间百分比
  • avg10=63.11 > avg300=8.62 → 磁盘压力正在快速升高(需要立即关注!)

9.3 pidstat——按进程查看磁盘I/O

pidstat -d 1    # 每秒刷新,显示有磁盘I/O的进程
UID    PID    kB_rd/s  kB_wr/s  kB_ccwr/s  iodelay  Command
0      2705   32468    0        0           5        tar
0      2706   0        8192     0           0        gzip
  • kB_rd/s:每秒读取KB
  • kB_wr/s:每秒写入KB
  • iodelay:进程被磁盘I/O阻塞的时钟tick数(越大越说明磁盘是瓶颈
  • kB_ccwr/s:写入被取消的KB(写入前数据被覆盖或文件被删除)

9.4 biolatency——I/O延迟分布直方图

biolatency 是BCC工具,显示磁盘I/O延迟的直方图分布:

biolatency 10 1    # 采样10秒,输出1次

示例输出:

usecs               : count    distribution
    128 -> 255      : 1065    |*****************                       |
    256 -> 511      : 2462    |****************************************|
    512 -> 1023     : 1949    |*******************************         |
   1024 -> 2047     : 373     |******                                  |
   2048 -> 4095     : 1815    |*****************************           |   <- 第二个峰!
   4096 -> 8191     : 591     |*********                               |
  16384 -> 32767    : 50      |                                        |   <- 延迟异常值

这个输出显示双峰分布:第一峰(256-1023微秒)和第二峰(2048-4095微秒)。双峰通常意味着两种I/O类型(如读命中缓存 vs 读未命中缓存,或者读I/O vs 写I/O)。
常用选项:

biolatency -m         # 以毫秒为单位
biolatency -Q         # 包含OS队列等待时间(测量完整块I/O请求时间)
biolatency -F         # 按I/O标志类型分别显示直方图
biolatency -D         # 按磁盘设备分别显示

9.5 biosnoop——逐条查看磁盘I/O事件

biosnoop    # 打印每个磁盘I/O的详细信息

输出示例:

TIME(s)      COMM     PID    DISK    T  SECTOR    BYTES    LAT(ms)
0.016809     mysqld   1948   nvme0n1 W  10227712  262144   1.82
0.017184     mysqld   1948   nvme0n1 W  10228224  262144   2.19
0.017679     mysqld   1948   nvme0n1 W  10228736  262144   2.68
0.018056     mysqld   1948   nvme0n1 W  10229248  262144   3.05

注意 LAT(ms) 列从1.82逐步增大到3.05:这是队列积压的典型模式——这批写I/O几乎同时发出,在磁盘内部队列中排队,越排越晚完成的延迟越高。
找延迟异常值(Outlier)的方法

# 1. 把输出写到文件
biosnoop > out.biosnoop.txt
# 2. 按延迟列排序,找最慢的5个I/O
sort -n -k 8,8 out.biosnoop.txt | tail -5

9.6 biotop——磁盘版top命令

biotop    # 实时显示占用磁盘I/O最多的进程
PID    COMM    D  DISK    I/O    Kbytes  AVGms
14501  cksum   R  xvda1   361    28832   3.39
6961   dd      R  xvda1   1628   13024   0.59

快速识别"谁在消耗磁盘"。

9.7 biostacks——I/O的内核调用栈

biostacks.bt    # 显示触发磁盘I/O的内核调用栈 + 延迟分布

这个工具非常适合调查神秘的后台磁盘I/O:比如ZFS后台数据校验、文件系统日志刷新等,不是应用程序直接触发的I/O。

9.8 blktrace——底层块设备事件跟踪

blktrace 是Linux的专用块设备跟踪工具,能记录每个I/O经历的每个阶段:

btrace /dev/sdb    # 等同于 blktrace -d /dev/sdb -o - | blkparse -i

每个I/O会产生多行输出,对应不同的动作事件:

事件代码 含义
Q I/O进入请求队列
G 获取请求结构体
I I/O插入请求队列
D I/O下发给驱动
C I/O完成
M I/O后合并
F I/O前合并
X I/O被拆分

btt工具分析I/O各阶段耗时

blktrace -d /dev/nvme0n1p1 -o out -w 10    # 采集10秒
blkparse -i out.blktrace.* -d out.bin
btt -i out.bin

输出的关键时间段:

  • Q2C:I/O从进队列到完成的全部时间
  • D2C:I/O从下发到磁盘到完成(磁盘响应时间)
  • I2D:I/O在内核请求队列中等待的时间

9.9 bpftrace——自定义磁盘追踪

# 统计各I/O类型(读/写/同步写等)的数量
bpftrace -e 't:block:block_rq_issue { @[args->rwbs] = count(); }'
# 按进程名统计I/O大小分布(直方图)
bpftrace -e 't:block:block_rq_issue /args->bytes/ { @[comm] = hist(args->bytes); }'
# 追踪磁盘I/O错误
bpftrace -e 't:block:block_rq_complete /args->error/ {
    printf("dev %d type %s error %d\n", args->dev, args->rwbs, args->error); }'
# 统计block I/O tracepoint事件数量
bpftrace -e 'tracepoint:block:* { @[probe] = count(); }'
# 测量磁盘I/O延迟(从下发到完成)
bpftrace -e '
t:block:block_rq_issue          { @start[args->dev, args->sector] = nsecs; }
t:block:block_rq_complete
/@start[args->dev, args->sector]/
{
    @usecs = hist((nsecs - @start[args->dev, args->sector]) / 1000);
    delete(@start[args->dev, args->sector]);
}'

RWBS字段说明(出现在多个工具中):

字母 含义
R 读(Read)
W 写(Write)
S 同步(Synchronous)
A 预读(Read-Ahead)
F 刷新(Flush)
D 丢弃/TRIM(Discard)
M 元数据(Metadata)

组合示例:WS = 同步写,RA = 预读,WM = 元数据写

9.10 smartctl——磁盘健康自检

smartctl --all /dev/sda    # 读取SMART健康数据

重点关注的字段:

  • SMART Health Status: OK → 磁盘整体健康
  • Elements in grown defect list → 坏扇区数量(应为0)
  • Errors Corrected by ECC → 纠错次数(持续增长说明磁盘老化)
  • uncorrected errors → 不可纠正错误数(非零 = 磁盘快挂了!)

10. 可视化分析

10.1 延迟散点图

用于可视化每个I/O的延迟,x轴是完成时间,y轴是延迟:

延迟(ms)
  ^
  |
150|     +          <- 读I/O异常值(150ms!)
  |
 50|
  |
 10|
  |
  1| °°°°°°°++++++°°°°°++++°°°°    <- 正常范围
  |
  +------------------------------> 时间(s)
  + = 读I/O   ° = 写I/O

当事件太多时,散点图变成"油漆",此时改用热力图。

10.2 延迟热力图

热力图是延迟分布随时间变化的最佳可视化方式:

  • X轴:时间
  • Y轴:延迟范围
  • 颜色深浅:该时间段落在该延迟范围的I/O数量(越深越多)
    热力图能显示散点图"看不见"的模式:比如定期出现的高延迟(flush操作)、双峰分布的演变等。

10.3 I/O偏移热力图(磁头轨迹图)

  • X轴:时间
  • Y轴:磁盘扇区偏移量(块地址)
  • 颜色:I/O密度
    深色水平线 = 顺序I/O,轻色云雾状 = 随机I/O。可以清楚看出工作负载的访问模式。

11. 磁盘基准测试

11.1 dd——简单顺序读写测试

# 顺序读测试(直接读设备,1MB块大小)
dd if=/dev/sda1 of=/dev/null bs=1024k count=1k
# 顺序写测试(通过文件系统,使用direct I/O绕过页缓存)
dd if=/dev/zero of=testfile bs=1024k count=1000 oflag=direct
# 输出示例:
# 1048576000 bytes copied, 1.79 s, 585 MB/s

iflag=direct 读文件时也绕过缓存。

11.2 ioping——低影响的延迟探测

ioping /dev/nvme0n1

类似网络的 ping 命令,每秒发一个4KB读I/O,打印每次的延迟:

4 KiB <<< /dev/nvme0n1: request=1 time=438.7 us
4 KiB <<< /dev/nvme0n1: request=2 time=421.0 us
...
min/avg/max/mdev = 412.6 us / 437.9 us / 468.8 us / 22.4 us

优点:磁盘利用率只有0.4%,不影响生产。可用于在线监测。

11.3 hdparm——快速基准

hdparm -Tt /dev/sdb
# Timing cached reads:   16718 MB in  2.00 seconds = 8367 MB/sec   <- 缓存命中速度
# Timing buffered disk reads: 846 MB in  3.00 seconds = 281 MB/sec  <- 实际磁盘速度

两者差距(16718 vs 846 MB/s)体现了缓存效果。

11.4 fio——灵活的I/O基准工具

# 随机4KB读,队列深度32
fio --name=randread --rw=randread --bs=4k --numjobs=1 --iodepth=32 \
    --direct=1 --filename=/dev/nvme0n1 --runtime=30
# 顺序1MB写
fio --name=seqwrite --rw=write --bs=1m --numjobs=1 --iodepth=4 \
    --direct=1 --filename=/dev/nvme0n1 --runtime=30

fio 支持几乎所有测试场景组合(随机/顺序、读/写/混合、不同块大小、不同并发度)。

12. 调优参数

12.1 操作系统调优

I/O调度器选择

# 查看可用调度器
cat /sys/block/nvme0n1/queue/scheduler
# 对NVMe SSD,推荐none(无调度开销)
echo none > /sys/block/nvme0n1/queue/scheduler
# 对机械盘,推荐mq-deadline(防止I/O饿死)
echo mq-deadline > /sys/block/sda/queue/scheduler

预读大小

# 顺序读工作负载可增大预读(单位KB)
echo 8192 > /sys/block/sda/queue/read_ahead_kb

请求队列深度

# 增大队列深度,允许更多并发I/O
echo 256 > /sys/block/nvme0n1/queue/nr_requests

ionice——设置进程I/O优先级

# 把备份进程设为空闲级别(不抢占其他进程的I/O)
ionice -c 3 -p $(pgrep backup)
# I/O调度类别:
# 1 = 实时(最高优先级,谨慎使用)
# 2 = 尽力而为(默认,0-7级别)
# 3 = 空闲(磁盘完全空闲时才处理)

cgroup磁盘限速

# 限制某cgroup的读写速率(字节/秒)
# 例如限制为100MB/s读,50MB/s写(设备号8:0对应/dev/sda)
echo "8:0 104857600" > /sys/fs/cgroup/blkio/<group>/blkio.throttle.read_bps_device
echo "8:0 52428800"  > /sys/fs/cgroup/blkio/<group>/blkio.throttle.write_bps_device

12.2 TRIM(SSD维护)

# 手动对整个文件系统执行TRIM(释放已删除文件占用的块)
fstrim -v /
# 挂载时自动启用TRIM(在/etc/fstab中添加discard选项)
# /dev/nvme0n1p1  /  ext4  defaults,discard  0 1

13. 综合示例分析

分析一段iostat输出

题目给出了以下输出:

avg-cpu:  %user   %nice  %system  %iowait  %steal  %idle
           3.23    0.00    45.16    31.18    0.00   20.43
Device:    rrqm/s  wrqm/s  r/s     w/s    rkB/s    wkB/s  avgrq-sz  avgqu-sz  await  r_await  w_await  svctm  %util
vda        39.78   13156   800.00  151.61  3466.67  41200  93.88     11.99     7.49   0.57     44.01    0.49   46.56

逐项分析:
CPU层面

  • %system = 45.16%:内核态CPU非常高,大量时间在处理I/O请求
  • %iowait = 31.18%:CPU有31%时间因等待I/O而空闲
  • %idle = 20.43%:只有20%真正空闲
    磁盘层面(vda)

指标 分析
r/s 800 每秒800次读I/O,读负载很高
w/s 151.61 每秒151次写I/O
wkB/s 41200 写吞吐40MB/s
wrqm/s 13156 大量写合并!顺序写负载
await 7.49ms 总平均响应时间
r_await 0.57ms 读延迟很低(可能命中缓存)
w_await 44.01ms 写延迟非常高!
avgqu-sz 11.99 队列长度约12,严重积压
%util 46.56% 利用率46%,看似不高

结论

  1. 写I/O延迟(44ms)远高于读(0.57ms),写是瓶颈
  2. 队列长度接近12,大量写I/O在排队
  3. 虽然利用率只有46%,但队列积压说明磁盘已经饱和(可能是虚拟磁盘的局限性,或写回缓存已满)
  4. 大量写合并(wrqm/s=13156)说明是顺序写工作负载
  5. %system 高达45% 与大量I/O操作有关
    下一步
biosnoop    # 查看具体是哪些I/O在等待
pidstat -d 1  # 找出是哪个进程在大量写

14. 完整C++代码示例

以下是一个完整的C++程序,演示磁盘I/O性能测量:顺序读、随机读的延迟对比,并读取/proc/diskstats获取实时磁盘统计。

/*
 * disk_io_bench.cpp
 * 功能:
 *   1. 对文件进行顺序读/随机读,测量平均延迟
 *   2. 从 /proc/diskstats 读取磁盘统计信息
 *
 * 编译:g++ -O2 -o disk_io_bench disk_io_bench.cpp
 * 运行:sudo ./disk_io_bench /path/to/testfile [磁盘名如nvme0n1]
 */
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <chrono>
#include <random>
#include <cstring>
#include <cstdlib>
#include <fcntl.h>        // open, O_RDONLY, O_DIRECT
#include <unistd.h>       // read, lseek, close, getpagesize
#include <sys/stat.h>     // fstat
#include <sys/types.h>    // off_t
// ============================================================
//  辅助函数:获取当前时间戳(纳秒)
// ============================================================
static long long now_ns() {
    using namespace std::chrono;
    return duration_cast<nanoseconds>(
        steady_clock::now().time_since_epoch()
    ).count();
}
// ============================================================
//  结构体:/proc/diskstats 的单块磁盘统计
//  字段含义参考 kernel docs: Documentation/iostats.txt
// ============================================================
struct DiskStats {
    std::string name;       // 设备名,如 nvme0n1
    unsigned long rd_ios;   // 完成的读I/O次数
    unsigned long rd_merges;// 合并的读请求数
    unsigned long rd_sectors;// 读取的扇区数(512字节/扇区)
    unsigned long rd_ticks; // 读I/O花费的总时间(毫秒)
    unsigned long wr_ios;   // 完成的写I/O次数
    unsigned long wr_merges;// 合并的写请求数
    unsigned long wr_sectors;// 写入的扇区数
    unsigned long wr_ticks; // 写I/O花费的总时间(毫秒)
    unsigned long in_flight;// 当前正在处理的I/O数
    unsigned long io_ticks; // 磁盘忙碌时间(毫秒),用于计算利用率
    unsigned long time_in_queue; // 加权I/O等待时间(用于计算平均队列长度)
};
// ============================================================
//  读取 /proc/diskstats,返回指定设备的统计信息
// ============================================================
bool read_diskstats(const std::string& dev_name, DiskStats& ds) {
    std::ifstream f("/proc/diskstats");
    if (!f.is_open()) {
        std::cerr << "无法打开 /proc/diskstats\n";
        return false;
    }
    std::string line;
    while (std::getline(f, line)) {
        std::istringstream iss(line);
        unsigned int major, minor;
        std::string name;
        iss >> major >> minor >> name;
        if (name != dev_name) continue;
        // /proc/diskstats 字段按顺序读取
        iss >> ds.rd_ios    >> ds.rd_merges  >> ds.rd_sectors >> ds.rd_ticks
            >> ds.wr_ios    >> ds.wr_merges  >> ds.wr_sectors >> ds.wr_ticks
            >> ds.in_flight >> ds.io_ticks   >> ds.time_in_queue;
        ds.name = name;
        return true;
    }
    return false; // 未找到该设备
}
// ============================================================
//  计算两次采样之间的磁盘利用率
//  利用率 = (io_ticks差值) / 采样间隔(ms) * 100%
// ============================================================
double calc_utilization(const DiskStats& before, const DiskStats& after,
                         double interval_ms) {
    unsigned long io_ticks_delta = after.io_ticks - before.io_ticks;
    // io_ticks 单位是毫秒,interval_ms 也是毫秒
    return (double)io_ticks_delta / interval_ms * 100.0;
}
// ============================================================
//  计算平均I/O响应时间(await),单位ms
//  await = time_in_queue差值 / (rd_ios+wr_ios)差值
// ============================================================
double calc_await(const DiskStats& before, const DiskStats& after) {
    unsigned long total_ios = (after.rd_ios + after.wr_ios)
                            - (before.rd_ios + before.wr_ios);
    if (total_ios == 0) return 0.0;
    unsigned long total_ticks = (after.rd_ticks + after.wr_ticks)
                               - (before.rd_ticks + before.wr_ticks);
    return (double)total_ticks / (double)total_ios; // 单位:毫秒
}
// ============================================================
//  顺序读测试
//  参数:
//    fd        -- 已打开的文件描述符(建议用O_DIRECT绕过页缓存)
//    buf       -- 对齐的缓冲区(O_DIRECT要求512字节或4096字节对齐)
//    block_sz  -- 每次读的大小(字节)
//    count     -- 读多少次
//  返回:平均每次I/O延迟(微秒)
// ============================================================
double sequential_read_test(int fd, char* buf, size_t block_sz, int count) {
    // 回到文件开头
    lseek(fd, 0, SEEK_SET);
    long long total_ns = 0;
    int actual_count = 0;
    for (int i = 0; i < count; ++i) {
        long long t0 = now_ns();
        ssize_t ret = read(fd, buf, block_sz);
        long long t1 = now_ns();
        if (ret <= 0) break; // 文件读完或出错
        total_ns += (t1 - t0);
        actual_count++;
    }
    if (actual_count == 0) return 0.0;
    return (double)total_ns / actual_count / 1000.0; // 转换为微秒
}
// ============================================================
//  随机读测试
//  在文件随机位置读取,每次定位到随机扇区偏移
// ============================================================
double random_read_test(int fd, char* buf, size_t block_sz, int count,
                         off_t file_size) {
    // 计算对齐的随机位置数量(必须按block_sz对齐,O_DIRECT要求)
    off_t max_offset = (file_size / block_sz - 1) * block_sz;
    if (max_offset <= 0) {
        std::cerr << "文件太小,无法做随机读测试\n";
        return 0.0;
    }
    // 使用固定种子的随机数,保证可重复性
    std::mt19937_64 rng(42);
    std::uniform_int_distribution<off_t> dist(0, max_offset / block_sz);
    long long total_ns = 0;
    for (int i = 0; i < count; ++i) {
        // 生成对齐的随机偏移
        off_t offset = dist(rng) * (off_t)block_sz;
        lseek(fd, offset, SEEK_SET);
        long long t0 = now_ns();
        ssize_t ret = read(fd, buf, block_sz);
        long long t1 = now_ns();
        if (ret <= 0) break;
        total_ns += (t1 - t0);
    }
    return (double)total_ns / count / 1000.0; // 转换为微秒
}
// ============================================================
//  主函数
// ============================================================
int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cerr << "用法: " << argv[0]
                  << " <测试文件路径> [磁盘设备名,如nvme0n1]\n"
                  << "示例: " << argv[0] << " /tmp/testfile nvme0n1\n";
        return 1;
    }
    const std::string filepath = argv[1];
    const std::string devname  = (argc >= 3) ? argv[2] : "";
    // -------------------------------------------------------
    // 1. 打开文件
    //    O_RDONLY  : 只读
    //    O_DIRECT  : 绕过页缓存(直接I/O),测量真实磁盘性能
    //    注意:O_DIRECT 要求缓冲区地址和读写大小都按扇区大小对齐
    // -------------------------------------------------------
    int flags = O_RDONLY;
#ifdef O_DIRECT
    flags |= O_DIRECT;
    std::cout << "使用 O_DIRECT 模式(绕过页缓存)\n";
#else
    std::cout << "警告:O_DIRECT 不可用,结果可能包含缓存效果\n";
#endif
    int fd = open(filepath.c_str(), flags);
    if (fd < 0) {
        std::cerr << "无法打开文件: " << filepath
                  << " (错误:" << strerror(errno) << ")\n";
        return 1;
    }
    // 获取文件大小
    struct stat st;
    fstat(fd, &st);
    off_t file_size = st.st_size;
    std::cout << "文件大小: " << file_size / 1024 / 1024 << " MB\n\n";
    // -------------------------------------------------------
    // 2. 分配对齐的缓冲区
    //    O_DIRECT 要求缓冲区按 512 或 4096 字节对齐
    //    posix_memalign 保证对齐
    // -------------------------------------------------------
    const size_t BLOCK_SZ = 4096; // 4KB,与现代磁盘扇区大小匹配
    void* raw_buf = nullptr;
    if (posix_memalign(&raw_buf, BLOCK_SZ, BLOCK_SZ) != 0) {
        std::cerr << "内存分配失败\n";
        close(fd);
        return 1;
    }
    char* buf = static_cast<char*>(raw_buf);
    memset(buf, 0, BLOCK_SZ);
    // -------------------------------------------------------
    // 3. 顺序读测试
    // -------------------------------------------------------
    const int SEQ_COUNT  = 200;  // 顺序读200次(共800KB)
    const int RAND_COUNT = 200;  // 随机读200次
    std::cout << "=== 顺序读测试 ===\n";
    std::cout << "块大小: " << BLOCK_SZ << " 字节,读取次数: " << SEQ_COUNT << "\n";
    // 采集磁盘统计(测试前)
    DiskStats ds_before, ds_after;
    bool have_stats = false;
    if (!devname.empty()) {
        have_stats = read_diskstats(devname, ds_before);
    }
    auto t_start = std::chrono::steady_clock::now();
    double seq_avg_us = sequential_read_test(fd, buf, BLOCK_SZ, SEQ_COUNT);
    auto t_end = std::chrono::steady_clock::now();
    double elapsed_ms = std::chrono::duration<double, std::milli>(t_end - t_start).count();
    std::cout << "平均顺序读延迟: " << seq_avg_us << " 微秒\n";
    std::cout << "总耗时: " << elapsed_ms << " ms\n";
    std::cout << "实际IOPS: " << (int)(SEQ_COUNT / (elapsed_ms / 1000.0)) << "\n";
    if (have_stats) {
        read_diskstats(devname, ds_after);
        double util = calc_utilization(ds_before, ds_after, elapsed_ms);
        double await = calc_await(ds_before, ds_after);
        std::cout << "磁盘利用率: " << util << "%\n";
        std::cout << "磁盘平均响应时间: " << await << " ms\n";
        ds_before = ds_after;
    }
    // -------------------------------------------------------
    // 4. 随机读测试
    // -------------------------------------------------------
    std::cout << "\n=== 随机读测试 ===\n";
    std::cout << "块大小: " << BLOCK_SZ << " 字节,读取次数: " << RAND_COUNT << "\n";
    t_start = std::chrono::steady_clock::now();
    double rand_avg_us = random_read_test(fd, buf, BLOCK_SZ, RAND_COUNT, file_size);
    t_end = std::chrono::steady_clock::now();
    elapsed_ms = std::chrono::duration<double, std::milli>(t_end - t_start).count();
    std::cout << "平均随机读延迟: " << rand_avg_us << " 微秒\n";
    std::cout << "总耗时: " << elapsed_ms << " ms\n";
    std::cout << "实际IOPS: " << (int)(RAND_COUNT / (elapsed_ms / 1000.0)) << "\n";
    if (have_stats) {
        read_diskstats(devname, ds_after);
        double util = calc_utilization(ds_before, ds_after, elapsed_ms);
        double await = calc_await(ds_before, ds_after);
        std::cout << "磁盘利用率: " << util << "%\n";
        std::cout << "磁盘平均响应时间: " << await << " ms\n";
    }
    // -------------------------------------------------------
    // 5. 对比报告
    // -------------------------------------------------------
    std::cout << "\n=== 顺序读 vs 随机读 对比 ===\n";
    std::cout << "顺序读平均延迟: " << seq_avg_us  << " 微秒\n";
    std::cout << "随机读平均延迟: " << rand_avg_us << " 微秒\n";
    if (rand_avg_us > 0 && seq_avg_us > 0) {
        double ratio = rand_avg_us / seq_avg_us;
        std::cout << "随机读比顺序读慢: " << ratio << " 倍\n";
        if (ratio > 10) {
            std::cout << "提示:差距>10倍,这台可能是机械硬盘(HDD)\n";
        } else if (ratio < 3) {
            std::cout << "提示:差距<3倍,这台可能是SSD\n";
        }
    }
    // 清理资源
    free(raw_buf);
    close(fd);
    return 0;
}

编译和运行

# 编译
g++ -O2 -o disk_io_bench disk_io_bench.cpp
# 先创建一个足够大的测试文件(100MB),避免文件系统缓存干扰
dd if=/dev/urandom of=/tmp/testfile bs=1M count=100
# 运行测试(可选:同时监控nvme0n1磁盘的统计)
sudo ./disk_io_bench /tmp/testfile nvme0n1

典型输出(SSD)

使用 O_DIRECT 模式(绕过页缓存)
文件大小: 100 MB
=== 顺序读测试 ===
块大小: 4096 字节,读取次数: 200
平均顺序读延迟: 380 微秒
总耗时: 76 ms
实际IOPS: 2631
=== 随机读测试 ===
块大小: 4096 字节,读取次数: 200
平均随机读延迟: 420 微秒
总耗时: 84 ms
实际IOPS: 2380
=== 顺序读 vs 随机读 对比 ===
顺序读平均延迟: 380 微秒
随机读平均延迟: 420 微秒
随机读比顺序读慢: 1.1 倍
提示:差距<3倍,这台可能是SSD

典型输出(机械硬盘)

顺序读平均延迟: 1200 微秒
随机读平均延迟: 8500 微秒
随机读比顺序读慢: 7.1 倍
提示:差距>10倍,这台可能是机械硬盘(HDD)

关键公式汇总

平均服务时间(近似,单磁盘时有效):
平均服务时间 (ms) = 利用率(%) IOPS \text{平均服务时间 (ms)} = \frac{\text{利用率(\%)}}{\text{IOPS}} 平均服务时间 (ms)=IOPS利用率(%)
磁盘读吞吐量上限(机械盘理论值):
最大吞吐量 = 每道扇区数 × 扇区大小 × rpm 60 \text{最大吞吐量} = \text{每道扇区数} \times \text{扇区大小} \times \frac{\text{rpm}}{60} 最大吞吐量=每道扇区数×扇区大小×60rpm
磁盘IOPS利用率关系:
IOPS = 1 平均服务时间(s) × 并发I/O数 \text{IOPS} = \frac{1}{\text{平均服务时间(s)}} \times \text{并发I/O数} IOPS=平均服务时间(s)1×并发I/O
Little定律(队列论基础):
平均队列长度 (aqu-sz) = IOPS × 平均响应时间 (s) \text{平均队列长度 (aqu-sz)} = \text{IOPS} \times \text{平均响应时间 (s)} 平均队列长度 (aqu-sz)=IOPS×平均响应时间 (s)
磁盘利用率(从/proc/diskstats计算):
利用率(%) = Δ io_ticks (ms) 采样间隔 (ms) × 100 % \text{利用率(\%)} = \frac{\Delta\text{io\_ticks (ms)}}{\text{采样间隔 (ms)}} \times 100\% 利用率(%)=采样间隔 (ms)Δio_ticks (ms)×100%

第十章:网络 —— 深度解析

0. 本章全景图

应用程序(HTTP、数据库驱动等)
        |
        | 系统调用(send/recv/connect/accept)
        v
+----------------------------+
|   Socket 层(BSD 接口)     |
+----------------------------+
        |
+----------------------------+
|   TCP / UDP / QUIC         |  <-- 传输层:可靠性/流控/拥塞控制
+----------------------------+
        |
+----------------------------+
|   IP(IPv4 / IPv6)        |  <-- 网络层:路由/寻址
+----------------------------+
        |
+----------------------------+
|   以太网帧(Ethernet)      |  <-- 数据链路层:MAC地址/帧
+----------------------------+
        |
   物理网卡(NIC)
        |
      网线/光纤
        |
   交换机/路由器
        |
      对端主机

1. 关键术语速查


术语 白话解释
接口(Interface) 操作系统看到的网络端点,对应一块网卡或一个虚拟网络
数据包(Packet) IP 层的消息单元
帧(Frame) 以太网层的消息单元,比包多了 MAC 地址头
Socket 网络编程的 API 端点,来自 BSD Unix
带宽(Bandwidth) 网络的理论最大速率,如 10 GbE = 10 Gbit/s
吞吐量(Throughput) 当前实际传输速率,通常低于带宽
延迟(Latency) 一次消息往返所需时间,或建立连接所需时间

利用率 = 当前吞吐量 协商带宽 × 100 % \text{利用率} = \frac{\text{当前吞吐量}}{\text{协商带宽}} \times 100\% 利用率=协商带宽当前吞吐量×100%

2. 网络模型

2.1 协议栈对比(TCP/IP vs OSI)

TCP/IP 模型              OSI 模型(7层)
                         +------------------+
应用层                   | 7. 应用层 (HTTP)  |
(HTTP/DNS/MySQL)         | 6. 表示层 (TLS)   |
                         | 5. 会话层         |
+------------------+     +------------------+
传输层                   | 4. 传输层 (TCP)   |
(TCP / UDP / QUIC)       +------------------+
+------------------+     | 3. 网络层 (IP)    |
网络层 (IP)              +------------------+
+------------------+     | 2. 数据链路层     |
数据链路层(以太网)        |    (Ethernet)     |
+------------------+     +------------------+
物理层(光纤/铜线)         | 1. 物理层(信号)   |
                         +------------------+

发送方向:数据从上层往下走,每层加头部(封装)
接收方向:数据从下层往上走,每层剥头部(解封装)

2.2 协议封装示意

原始数据: [HTTP 数据]
加 TCP:   [TCP 头] [HTTP 数据]
加 IP:    [IP 头] [TCP 头] [HTTP 数据]
加以太网: [以太网头] [IP 头] [TCP 头] [HTTP 数据] [以太网尾]

每加一层头部,总包大小略微增加(额外开销 overhead)。

3. 核心概念

3.1 网络延迟的六种量法

客户端发起请求

DNS解析延迟
域名->IP地址

Ping延迟
ICMP往返时间

TCP连接延迟
三次握手时间

首字节延迟TTFB
连接建立到收到第一个字节

往返时间RTT
请求+响应的总时间

连接生命周期
从建立到关闭的总时长

各延迟的典型值(参考):

延迟类型 场景 典型值
Ping 延迟 同一机器(本地回环) 0.05ms
Ping 延迟 同子网(10GbE) 0.2ms
Ping 延迟 旧金山到纽约 40ms
Ping 延迟 旧金山到澳大利亚 183ms
DNS解析 超时场景 可达数十秒

直觉化对比:假设本机 Ping 耗时 1 秒,那么旧金山到澳大利亚相当于 1 小时。

3.2 TCP 三次握手

客户端                           服务端
   |                               |
   |------- SYN (seq=x) --------->|  客户端发起连接请求
   |                               |
   |<----- SYN+ACK (seq=y) -------|  服务端确认并回应
   |                               |
   |------- ACK (seq=y+1) ------->|  客户端确认
   |                               |
   |====== 数据传输开始 ===========|
   ^                   ^
   |连接延迟从这里开始   |连接延迟到这里结束(发出最后ACK)

关键点:三次握手本身要消耗一个 RTT 的时间,这是 TCP 连接建立的固有延迟。

3.3 缓冲与滑动窗口

问题:网络延迟高(如 40ms),如果每发一个包都等确认(ACK),效率极低。

无缓冲(效率低):
发包1 -> [等40ms] -> 收ACK -> 发包2 -> [等40ms] -> ...
每40ms只能发1个包
滑动窗口(高效):
发包1 -> 发包2 -> 发包3 -> 发包4 ... (窗口大小=4)
[等ACK]   [ACK到了,窗口向前滑动,继续发]

理论最大吞吐量 = 窗口大小(字节) RTT(秒) \text{理论最大吞吐量} = \frac{\text{窗口大小(字节)}}{\text{RTT(秒)}} 理论最大吞吐量=RTT(秒)窗口大小(字节)
例子:窗口 = 64KB,RTT = 40ms
64 × 1024  bytes 0.04  s = 1.6  MB/s \frac{64 \times 1024 \text{ bytes}}{0.04 \text{ s}} = 1.6 \text{ MB/s} 0.04 s64×1024 bytes=1.6 MB/s

3.4 TCP 拥塞控制

目标:避免发太快导致路由器丢包,从而触发更差的性能。

慢启动(Slow Start):
拥塞窗口从小开始,每收到ACK就加倍
  第1轮: 发1个包
  第2轮: 发2个包
  第3轮: 发4个包
  第4轮: 发8个包
  ...(指数增长,直到达到阈值)
之后线性增长(拥塞避免阶段)
遇到丢包(检测到重复ACK):
  -> 窗口减半(Reno/CUBIC)
  -> 重新开始慢启动

常用算法对比

算法 特点 Linux 现状
CUBIC 三次函数扩窗,比 Reno 更激进 Linux 默认
BBR 基于带宽和RTT建模,而非丢包触发 Netflix 使用,吞吐量提升3倍
RENO 经典算法,三重重复ACK触发减半 备选
DCTCP 依赖 ECN,适合数据中心内部 受控环境

3.5 连接积压(Backlog)

服务端在高并发时,如何处理大量新连接请求:

新连接请求 SYN
        |
        v
+-------------------+
|  SYN 半连接队列    |  (第一队列,握手未完成的连接)
|  tcp_max_syn_     |  默认容量:4096
|  backlog          |
+-------------------+
        |(三次握手完成)
        v
+-------------------+
|  全连接队列        |  (第二队列,等待 accept() 取走)
|  somaxconn        |  默认容量:1024
+-------------------+
        |(应用调用 accept())
        v
      应用处理连接

队列满了会怎样:SYN 包被丢弃,客户端触发超时重传,连接延迟剧增。

3.6 MTU 和巨型帧

  • 标准以太网 MTU = 1500 字节(历史遗留,1970-80年代设计)
  • 巨型帧(Jumbo Frame)MTU = ~9000 字节
    数据包效率 = 有效载荷大小 MTU = MTU − 头部大小 MTU \text{数据包效率} = \frac{\text{有效载荷大小}}{\text{MTU}} = \frac{\text{MTU} - \text{头部大小}}{\text{MTU}} 数据包效率=MTU有效载荷大小=MTUMTU头部大小
    MTU=1500 的效率 1500 − 54 1500 ≈ 96.4 % \frac{1500-54}{1500} \approx 96.4\% 150015005496.4%
    MTU=9000 的效率 9000 − 54 9000 ≈ 99.4 % \frac{9000-54}{9000} \approx 99.4\% 900090005499.4%
    传输同样大小的数据,MTU=9000 需要的包数量更少,协议头开销更小。

3.7 本地连接(Localhost)

同一台机器上的进程互相通信时有两种方式:

方式1:IP Socket(走完整TCP/IP协议栈)
进程A -> [TCP] -> [IP] -> 回环接口(lo) -> [IP] -> [TCP] -> 进程B
延迟:约 0.05ms,需要走内核协议栈
方式2:Unix Domain Socket(绕过TCP/IP)
进程A -> [UDS文件] -> 进程B
延迟:更低,无需协议封装/解封装

4. 网络架构

4.1 Linux 网络栈(从上到下)

应用程序
    |  read()/write()/send()/recv()
    v
+----------------------------------+
|         Socket 层                |
|  发送缓冲区 [====>]               |
|  接收缓冲区 [<====]               |
+----------------------------------+
    |
    v
+----------------------------------+
|         TCP/UDP 层               |
|  拥塞控制、滑动窗口、重传机制      |
+----------------------------------+
    |
    v
+----------------------------------+
|         IP 层                    |
|  路由查找、分片、ECN              |
+----------------------------------+
    |
    v
+----------------------------------+
|       排队规则(qdisc)           |
|  fq_codel(默认)、pfifo_fast 等 |
+----------------------------------+
    |
    v
+----------------------------------+
|       网卡驱动层                  |
|  环形缓冲区(Ring Buffer)        |
|  TSO/GSO/GRO 硬件卸载            |
+----------------------------------+
    |
    v
   物理网卡(NIC)

4.2 多 CPU 收包(CPU扩展性)

高速网络(10G/100G)需要多 CPU 并行处理:

物理网卡
收到数据包

哈希
源IP+目的IP+端口

队列0

队列1

队列2

队列3

CPU 0

CPU 1

CPU 2

CPU 3


技术 实现方式 说明
RSS 硬件实现 NIC 自己把包哈希到不同队列
RPS 软件实现 内核软件模拟多队列
RFS 软件+亲和性 同连接的包给同一 CPU,提升缓存命中
XPS 发送多队列 多 CPU 并行发包

4.3 TCP 发包路径中的各优化组件

应用层数据
    |
    v
Socket 发送缓冲区
    |
    v
拥塞控制(CUBIC/BBR)-- 控制窗口大小
    |
    v
TSO(TCP Segmentation Offload)-- 合成超级大包(64KB)
    |
    v
Pacing(流量整形)-- 均匀散布包,避免突发
    |
    v
TSQ(TCP Small Queues)-- 限制在网络栈里排队的量
    |
    v
qdisc(排队规则 fq_codel)-- 公平调度,避免缓冲膨胀
    |
    v
BQL(字节队列限制)-- 自动调整驱动队列大小
    |
    v
网卡驱动环形缓冲区
    |
    v
NIC 硬件发出帧

4.4 QUIC 协议

QUIC = 基于 UDP 的新一代传输协议(HTTP/3 底层)

传统 HTTPS:
[HTTP/1.1 或 HTTP/2] + [TLS] + [TCP] + [IP] + [Ethernet]
握手:TCP握手(1RTT) + TLS握手(1~2RTT) = 共2~3RTT
QUIC(HTTP/3):
[HTTP/3] + [QUIC(内置加密)] + [UDP] + [IP] + [Ethernet]
握手:0-RTT(老连接)或 1-RTT(新连接)

QUIC 的关键优势:

  • 0-RTT 重连(老朋友不用重新握手)
  • 多路复用(一个连接跑多个流,互不阻塞)
  • 连接迁移(换 WiFi 不断线)
  • 内置加密

5. 分析方法论

5.1 USE 方法应用于网络接口

对每个网络接口(eth0、eth1 等),分发送(TX)和接收(RX)两个方向分别检查:

指标 检查什么 工具
利用率(Utilization) 当前吞吐量 协商带宽 \frac{\text{当前吞吐量}}{\text{协商带宽}} 协商带宽当前吞吐量 nicstat、sar -n DEV
饱和度(Saturation) 队列溢出、应用阻塞等待 netstat -s(检查overflow)
错误(Errors) CRC错误、丢包、碰撞 ip -s link、netstat -i

5.2 延迟分析

检查顺序(从外到内,由粗到细):

第一步:ping 对端,看基础网络是否正常
    ping target_host
第二步:查 TCP 统计,看有无大量重传
    nstat -s | grep Retrans
第三步:用 tcplife 看每个连接的生命周期和吞吐
    tcplife
第四步:用 tcpretrans 找具体哪些连接在重传
    tcpretrans
第五步:必要时 tcpdump 抓包分析
    tcpdump -i eth0 -w /tmp/cap.pcap

5.3 工作负载特征描述

描述一个网络负载需要这些指标:

指标 说明
接口吞吐量 TX/RX bytes/s,分收发方向
接口包率 TX/RX packets/s(帧每秒)
TCP 连接率 主动建立/被动建立,connections/s
平均包大小 bytes,反映业务特征(大文件 vs 小请求)
协议分布 TCP vs UDP 各占比例
活跃端口 哪些端口流量最大

6. 观测工具详解

6.1 工具速览

ss         -> 查看当前所有 Socket 的状态和统计
ip -s link -> 网络接口统计(收发字节、错误、丢包)
ifconfig   -> 同上(旧工具,已不推荐)
nstat      -> 内核网络计数器(标准 SNMP 名称)
netstat -s -> 网络栈统计(TCP重传、连接数等)
sar -n TCP -> 历史 TCP 统计(连接率、包率)
nicstat    -> 网卡吞吐量和利用率(最直观)
ethtool    -> 网卡驱动级统计和配置
tcplife    -> 追踪 TCP 会话完整生命周期
tcptop     -> 实时显示流量最大的 TCP 连接
tcpretrans -> 追踪 TCP 重传事件(超低开销)
tcpdump    -> 抓包分析(高开销,慎用于生产)
Wireshark  -> 图形化抓包分析
bpftrace   -> 自定义 BPF 追踪脚本

6.2 ss 输出解读

# 看单个连接的详细 TCP 状态
ss -tiepm

典型输出关键字段解析:

ESTAB  0  0  本机IP:本机端口  对端IP:对端端口
users:(("java",pid=4195,fd=10865))  <- 哪个进程、PID、文件描述符
rto:204           <- 重传超时时间 204ms
rtt:0.159/0.009   <- 平均RTT 0.159ms,标准差 0.009ms
mss:1448          <- 最大段大小 1448字节
cwnd:152          <- 拥塞窗口 152个MSS
app_limited       <- 应用层限速(不是网络瓶颈)
bbr:(bw:328.6Mbps,mrtt:0.149)  <- BBR算法估算带宽和RTT

“limited” 标志的含义

标志 含义
app_limited 应用发送太慢,网络还有余量
rwnd_limited 接收方窗口限制了发送速率
sndbuf_limited 发送缓冲区太小,需要调大

6.3 nstat 关键指标

nstat -s

需要重点关注的计数器:

计数器 含义 异常判断
TcpRetransSegs TCP重传段数 与 TcpOutSegs 之比若 > 1% 需警惕
TcpActiveOpens 主动发起的连接数 反映客户端连接行为
TcpPassiveOpens 被动接受的连接数 反映服务端负载
TCPSynRetrans SYN重传次数 高说明服务端积压队列满了

重传率 = TcpRetransSegs TcpOutSegs × 100 % \text{重传率} = \frac{\text{TcpRetransSegs}}{\text{TcpOutSegs}} \times 100\% 重传率=TcpOutSegsTcpRetransSegs×100%

6.4 nicstat 利用率解读

Time      Int   rKB/s   wKB/s   rPk/s   wPk/s  %Util  Sat
01:21:00  eth4  41834   977     28221   14058   35.1   0.00
rKB/s  = 接收速率 41834 KB/s ≈ 41 MB/s
wKB/s  = 发送速率 977 KB/s ≈ 1 MB/s
rPk/s  = 接收包率 28221 pkt/s
%Util  = 接口利用率 35.1%(RX方向,已是主导方向)
Sat    = 饱和度指标(0表示正常)

7. TCP 重传分析

7.1 两种重传机制

机制1:定时器重传(慢,最坏情况)
发出包 -> 等待 RTO(至少200ms)-> 超时 -> 重传
RTO 指数退避: 200ms -> 400ms -> 800ms -> 1600ms...
机制2:快速重传(快,基于重复ACK)
发包10,11,12,13
包11丢失
收到对包12的ACK(实际是重复请求11)
收到对包13的ACK(又是重复请求11)
收到3个重复ACK -> 立即重传包11(不等超时)

7.2 尾丢失探测(TLP)

问题:最后一个包丢了,没有后续包触发重复ACK
解决:
发最后一个包 -> 短暂等待(约1~2RTT)-> 发一个探测包
探测包触发ACK -> 检测到包丢失 -> 快速重传
(不需要等 RTO 超时,避免了数百毫秒的延迟)

8. 性能测试工具

8.1 ping —— 基础连通性测试

ping www.example.com
# 输出示例:
64 bytes from 1.2.3.4: icmp_seq=1 ttl=55 time=32.3 ms
64 bytes from 1.2.3.4: icmp_seq=2 ttl=55 time=34.1 ms
--- ping statistics ---
3 packets, 0% packet loss
rtt min/avg/max/mdev = 32.3/33.2/34.1/0.9 ms

注意:ICMP 可能被路由器降低优先级,延迟比实际应用稍高。

8.2 iperf —— 带宽测试

# 服务端启动
iperf -s -l 128k
# 客户端测试(2个并发线程,跑60秒)
iperf -c 服务端IP -l 128k -P 2 -i 1 -t 60

选项说明:

  • -l 128k:Socket 缓冲区 128KB(加大可提升吞吐)
  • -P 2:2个并行流(100G网卡可能需要几十个)
  • -i 1:每1秒打印一次
  • -t 60:测试60秒

8.3 tc netem —— 模拟差网络

# 模拟 1% 丢包率(用于测试应用对网络的容忍性)
tc qdisc add dev eth0 root netem loss 1%
# 模拟 50ms 延迟
tc qdisc add dev eth0 root netem delay 50ms
# 模拟 50ms 延迟 + 10ms 抖动
tc qdisc add dev eth0 root netem delay 50ms 10ms
# 查看当前 qdisc
tc -s qdisc show dev eth0
# 删除(恢复正常)
tc qdisc del dev eth0 root

9. 调优参数

9.1 重要 sysctl 参数

# 查看所有 TCP 参数
sysctl -a | grep tcp

Netflix 生产环境配置示例及说明:

参数 Netflix 设置值 说明
net.core.default_qdisc fq 公平队列,减少bufferbloat
net.ipv4.tcp_congestion_control bbr BBR 拥塞控制算法
net.core.rmem_max 16777216 最大接收缓冲 16MB
net.core.wmem_max 16777216 最大发送缓冲 16MB
net.ipv4.tcp_rmem 4096 12582912 16777216 TCP接收缓冲(最小/默认/最大)
net.ipv4.tcp_wmem 4096 12582912 16777216 TCP发送缓冲(最小/默认/最大)
net.ipv4.tcp_max_syn_backlog 8192 SYN 半连接队列大小
net.core.somaxconn 1024 全连接队列最大值
net.ipv4.tcp_tw_reuse 1 允许复用 TIME_WAIT 连接
net.ipv4.tcp_slow_start_after_idle 0 空闲后不重新慢启动

9.2 关键 Socket 选项


选项 作用
TCP_NODELAY 禁用 Nagle,立即发小包(低延迟优先)
TCP_CORK 积攒满包再发(高吞吐优先)
TCP_CONGESTION 为单个连接设置拥塞算法
SO_SNDBUF/SO_RCVBUF 设置单个 Socket 的缓冲区大小
SO_REUSEPORT 多进程绑定同一端口,内核负载均衡
SO_BUSY_POLL 忙轮询(降低延迟,牺牲 CPU)

Nagle vs 延迟 ACK 的冲突

Nagle 算法:积攒数据再发(减少小包)
延迟 ACK:积攒 ACK 再发(减少 ACK 数量)
两者同时启用时:
应用写一小块数据 -> Nagle说"等更多数据"
                    对端ACK被延迟发回
                    Nagle在等ACK -> 死锁!(最坏400ms延迟)
解决:对延迟敏感的应用(MySQL等)设置 TCP_NODELAY 禁用 Nagle

10. C++ 代码演示:网络延迟测量

#include <iostream>
#include <chrono>
#include <string>
#include <vector>
#include <cstring>
#include <cstdlib>
#include <numeric>
#include <algorithm>
// POSIX 网络头文件
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netdb.h>
#include <fcntl.h>
// 将主机名解析为 IP 地址(含 DNS 延迟测量)
// 返回解析到的 IP 字符串,失败返回空串
std::string resolve_hostname(const std::string& hostname, double& latency_ms) {
    auto start = std::chrono::high_resolution_clock::now();
    struct addrinfo hints{};
    hints.ai_family   = AF_INET;       // 只查 IPv4
    hints.ai_socktype = SOCK_STREAM;   // TCP
    struct addrinfo* result = nullptr;
    // getaddrinfo 是标准 DNS 解析函数
    int ret = getaddrinfo(hostname.c_str(), nullptr, &hints, &result);
    auto end = std::chrono::high_resolution_clock::now();
    latency_ms = std::chrono::duration<double, std::milli>(end - start).count();
    if (ret != 0 || result == nullptr) {
        return "";
    }
    // 把结构体里的 IP 地址转为点分十进制字符串
    char ip_buf[INET_ADDRSTRLEN];
    auto* sin = reinterpret_cast<struct sockaddr_in*>(result->ai_addr);
    inet_ntop(AF_INET, &sin->sin_addr, ip_buf, sizeof(ip_buf));
    freeaddrinfo(result);
    return std::string(ip_buf);
}
// 测量 TCP 连接建立延迟(三次握手时间)
// host: 目标 IP,port: 目标端口
// 返回连接延迟(毫秒),失败返回 -1
double measure_tcp_connect_latency(const std::string& host, int port) {
    // 创建 TCP socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        std::cerr << "socket() 创建失败" << std::endl;
        return -1.0;
    }
    // 设置目标地址
    struct sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port   = htons(static_cast<uint16_t>(port)); // 端口转为网络字节序
    if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) <= 0) {
        close(sock);
        return -1.0;
    }
    // 记录连接开始时间
    auto start = std::chrono::high_resolution_clock::now();
    // connect() 阻塞直到三次握手完成(或超时/失败)
    int ret = connect(sock, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
    auto end = std::chrono::high_resolution_clock::now();
    close(sock);
    if (ret < 0) {
        return -1.0;
    }
    return std::chrono::duration<double, std::milli>(end - start).count();
}
// 统计结构体:存储延迟测量的统计结果
struct LatencyStats {
    double min_ms;    // 最小延迟
    double max_ms;    // 最大延迟
    double avg_ms;    // 平均延迟
    double p95_ms;    // 第95百分位延迟(95%的请求比这快)
    double p99_ms;    // 第99百分位延迟(99%的请求比这快)
    int    success;   // 成功次数
    int    fail;      // 失败次数
};
// 计算延迟统计数据
LatencyStats compute_stats(std::vector<double>& latencies, int total_attempts) {
    LatencyStats stats{};
    stats.success = static_cast<int>(latencies.size());
    stats.fail    = total_attempts - stats.success;
    if (latencies.empty()) {
        return stats;
    }
    // 先排序,方便计算百分位
    std::sort(latencies.begin(), latencies.end());
    stats.min_ms = latencies.front();
    stats.max_ms = latencies.back();
    stats.avg_ms = std::accumulate(latencies.begin(), latencies.end(), 0.0)
                   / latencies.size();
    // 百分位计算:取对应下标
    auto pct = [&](double p) -> double {
        size_t idx = static_cast<size_t>(p / 100.0 * latencies.size());
        if (idx >= latencies.size()) idx = latencies.size() - 1;
        return latencies[idx];
    };
    stats.p95_ms = pct(95.0);
    stats.p99_ms = pct(99.0);
    return stats;
}
// 打印延迟分布直方图(ASCII 版本)
// 类似 bpftrace 的 hist() 输出
void print_histogram(const std::vector<double>& latencies, const std::string& title) {
    if (latencies.empty()) return;
    // 定义延迟区间(毫秒)
    std::vector<double> buckets = {0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0};
    std::vector<int>    counts(buckets.size() + 1, 0);
    for (double lat : latencies) {
        bool found = false;
        for (size_t i = 0; i < buckets.size(); i++) {
            if (lat < buckets[i]) {
                counts[i]++;
                found = true;
                break;
            }
        }
        if (!found) counts.back()++;
    }
    int max_count = *std::max_element(counts.begin(), counts.end());
    std::cout << "\n=== " << title << " 延迟分布 ===" << std::endl;
    std::cout << "区间(ms)          计数  分布" << std::endl;
    std::cout << std::string(60, '-') << std::endl;
    auto print_bar = [&](const std::string& label, int count) {
        // 用 * 画柱状图,最多40个*
        int bar_len = (max_count > 0) ? (count * 40 / max_count) : 0;
        std::cout << label;
        // 对齐
        for (int i = label.size(); i < 18; i++) std::cout << ' ';
        std::cout << count << "\t|";
        std::cout << std::string(bar_len, '*') << std::endl;
    };
    print_bar("< 0.1ms",    counts[0]);
    print_bar("0.1~0.5ms",  counts[1]);
    print_bar("0.5~1ms",    counts[2]);
    print_bar("1~2ms",      counts[3]);
    print_bar("2~5ms",      counts[4]);
    print_bar("5~10ms",     counts[5]);
    print_bar("10~20ms",    counts[6]);
    print_bar("20~50ms",    counts[7]);
    print_bar("50~100ms",   counts[8]);
    print_bar(">= 100ms",   counts[9]);
}
int main(int argc, char* argv[]) {
    // 默认测试目标:8.8.8.8:80(Google DNS,HTTP端口,基本都能连)
    std::string target_ip   = "8.8.8.8";
    int         target_port = 80;
    int         rounds      = 20;  // 测试轮数
    // 如果命令行提供了IP和端口,使用用户指定的
    if (argc >= 2) target_ip   = argv[1];
    if (argc >= 3) target_port = std::stoi(argv[2]);
    if (argc >= 4) rounds      = std::stoi(argv[3]);
    std::cout << "=== 网络延迟测量工具 ===" << std::endl;
    std::cout << "目标: " << target_ip << ":" << target_port << std::endl;
    std::cout << "测试轮数: " << rounds << std::endl << std::endl;
    // --- 测试1:DNS解析延迟 ---
    std::cout << "--- 测试 DNS 解析延迟 ---" << std::endl;
    {
        double dns_lat = 0.0;
        // 使用目标IP的反向域名作为示例(实际测试中换成真实域名)
        std::string resolved = resolve_hostname("dns.google", dns_lat);
        if (!resolved.empty()) {
            std::cout << "  dns.google 解析到: " << resolved << std::endl;
            std::cout << "  DNS 解析延迟: " << dns_lat << " ms" << std::endl;
        } else {
            std::cout << "  DNS 解析失败(可能无网络)" << std::endl;
        }
    }
    // --- 测试2:TCP 连接建立延迟(多次测量)---
    std::cout << "\n--- 测试 TCP 连接延迟(" << rounds << " 次)---" << std::endl;
    std::vector<double> connect_latencies;
    connect_latencies.reserve(rounds);
    for (int i = 0; i < rounds; i++) {
        double lat = measure_tcp_connect_latency(target_ip, target_port);
        if (lat >= 0) {
            connect_latencies.push_back(lat);
            std::cout << "  第" << (i + 1) << "次: " << lat << " ms" << std::endl;
        } else {
            std::cout << "  第" << (i + 1) << "次: 连接失败" << std::endl;
        }
        // 每次测试间隔 100ms,避免连接复用(TIME_WAIT 影响)
        usleep(100 * 1000);
    }
    // --- 统计结果 ---
    if (!connect_latencies.empty()) {
        auto stats = compute_stats(connect_latencies, rounds);
        std::cout << "\n=== TCP 连接延迟统计 ===" << std::endl;
        std::cout << "  成功次数: " << stats.success << "/" << rounds << std::endl;
        std::cout << "  最小延迟: " << stats.min_ms << " ms" << std::endl;
        std::cout << "  最大延迟: " << stats.max_ms << " ms" << std::endl;
        std::cout << "  平均延迟: " << stats.avg_ms << " ms" << std::endl;
        std::cout << "  P95 延迟: " << stats.p95_ms << " ms  (95%的连接比这快)" << std::endl;
        std::cout << "  P99 延迟: " << stats.p99_ms << " ms  (99%的连接比这快)" << std::endl;
        // 打印直方图
        print_histogram(connect_latencies, "TCP 连接");
        // 判断网络质量
        std::cout << "\n=== 网络质量评估 ===" << std::endl;
        if (stats.avg_ms < 1.0) {
            std::cout << "  本地网络(<1ms),可能是 localhost 或同机房" << std::endl;
        } else if (stats.avg_ms < 5.0) {
            std::cout << "  局域网或同城网络(1~5ms)" << std::endl;
        } else if (stats.avg_ms < 50.0) {
            std::cout << "  跨城市或国内长距离网络(5~50ms)" << std::endl;
        } else if (stats.avg_ms < 200.0) {
            std::cout << "  跨洲际网络(50~200ms)" << std::endl;
        } else {
            std::cout << "  极高延迟(>200ms),检查网络路径或重传" << std::endl;
        }
        // 检查延迟抖动(最大值和平均值差距大说明有重传或不稳定)
        double jitter = stats.max_ms - stats.min_ms;
        if (jitter > stats.avg_ms * 2) {
            std::cout << "  警告:延迟抖动大(jitter=" << jitter
                      << "ms),可能存在 TCP 重传或网络不稳定" << std::endl;
        }
    } else {
        std::cout << "所有连接均失败,请检查目标地址和网络连通性" << std::endl;
    }
    return 0;
}

编译和运行

g++ -O2 -std=c++17 -o net_latency net_latency.cpp
./net_latency                          # 测试 8.8.8.8:80
./net_latency 1.2.3.4 80 30            # 自定义目标和轮数

典型输出示例

=== 网络延迟测量工具 ===
目标: 8.8.8.8:80
测试轮数: 20
--- 测试 DNS 解析延迟 ---
  dns.google 解析到: 8.8.8.8
  DNS 解析延迟: 12.3 ms
--- 测试 TCP 连接延迟(20 次)---
  第1次: 32.1 ms
  第2次: 33.5 ms
  ...
=== TCP 连接延迟统计 ===
  成功次数: 20/20
  最小延迟: 31.2 ms
  最大延迟: 38.7 ms
  平均延迟: 33.4 ms
  P95 延迟: 37.1 ms
  P99 延迟: 38.5 ms
=== TCP 连接延迟分布 ===
区间(ms)          计数  分布
------------------------------------------------------------
< 0.1ms           0     |
...
20~50ms           20    |****************************************
...
=== 网络质量评估 ===
  跨城市或国内长距离网络(5~50ms)

11. 完整排查流程

app_limited

rwnd_limited

sndbuf_limited

发现网络性能问题

ping 对端
检查基本连通性

ping 延迟正常?

检查路由
traceroute 定位瓶颈节点

nicstat 查看接口利用率

利用率接近100%?

网络带宽瓶颈
升级带宽或优化流量

nstat 查重传率

重传率高?

tcpretrans 找具体重传连接
查看 TCP 状态

ss -tiepm 查单连接状态
看 app_limited/rwnd_limited

有 limited 标志?

瓶颈在应用
优化应用代码

接收方窗口小
调大 rmem

发送缓冲区小
调大 wmem

tcpdump 抓包
深度分析

检查是否服务端过载
SYN Backlog 是否满

12. 总结:网络性能的黄金原则

原则一:先排查错误,再分析性能
检查顺序 : Errors → Utilization → Saturation \text{检查顺序} : \text{Errors} \rightarrow \text{Utilization} \rightarrow \text{Saturation} 检查顺序:ErrorsUtilizationSaturation
原则二:区分网络问题和应用问题

  • 延迟高 = 不一定是网络问题(可能是服务端处理慢 → 看首字节延迟)
  • 吞吐低 = 不一定是带宽不够(可能是 TCP 窗口太小 → 看 cwnd)
    原则三:重传是性能杀手
    1次重传 ⇒ 至少增加 200ms 延迟(RTO_MIN) \text{1次重传} \Rightarrow \text{至少增加 200ms 延迟(RTO\_MIN)} 1次重传至少增加 200ms 延迟(RTO_MIN
    重传指数退避: 200 ms → 400 ms → 800 ms → … 200\text{ms} \rightarrow 400\text{ms} \rightarrow 800\text{ms} \rightarrow \ldots 200ms400ms800ms
    原则四:缓冲区大小要匹配 BDP
    带宽延迟积(Bandwidth-Delay Product,BDP):
    BDP = 带宽 ( bytes/s ) × RTT ( s ) \text{BDP} = \text{带宽}(\text{bytes/s}) \times \text{RTT}(\text{s}) BDP=带宽(bytes/s)×RTT(s)
    例子:10 Gbps 链路,RTT = 100ms:
    BDP = 10 × 10 9 8 × 0.1 = 125  MB \text{BDP} = \frac{10 \times 10^9}{8} \times 0.1 = 125 \text{ MB} BDP=810×109×0.1=125 MB
    Socket 缓冲区至少要设到 BDP 大小,才能让网络跑满。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐