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 的路径差异;
  • 延迟如何由各阶段组成,为什么吞吐提高后延迟会突然恶化;
  • 如何使用 iostatfio、sysfs 和块层跟踪工具验证判断;
  • 常见误解、失败表现和生产环境取舍。

一次 read() 为什么会进入块层

先区分三个层次:

  1. 文件 I/O:应用通过文件描述符读写文件,例如 read()write()pread()pwrite()
  2. 块 I/O:内核以逻辑块地址和数据段为单位,向块设备提交读写请求。
  3. 设备 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
    → 一个或多个设备命令

biorequest 的区别

可以把两者分别理解为:

  • 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 生命周期

以一次从块设备读取数据为例,简化生命周期如下:

  1. 文件系统确认需要从某个逻辑块读取数据;
  2. 分配并填充 bio
  3. 设置目标扇区、长度、读方向和数据页;
  4. 调用块层提交接口;
  5. 块层可能执行拆分、合并、映射或重排;
  6. bio 被放入请求路径;
  7. 驱动将请求转换为设备命令;
  8. 设备完成命令并触发中断或轮询完成处理;
  9. 驱动设置完成状态;
  10. 块层调用完成路径;
  11. 文件系统检查状态并填充页缓存;
  12. 阻塞的进程被唤醒,read() 返回。

如果设备返回介质错误、传输错误或超时,错误状态会沿完成路径向上传递。应用最终可能看到 EIOETIMEDOUT 或文件系统根据错误策略采取只读、重试、日志错误等行为。


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 设备通常具有更有限的并行能力,虚拟块设备的队列又由虚拟化后端决定。

因此“队列”至少有三种常被混淆的含义:

  1. 内核请求队列:块层管理请求的对象;
  2. 硬件队列:设备驱动或设备协议提供的并行提交通道;
  3. 队列深度:某个队列中允许同时未完成的请求数量。

fio 中的 iodepth=32 是工作负载试图维持的并发深度,不等于设备一定实际拥有 32 个并行硬件槽位,也不等于块层始终存在 32 个待服务请求。

队列深度与并发

假设应用以固定速率发起请求,设备平均每秒完成 λ 个请求,每个请求从进入系统到完成平均花费 W 秒,则 Little 定律给出:

L=λWL = \lambda W

其中:

  • L 是系统中平均未完成请求数;
  • λ 是完成速率,也就是 IOPS;
  • W 是从请求进入统计范围到完成的平均延迟。

例如:

IOPS = 100,000
平均延迟 = 2 ms = 0.002 s

则:

L=100000×0.002=200L = 100000 \times 0.002 = 200

这意味着平均约有 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

这个例子中:

  1. O_DIRECT 请求 Direct I/O;
  2. posix_memalign() 提供对齐缓冲区;
  3. 4096 字节长度和偏移 0 适用于许多常见设备,但不是所有文件系统都保证接受;
  4. pwrite() 检查短写;
  5. 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 的端到端延迟可以近似拆成:

Ttotal=Tapp+Tfs+Tqueue+Tdispatch+Tdevice+TcompletionT_{\text{total}} = T_{\text{app}} + T_{\text{fs}} + T_{\text{queue}} + T_{\text{dispatch}} + T_{\text{device}} + T_{\text{completion}}

其中:

  • T_app:应用等待、锁竞争、事件循环调度等时间;
  • T_fs:文件系统查找、页缓存处理、日志或空间映射时间;
  • T_queue:在软件队列、调度器和硬件队列中等待的时间;
  • T_dispatch:驱动提交设备命令的时间;
  • T_device:设备实际服务时间,包括介质、控制器和设备内部队列;
  • T_completion:中断或轮询完成、块层回调和唤醒应用的时间。

对 buffered read 命中页缓存时,T_queueT_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/sw/s:每秒读写请求数;
  • rkB/swkB/s:每秒读写吞吐;
  • rrqm/swrqm/s:请求合并相关统计,具体含义受工具和内核版本影响;
  • r_awaitw_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

可作如下初步解读:

  1. 读请求约 80,000 IOPS;
  2. 读吞吐约 312.5 MiB/s;
  3. 平均读延迟约 0.18 ms;
  4. 平均有 14.2 个请求处于未完成或等待统计范围;
  5. %util 接近 100%,说明设备统计上几乎持续有工作。

但不能仅凭 %util=100% 断言“设备已经达到物理极限”。多队列 NVMe 可能在高并行下长期忙碌但仍有吞吐空间;虚拟设备的 %util 也可能只反映宿主机前端队列状态。应结合吞吐、IOPS、延迟、队列长度和业务目标判断。

用 Little 定律检查示例

若上例读写合计约 82,000 IOPS,平均延迟粗略按 0.18 ms 计算:

L82000×0.0001814.76L \approx 82000 \times 0.00018 \approx 14.76

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 更适合应用明确管理缓存和持久化的场景。采用前应验证:

  1. 文件系统是否支持目标用法;
  2. 缓冲区、偏移和长度是否满足对齐要求;
  3. 是否会与 buffered I/O 并发访问同一文件;
  4. 应用是否实现了预读、缓存、重试和错误处理;
  5. fsync() 或等效同步操作是否满足业务持久化语义;
  6. 测试是否覆盖真实工作集,而非只测空文件或缓存命中。

一套可复用的诊断流程

当业务报告“磁盘延迟变高”时,可以按以下因果顺序排查:

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 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。