Linux 基础体系 · 第 53/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Linux 块 I/O 栈:Bio、Queue、Scheduler、Direct I/O 和延迟
Linux 的块 I/O 性能问题,通常不能只用“磁盘读写速度”解释。一次应用层的 read() 或 write(),可能经过文件系统、页缓存、块层队列、I/O 调度器、设备驱动和存储控制器,最终才变成设备上的请求;而“延迟”也不只是设备服务时间,还可能包括线程等待、页缓存回写、块层排队、请求合并、文件系统日志提交和设备内部排队。
本文从一次块 I/O 的数据流开始,依次解释:
bio表示什么,和request有什么区别;- 块设备队列
request_queue、硬件队列和队列深度如何工作; - I/O scheduler 在现代
blk-mq中处于什么位置; - buffered I/O 与 Direct I/O 的路径差异;
- 延迟如何由各阶段组成,为什么吞吐提高后延迟会突然恶化;
- 如何使用
iostat、fio、sysfs 和块层跟踪工具验证判断; - 常见误解、失败表现和生产环境取舍。
一次 read() 为什么会进入块层
先区分三个层次:
- 文件 I/O:应用通过文件描述符读写文件,例如
read()、write()、pread()、pwrite()。 - 块 I/O:内核以逻辑块地址和数据段为单位,向块设备提交读写请求。
- 设备 I/O:NVMe、SATA、SAS、虚拟磁盘或 RAID 控制器把块请求转换为设备协议命令。
典型路径如下:
flowchart TD
A[应用 read/write/pread/pwrite] --> B{是否命中页缓存}
B -->|读命中| C[直接从页缓存复制到用户空间]
B -->|读未命中| D[文件系统构造 bio]
A --> E[buffered write 写入页缓存]
E --> F[后台回写或 fsync 触发]
A --> G[O_DIRECT 路径]
G --> H[文件系统直接构造 bio]
D --> I[块层 bio]
H --> I
F --> I
I --> J[必要时拆分、合并、形成 request]
J --> K[blk-mq 软件队列]
K --> L[I/O scheduler]
L --> M[硬件派发队列]
M --> N[设备驱动]
N --> O[存储设备]
O --> N
N --> P[完成回调]
P --> Q[唤醒等待线程或完成异步请求]
这张图需要注意两个事实:
- 不是每次
read()都会进入块层。读请求命中页缓存时,数据直接从内核内存复制给应用。 write()返回也不一定意味着数据已经到达持久化介质。普通 buffered write 往往只表示数据已经写入页缓存;fsync()、fdatasync()或特定同步语义才会进一步要求持久化。
因此,讨论块 I/O 性能前,必须先说明测试的是哪一种路径:
- 页缓存命中;
- 页缓存未命中;
- buffered write 的提交速度;
- 回写速度;
fsync()的持久化延迟;- Direct I/O;
- 文件系统、LVM、RAID 或虚拟块设备之下的实际设备 I/O。
bio:块层中的数据描述对象
bio 的定义
bio 是 Linux 块层用于描述一次块 I/O 的核心数据结构。它通常包含:
- I/O 方向:读、写,以及 flush、discard 等操作类型;
- 起始扇区或逻辑块地址;
- 要传输的扇区数;
- 一个或多个数据段;
- 完成回调和状态;
- 与请求上下文、内存页相关的信息。
bio 中的数据段通常由 bio_vec 表示。一个数据段指向一段内存页,而这些页在虚拟地址空间中不要求连续。也就是说,一个大 I/O 可以由多个不连续的物理页组成。
概念上可以表示为:
bio
├── 起始逻辑扇区:100000
├── 总长度:128 KiB
├── 方向:READ
└── bio_vec
├── page A, offset 0, length 64 KiB
└── page B, offset 0, length 64 KiB
bio 描述的是“从哪些内存位置,向块设备的哪些逻辑扇区,传输多少数据”,而不是一个完整的用户系统调用。
一个 bio 不等于一个应用 I/O
一个应用的 I/O 可能:
- 被文件系统拆成多个
bio; - 多个较小的
bio被块层合并到同一个更大的设备请求; - 一个
bio因设备最大请求大小、边界或队列限制而被拆分; - 在 RAID、device mapper 或加密层中被重新映射或复制。
因此以下等式通常不成立:
一次 read() = 一个 bio = 一个 request = 一个设备命令
更准确的关系是:
应用 I/O
→ 文件系统逻辑操作
→ 一个或多个 bio
→ 一个或多个 request
→ 一个或多个设备命令
bio 和 request 的区别
可以把两者分别理解为:
bio:描述数据和逻辑块范围;request:块层准备派发给驱动的工作单元,可能包含一个或多个bio。
例如,应用产生三个相邻的 4 KiB 写入:
bio1: LBA 1000,4 KiB
bio2: LBA 1008,4 KiB
bio3: LBA 1016,4 KiB
如果它们满足合并条件,块层可能形成:
request:
LBA 1000
长度 12 KiB
bio1 + bio2 + bio3
合并减少了请求描述、调度和设备命令的开销,但并不保证一定发生。以下条件可能阻止合并:
- 逻辑块地址不连续;
- 读写方向不同;
- 跨越设备或文件系统限制;
- 数据段数量超过限制;
- 请求属性不同,例如同步或特殊操作;
- 底层设备或映射层要求拆分;
- 当前队列已经达到限制。
bio 生命周期
以一次从块设备读取数据为例,简化生命周期如下:
- 文件系统确认需要从某个逻辑块读取数据;
- 分配并填充
bio; - 设置目标扇区、长度、读方向和数据页;
- 调用块层提交接口;
- 块层可能执行拆分、合并、映射或重排;
bio被放入请求路径;- 驱动将请求转换为设备命令;
- 设备完成命令并触发中断或轮询完成处理;
- 驱动设置完成状态;
- 块层调用完成路径;
- 文件系统检查状态并填充页缓存;
- 阻塞的进程被唤醒,
read()返回。
如果设备返回介质错误、传输错误或超时,错误状态会沿完成路径向上传递。应用最终可能看到 EIO、ETIMEDOUT 或文件系统根据错误策略采取只读、重试、日志错误等行为。
Queue:块设备队列不是一个简单的 FIFO
request_queue 的含义
每个块设备都有一个块层队列,传统上称为 request_queue。它保存或管理:
- 设备的最大请求大小;
- 最大段数;
- 逻辑块大小和物理块大小;
- 是否支持 flush、FUA、discard;
- 队列深度;
- 软件提交队列;
- 硬件派发队列;
- I/O 调度器;
- 请求分配和完成所需的状态。
可以通过 sysfs 查看部分属性:
dev=nvme0n1
cat /sys/block/$dev/queue/logical_block_size
cat /sys/block/$dev/queue/physical_block_size
cat /sys/block/$dev/queue/max_sectors_kb
cat /sys/block/$dev/queue/nr_requests
cat /sys/block/$dev/queue/scheduler
cat /sys/block/$dev/queue/write_cache
示例输出可能类似:
512
4096
128
256
[none] mq-deadline
write back
含义是:
- 设备逻辑扇区为 512 字节;
- 物理块或最优物理粒度为 4 KiB;
- 单个请求的最大大小约为 128 KiB;
- 队列相关的请求资源上限为 256;
- 当前调度器是
none,可选调度器还包括mq-deadline; - 设备报告带有写缓存。
具体字段和可选值取决于内核版本、设备驱动和发行版配置。sysfs 是运行时实现接口,不应把某个机器上的数值当作所有设备的规范保证。
软件队列和硬件队列
现代 Linux 块层主要使用多队列块层,也就是 blk-mq。它的目的之一是避免单个全局锁成为高并发设备的瓶颈。
常见结构可以抽象为:
CPU 0 → 软件提交队列 0 ─┐
CPU 1 → 软件提交队列 1 ─┼→ 硬件队列 0 → 驱动 → 设备
CPU 2 → 软件提交队列 2 ─┤
CPU 3 → 软件提交队列 3 ─┘
实际映射可能按 CPU、NUMA 节点或驱动策略变化。NVMe 通常可以暴露多个硬件 I/O 队列,SATA 设备通常具有更有限的并行能力,虚拟块设备的队列又由虚拟化后端决定。
因此“队列”至少有三种常被混淆的含义:
- 内核请求队列:块层管理请求的对象;
- 硬件队列:设备驱动或设备协议提供的并行提交通道;
- 队列深度:某个队列中允许同时未完成的请求数量。
fio 中的 iodepth=32 是工作负载试图维持的并发深度,不等于设备一定实际拥有 32 个并行硬件槽位,也不等于块层始终存在 32 个待服务请求。
队列深度与并发
假设应用以固定速率发起请求,设备平均每秒完成 λ 个请求,每个请求从进入系统到完成平均花费 W 秒,则 Little 定律给出:
其中:
L是系统中平均未完成请求数;λ是完成速率,也就是 IOPS;W是从请求进入统计范围到完成的平均延迟。
例如:
IOPS = 100,000
平均延迟 = 2 ms = 0.002 s
则:
这意味着平均约有 200 个请求处于未完成状态。这里的“系统范围”必须明确:如果延迟从应用提交开始计时,L 包含应用、文件系统和块层中的请求;如果只统计设备队列,含义就不同。
这个公式不是性能调优公式,而是一个一致性检查工具。若监控显示 100k IOPS、2 ms 端到端延迟,却声称整个系统只有平均 8 个未完成请求,通常说明统计口径不同,或者指标并非同一时间窗口。
队列满时会发生什么
当队列或请求资源不足时,提交方可能:
- 暂时阻塞;
- 被迫等待资源释放;
- 在异步接口中收到暂时无法提交的状态;
- 使应用层线程堆积;
- 让平均延迟和尾延迟同时上升。
对于同步 read(),线程通常会睡眠等待 I/O 完成。对于异步 I/O,应用可能继续提交其他请求,但达到深度上限后仍然会受到背压。
这解释了一个常见现象:
队列深度增加
→ 并行度提高
→ 吞吐上升
→ 设备接近饱和
→ 等待时间快速增加
→ 吞吐增长变小,尾延迟恶化
队列深度不是越大越好。它只是把更多工作交给系统,不能创造设备不存在的服务能力。
Scheduler:I/O 调度器到底调度什么
调度器的职责
块 I/O 调度器位于块层请求形成和驱动派发之间。它可能负责:
- 维护待处理请求;
- 按逻辑块地址排序;
- 合并相邻请求;
- 控制读写请求之间的公平性;
- 为同步请求提供优先级或期限;
- 将请求派发到硬件队列。
调度器调度的主要对象是块层请求,而不是应用线程,也不是文件系统事务。
在多队列块层中,调度器并不一定是所有路径的必经之路。某些设备可以使用 none,请求从软件队列直接进入硬件派发路径;如果设备本身具有强大的内部调度能力,内核再做一层复杂排序未必有益。
常见调度器
现代发行版中常见的多队列调度器包括:
none:块层基本不做额外排序,强调低开销和将调度交给设备;mq-deadline:为读写请求维护期限,尽量避免某类请求长期饥饿;bfq:更强调进程或工作负载间的公平性和交互响应,是否可用取决于内核配置和设备类型;kyber:通过控制不同 I/O 类型的并发量来限制延迟,具体可用性与版本有关。
可用调度器和默认调度器并没有统一答案。查看当前设备:
cat /sys/block/sda/queue/scheduler
cat /sys/block/nvme0n1/queue/scheduler
输出中的方括号表示当前选项,例如:
none [mq-deadline] kyber
表示当前使用 mq-deadline。
临时切换可以这样做:
echo mq-deadline | sudo tee /sys/block/sda/queue/scheduler
风险和边界:
- 需要 root 权限;
- 设备必须提供该调度器;
- 重启后是否保留取决于发行版和 udev/systemd 配置;
- 对已经饱和的设备,切换调度器不一定能降低设备内部延迟;
- 在生产环境应先使用同等负载、同等数据集验证,而不是只根据名称选择。
调度器与设备内部调度的边界
块层只能看到逻辑块地址和请求属性,通常看不到 SSD 闪存映射、垃圾回收、磨损均衡、SLC 缓存、RAID 条带内部状态或云存储后端队列。
因此:
块层排序合理
并不意味着:
设备实际服务顺序完全相同
对于旋转磁盘,减少寻道和旋转等待曾经非常重要;对于 NVMe SSD,逻辑块相邻性通常不等价于物理介质相邻性,但队列公平性、写入节流、同步请求保护仍然可能影响延迟。
从 bio 到设备命令:拆分、合并和映射
请求合并
相邻请求可以合并,以减少请求数量。例如:
bio A: [LBA 1000, 4 KiB]
bio B: [LBA 1008, 4 KiB]
如果中间没有空洞,且其他属性允许,它们可以合并为:
request: [LBA 1000, 8 KiB]
合并通常降低每次 I/O 的固定开销,但会带来两个边界:
- 等待更多相邻请求可能增加单个请求的等待时间;
- 大请求可能占用设备带宽,使小的延迟敏感请求等待更久。
因此“合并率越高越好”也不是普遍成立。吞吐型顺序工作负载通常受益于较大请求;低延迟随机工作负载可能更关注小请求的及时派发。
请求拆分
块层或底层映射层可能因为以下原因拆分请求:
- 超过
max_sectors_kb; - 超过最大 scatter-gather 段数;
- 跨越设备边界;
- 通过 device mapper、RAID、加密层后需要重新映射;
- RAID 条带边界导致不同成员设备分别处理;
- 目标设备不支持某种操作。
例如,一个 1 MiB 的应用写入,在设备最大请求大小为 128 KiB 时,至少可能被拆为多个较小的底层请求。实际数量还会受到内存段布局、文件系统和映射层影响。
RAID 和 device mapper 会改变观察结果
如果文件系统位于 LVM、软件 RAID、dm-crypt 或多层虚拟设备之上:
文件系统 bio
→ dm/LVM/RAID 映射
→ 下层设备的多个 bio
上层看到一个请求,不代表物理盘只处理一个请求。例如 RAID-5 的非全条带小写入,可能需要读取旧数据和旧校验,再写入新数据和新校验,形成读-改-写放大。
所以用 iostat 只看逻辑卷设备时,可能低估了物理成员盘的请求数和延迟;应同时观察相关下层设备,并结合 RAID 条带、校验和缓存策略分析。
Buffered I/O:页缓存让“写入完成”变得含糊
Buffered read
普通文件读取通常先查页缓存:
应用 read()
→ 文件系统查页缓存
→ 命中:复制数据到用户缓冲区
→ 未命中:构造 bio 读取磁盘
第一次读取可能产生块 I/O,后续读取可能完全不触碰设备。因此重复运行相同的测试时,第一次和第二次结果可能完全不同。
可以使用 file 作为测试对象,并在测试前清理页缓存,但这需要 root 权限,而且会影响整台机器:
sync
echo 3 | sudo tee /proc/sys/vm/drop_caches
drop_caches 会丢弃可回收的页缓存和 inode/dentry 缓存,不会删除文件数据;但它可能干扰其他业务、造成后续缓存重新建立,不应在生产环境随意执行。
Buffered write
普通写入通常先修改页缓存中的页并标记为脏页:
write()
→ 修改页缓存
→ 页面变脏
→ write() 返回
→ 后台回写或同步调用产生块 I/O
因此:
write() 返回快
不等于:
数据已经安全地存储在持久化介质上
fsync(fd) 通常要求与文件描述符相关的数据和必要元数据进入持久化路径;fdatasync(fd) 主要关注数据及保证后续读取所需的元数据。实际持久化能力还取决于文件系统、设备缓存和设备是否正确实现 flush/FUA 语义。
如果设备报告写缓存为 write back:
cat /sys/block/sda/queue/write_cache
可能看到:
write back
这表示设备可能先把数据放在易失性写缓存中。只要设备正确支持并执行 flush,文件系统仍可建立持久化顺序;如果设备或虚拟化后端错误地丢弃 flush,内核无法从软件层保证断电后的数据安全。
Direct I/O:绕过页缓存,不等于绕过内核
Direct I/O 的定义
Direct I/O 通常通过 O_DIRECT 打开文件,例如:
int fd = open("data.bin", O_RDONLY | O_DIRECT);
其主要目标是让文件数据传输绕过页缓存,直接在用户缓冲区和存储设备之间进行 I/O。这样可以避免:
- 数据先复制到页缓存;
- 应用缓存和页缓存重复占用内存;
- 大规模顺序扫描污染其他热点数据。
但 Direct I/O 并不表示:
- 不经过文件系统;
- 不经过块层;
- 不经过设备驱动;
- 不经过设备内部缓存;
- 不需要文件系统元数据;
- 一定比 buffered I/O 快。
典型路径变为:
应用用户缓冲区
→ 文件系统 direct I/O 检查
→ bio
→ 块层
→ 驱动
→ 设备
文件系统仍然需要进行权限检查、文件偏移映射、空间映射和错误处理;元数据也可能继续使用内核缓存。
对齐要求
Direct I/O 常见要求包括:
- 用户缓冲区地址按设备或文件系统要求对齐;
- 文件偏移按要求对齐;
- I/O 长度按要求对齐。
要求并不完全统一。不同文件系统、内核版本、设备和挂载方式可能允许不同粒度;Linux 某些文件系统可以通过 statx() 的 direct-I/O 对齐信息暴露实际约束。不能只假设“4 KiB 对齐永远足够”。
一个可运行的 C 示例:
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void) {
const size_t size = 4096;
void *buf = NULL;
int fd = open("direct-test.bin", O_CREAT | O_RDWR | O_DIRECT, 0600);
if (fd == -1) {
perror("open");
return 1;
}
if (posix_memalign(&buf, 4096, size) != 0) {
fprintf(stderr, "posix_memalign failed\n");
close(fd);
return 1;
}
memset(buf, 'A', size);
ssize_t n = pwrite(fd, buf, size, 0);
if (n == -1) {
perror("pwrite");
free(buf);
close(fd);
return 1;
}
if ((size_t)n != size) {
fprintf(stderr, "short write: %zd/%zu\n", n, size);
free(buf);
close(fd);
return 1;
}
if (fsync(fd) == -1) {
perror("fsync");
free(buf);
close(fd);
return 1;
}
free(buf);
close(fd);
return 0;
}
编译和运行:
gcc -O2 -Wall -Wextra direct-test.c -o direct-test
./direct-test
这个例子中:
O_DIRECT请求 Direct I/O;posix_memalign()提供对齐缓冲区;- 4096 字节长度和偏移 0 适用于许多常见设备,但不是所有文件系统都保证接受;
pwrite()检查短写;fsync()用于测试显式同步语义,而不是把pwrite()返回误认为断电安全。
如果看到:
Invalid argument
常见原因是缓冲区地址、长度或偏移不满足当前文件系统和设备的对齐要求。也可能是目标文件系统不支持该组合的 Direct I/O 语义。
Direct I/O 与 buffered I/O 混用
同一个文件同时使用 Direct I/O 和 buffered I/O,可能造成:
- 页缓存中存在过期数据;
- 应用看到的内容依赖同步时序;
- 文件范围锁和并发控制复杂;
- 性能结果不稳定;
- 文件系统需要额外处理缓存一致性。
某些文件系统会在 Direct I/O 前后执行必要的缓存失效或同步,但应用仍不应把混用当作无需协调的并发模型。数据库通常使用 Direct I/O,是因为它自己管理缓冲池、刷盘顺序和并发协议,而不是因为 Direct I/O 自动解决了持久化问题。
Direct I/O 的性能边界
Direct I/O 可能有益于:
- 数据集远大于内存;
- 应用自己拥有成熟缓存;
- 顺序扫描不希望污染页缓存;
- 应用需要明确控制提交和完成;
- 数据库或高性能存储引擎管理自己的 I/O 调度。
它也可能变差:
- 小 I/O 导致更多用户态对齐和管理成本;
- 应用缓存策略不如内核页缓存;
- 没有预读,随机读取更依赖应用自己发起并发;
- 文件系统仍需处理元数据;
- 没有正确使用异步并发时,延迟和吞吐都低。
因此正确问题不是“Direct I/O 是否更快”,而是:
工作集、访问模式、缓存策略、同步语义和并发模型是否适合绕过页缓存?
延迟:从应用视角到设备视角
延迟的组成
一次 I/O 的端到端延迟可以近似拆成:
其中:
T_app:应用等待、锁竞争、事件循环调度等时间;T_fs:文件系统查找、页缓存处理、日志或空间映射时间;T_queue:在软件队列、调度器和硬件队列中等待的时间;T_dispatch:驱动提交设备命令的时间;T_device:设备实际服务时间,包括介质、控制器和设备内部队列;T_completion:中断或轮询完成、块层回调和唤醒应用的时间。
对 buffered read 命中页缓存时,T_queue 和 T_device 可能为零;对 buffered write 的普通 write(),设备写入可能发生在系统调用返回之后;对 fsync(),持久化路径中的等待才会被暴露出来。
排队延迟和服务时间
设一个请求进入设备服务系统的时间为 t0,开始被设备处理的时间为 t1,完成时间为 t2:
排队延迟 = t1 - t0
服务时间 = t2 - t1
总设备侧延迟 = t2 - t0
当设备空闲时:
总延迟 ≈ 服务时间
当设备接近饱和时:
总延迟 = 排队延迟 + 服务时间
服务时间可能只略有变化,但排队延迟可以快速增长。这就是为什么 SSD 在低负载时延迟很低,一旦并发和写入压力达到某个点,P99 延迟会突然恶化。
完整算例:为什么吞吐提高后延迟变差
假设某设备在低负载时:
平均服务时间 = 100 μs
工作负载逐步增加:
| 到达率 | 平均排队时间 | 平均服务时间 | 平均总延迟 |
|---|---|---|---|
| 5,000 IOPS | 10 μs | 100 μs | 110 μs |
| 20,000 IOPS | 30 μs | 100 μs | 130 μs |
| 50,000 IOPS | 200 μs | 105 μs | 305 μs |
| 80,000 IOPS | 2 ms | 110 μs | 2.11 ms |
在 80,000 IOPS 时,设备服务时间仍只有约 110 μs,但总延迟超过 2 ms,主要原因是请求在队列中等待。
如果业务目标是 P99 小于 1 ms,仅查看平均延迟可能误导。尾延迟还可能受以下因素影响:
- SSD 垃圾回收和写放大;
- RAID 重建;
- 文件系统日志提交;
- cgroup I/O 限速;
- 虚拟机宿主机争用;
- 设备固件的后台任务;
- 大 I/O 与小 I/O 共享队列;
- 写缓存耗尽后从突发写入变为直接写介质。
同步、异步和队列深度如何改变延迟
同步 I/O
同步阻塞 I/O 的典型形式是:
ssize_t n = pread(fd, buf, len, offset);
如果数据不在页缓存,线程通常提交请求后睡眠,完成时被唤醒。单个线程的并发深度往往接近 1:
提交一个请求
→ 等待完成
→ 再提交下一个请求
这适合低并发、简单控制流,但可能无法充分利用高性能 NVMe。
多线程或异步 I/O
多个线程、Linux AIO 或 io_uring 可以同时保持多个未完成请求:
submit 1
submit 2
submit 3
...
wait completions
并发深度提高后,设备可以并行处理更多请求,但排队延迟也可能增加。高性能存储通常需要一定队列深度才能达到峰值吞吐;低延迟服务则可能故意限制深度,避免把设备推入饱和区。
io_uring 的提交队列和完成队列是应用与内核之间的异步接口,不应直接等同于块层硬件队列。一个 io_uring 请求仍然可能经过文件系统、页缓存或 Direct I/O 路径,最终形成一个或多个 bio。
iostat:先确认你测到的是什么
安装 sysstat 后可以运行:
iostat -xz 1
常见字段包括:
r/s、w/s:每秒读写请求数;rkB/s、wkB/s:每秒读写吞吐;rrqm/s、wrqm/s:请求合并相关统计,具体含义受工具和内核版本影响;r_await、w_await:读写请求平均等待时间;aqu-sz:平均队列长度;%util:设备忙碌时间比例。
需要特别注意 await 的语义:它通常表示从请求进入设备统计范围到完成的平均时间,包含排队和服务时间,不只是介质服务时间。不同 sysstat 版本字段名称和统计细节可能略有差异,应结合本机 man iostat 确认。
一个示例:
Device r/s w/s rMB/s wMB/s r_await w_await aqu-sz %util
nvme0n1 80000 2000 312.5 8.0 0.18 0.45 14.2 98.7
可作如下初步解读:
- 读请求约 80,000 IOPS;
- 读吞吐约 312.5 MiB/s;
- 平均读延迟约 0.18 ms;
- 平均有 14.2 个请求处于未完成或等待统计范围;
%util接近 100%,说明设备统计上几乎持续有工作。
但不能仅凭 %util=100% 断言“设备已经达到物理极限”。多队列 NVMe 可能在高并行下长期忙碌但仍有吞吐空间;虚拟设备的 %util 也可能只反映宿主机前端队列状态。应结合吞吐、IOPS、延迟、队列长度和业务目标判断。
用 Little 定律检查示例
若上例读写合计约 82,000 IOPS,平均延迟粗略按 0.18 ms 计算:
与 aqu-sz=14.2 大致一致,说明这些指标的统计口径在数量级上相互匹配。若差异很大,可能是读写延迟不同、窗口不同、aqu-sz 统计方式不同,不能机械要求完全相等。
fio:建立可重复的块 I/O 实验
使用文件测试非破坏性路径
对挂载文件系统,先用测试文件而不是裸设备:
fio --name=randread \
--filename=/var/tmp/fio-test.bin \
--size=4G \
--rw=randread \
--bs=4k \
--ioengine=libaio \
--iodepth=32 \
--direct=1 \
--numjobs=1 \
--runtime=60 \
--time_based \
--group_reporting
参数含义:
--filename:测试文件;--size=4G:测试范围;--rw=randread:随机读;--bs=4k:每个 I/O 的大小;--ioengine=libaio:使用 Linux AIO 引擎,实际行为与文件系统和版本有关;--iodepth=32:尝试维持 32 个未完成请求;--direct=1:请求 Direct I/O;--runtime=60 --time_based:运行 60 秒;--group_reporting:合并多个 job 的报告。
现代系统也常使用:
fio --name=randread \
--filename=/var/tmp/fio-test.bin \
--size=4G \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--iodepth=32 \
--direct=1 \
--runtime=60 \
--time_based \
--group_reporting
io_uring 是否支持某些高级选项,以及 Direct I/O 是否被文件系统完全接受,取决于发行版内核、fio 版本和文件系统。
输出中重点关注:
IOPS
BW
clat
slat
lat
99.00th percentile
通常可以这样理解:
slat:提交请求相关的时间;clat:完成等待时间;lat:总延迟,具体统计定义以 fio 输出和版本为准;- 百分位延迟:比平均值更能反映尾延迟。
逐步改变队列深度
不要只测一个 iodepth。可以分别测试:
for qd in 1 2 4 8 16 32 64; do
fio --name=qd-$qd \
--filename=/var/tmp/fio-test.bin \
--size=4G \
--rw=randread \
--bs=4k \
--ioengine=io_uring \
--iodepth=$qd \
--direct=1 \
--runtime=20 \
--time_based \
--group_reporting
done
分析时画出两条曲线:
队列深度 → IOPS
队列深度 → P99 延迟
常见结果是:
QD 1 → 延迟低,IOPS 较低
QD 8 → IOPS 明显增加,延迟仍可接受
QD 32 → IOPS 接近平台,P99 开始上升
QD 64 → 吞吐增长很小,P99 急剧恶化
这不是设备的固定规律,而是一个用于识别饱和点的实验方法。
写入测试的生产风险
下面这种命令可能破坏数据:
fio --filename=/dev/nvme0n1 ...
对裸设备测试前必须确认:
- 目标不是系统盘;
- 目标没有挂载;
- 目标没有被 LVM、RAID 或数据库使用;
- 测试团队明确接受数据销毁;
- 有可验证的恢复方案。
即使对测试文件进行写入,也可能消耗空间、触发文件系统回写、影响同一设备上的生产业务。测试文件删除后,SSD 的内部状态、写放大和垃圾回收状态也不会立刻恢复到测试前。
如何定位延迟发生在哪一层
先看设备和文件系统路径
findmnt -T /var/tmp/fio-test.bin
lsblk -o NAME,TYPE,SIZE,FSTYPE,MOUNTPOINTS,PKNAME
ls -l /sys/block/
这一步用于确认:
文件
→ 挂载点
→ 文件系统
→ 逻辑卷或 device mapper
→ RAID
→ 物理块设备
如果只监控了逻辑卷,却忽略物理成员盘,可能看不到真正饱和的下层设备。
查看块层统计
iostat -xz 1
在测试开始前记录基线,测试过程中观察:
- IOPS 是否随队列深度增加;
- 吞吐是否达到平台上限;
await是否随负载快速增加;aqu-sz是否持续升高;- 读写是否相互干扰;
- 哪一层设备先出现高延迟。
查看调度器和队列参数
for d in /sys/block/*; do
[ -f "$d/queue/scheduler" ] || continue
echo "== $d =="
cat "$d/queue/scheduler"
cat "$d/queue/nr_requests" 2>/dev/null
cat "$d/queue/max_sectors_kb" 2>/dev/null
done
这能帮助判断不同设备是否使用了不同调度器,以及测试文件实际落在哪个设备上。
使用块层事件跟踪
内核提供 tracepoint,可通过 tracefs 查看。不同发行版的挂载路径可能不同:
mount | grep trace
常见 tracepoint 包括:
block_rq_insert
block_rq_issue
block_rq_complete
block_bio_queue
block_bio_complete
它们可用于区分:
请求何时进入块层
→ 何时真正派发
→ 何时完成
概念上:
block_rq_insert → block_rq_issue = 块层排队时间
block_rq_issue → block_rq_complete = 驱动及设备完成时间
但 tracepoint 的可用字段和输出格式随内核版本变化,生产环境更适合使用经过验证的 bpftrace、BCC 工具或现有发行版工具包,并控制采样范围。全量跟踪高 IOPS 设备本身可能增加开销,甚至改变被测延迟。
Flush、FUA 和“写入完成”的持久化语义
块 I/O 不只有读和写,还涉及持久化顺序:
- flush:要求此前写入的数据按设备语义推进到持久化介质;
- FUA(Force Unit Access):要求特定写入绕过易失性写缓存,或以等效方式保证持久化;
- 文件系统可能通过屏障、flush 或 FUA 实现日志和数据顺序。
一个应用写入日志后调用:
write(fd, buf, len);
fsync(fd);
它表达的是比单独 write() 更强的持久化要求。但这仍依赖整个路径正确支持:
应用
→ 文件系统
→ 块层
→ 驱动
→ RAID/虚拟化后端
→ 物理设备
如果中间某层错误地吞掉 flush,或者设备固件声称有安全写缓存但实际不是,软件无法通过普通 I/O API 修复硬件行为。
这也是为什么数据库会区分:
- 写入内存;
- 发送持久化请求;
- 收到完成;
- 事务提交。
“系统调用返回”与“断电后数据仍存在”是两个不同的问题。
常见误解与反例
误解一:bio 就是磁盘的一次操作
反例:
一次 1 MiB 文件写入
→ 8 个 128 KiB bio
→ 经 RAID 拆成多个成员盘 bio
→ 每个成员盘再形成多个 request
也可能反过来:
多个相邻 4 KiB bio
→ 合并成一个 64 KiB request
→ 设备发出一个较大的命令
所以必须明确观察层次:文件系统层、块层、逻辑设备层还是物理设备层。
误解二:O_DIRECT 等于“直接访问磁盘”
O_DIRECT 主要描述文件数据与页缓存之间的关系。它仍然经过文件系统、块层、驱动和设备内部缓存。它也不自动提供:
- 事务一致性;
- 写入顺序;
- 断电保护;
- 更低延迟;
- 更高吞吐。
误解三:%util=100% 说明设备一定达到极限
%util 反映统计窗口内设备是否持续有请求,不等价于:
设备已经达到最大 IOPS
一个设备可能因为单个长请求持续占用而接近 100%,但随机小 I/O 的性能仍有差异;虚拟设备、RAID 和多队列设备也会使该指标更难单独解释。
误解四:增加 iodepth 一定提升性能
如果设备在 QD=8 已经接近饱和,继续增加到 QD=64 可能只增加排队延迟。对延迟敏感服务,较小队列深度可能比峰值吞吐更重要。
误解五:顺序 I/O 一定比随机 I/O 快
“顺序”必须在正确的层次定义。应用顺序写入文件后,文件系统可能因为碎片、写时复制、日志、RAID 条带或设备内部映射,形成不同的底层访问模式。反之,SSD 的随机访问也不一定像机械盘那样严重受寻道影响。
误解六:fsync() 只同步数据,不影响其他请求
fsync() 可能触发:
- 脏页回写;
- 文件系统日志提交;
- 元数据更新;
- flush 或 FUA;
- 与同一文件、同一日志事务相关的额外工作。
因此数据库或日志服务的同步写延迟,往往不能用普通随机写的平均延迟替代。
生产环境中的取舍
低延迟服务
低延迟服务通常关注:
- 限制队列深度;
- 观察 P95、P99、P99.9,而不只看平均值;
- 避免大吞吐任务和小同步请求共享同一无约束队列;
- 验证调度器对读写公平性和尾延迟的影响;
- 识别后台回写、RAID 重建和设备垃圾回收。
如果把队列深度压得过低,吞吐可能下降;如果放得过高,平均和尾延迟可能失控。正确值应由业务延迟目标和设备饱和曲线共同决定。
吞吐型任务
备份、离线扫描和大文件复制通常受益于:
- 较大的块大小;
- 足够但不过量的并发;
- 顺序访问;
- 合适的预读或应用层流水线;
- 与在线业务隔离。
但大请求会增加共享设备上小请求的等待时间。吞吐测试必须同时记录业务请求的延迟,而不是只看测试工具报告的带宽。
Direct I/O 的使用条件
Direct I/O 更适合应用明确管理缓存和持久化的场景。采用前应验证:
- 文件系统是否支持目标用法;
- 缓冲区、偏移和长度是否满足对齐要求;
- 是否会与 buffered I/O 并发访问同一文件;
- 应用是否实现了预读、缓存、重试和错误处理;
fsync()或等效同步操作是否满足业务持久化语义;- 测试是否覆盖真实工作集,而非只测空文件或缓存命中。
一套可复用的诊断流程
当业务报告“磁盘延迟变高”时,可以按以下因果顺序排查:
1. 确认是否真的发生了块 I/O
- 读是否命中页缓存;
- 写是否只是进入页缓存;
- 是否存在
fsync()、日志提交或周期性回写; - Direct I/O 是否因为对齐失败退化或直接返回错误。
2. 确认设备映射关系
- 文件位于哪个挂载点;
- 挂载点对应哪个逻辑设备;
- 是否经过 LVM、RAID、dm-crypt 或虚拟块设备;
- 哪个物理设备的
await、队列长度和吞吐异常。
3. 区分排队时间和服务时间
- 块层插入到派发之间很长:关注队列、调度器、请求资源和上层并发;
- 派发到完成很长:关注设备服务时间、设备内部队列、RAID、介质或后端;
- 应用端很长但块层不忙:关注锁、调度、页缓存、文件系统或应用自身。
4. 改变一个变量进行实验
依次改变:
buffered / direct
QD=1 / 8 / 32
4 KiB / 128 KiB
randread / seqread
单任务 / 多任务
读 / 写 / 混合读写
每次只改变一个主要变量,并记录:
IOPS、吞吐、平均延迟、P99 延迟、aqu-sz、%util
5. 记录恢复和回滚条件
- 不要直接对生产裸设备执行写测试;
- 不要在高峰期随意切换调度器或清理缓存;
- 修改 sysfs 前记录原值;
- 修改队列参数后确认是否影响其他业务;
- 测试结束后验证文件系统、RAID 和应用数据状态。
总结
Linux 块 I/O 栈中的几个核心对象具有不同职责:
bio 描述数据缓冲区与逻辑块范围
request 块层准备派发的请求工作单元
queue 管理请求资源、软件队列和硬件派发能力
scheduler 对待处理请求进行排序、公平控制或延迟控制
driver 将块层请求转换为设备协议命令
device 执行命令,并拥有块层看不到的内部队列和缓存
一次 I/O 的延迟不是固定的设备属性,而是工作负载、队列深度、调度策略、文件系统、缓存语义、映射层和设备状态共同产生的结果。尤其要区分:
应用系统调用延迟
块层请求延迟
设备服务时间
持久化完成延迟
O_DIRECT 解决的是页缓存路径问题,不自动解决持久化、一致性和性能问题;I/O scheduler 解决的是块层请求组织问题,也不能替代设备内部调度;增加队列深度可以提高并发,却可能把更多时间转化为排队延迟。
只有把 bio、队列、调度器、缓存路径和延迟统计放在同一条数据流中观察,才能判断性能瓶颈究竟发生在应用、文件系统、块层、设备驱动,还是存储设备内部。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Ansible Linux 自动化:Inventory、Playbook、幂等、Secret 和滚动变更
- 下一篇:Linux Page Cache 与 Writeback:脏页、回写、fsync 和数据持久性
- 延伸:Linux 块设备与存储:分区、LVM、RAID、挂载和在线扩容
- 延伸:Linux 磁盘性能诊断:iostat、延迟、队列深度、吞吐和饱和度
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论