Linux 存储与文件系统深度剖析(九):性能优化与调试实战

存储 IO 性能问题是生产环境中最常见也最棘手的问题之一。数据库响应变慢、应用延迟飙升、批处理任务超时,背后往往隐藏着复杂的 IO 瓶颈。本文从内核源码层面出发,系统梳理 Linux 存储性能的监控、分析与调优方法,覆盖从 iostat 到 eBPF、从 blktrace 到 ftrace 的完整工具链。

一、性能问题的分类

在着手分析之前,准确识别问题类型能大幅缩短排查时间。IO 性能问题通常可归为以下四类:

1.1 IO 延迟(Latency)

单次 IO 操作耗时过长。典型症状是 iostatawait 指标偏高,而 %util 不一定很高。常见根因包括:存储设备队列深度不足、RAID 重建、网络存储(NFS/iSCSI)抖动、内核调度延迟。

1.2 带宽饱和(Bandwidth Saturation)

设备带宽被打满,新 IO 请求被迫排队。表现为 %util 持续接近 100%,顺序读写场景下吞吐量触及硬件上限。

1.3 元数据瓶颈(Metadata Bottleneck)

频繁的 stat()open()readdir() 等系统调用导致文件系统元数据操作成为瓶颈。在小文件密集型工作负载(如邮件服务器、包管理器)中尤为突出,此时磁盘 %util 可能并不高,但延迟居高不下。

1.4 内存压力(Memory Pressure)

页缓存(page cache)不足导致缓存命中率下降,读操作频繁穿透缓存到达磁盘。free -m 显示可用内存极少,sar -B 显示大量 pgpgoutpgscank


二、基础监控工具全解

2.1 iostat -x 字段详解

1
2
# 每秒刷新一次,显示扩展统计
iostat -x 1

典型输出:

1
2
3
Device   r/s    w/s    rkB/s   wkB/s  rrqm/s  wrqm/s  %rrqm  %wrqm  r_await  w_await  aqu-sz  rareq-sz  wareq-sz  svctm  %util
sda 12.00 48.00 512.00 4096.00 0.50 8.00 4.00 14.29 2.50 3.80 0.23 42.67 85.33 1.20 7.20
nvme0n1 256.00 512.00 8192.00 16384.00 0.00 0.00 0.00 0.00 0.08 0.12 0.04 32.00 32.00 0.03 23.00

各字段含义:

字段 含义
r/s / w/s 每秒完成的读/写请求数
rkB/s / wkB/s 每秒读取/写入的数据量(KB)
rrqm/s / wrqm/s 每秒被合并的读/写请求数(在提交给设备前在队列中合并)
%rrqm / %wrqm 被合并的读/写请求占总请求的百分比,越高说明顺序性越好
r_await / w_await 读/写请求的平均等待时间(毫秒),包含队列等待时间 + 设备服务时间
aqu-sz 平均请求队列长度(队列深度),若持续 > 1 说明设备跟不上请求速率
rareq-sz / wareq-sz 平均读/写请求大小(KB),反映 IO 粒度
svctm 平均设备服务时间(已废弃,不可靠,建议忽略)
%util 设备忙于处理 IO 的时间百分比,接近 100% 表示设备饱和

关键判断依据:

  • r_await / w_await > 10ms(HDD)或 > 1ms(NVMe)需关注
  • aqu-sz 持续 > 设备标称队列深度,说明存在积压
  • %util 接近 100% 且 await 高,设备是真正瓶颈;%util 低但 await 高,通常是单线程串行 IO 或软件层问题

2.2 iotop 进程级 IO 监控

iotop 能直接定位到消耗 IO 的进程:

1
2
3
4
5
# 需要 root 权限
sudo iotop -o # 只显示有 IO 活动的进程
sudo iotop -a # 显示累计 IO 而非瞬时
sudo iotop -p 1234 # 只监控指定 PID
sudo iotop -d 2 # 每 2 秒刷新

输出示例:

1
2
3
4
Total DISK READ:    45.23 M/s | Total DISK WRITE:    12.45 M/s
PID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
8421 be/4 mysql 38.12 M/s 3.21 M/s 0.00 % 92.45 % mysqld
987 be/4 root 5.11 M/s 1.22 M/s 0.00 % 7.21 % rsync

IO> 列是最关键的指标,表示进程等待 IO 的时间占比。

2.3 /proc/diskstats 字段解析

/proc/diskstatsiostat 的数据来源。其生成逻辑位于内核 block/genhd.cdiskstats_show() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// block/genhd.c: diskstats_show()
seq_printf(seqf, "%4d %7d %pg "
"%lu %lu %lu %u " // reads: ios, merges, sectors, ms
"%lu %lu %lu %u " // writes: ios, merges, sectors, ms
"%u %u %u " // inflight, io_ticks, time_in_queue
"%lu %lu %lu %u " // discards: ios, merges, sectors, ms
"%lu %u" // flushes: ios, ms
"\n",
MAJOR(hd->bd_dev), MINOR(hd->bd_dev), hd,
stat.ios[STAT_READ],
stat.merges[STAT_READ],
stat.sectors[STAT_READ],
(unsigned int)div_u64(stat.nsecs[STAT_READ], NSEC_PER_MSEC),
stat.ios[STAT_WRITE],
stat.merges[STAT_WRITE],
stat.sectors[STAT_WRITE],
(unsigned int)div_u64(stat.nsecs[STAT_WRITE], NSEC_PER_MSEC),
inflight,
jiffies_to_msecs(stat.io_ticks),
(unsigned int)div_u64(stat.nsecs[STAT_READ] +
stat.nsecs[STAT_WRITE] +
stat.nsecs[STAT_DISCARD] +
stat.nsecs[STAT_FLUSH], NSEC_PER_MSEC),
stat.ios[STAT_DISCARD],
...);

字段编号对应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
字段1-2:   主设备号, 次设备号
字段3: 设备名
字段4: reads_completed - 成功完成的读请求数
字段5: reads_merged - 相邻读请求被合并的次数
字段6: sectors_read - 读取的扇区数(512B 为单位)
字段7: time_reading_ms - 读操作总耗时(毫秒)
字段8: writes_completed - 成功完成的写请求数
字段9: writes_merged - 相邻写请求被合并的次数
字段10: sectors_written - 写入的扇区数
字段11: time_writing_ms - 写操作总耗时(毫秒)
字段12: io_in_progress - 当前正在进行中的 IO 请求数
字段13: time_doing_io_ms - 设备处于活跃状态的总时间(毫秒)
字段14: weighted_time_io_ms - 加权 IO 时间(字段12的积分,用于计算 await)
字段15-18: discard_* - TRIM/discard 操作统计
字段19-20: flush_* - flush 操作统计

统计数据在内核中按 CPU 分片存储(per_cpu_ptr(part->bd_stats, cpu)),读取时对所有 CPU 求和,避免了锁竞争:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// block/genhd.c
static void part_stat_read_all(struct block_device *part,
struct disk_stats *stat)
{
int cpu;
memset(stat, 0, sizeof(struct disk_stats));
for_each_possible_cpu(cpu) {
struct disk_stats *ptr = per_cpu_ptr(part->bd_stats, cpu);
int group;
for (group = 0; group < NR_STAT_GROUPS; group++) {
stat->nsecs[group] += ptr->nsecs[group];
stat->sectors[group] += ptr->sectors[group];
stat->ios[group] += ptr->ios[group];
stat->merges[group] += ptr->merges[group];
}
stat->io_ticks += ptr->io_ticks;
}
}

2.4 /sys/block/*/queue/ 参数解释

队列参数直接影响 IO 性能,位于 /sys/block/<dev>/queue/

1
ls /sys/block/sda/queue/

关键参数:

参数文件 含义 典型值
scheduler 当前 IO 调度器 [mq-deadline] kyber bfq none
nr_requests 请求队列深度 64 (HDD) / 1024 (NVMe)
read_ahead_kb 预读大小(KB) 128~4096
max_sectors_kb 单次 IO 最大大小(KB) 512~2048
rotational 是否旋转设备(0=SSD) 0 或 1
add_random 是否向熵池贡献(SSD 可关闭) 0 或 1
nomerges 禁用请求合并级别(0=全合并) 0/1/2
iostats 是否开启 IO 统计(开销很小) 1
wbt_lat_usec WBT 写回抑制目标延迟(微秒) 75000

rotational 参数由 blk-sysfs.c 中的 QUEUE_SYSFS_BIT_FNS(nonrot, NONROT, 1) 宏生成,它反转了 QUEUE_FLAG_NONROT 标志位,供调度器据此选择算法策略。


三、blktrace/blkparse 深度使用

3.1 blktrace 事件类型

blktrace 在内核块层各个关键路径上埋设 tracepoint,这些 tracepoint 定义在 include/trace/events/block.h 中。结合源码,完整事件生命周期如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户进程                内核块层               IO 调度器              设备驱动
│ │ │ │
│── write(fd) ──► │ │ │
│ block_bio_queue │ │
│ │── bio 提交 ──► │ │
│ block_getrq (分配 request) │ │
│ │ block_rq_insert │
│ (可能触发) block_bio_backmerge │ │
│ (可能触发) block_bio_frontmerge │ │
│ (可能触发) block_split │ │
│ │ block_rq_issue ──────────────►│
│ │ │ (设备处理)
│ │ │ block_rq_complete
│ │ │ │
│◄── 返回 ────────────│ │ │

各 tracepoint 对应的源码定义:

  • block_bio_queue: bio 被放入请求队列,此时尚未分配 request 结构体
  • block_getrq: 为 bio 分配了 request 结构体
  • block_rq_insert: request 被插入调度器队列(elevator queue)
  • block_rq_issue: 调度器将 request 派发给设备驱动
  • block_rq_complete: 设备驱动报告 request 完成
  • block_rq_requeue: request 因某种原因被退回队列重新调度
  • block_rq_merge: 两个 request 在调度器中被合并
  • block_bio_bounce: 使用了 bounce buffer(DMA 地址限制导致),性能损耗信号
  • block_split: 一个大 bio 被拆分为两个(受设备段数/大小限制)
  • block_plug / block_unplug: 请求积攒(plug)和批量提交(unplug)机制

block_rq_complete 的定义展示了完整的事件结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// include/trace/events/block.h
DECLARE_EVENT_CLASS(block_rq_completion,
TP_PROTO(struct request *rq, blk_status_t error, unsigned int nr_bytes),
TP_ARGS(rq, error, nr_bytes),
TP_STRUCT__entry(
__field( dev_t, dev )
__field( sector_t, sector )
__field( unsigned int, nr_sector )
__field( int, error )
__array( char, rwbs, RWBS_LEN )
__dynamic_array( char, cmd, 1 )
),
TP_fast_assign(
__entry->dev = rq->q->disk ? disk_devt(rq->q->disk) : 0;
__entry->sector = blk_rq_pos(rq);
__entry->nr_sector = nr_bytes >> 9;
__entry->error = blk_status_to_errno(error);
blk_fill_rwbs(__entry->rwbs, rq->cmd_flags);
__get_str(cmd)[0] = '\0';
),
TP_printk("%d,%d %s (%s) %llu + %u [%d]",
MAJOR(__entry->dev), MINOR(__entry->dev),
__entry->rwbs, __get_str(cmd),
(unsigned long long)__entry->sector,
__entry->nr_sector, __entry->error)
);

rwbs 字段是一个最多 8 字节的字符串,编码了 IO 操作类型:R=读,W=写,D=discard,F=flush,S=sync,M=元数据,A=readahead,N=none。

3.2 完整 blktrace 分析流程

步骤一:采集

1
2
3
4
5
6
7
8
# 方法一:直接采集到文件
sudo blktrace -d /dev/sda -o /tmp/sda_trace -w 30 # 采集 30 秒

# 方法二:实时管道分析
sudo blktrace -d /dev/sda -o - | blkparse -i -

# 方法三:多设备同时采集
sudo blktrace -d /dev/sda -d /dev/sdb -o /tmp/trace

步骤二:解析

1
2
3
4
5
6
7
8
9
10
# 将二进制 trace 文件转换为可读格式
blkparse -i /tmp/sda_trace -o /tmp/sda_trace.txt

# 输出格式: 设备 CPU 序号 时间戳 PID 动作 类型 扇区+长度 [进程名]
# 示例:
# 8,0 3 1 0.000000000 987 Q W 2048 + 8 [mysqld]
# 8,0 3 2 0.000001234 987 G W 2048 + 8 [mysqld]
# 8,0 3 3 0.000002100 987 I W 2048 + 8 [mysqld]
# 8,0 3 4 0.000003400 0 D W 2048 + 8 [swapper]
# 8,0 3 5 0.001234567 0 C W 2048 + 8 [0]

动作字符含义:Q=queued,G=get request,I=inserted,D=dispatched(issued),C=completed,M=merged,F=front merged,S=split,P=plug,U=unplug,X=remap,A=remap (bio)

步骤三:统计分析

1
2
3
4
5
6
7
8
9
10
# 生成综合统计报告
btt -i /tmp/sda_trace.bin

# 关键输出指标:
# Q2C: Q 事件到 C 事件的时间,即完整 IO 延迟
# D2C: D 事件到 C 事件的时间,即纯设备服务时间
# Q2D: Q 事件到 D 事件的时间,即调度器排队时间
# Q2G: Q 到 G 的时间(获取 request struct 的时间)
# G2I: G 到 I 的时间(插入调度器队列的时间)
# I2D: I 到 D 的时间(在调度器中等待的时间)

步骤四:热点定位

1
2
3
4
5
6
7
8
# 统计各进程的 IO 操作数
blkparse -i /tmp/sda_trace -f "%C\n" | sort | uniq -c | sort -rn | head -20

# 生成 IO 时间线图(需要 seekwatcher 或 iowatcher)
iowatcher -t /tmp/sda_trace.bin -o io_timeline.mp4

# 找出延迟最高的 IO 操作(Q2C > 100ms)
btt -i /tmp/sda_trace.bin -l | awk '$3 > 0.1'

四、BPF/eBPF 工具集

BCC (BPF Compiler Collection) 和 bpftrace 提供了比 blktrace 更灵活、开销更低的分析手段。

4.1 biolatency — IO 延迟分布直方图

biolatency 捕获块设备 IO 的完整延迟分布,使用直方图展示:

1
2
3
sudo biolatency -d sda 10    # 针对 sda,采集 10 秒
sudo biolatency -F 10 # 按标志位(读/写/discard)分组
sudo biolatency -Q 10 # 包含队列等待时间(从 bio 排队开始计时)

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
Tracing block device I/O... Hit Ctrl-C to end.

usecs : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 12 |* |
8 -> 15 : 234 |******************** |
16 -> 31 : 512 |*************************************** |
32 -> 63 : 289 |************************ |
64 -> 127 : 45 |*** |
128 -> 255 : 8 | |
256 -> 511 : 1 | |

原理biolatency 挂钩 block:block_rq_issueblock:block_rq_complete tracepoint,记录每个 request 的起止时间戳,差值填入 BPF map(直方图数据结构),用户态周期性读取并打印。核心 BPF 逻辑:

1
2
3
4
5
6
# 伪代码说明原理
b.attach_kprobe(event="blk_account_io_start", fn_name="trace_start")
b.attach_kprobe(event="blk_account_io_done", fn_name="trace_done")

# trace_start: start[req] = bpf_ktime_get_ns()
# trace_done: delta = bpf_ktime_get_ns() - start[req]; hist.increment(log2(delta))

4.2 biosnoop — IO 事件追踪

输出每一次 IO 操作的详细信息:

1
2
sudo biosnoop -Q           # 包含排队时间
sudo biosnoop -d nvme0n1 # 过滤指定设备

输出示例:

1
2
3
4
TIME(s)     COMM           PID    DISK    T  SECTOR     BYTES  QUE(ms) LAT(ms)
0.000001 mysqld 8421 sda W 2048 65536 0.12 1.34
0.001234 kworker/u4 237 sda W 4096 4096 0.08 0.92
0.002100 postgres 9912 nvme0n1 R 16384 32768 0.01 0.08

适合在延迟抖动发生时捕获具体的”问题 IO”。

4.3 fileslower — 慢文件操作检测

检测超过阈值的文件读写操作(VFS 层):

1
2
sudo fileslower 10         # 检测 > 10ms 的文件 IO
sudo fileslower -p 1234 5 # 只监控 PID 1234,阈值 5ms

biosnoop 的区别:fileslower 工作在 VFS 层(vfs_read/vfs_write),能看到文件名和路径,而 biosnoop 工作在块设备层,只能看到扇区号。

4.4 filetop — 文件 IO Top

1
2
sudo filetop -C 5         # 每 5 秒刷新,不清屏
sudo filetop 1 # 每 1 秒刷新

输出:

1
2
3
4
5
6
7
Tracing... Output every 1 secs. Hit Ctrl-C to end

14:23:11 loadavg: 2.34 2.12 1.98 3/423 8421

TID COMM READS WRITES R_Kb W_Kb T FILE
8421 mysqld 234 89 18432 7168 R /var/lib/mysql/ib_logfile0
9912 postgres 128 45 8192 3584 R /var/lib/postgresql/base/16384/1259

4.5 ext4slower / xfsslower — 文件系统慢操作检测

检测文件系统层(不是 VFS 层,而是具体文件系统)的慢操作:

1
2
3
sudo ext4slower 10        # ext4 文件系统,阈值 10ms
sudo xfsslower 5 # XFS 文件系统,阈值 5ms
sudo btrfsslower 20 # Btrfs 文件系统,阈值 20ms

输出包含操作类型(READ/WRITE/OPEN/FSYNC)、文件路径和实际延迟,能直接定位到具体文件。

4.6 自定义 BPF 程序:追踪文件系统写操作延迟

以下是一个使用 bpftrace 追踪 ext4 写操作延迟的自定义脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env bpftrace

// 追踪 ext4_file_write_iter 函数的延迟
// 保存到文件: trace_ext4_write.bt

kprobe:ext4_file_write_iter
{
@start[tid] = nsecs;
}

kretprobe:ext4_file_write_iter
/@start[tid]/
{
$lat_us = (nsecs - @start[tid]) / 1000;
@latency_us = hist($lat_us);

if ($lat_us > 10000) { // 超过 10ms
printf("SLOW ext4 write: PID=%d COMM=%s lat=%d us\n",
pid, comm, $lat_us);
}

delete(@start[tid]);
}

END
{
printf("\next4 write latency distribution (us):\n");
print(@latency_us);
}

运行方法:

1
sudo bpftrace trace_ext4_write.bt

追踪 submit_bio 以统计各进程的块 IO 大小分布:

1
2
3
4
5
6
7
sudo bpftrace -e '
kprobe:submit_bio {
@[comm, args->bio->bi_opf & 1 ? "W" : "R"] =
hist(args->bio->bi_iter.bi_size);
}
interval:s:10 { print(@); clear(@); }
'

五、内核 ftrace 追踪存储路径

5.1 function tracer 追踪特定函数

ftrace 可以以极低开销追踪内核函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 挂载 debugfs(通常已挂载)
mount -t debugfs debugfs /sys/kernel/debug

# 查看可用 tracer
cat /sys/kernel/debug/tracing/available_tracers
# function function_graph nop blk ...

# 追踪 submit_bio 及其调用链
cd /sys/kernel/debug/tracing
echo function_graph > current_tracer
echo submit_bio > set_graph_function
echo 1 > tracing_on
sleep 5
echo 0 > tracing_on
cat trace | head -100

输出示例(function_graph):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1)               |  submit_bio() {
1) | bio_set_dev() {
1) 0.125 us | bio_associate_blkg();
1) 0.456 us | }
1) | blk_mq_submit_bio() {
1) | __blk_mq_alloc_request() {
1) 0.234 us | blk_mq_get_tag();
1) 1.234 us | }
1) | blk_mq_bio_to_request() {}
1) | blk_mq_run_hw_queue() {
1) 3.456 us | ...
1) 4.123 us | }
1) 12.345 us | }
1) 13.890 us | }

5.2 block tracepoint 事件

直接使用块设备的 tracepoint(性能优于 function tracer):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cd /sys/kernel/debug/tracing

# 启用所有块设备 tracepoint
echo 1 > events/block/enable

# 或者只启用特定事件
echo 1 > events/block/block_rq_issue/enable
echo 1 > events/block/block_rq_complete/enable

# 设置过滤器(只追踪 sda)
echo 'dev == 0x800' > events/block/block_rq_issue/filter # 8,0 = sda

echo 1 > tracing_on
sleep 10
echo 0 > tracing_on
cat trace | grep -v "^#" | head -50

查看可用的块设备 tracepoint:

1
2
3
4
5
6
7
8
ls /sys/kernel/debug/tracing/events/block/
# block_bio_backmerge block_bio_bounce block_bio_complete
# block_bio_frontmerge block_bio_queue block_bio_remap
# block_dirty_buffer block_getrq block_plug
# block_rq_complete block_rq_error block_rq_insert
# block_rq_issue block_rq_merge block_rq_remap
# block_rq_requeue block_split block_touch_buffer
# block_unplug

这与 include/trace/events/block.h 中定义的 tracepoint 一一对应。

5.3 trace-cmd 完整使用示例

trace-cmd 是 ftrace 的前端工具,大幅简化操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 安装
sudo apt install trace-cmd # Debian/Ubuntu
sudo dnf install trace-cmd # Fedora/RHEL

# 记录 30 秒内所有块设备事件
sudo trace-cmd record -e block:block_rq_issue \
-e block:block_rq_complete \
-e block:block_bio_queue \
sleep 30

# 分析记录结果
trace-cmd report | head -100

# 只追踪特定设备(sda = 8,0)
sudo trace-cmd record \
-e block:block_rq_issue \
--filter 'dev==0x800' \
sleep 10

# 同时追踪函数调用栈(诊断谁在发起 IO)
sudo trace-cmd record \
-e block:block_rq_issue \
-O stacktrace \
sleep 10

trace-cmd report | grep -A 20 "block_rq_issue"

结合调用栈,可以准确定位到哪段代码触发了特定 IO:

1
2
3
4
5
6
7
8
9
10
11
12
13
   mysqld-8421  [003]  1234.567890: block_rq_issue: 8,0 W 512 (NULL) 2048 + 8 [mysqld]
mysqld-8421 [003] 1234.567890: kernel_stack:
=> blk_mq_dispatch_rq_list
=> blk_mq_do_dispatch_sched
=> blk_mq_sched_dispatch_requests
=> __blk_mq_run_hw_queue
=> blk_mq_run_hw_queue
=> blk_mq_submit_bio
=> submit_bio
=> ext4_io_submit
=> ext4_writepages
=> do_writepages
=> __writeback_single_inode

六、页缓存调优

6.1 vm.dirty_ratiovm.dirty_background_ratio

这两个参数控制内核何时将脏页(dirty pages)回写到磁盘:

1
2
# 查看当前值
sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs
参数 默认值 含义
vm.dirty_background_ratio 10 当脏页占可用内存的比例超过此值,后台回写线程(kworker)开始回写
vm.dirty_ratio 20 当脏页占比超过此值,阻塞写入进程直到脏页降下来(写停顿!)
vm.dirty_background_bytes 0 与 ratio 版本互斥,以绝对字节数指定触发阈值
vm.dirty_bytes 0 与 ratio 版本互斥
vm.dirty_expire_centisecs 3000 脏页超过 30 秒未回写则被标记为”过期”,强制回写
vm.dirty_writeback_centisecs 500 后台回写线程的唤醒间隔(0=禁用,不推荐)

调优建议:

1
2
3
4
5
6
7
8
# 数据库服务器(需要低延迟,避免写停顿)
sysctl -w vm.dirty_background_ratio=5
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_expire_centisecs=1000

# 大内存批处理服务器(提高吞吐量,允许累积更多脏页)
sysctl -w vm.dirty_background_bytes=268435456 # 256MB
sysctl -w vm.dirty_bytes=1073741824 # 1GB

写入 /etc/sysctl.conf 持久化:

1
2
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10

6.2 vm.vfs_cache_pressure 调优

该参数控制内核回收 VFS 缓存(inode 和 dentry 缓存)相对于页缓存的积极程度:

1
2
sysctl vm.vfs_cache_pressure
# vm.vfs_cache_pressure = 100 (默认)
  • = 100:平等对待页缓存和 VFS 缓存(默认)
  • < 100(如 50):倾向于保留 VFS 缓存(有利于元数据密集型负载)
  • > 100(如 200):更积极地回收 VFS 缓存(内存紧张时)
  • = 0:永不回收 inode/dentry 缓存(有 OOM 风险,不推荐生产使用

对于需要频繁 stat()open() 小文件的服务(如 Web 服务器静态文件):

1
sysctl -w vm.vfs_cache_pressure=50

6.3 清除页缓存的场景和方法

1
2
3
4
5
6
7
8
# 只清除页缓存(不影响 inode/dentry 缓存)
echo 1 > /proc/sys/vm/drop_caches

# 清除 inode 和 dentry 缓存
echo 2 > /proc/sys/vm/drop_caches

# 清除所有缓存(页缓存 + inode + dentry)
echo 3 > /proc/sys/vm/drop_caches

注意:清除前建议先同步:

1
sync && echo 3 > /proc/sys/vm/drop_caches

使用场景

  • 性能测试前消除缓存影响,确保测试一致性
  • 内存不足时临时释放(治标不治本)
  • 验证应用是否依赖页缓存(清除后看性能是否下降)

监控页缓存使用情况:

1
2
3
4
5
6
7
8
9
# 查看缓存占用
free -m
vmstat -s | grep -i cache

# 详细查看 buffer/cache 分布
cat /proc/meminfo | grep -E "Cached|Buffers|Dirty|Writeback"

# 查看哪些文件占用了页缓存(需要 vmtouch 工具)
vmtouch -v /var/lib/mysql/

七、IO 调度器调优

7.1 调度器选择

Linux 内核当前支持以下 IO 调度器(block/ 目录下):

调度器 文件 适用场景
mq-deadline mq-deadline.c 通用 HDD/SATA SSD,防止 IO 饥饿
kyber kyber-iosched.c 高性能 NVMe,低延迟设备
bfq bfq-iosched.c 桌面/混合负载,按进程公平分配带宽
none NVMe/高性能设备(绕过调度器)

查看和切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 查看当前调度器(方括号表示当前选中)
cat /sys/block/sda/queue/scheduler
# [mq-deadline] kyber bfq none

# 切换调度器
echo mq-deadline > /sys/block/sda/queue/scheduler
echo none > /sys/block/nvme0n1/queue/scheduler

# 持久化(udev 规则)
cat > /etc/udev/rules.d/60-scheduler.rules << 'EOF'
# 旋转设备使用 mq-deadline
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", \
ATTR{queue/scheduler}="mq-deadline"

# NVMe 设备使用 none
ACTION=="add|change", KERNEL=="nvme[0-9]*", \
ATTR{queue/scheduler}="none"

# SSD(非旋转)使用 mq-deadline
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", \
ATTR{queue/scheduler}="mq-deadline"
EOF

udevadm control --reload-rules

7.2 调度器参数调优

mq-deadline:

1
2
3
4
5
6
7
8
9
# 读请求最大等待时间(毫秒),防止读饥饿
cat /sys/block/sda/queue/iosched/read_expire # 默认 500ms
echo 200 > /sys/block/sda/queue/iosched/read_expire

# 写请求最大等待时间(毫秒)
cat /sys/block/sda/queue/iosched/write_expire # 默认 5000ms

# 每次批量派发的最大请求数
cat /sys/block/sda/queue/iosched/fifo_batch # 默认 16

BFQ(适合桌面和混合负载):

1
2
3
4
5
# 开启 BFQ 低延迟模式(对交互式进程优先)
echo 1 > /sys/block/sda/queue/iosched/low_latency

# 为特定 cgroup 设置 IO 权重(100-1000,默认 100)
echo 200 > /sys/fs/cgroup/blkio/mygroup/blkio.bfq.weight

八、文件系统挂载选项优化

8.1 ext4 性能挂载选项

1
2
3
4
5
6
7
# 高性能配置(适合非关键数据)
mount -o noatime,nodiratime,data=writeback,barrier=0,commit=60 \
/dev/sda1 /data

# 均衡配置(生产环境)
mount -o noatime,nodiratime,data=ordered,commit=30 \
/dev/sda1 /data
选项 含义 建议
noatime 不更新文件访问时间(atime) 几乎所有场景都应开启
nodiratime 不更新目录访问时间 配合 noatime 使用
data=writeback 元数据写入前不保证数据落盘,性能最高,崩溃可能数据不一致 仅用于非关键数据
data=ordered 默认,数据先于元数据写入,安全与性能的平衡 推荐生产使用
data=journal 数据和元数据都经过 journal,最安全最慢 关键金融数据
barrier=0 关闭写屏障,提升顺序写性能,有电源丢失风险 有 BBU/UPS 时可用
commit=N journal 提交间隔(秒),默认 5 提高到 30-60 可降低 IO
delalloc 延迟分配(默认开启),有助于减少碎片 保持默认
stripe=N RAID 条带大小(512 字节为单位),告知文件系统 RAID 参数 RAID 环境建议设置

8.2 XFS 性能挂载选项

1
2
3
4
5
6
7
# XFS 高性能挂载
mount -o noatime,nodiratime,logbsize=256k,logbufs=8,allocsize=1m \
/dev/sda1 /data

# NVMe 上的 XFS(大 stripe unit)
mount -o noatime,nodiratime,logbsize=256k,swalloc \
/dev/nvme0n1p1 /data
选项 含义
logbsize=256k 日志缓冲区大小,增大可提升写入吞吐量(最大 256k)
logbufs=8 日志缓冲区数量(默认 8)
allocsize=1m 延迟分配时的预分配大小,减少碎片化
swalloc 条带宽度对齐分配,RAID 场景有益
largeio 允许更大的 IO 请求,利于顺序 IO 场景

8.3 Btrfs 性能挂载选项

1
2
3
4
5
6
7
# Btrfs 高性能配置(SSD)
mount -o noatime,nodiratime,ssd,compress=lz4,space_cache=v2,discard=async \
/dev/nvme0n1p1 /data

# Btrfs HDD 配置
mount -o noatime,nodiratime,compress=zstd:3,space_cache=v2 \
/dev/sda1 /data
选项 含义
ssd 开启 SSD 优化(禁用旋转磁盘优化算法)
compress=lz4 透明压缩(lz4 最快,zstd 压缩率更高)
space_cache=v2 新版空间缓存,性能更好
discard=async 异步 TRIM,避免同步 TRIM 的延迟开销
autodefrag 自动碎片整理(数据库工作负载不建议开启)
thread_pool=N 并行 IO 线程数(默认 2)

九、实战案例:排查 IO 延迟飙升问题

场景描述

某生产环境 MySQL 服务器,业务反馈每隔约 30 分钟出现一次写入延迟飙升(约 5-10 秒),持续约 1 分钟后恢复正常,iostat 在正常时段 %util 约 40%,飙升期间 %util 达 100%,w_await 从 3ms 跳升至 200ms 以上。

排查步骤

第一步:确认基线,收集现场数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 持续监控(保存到文件)
iostat -x 1 3600 > /tmp/iostat.log &

# 同时启动 blktrace(循环模式,避免磁盘满)
sudo blktrace -d /dev/sda -o /tmp/blktrace -b 8192 -n 8 &

# 监控内存压力
vmstat 1 3600 > /tmp/vmstat.log &

# 监控 dirty page 变化
while true; do
echo "$(date) $(grep -E 'Dirty|Writeback' /proc/meminfo | tr '\n' ' ')"
sleep 5
done > /tmp/dirty.log &

第二步:分析 vmstat 日志,找到延迟发生时间点

1
2
3
4
5
6
7
8
# 找到 wa(IO wait)突升的时间窗口
grep -n "^[0-9]" /tmp/vmstat.log | awk '$17 > 50 {print NR, $0}' | head -20

# 分析脏页日志,看是否与 dirty page 清理有关
grep "Dirty" /tmp/dirty.log | awk '{
split($4, a, ":"); dirty=a[2]+0
if (dirty > 1000000) print $0
}'

第三步:分析 dirty page 日志

1
2
3
cat /tmp/dirty.log | grep -E "Dirty: [0-9]{6,}"
# 发现规律:每隔 ~30 分钟,Dirty 页从 ~200MB 突升至 ~2GB,随后触发写停顿
# 这与 vm.dirty_ratio=20% × 10GB RAM = 2GB 吻合

第四步:用 blkparse 分析 IO 模式

1
2
3
4
5
6
7
8
9
# 截取问题时间段的 trace(假设延迟发生在第 1820-1880 秒)
blkparse -i /tmp/blktrace \
--start-time=1820 --stop-time=1880 \
-o /tmp/problem_period.txt

# 统计问题时段的 IO 延迟分布
btt -i /tmp/blktrace.bin -B problem_latency

# 输出中关注 Q2C(完整延迟)的 percentile 分布

第五步:使用 biolatency 精确定位

在下一次延迟发生时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 在告警触发后立即运行
sudo biolatency -d sda -F 60 # 采集 60 秒,按读写分组

# 同时抓取调用栈,找出谁在发起大量写 IO
sudo bpftrace -e '
kprobe:submit_bio
/args->bio->bi_opf & 1/ // 写操作
{
@[kstack(5)] = count();
}
interval:s:60 {
print(@); clear(@); exit();
}
'

第六步:发现根因

分析结果揭示:

  1. vm.dirty_ratio=20%(10GB 内存 → 2GB 脏页上限),MySQL 的 innodb_buffer_pool_size=8GB 会周期性刷脏页
  2. 刷脏页触发时,瞬间产生大量写 IO,达到 dirty_ratio 阈值,阻塞 MySQL 写入线程
  3. blkparse 显示问题时段存在大量顺序写,D2C(设备服务时间)本身正常,是 Q2D(队列等待)过长

第七步:修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 降低 dirty_ratio,提前触发后台回写,避免积累到停顿点
sysctl -w vm.dirty_background_ratio=3
sysctl -w vm.dirty_ratio=8
sysctl -w vm.dirty_expire_centisecs=500 # 脏页 5 秒就开始回写

# MySQL 侧配合调整(减少单次 checkpoint 写入量)
# innodb_io_capacity=2000
# innodb_io_capacity_max=4000
# innodb_max_dirty_pages_pct=50 # 默认 90,降低减少积累

# 持久化内核参数
cat >> /etc/sysctl.conf << 'EOF'
vm.dirty_background_ratio = 3
vm.dirty_ratio = 8
vm.dirty_expire_centisecs = 500
EOF
sysctl -p

第八步:验证效果

1
2
3
4
5
6
7
8
# 观察延迟是否消失
sudo biolatency -d sda 300 # 观察 5 分钟

# 对比 dirty page 变化规律
watch -n 5 "grep -E 'Dirty|Writeback' /proc/meminfo"

# 确认 w_await 恢复正常
iostat -x 5 12 | grep sda

结果:脏页峰值从 2GB 降至 300MB 以内,w_await 稳定在 3-5ms,定期写停顿消失。


十、总结

Linux 存储性能调优是一个多层次的工程:从 iostat 的宏观视角,到 /proc/diskstats 和内核 genhd.c 揭示的统计细节;从 blktrace 捕获的每一个块 IO 事件(对应 include/trace/events/block.h 中的 tracepoint),到 eBPF 工具提供的动态追踪能力;从 ftrace 的函数级追踪,到页缓存参数和挂载选项的精细调整。

性能问题的排查没有万能公式,但有清晰的方法论:先监控定位层次(应用/VFS/块设备/驱动)、再用精细工具缩小范围(blktrace/biosnoop/ext4slower)、最后结合内核源码理解根因。当你能直接阅读 genhd.cdiskstats_show() 函数来理解 iostat 数字的来源,能对照 block.h 的 tracepoint 定义来解读 blktrace 输出时,存储调优便从经验主义升华为真正的工程科学。