Systems Performance学习:Linux 内存系统深度解析(第七章笔记)
可以把计算机的存储层级想象成一个仓库体系:当主内存装满时,系统被迫把数据换到磁盘上,速度骤降。这就是内存性能的核心问题。内存满了会发生什么?虚拟内存是操作系统给每个进程的一个幻觉:让每个进程都以为自己独占一大片连续内存。好处:换页是以"页"为单位(通常4KB)在RAM和磁盘之间搬运数据。分为两种:文件系统换页("好"的换页)把内存映射文件的页在RAM和磁盘间移动。页若未被修改(干净页),直接丢弃即
本文基于《Systems Performance》第7章整理,面向希望深入理解Linux内存机制的读者。
目标:把复杂的内存概念用最通俗的语言讲清楚。
目录
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缓存大小} 性能优⟺WSS≤CPU缓存大小
性能差 ⟺ 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的进程数
+------------------------------------------ 运行队列长度
快速判断:si 和 so 持续非零 = 系统正在换页 = 内存不足!
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{操作请求时间} 延迟=操作完成时间−操作请求时间
延迟包含三部分时间:
- 文件系统软件处理时间
- 磁盘 I/O 子系统排队时间
- 磁盘设备实际读写时间
关键结论:大多数情况下,应用线程会阻塞等待文件系统响应,
所以文件系统延迟 直接等比影响 应用性能。
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 的差异类型:
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 主流文件系统对比
各文件系统核心性能特点:
| 文件系统 | 核心特点 | 适用场景 |
|---|---|---|
| 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 <- 又一波换出
分析结论:
si列始终为0 → 没有换入(好事,说明换出的数据暂未被访问回来)so列多次非零(4692、196、8472)→ 系统在持续换出匿名页到swapswpd从413344增长到434252 → 共有约20MB新数据被换到swapbi(块读入)出现23924这样的高峰 → 大量磁盘读,可能是读取文件数据或换回已换出的页wa(CPU等待IO)出现11%、6% → CPU在等磁盘,说明IO是瓶颈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} VSZ≥RSS
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的核心知识讲清楚。
目录
- 为什么磁盘性能很重要
- 核心术语速查
- 磁盘基础模型
- 关键概念详解
- 硬件架构
- Linux磁盘I/O软件栈
- RAID原理与性能
- 性能分析方法论
- 常用观测工具
- 可视化分析
- 磁盘基准测试
- 调优参数
- 综合示例分析
- 完整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内核队列中排队的时间(
t0到t1) - 块I/O服务时间:I/O从发给磁盘到磁盘返回完成的时间(
t1到t3),这是iostat默认显示的await - 块I/O请求时间:I/O从创建到完成的全部时间(
t0到t3)
平均磁盘服务时间可以用以下公式推算(但此公式在并行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到数据从磁盘返回,经过以下层次:
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级别 | 读性能 | 写性能 | 容错 | 说明 |
|---|---|---|---|---|
| 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/s、rkB/s、r_await)和写(w/s、wkB/s、w_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:每秒读取KBkB_wr/s:每秒写入KBiodelay:进程被磁盘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%,看似不高 |
结论:
- 写I/O延迟(44ms)远高于读(0.57ms),写是瓶颈
- 队列长度接近12,大量写I/O在排队
- 虽然利用率只有46%,但队列积压说明磁盘已经饱和(可能是虚拟磁盘的局限性,或写回缓存已满)
- 大量写合并(wrqm/s=13156)说明是顺序写工作负载
%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 网络延迟的六种量法
各延迟的典型值(参考):
| 延迟类型 | 场景 | 典型值 |
|---|---|---|
| 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\% 15001500−54≈96.4%
MTU=9000 的效率: 9000 − 54 9000 ≈ 99.4 % \frac{9000-54}{9000} \approx 99.4\% 90009000−54≈99.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 并行处理:
| 技术 | 实现方式 | 说明 |
|---|---|---|
| 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. 完整排查流程
12. 总结:网络性能的黄金原则
原则一:先排查错误,再分析性能
检查顺序 : Errors → Utilization → Saturation \text{检查顺序} : \text{Errors} \rightarrow \text{Utilization} \rightarrow \text{Saturation} 检查顺序:Errors→Utilization→Saturation
原则二:区分网络问题和应用问题
- 延迟高 = 不一定是网络问题(可能是服务端处理慢 → 看首字节延迟)
- 吞吐低 = 不一定是带宽不够(可能是 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 200ms→400ms→800ms→…
原则四:缓冲区大小要匹配 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 大小,才能让网络跑满。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)