用 strace 追踪一次 write() 调用,从系统调用返回到数据真正落在磁盘上,中间还隔着页缓存、块层、I/O 调度器、设备驱动四层。SATA HDD 时代,单队列就够用;NVMe SSD 一秒能处理百万级 I/O 请求,单队列反而成了瓶颈——这就是 blk-mq 多队列架构诞生的原因。
Linux 块层与 I/O 栈是系统调用与磁盘之间的完整通路。理解这条通路,才能理解 I/O 延迟的构成和优化的空间。
一、Linux I/O 栈全景
1.1 I/O 栈的层次结构
1.2 I/O 请求的完整路径
一个 write() 请求从应用到磁盘的完整路径:
| 步骤 | 层次 | 操作 | 延迟贡献 |
|---|---|---|---|
| 1 | 用户空间 | write(fd, buf, size) 系统调用 | ~1 μs |
| 2 | VFS | 虚拟文件系统查找文件 | ~1 μs |
| 3 | 文件系统 | Extent 查找、块分配 | ~5 μs |
| 4 | 页缓存 | 写入 Page Cache,标记脏页 | ~1 μs |
| 5 | 返回 | write() 返回(数据在内存中) | — |
| 6 | 块层 | 脏页回写:构造 bio 请求 | ~5 μs |
| 7 | I/O 调度 | 合并、排序请求 | ~1 μs |
| 8 | 设备驱动 | 提交到设备队列 | ~1 μs |
| 9 | 硬件 | DMA 传输到磁盘 | 10 μs–10 ms |
默认情况下,write() 系统调用只将数据写入 Page Cache 就返回了,并不会等待数据真正落盘。要保证数据持久化,必须调用 fsync() 或使用 O_DIRECT + O_SYNC。
1.3 关键数据结构
// bio: 块 I/O 请求的核心数据结构struct bio { struct gendisk *bi_disk; // 目标磁盘 unsigned int bi_opf; // 操作标志(READ/WRITE/SYNC) struct bvec_iter bi_iter; // 迭代器 unsigned short bi_vcnt; // bio_vec 数量 struct bio_vec *bi_io_vec; // 数据向量数组 bio_end_io_t *bi_end_io; // 完成回调 void *bi_private; // 私有数据};
// bio_vec: 描述一个内存段struct bio_vec { struct page *bv_page; // 内存页 unsigned int bv_len; // 长度 unsigned int bv_offset; // 页内偏移};
// request: 经 I/O 调度器处理后的请求struct request { struct request_queue *q; // 所属队列 struct bio *bio; // 关联的 bio sector_t __sector; // 起始扇区 unsigned int __data_len; // 数据长度 struct list_head queuelist; // 队列链表};二、blk-mq 多队列架构
2.1 传统单队列的瓶颈
Linux 3.x 之前使用单队列(Legacy Block Layer):
单队列的问题:
- 全局锁竞争:所有 CPU 共享一个队列,锁竞争严重
- 单线程提交:I/O 调度器单线程处理,无法并行
- 中断瓶颈:完成中断集中在单个 CPU
2.2 blk-mq 架构
| I/O 模型 | 提交方式 | 完成通知 | 系统调用次数 | 适用场景 |
|---|---|---|---|---|
| 同步 read/write | 阻塞调用 | 函数返回 | 每次 I/O 1 次 | 简单场景,低并发 |
| libaio | io_submit() | io_getevents() | 提交+收割各 1 次 | 数据库(Direct I/O) |
| io_uring | 写入 SQE 环 | 读取 CQE 环 | 批量提交 0 次 | 高并发、低延迟 |
io_uring 的”零系统调用”模式需要内核 5.1+ 和 IORING_SETUP_SQPOLL 标志。数据库场景下,io_uring 相比 libaio 可提升 15-30% 的 I/O 吞吐量。
blk-mq(Block Multi-Queue)引入多队列架构,解决单队列瓶颈:
| 组件 | 说明 | 数量 |
|---|---|---|
| 软件队列(Software Queue) | 每 CPU 一个,无锁操作 | = CPU 数量 |
| 硬件队列(Hardware Queue) | 对应设备的提交队列 | = 设备队列数 |
| 标记集(Tag Set) | 管理请求标签分配 | 1/设备 |
| I/O 调度 | 在软件队列上执行 | 每 HW 队列一个 |
# 查看 blk-mq 队列信息cat /sys/block/nvme0n1/queue/nr_requestscat /sys/block/nvme0n1/mq/*/cpu_listls /sys/block/nvme0n1/mq/
# 输出示例(4 核 CPU + NVMe SSD):# 0/ 1/ 2/ 3/# 表示 4 个硬件队列2.3 blk-mq 请求流程
// blk-mq 请求处理流程(简化)void blk_mq_submit_bio(struct bio *bio) { // 1. 获取当前 CPU 对应的软件队列 struct blk_mq_ctx *ctx = blk_mq_get_ctx(bio);
// 2. 获取请求标签(预分配的 request 结构) struct request *rq = blk_mq_alloc_request(ctx, bio->bi_opf);
// 3. 将 bio 附加到 request blk_mq_bio_to_request(rq, bio);
// 4. I/O 调度(如果启用) if (has_scheduler(q)) { blk_mq_sched_insert_request(rq); }
// 5. 提交到硬件队列 blk_mq_run_hw_queue(hctx, true);}
// 完成回调void blk_mq_complete_request(struct request *rq) { // 1. 调用 bio 的完成回调 rq->bio->bi_end_io(rq->bio);
// 2. 释放请求标签 blk_mq_free_request(rq);}三、I/O 调度器
3.1 I/O 调度器的作用
I/O 调度器在块层和设备驱动之间,负责合并、排序和调度 I/O 请求:
| 功能 | 说明 | 目的 |
|---|---|---|
| 合并(Merge) | 将相邻的请求合并为一个 | 减少请求数量 |
| 排序(Sort) | 按扇区号排序请求 | 减少寻道(HDD) |
| 调度(Schedule) | 决定请求的执行顺序 | 公平性、延迟保证 |
3.2 常见 I/O 调度器
| 调度器 | 适用设备 | 特点 | 配置 |
|---|---|---|---|
| none(noop) | NVMe SSD | 不做排序,直接提交 | 最快,SSD 不需要排序 |
| mq-deadline | SATA SSD / HDD | 保证请求延迟上限 | 默认,平衡性能与延迟 |
| bfq | 桌面 HDD | 公平带宽分配 | 交互式场景 |
| kyber | NVMe SSD | 基于令牌的调度 | 低延迟场景 |
# 查看当前 I/O 调度器cat /sys/block/nvme0n1/queue/scheduler# [none] mq-deadline kyber bfq
# 切换 I/O 调度器echo mq-deadline > /sys/block/nvme0n1/queue/scheduler
# 数据库推荐:# NVMe SSD → none(最快,SSD 不需要排序)# SATA SSD → mq-deadline(平衡延迟)# HDD → bfq 或 mq-deadline(减少寻道)3.3 mq-deadline 调度器
mq-deadline 是最常用的调度器,保证每个请求在超时前被处理:
| 参数 | 默认值 | 说明 |
|---|---|---|
read_expire | 500 ms | 读请求超时时间 |
write_expire | 5000 ms | 写请求超时时间 |
writes_starved | 2 | 连续派发读请求的最大次数 |
front_merges | 1 | 是否允许前向合并 |
四、io_uring 异步 I/O
4.1 I/O 模型演进
| I/O 模型 | 系统调用次数 | 是否阻塞 | 缓冲区对齐 | 适用场景 |
|---|---|---|---|---|
| 同步 I/O | 每次操作 1 次 | 是 | 无要求 | 简单场景 |
| libaio | 提交+完成 2 次 | 否 | 必须 4KB 对齐 | O_DIRECT |
| io_uring | 批量提交 0–1 次 | 否 | 无要求 | 高性能场景 |
4.2 io_uring 架构
// io_uring 核心数据结构struct io_uring { // 提交队列(SQ):应用→内核 struct io_uring_sq { uint32_t *khead; // 内核写,应用读 uint32_t *ktail; // 应用写,内核读 uint32_t *kring_mask; struct io_uring_sqe *sqes; // 提交队列条目 };
// 完成队列(CQ):内核→应用 struct io_uring_cq { uint32_t *khead; // 应用写,内核读 uint32_t *ktail; // 内核写,应用读 uint32_t *kring_mask; struct io_uring_cqe *cqes; // 完成队列条目 };};
// 提交队列条目(SQE)struct io_uring_sqe { uint8_t opcode; // 操作码(read/write/fsync/...) uint32_t fd; // 文件描述符 uint64_t off; // 偏移量 uint64_t addr; // 数据缓冲区地址 uint32_t len; // 数据长度 uint64_t user_data; // 用户数据(完成时返回)};4.3 io_uring 的优势
# io_uring vs libaio vs 同步 I/O 性能对比import subprocess
# 同步 I/O# 每次操作需要 2 次系统调用(write + fsync)# 4KB 写入延迟:~20μs(NVMe)
# libaio# 批量提交,但需要 O_DIRECT 和缓冲区对齐# 4KB 写入延迟:~12μs(NVMe)
# io_uring# 零系统调用提交(SQ 环满时才调用 io_uring_enter)# 4KB 写入延迟:~8μs(NVMe)# 批量提交 1000 个请求:~200μs(0.2μs/请求)io_uring 是 Linux 5.1+ 引入的高性能异步 I/O 接口,通过共享内存环形缓冲区避免了系统调用开销。RocksDB 7.x+ 已支持 io_uring,在 NVMe SSD 上可提升 10–30% 的 IOPS。
五、Direct I/O 与 Buffered I/O
5.1 两种 I/O 模式对比
| 特性 | Buffered I/O | Direct I/O |
|---|---|---|
| Page Cache | 经过 | 绕过 |
| 数据对齐 | 无要求 | 4KB 对齐 |
| 写入路径 | write→Page Cache→后台回写 | write→直接到磁盘 |
| 读取路径 | 先查 Page Cache→未命中读磁盘 | 直接读磁盘 |
| 适用场景 | 通用 | 数据库(自行管理缓存) |
5.2 数据库为什么用 Direct I/O
// 数据库使用 Direct I/O 的原因// 1. 避免 Double Caching// 数据库有自己的 Buffer Pool,如果用 Buffered I/O,// 同一数据在 Buffer Pool 和 Page Cache 中各存一份,浪费内存
// 2. 控制刷盘时机// 数据库需要精确控制 WAL 先于数据页落盘(fsync 语义)// Buffered I/O 的后台回写时机不可控
// 3. 避免预读干扰// 操作系统的预读策略不适合数据库的随机访问模式
// MySQL InnoDB 配置// innodb_flush_method = O_DIRECT// 绕过 Page Cache,使用 O_DIRECT 写入数据文件和 WAL
// PostgreSQL 配置// PostgreSQL 默认使用 Buffered I/O// 但 WAL 可以配置:wal_sync_method = open_datasync# MySQL Direct I/O 配置[mysqld]innodb_flush_method = O_DIRECT# O_DIRECT — 数据文件和 WAL 都用 Direct I/O# O_DSYNC — WAL 用 O_SYNC 打开# fdatasync — 默认,用 fdatasync() 刷盘
# PostgreSQL WAL 配置wal_sync_method = open_datasync # Linux 默认# fsync — 使用 fsync()# fdatasync — 使用 fdatasync()# open_sync — O_SYNC 打开# open_datasync — O_DSYNC 打开六、I/O 栈性能调优
6.1 关键调优参数
# 虚拟内存调优# 脏页比例(开始后台回写)echo 10 > /proc/sys/vm/dirty_background_ratio# 脏页比例(开始同步回写)echo 20 > /proc/sys/vm/dirty_ratio# 脏页过期时间(centisecs)echo 3000 > /proc/sys/vm/dirty_writeback_centisecs
# 块层调优# 队列深度echo 256 > /sys/block/nvme0n1/queue/nr_requests# 预读大小echo 0 > /sys/block/nvme0n1/queue/read_ahead_kb
# NVMe 调优# 最大硬件队列数cat /sys/block/nvme0n1/queue/nr_hw_queues# 每队列深度cat /sys/block/nvme0n1/queue/queue_depth6.2 数据库 I/O 调优矩阵
| 参数 | MySQL (InnoDB) | PostgreSQL | RocksDB |
|---|---|---|---|
| I/O 模式 | O_DIRECT | Buffered | O_DIRECT |
| I/O 调度器 | none (NVMe) | none (NVMe) | none (NVMe) |
| 队列深度 | 128–256 | 128–256 | 128–1024 |
| 脏页比例 | 10/20 | 5/10 | 10/20 |
| 预读 | 关闭 | 默认 | 关闭 |
| io_uring | 实验性 | 不支持 | 支持(7.x+) |
七、实战:I/O 栈观察与调试
7.1 I/O 栈跟踪
# 使用 ftrace 跟踪 I/O 路径trace-cmd record -e block:block_rq_issue -e block:block_rq_complete \ -p function_graph -g blk_mq_submit_bio sleep 10
# 使用 bcc 观察 I/O 延迟# 按进程统计 I/O 延迟/usr/share/bcc/tools/biolatency -p $(pidof mysqld) 1 5
# 按进程统计 I/O 大小/usr/share/bcc/tools/biosnoop -p $(pidof mysqld)
# 观察块层统计cat /sys/block/nvme0n1/stat# Field 1: read I/Os# Field 5: write I/Os# Field 9: in-flight I/Os7.2 I/O 栈性能分析
# 使用 perf 分析 I/O 热点perf record -e block:block_rq_issue -a sleep 10perf report
# 使用 iostat 观察 I/O 统计iostat -x 1# await: 平均 I/O 延迟(ms)# %util: 设备利用率# svctm: 平均服务时间(ms)# avgqu-sz: 平均队列深度
# 使用 iotop 观察进程 I/Oiotop -oP7.3 blk-mq 队列观察
# 查看 blk-mq 统计for q in /sys/block/nvme0n1/mq/*/; do echo "=== Queue $(basename $q) ===" cat "$q/cpu_list" cat "$q/tags"done
# 查看 I/O 调度器统计cat /sys/block/nvme0n1/mq/0/scheduler块层的多队列架构(blk-mq)是现代存储性能的基础。如果你的内核还在使用单队列(legacy block layer),升级到 blk-mq 可以显著提升 NVMe SSD 的 IOPS。大多数现代发行版(内核 5.x+)已默认启用 blk-mq。
八、总结
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| I/O 栈全景 | 从系统调用到磁盘 9 个步骤,延迟从 1μs 到 10ms | I/O 栈, 延迟分布 |
| blk-mq | 多队列架构消除单队列锁竞争,是 NVMe SSD 标配 | 多队列, 无锁 |
| I/O 调度器 | NVMe SSD 用 none,SATA SSD 用 mq-deadline,HDD 用 bfq | 调度策略, 设备匹配 |
| io_uring | 零系统调用的异步 I/O,比 libaio 快 30%+ | 异步 I/O, 零拷贝 |
| Direct I/O | 数据库绕过 Page Cache,自行管理缓存和刷盘 | O_DIRECT, 自管理 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






