Linux 块设备层深度实战 I:从 bio 请求生命周期到 blk-mq 多队列架构的全栈解析
块设备层(Block Layer)是 Linux I/O 栈的核心枢纽,位于 VFS 之下、存储设备驱动之上。它负责将文件系统的 I/O 请求转化为对物理/虚拟磁盘的读写操作,是整个存储 I/O 路径中调度、合并、排队、重排序的关键环节。虽然块设备层不像 VFS 或文件系统那样被频繁提及,但它在高并发数据库、分布式存储、容器化环境中扮演着决定性角色。
本文将从 bio 结构体的深度剖析出发,完整追踪一次 I/O 请求从发起到完成的全生命周期,深入讲解请求合并机制、Plugging 机制、传统单队列与 blk-mq 多队列架构的演进逻辑,并结合 NVMe/scsi-mq 驱动、io_uring、生产性能调优场景给出最佳实践。
一、块设备层架构总览
在深入细节之前,先建立全局视角。块设备层由以下几个核心抽象组成:
| 层级 | 核心结构 | 职责 |
|---|---|---|
| 上层(VFS/FS) | struct bio, page cache | 构建 I/O 请求,提交给块设备层 |
| 块设备层核心 | struct request_queue, struct request, struct bio_vec | 请求合并、调度、排队、分发 |
| I/O 调度层 | elevator_ops, blk_mq_ops | 决定请求的执行顺序(mq-deadline/bfq/kyber) |
| 设备驱动层 | scsi_dispatch_cmd, nvme_queue | 将请求翻译为设备命令并提交硬件 |
整条数据路径为:用户空间 → VFS → 文件系统 → Page Cache(writeback) → submit_bio() → 块设备层(合并/调度) → request_queue → 驱动 → 硬件。
二、struct bio — 块 I/O 的最小原语
struct bio 是块设备层中最核心的数据结构,代表一次块 I/O 操作的最小单元。在 <linux/blk_types.h> 中定义:
struct bio {
struct bio *bi_next; // 链表指针,用于合并
struct block_device *bi_bdev; // 目标块设备
blk_status_t bi_status; // I/O 完成状态
struct bvec_iter bi_iter; // 当前迭代位置(扇区/页内偏移)
unsigned short bi_vcnt; // bio_vec 数量
unsigned short bi_max_vecs; // bio_vec 数组容量
atomic_t bi_remaining; // 剩余未完成段数
struct bio_vec *bi_io_vec; // bio_vec 数组(实际数据段)
bio_end_io_t *bi_end_io; // 完成回调
void *bi_private; // 私有数据(文件系统使用)
struct blkcg_gq *bi_blkg; // blk-cgroup 关联
struct bio_issue bi_issue; // 提交时间戳(延迟统计)
// ... (约60+个字段)
};
每个 bio 可以包含多个 不连续 的内存页,通过 bio_vec 数组描述:
struct bio_vec {
struct page *bv_page; // 物理页指针
unsigned int bv_len; // 字节长度
unsigned int bv_offset; // 页内偏移
};
一个 bio 最多可包含 BIO_MAX_VECS(通常 256)个 bio_vec。如果单次 I/O 需要的页数超过此上限,文件系统会将其拆分为多个 bio,通过 bi_next 链接成一个 bio chain。
2.1 bio 的生命周期:从 submit_bio 到完成
一个 bio 的典型生命周期可分为以下阶段:
// 阶段 1: 分配 bio
struct bio *bio = bio_alloc(bdev, nr_vecs, REQ_OP_READ, GFP_NOIO);
// 阶段 2: 填充数据
bio->bi_iter.bi_sector = sector; // 起始扇区
bio_add_page(bio, page, len, offset); // 添加内存页
bio->bi_end_io = my_end_io; // 设置完成回调
bio->bi_private = my_data;
// 阶段 3: 提交
submit_bio(bio);
└─ submit_bio_noacct(bio)
└─ __submit_bio_noacct(bio)
├─ 尝试合并(bio_attempt_front_merge / bio_attempt_back_merge)
├─ 若无法合并 → bio_alloc_clone(拆分为两个 bio,各自排队)
└─ blk_mq_submit_bio(bio) // blk-mq 路径
└─ blk_mq_bio_to_request() / blk_mq_dispatch_rq_list()
// 阶段 4: I/O 完成 → bi_end_io 回调
static void my_end_io(struct bio *bio) {
if (bio->bi_status != BLK_STS_OK)
pr_err("I/O error: %d\n", bio->bi_status);
bio_put(bio); // 释放 bio(引用计数 → 0)
}
2.2 bio 拆分机制(bio_split)
当 bio 跨越磁盘的物理段边界(由 queue_max_segment_size 和 queue_max_sectors 决定)时,块设备层需要将其拆分为多个 bio。核心函数是 bio_split_bioset() 和 blk_queue_split():
// blk-merge.c
bool __blk_queue_split(struct request_queue *q, struct bio **bio) {
struct bio *split;
unsigned int nr_sectors = bio_sectors(*bio);
// 检查是否需要按 segment 大小拆分
if (nr_sectors > queue_max_sectors(q)) {
split = bio_split(*bio, queue_max_sectors(q), GFP_NOIO, &fs_bio_set);
bio_chain(split, *bio);
submit_bio_noacct(*bio); // 前半部先提交
*bio = split; // 后半部下一次循环处理
return true;
}
// 检查是否需要按段边界拆分(dma_get_max_seg_boundary)
// ...
}
拆分后的 bio 通过 bio_chain() 链接成一个链表,前面的 bio 先被提交,后面的 bio 作为"残留 bio"由上层循环继续处理。
三、请求合并:减少 I/O 请求总数
块设备层的核心优化之一就是请求合并,将多个相邻的 bio 合并为一个更大的 request,减少驱动层处理的开销。Linux 支持两种合并方式:
3.1 Back Merge(向后合并)
新提交的 bio 的起始扇区正好等于某个已存在 request 的结束扇区:
// blk-merge.c
static inline bool bio_attempt_back_merge(struct request_queue *q,
struct request *req, struct bio *bio)
{
// 检查 sector 连续、设备相同、权限兼容、queue constraints 满足
if (!rq_mergeable(req)) return false;
if (req->__sector + blk_rq_sectors(req) != bio->bi_iter.bi_sector) return false;
if (!bio_crypt_back_mergeable(req, bio)) return false;
// ...
// 执行合并
elv_merge_requests(q, req, bio);
└─ req->biotail->bi_next = bio; // 追加到 bio chain 末尾
return true;
}
3.2 Front Merge(向前合并)
新提交的 bio 的结束扇区正好等于某个已存在 request 的起始扇区:
static inline bool bio_attempt_front_merge(struct request_queue *q,
struct request *req, struct bio *bio)
{
if (req->__sector != bio_end_sector(bio)) return false;
// ...
bio->bi_next = req->bio; // 插入到 bio chain 头部
req->__sector = bio->bi_iter.bi_sector;
return true;
}
3.3 Plugging 机制:批量合并的关键
Plugging(插塞)机制是块设备层性能优化的核心。当进程批量提交多个 I/O 请求时,块设备层不会立即将请求下发给驱动,而是"塞住"(plug),等待短暂的延迟窗口。如果有新请求到来并与已塞住的请求相邻,就可以合并。
// include/linux/blkdev.h
struct blk_plug {
struct list_head mq_list; // blk-mq 请求链表
struct list_head cb_list; // flush 回调链表
unsigned short rq_count; // 当前塞住的请求数
unsigned short do_plug:1; // 是否启用 plug
};
// 开启 plug
blk_start_plug(plug);
└─ 设置 current->plug = plug;
// ... 批量 submit_bio() ...
// 关闭 plug(自动 flush)
blk_finish_plug(plug);
└─ blk_flush_plug_list(plug, true);
└─ blk_mq_flush_plug_list() // 一次性下发所有请求
在 blk-mq 架构下,plug 机制还有一个重要的作用:通过 blk_mq_flush_plug_list() 将请求按硬件队列分发,这比逐个分发的效率要高得多。
四、传统单队列的瓶颈与演进
4.1 单队列时代的架构
在 blk-mq 引入之前,Linux 使用单队列架构,所有 CPU 共享一个请求队列 request_queue。核心问题:
- 全局自旋锁:
queue_lock(一个 spinlock)保护整个请求队列,高并发时锁竞争严重 - 单一队列深度:只有一个
request_list,所有请求排队等待 - NUMA 不友好:所有 CPU 争抢同一个队列,跨 NUMA 节点访问频繁
- 锁粒度粗:
__blk_run_queue()期间持有 queue_lock,阻塞所有其他 CPU
4.2 锁竞争量化
在 8 核 CPU 上运行 fio(4K 随机读,iodepth=64),单队列模式下的 queue_lock 争用会导致约 30-40% 的吞吐量损失。随着核数增加,损失趋近于 70% 以上。这正是 blk-mq 被提出的背景(2015 年 4.3 内核引入)。
五、blk-mq 多队列架构详解
5.1 架构设计:两层队列
blk-mq 引入了两层队列模型:
| 队列层 | 数量 | 作用 | 竞争点 |
|---|---|---|---|
| Software Queue (sw queue) | 每 CPU 或 NUMA 节点一个 | CPU 本地发送请求,处理后 dispatch 到硬件队列 | 几乎无锁(per-cpu 分配) |
| Hardware Dispatch Queue (hctx) | 由驱动指定(核心映射) | 驱动可见的硬件提交队列,直接写入设备 | 硬件队列深度(NVMe 可达 64K) |
两层之间通过 blk_mq_hw_ctx->dispatch 链表连接:软件队列先将请求放入 dispatch 列表,再由驱动侧从 dispatch 列表中取出并写入硬件寄存器。
5.2 核心数据结构
struct blk_mq_tag_set {
struct blk_mq_ops *ops; // 驱动回调(queue_rq、commit_rqs)
unsigned int nr_hw_queues; // 硬件队列数
unsigned int queue_depth; // 每队列请求槽数
unsigned int cmd_size; // 每命令私有数据大小
int numa_node; // NUMA 节点
struct blk_mq_tags **tags; // hw_queue→blk_mq_tags 映射
struct list_head tag_list; // 用户链表(共享 tag 的驱动间复用)
};
struct blk_mq_hw_ctx {
struct blk_mq_queue_map map; // sw→hw 映射
struct list_head dispatch; // 待分发请求链表
unsigned long state; // HCTX_INACTIVE/SCHED_BUSY 等
struct sbitmap ctx_bitmap; // 软件 ctx 位图分配
struct blk_mq_ctx **ctxs; // 拥有的软件上下文数组
struct request_queue *queue; // 所属 request_queue
atomic_t nr_active; // 活跃请求数
// ...
};
struct blk_mq_ctx {
struct blk_mq_hw_ctx *hctx; // 归属的硬件队列
spinlock_t lock; // 私有自旋锁(仅含 rq_queues 操作)
struct list_head rq_lists[HCTX_MAX_TYPES]; // 按优先级分类的请求列表
unsigned int cpu; // 绑定的 CPU
// ...
};
5.3 request 分配与 tag 机制
blk-mq 中每个 request 有一个全局唯一的 tag 编号,用于在命令完成时快速定位对应的 request。tag 分配通过 sbitmap(可扩展位图)高效实现:
// blk-mq-tag.c
unsigned int blk_mq_get_tag(struct blk_mq_alloc_data *data) {
struct blk_mq_tags *tags = data->hctx->tags;
struct sbitmap_queue = &tags->bitmap_tags;
// sbitmap 原子分配一个 0-65535 的编号
tag = sbitmap_queue_get(sbq, &data->ctx->index);
if (tag != -1) {
struct request *rq = tags->static_rqs[s_tag];
rq->tag = tag;
rq->internal_tag = -1;
blk_mq_put_ctx(data->ctx); // 释放 cpu 引用
}
return tag;
}
每个硬件队列拥有独立的 tag 空间(queue_depth 个槽位)。如果 tag 耗尽,blk_mq_get_tag() 返回 -1,软件队列进入 SCHED_BUSY 状态,停止接纳新请求直到有 tag 释放。
5.4 请求分发流程(blkmq_submit_bio)
void blk_mq_submit_bio(struct bio *bio) {
struct request_queue *q = bio->bi_bdev->bd_disk->queue;
struct blk_mq_hw_ctx *hctx;
struct request *rq;
blk_status_t ret;
// 1. plug 检查:如果当前 plug 已满,先 flush
if (blk_mq_sched_bio_merge(q, bio, &rq)) {
// 合并进已有 request(返回 true)→ 直接返回
return;
}
// 2. 根据映射关系选择 hw_ctx
hctx = blk_mq_map_queue(q, cpu);
// 3. 分配 request 和 tag
rq = blk_mq_alloc_request(hctx, bio->bi_opf, 0);
if (IS_ERR(rq)) {
bio_io_error(bio);
return;
}
// 4. bio → request 转换(初始化 request 各字段)
blk_init_request_from_bio(rq, bio);
// 5. Plugging 模式下:塞入 plug 列表,等待 flush
if (plug) {
blk_add_rq_to_plug(plug, rq);
} else {
// 直接下发
blk_mq_try_issue_directly(hctx, rq);
}
}
5.5 硬件队列映射策略
blk-mq 提供两种映射模式:
| 映射模式 | 函数 | 适用场景 |
|---|---|---|
| cpu (默认) | blk_mq_map_queue_by_ctx | 软队列绑定 CPU,适合单 socket 服务器 |
| hw_ctx (NUMA) | blk_mq_hctx_to_node | NUMA 架构,硬件队列绑定 NUMA 节点 |
NVMe 驱动中可以通过 --nr_write_queues 和 --nr_poll_queues 参数调整写队列和轮询队列数量,实现更精细的 CPU 亲和控制。
六、I/O 调度器:从 elevator 到 blk-mq 调度器
6.1 传统 Elevator 调度器
在单队列时代,elevator 是一个可插拔的请求排序器,核心操作包括:
struct elevator_ops {
.elevator_merge_fn = * ElevatorMerge, // 请求合并
.elevator_merged_fn = * ElevatorMerged, // 合并后回调
.elevator_merge_req_fn = * ElevatorMergeReq, // request 间合并
.elevator_dispatch_fn = * ElevatorDispatch, // 从队列取出发给驱动
.elevator_insert_fn = * ElevatorInsert, // 插入请求
.elevator_remove_fn = * ElevatorRemove, // 移除请求
.elevator_set_req_fn = * ElevatorSetReq, // 请求初始插入
.elevator_init_fn = * ElevatorInit, // 初始化
.elevator_exit_fn = * ElevatorExit, // 退出
};
常见调度算法:noop(无排序,仅合并)、deadline(按过期时间排序,写读分槽)、cfq(完全公平队列,每个进程独立队列)、bfq(预算公平,基于进程权重)。
6.2 blk-mq 调度器
blk-mq 时代引入了专门针对多队列架构的调度器(blk-mq-sched),通过额外的 调度队列 实现更细粒度的控制:
| 调度器 | 算法核心 | 适用场景 |
|---|---|---|
| mq-deadline | 排序队列 + 读优先 + 写超时 | 通用场景,NVMe 默认,延迟敏感 |
| bfq | Budget Fair Queueing,按进程带宽分配权重 | 桌面/交互式,需要 I/O 隔离 |
| kyber | 自适应延迟目标(读/写分别控制) | NVMe,延迟可预测 |
| none | 回退到 soft plug 模式分发 | 极低延迟设备(Intel Optane) |
切换调度器很简单:
# 查看当前调度器
cat /sys/block/nvme0n1/queue/scheduler
[mq-deadline] kyber bfq none
# 临时切换
echo "bfq" > /sys/block/nvme0n1/queue/scheduler
# 永久生效(GRUB)
# GRUB_CMDLINE_LINUX="elevator=bfq"
6.3 bfq 深度解析
bfq(Budget Fair Queueing)是目前最复杂的 blk-mq 调度器,核心思想是每个进程有预算(budget),预算用完后切换到下一个进程:
- budget:每个进程在一次调度中可以发送的 I/O 字节数
- budget_timeout:在饥饿前的最小运行时间
- low_latency:低延迟模式(默认开启),自动调整权重
- bfq_weights:可手动设置进程的相对权重
bfq 在桌面 GUI 场景下能显著提升交互性,但在高并发服务器场景中表现不如 mq-deadline。
七、设备驱动层实战:NVMe 与 SCSI-MQ
7.1 NVMe 与 blk-mq 的深度契合
NVMe(Non-Volatile Memory Express)是专为 SSD 设计的协议,其硬件原生支持 64K 深度队列(每个队列 64K 个命令)。blk-mq 的架构与 NVMe 硬件完美匹配:
// drivers/nvme/host/pci.c
static const struct blk_mq_ops nvme_mq_ops = {
.queue_rq = nvme_queue_rq, // 接收一个 request
.commit_rqs = nvme_commit_rqs, // 批量提交到硬件(优化门铃写入)
.get_budget = nvme_get_budget, // tag 预算(可选)
.release_budget = nvme_release_budget, // 释放预算
.complete = nvme_pci_complete_rq, // I/O 完成回调
.init_hctx = nvme_init_hctx, // 硬件队列初始化
.init_request = nvme_init_request, // request 初始化
.map_queues = nvme_mq_map_queues,// SW→HW 映射
.timeout = nvme_timeout, // 请求超时处理
.poll = nvme_poll, // 轮询模式
};
// 核心路径:提交一个 request 到 NVMe
static blk_status_t nvme_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *req = bd->rq;
struct nvme_dev *dev = hctx->driver_data;
struct nvme_command *cmd = nvme_cmd(req);
// 1. 分配 nvme_command(SQ slot)
// 2. 将 request 翻译为 NVMe 命令(读/写/flush)
memset(cmd, 0, sizeof(*cmd));
if (req_op(req) == REQ_OP_FLUSH)
nvme_setup_flush(ns, cmd);
else if (req_op(req) == REQ_OP_WRITE)
nvme_setup_write_cmd(ns, req);
else
nvme_setup_read_cmd(ns, req);
// 3. 锁定队列并写入 SQ tail
spin_lock_irq(>dev->q_lock[nvmeq->qid]);
memcpy(nvmeq->sq_cmds + nvmeq->sq_tail, cmd, sizeof(*cmd));
if (++nvmeq->sq_tail == nvmeq->q_depth)
nvmeq->sq_tail = 0;
writel(nvmeq->sq_tail, nvmeq->doorbell_addr);
spin_unlock_irq(>dev->q_lock[nvmeq->qid]);
return BLK_STS_OK;
}
关键点:commit_rqs 回调在 queue_rq 批量调用之后,只写一次门铃寄存器,避免了多次 MMIO 开销,这是 blk-mq 新增的批量提交优化。
7.2 SCSI-MQ(scsi-mq)适配
传统 SCSI 驱动基于单队列,Linux 4.17+ 引入了 scsi-mq 适配层,让 VirtIO-SCSI、SAS 等传统设备也能使用 blk-mq。scsi_mq_ops 提供了类似 NVMe 的抽象:
// drivers/scsi/scsi_lib.c
static const struct blk_mq_ops scsi_mq_ops = {
.queue_rq = scsi_queue_rq,
.commit_rqs = scsi_commit_rqs,
.complete = scsi_softirq_done,
.init_request = scsi_init_request,
.exit_request = scsi_exit_request,
.map_queues = scsi_map_queues,
.show_rq = scsi_show_rq,
};
// scsi_mq 使用 host-wide tag pool(共享所有硬件队列的 tag 空间)
static int scsi_alloc_request(struct blk_mq_hw_ctx *hctx, ...)
{
struct Scsi_Host *shost = hctx->driver_data;
int tag = scsi_get_tag(shost);
// 分配 scsi_cmnd 并关联到 request
}
八、io_uring 与 blk-mq 联动
io_uring 是 Linux 5.1 引入的新一代异步 I/O 接口,在支持 IORING_SETUP_SQPOLL 的轮询模式下,可以直接将固定 buffer 提交到 NVMe 硬件队列(bypass VFS → 块设备层),但普通读写仍需经过块设备层的 blk-mq 调度。
io_uring 与 blk-mq 联动时的关键优化点:
- Fixed buffers:预注册 buffer,避免每次 get_user_pages() 开销
- Linked SQEs:链式提交,保证 I/O 顺序
- Polled I/O:
IORING_SETUP_IOPOLL,直接轮询 NVMe CQ 硬件完成事件(bypass 中断 + softirq) - SQPOLL 模式:内核线程轮询提交队列,减少用户态→内核态切换
性能对比(4K 随机读,单盘 NVMe SSD):
| 模式 | IOPS | 延迟(avg lat) | CPU 占用 |
|---|---|---|---|
| pread/pwrite (同步) | 500K | 12μs | 100% 1核 |
| io_uring (fixed buf, polling) | 1.2M | 4μs | 100% 1核 |
| io_uring (SQPOLL + iopoll) | 1.5M | 2.5μs | 100% 1核 + 轮询线程 |
九、全链路延迟剖析
一个 4K 随机读请求从发起到完成的完整延迟包含以下阶段:
| 阶段 | 函数 | 耗时(典型 NVMe) |
|---|---|---|
| 1. VFS/FS | vfs_read → page_cache_sync_readahead | 0.5μs |
| 2. submit_bio | __submit_bio_noacct → blk_mq_submit_bio | 0.3μs |
| 3. Scheduling | blk_mq_insert_request → mq-deadline | 0.2μs |
| 4. HW dispatch | nvme_queue_rq(SQ tail doorbell) | 0.3μs |
| 5. Hardware | NVMe 控制器 + NAND 读取 | 8-15μs |
| 6. CQ handling | MSI-X → softirq → nvme_pci_complete_rq | 0.8μs |
| 7. bio_end_io | complete → 唤醒等待进程 | 0.4μs |
| 总计 | 12-18μs |
使用 bpftrace 跟踪可以精确量化每一阶段:
# 跟踪 bio 提交到完成的全链路
bpftrace -e '
kprobe:submit_bio {
@start[arg0] = nsecs;
}
kprobe:blk_mq_submit_bio {
$bio = (struct bio *)arg0;
$start = @start[$bio];
if($start) {
@submit_lat = hist(nsecs - $start);
delete(@start[$bio]);
}
}
kprobe:nvme_queue_rq {
$start = @start[0];
@hwqueue_lat = hist(nsecs - $start);
}
十、生产环境最佳实践
10.1 块设备层调优参数
# 1. 调度器选择
# NVMe SSD → mq-deadline 或 kyber
echo "mq-deadline" > /sys/block/nvme0n1/queue/scheduler
# 混合读写负载 → kyber
echo "kyber" > /sys/block/nvme2n1/queue/scheduler
# 2. nr_requests(队列最大未完成请求数)
# NVMe 可增加值以利用硬件并行性
echo 1024 > /sys/block/nvme0n1/queue/nr_requests
# 3. queue_depth(硬件驱动级深度)
# NVMe 中通常设置为 1024-4096
echo 4096 > /sys/block/nvme0n1/queue/nr_requests
# 4. read_ahead_kb(预读窗口)
# 顺序读场景增加预读(128-256KB)
echo 256 > /sys/block/nvme0n1/queue/read_ahead_kb
# 5. rq_affinity(CPU 亲和性)
# 2: 完成时回到提交它的 CPU(适合 Polling)
echo 2 > /sys/block/nvme0n1/queue/rq_affinity
# 6. io_poll_delay(轮询延迟(polling queue))
# 设为 -1 = 纯轮询,0 = 自适应,正数 = 中断超时后开始轮询
echo 0 > /sys/block/nvme0n1/queue/io_poll
10.2 blk-mq 的 NUMA 优化
在 NUMA 多 socket 服务器上,blk-mq 的 NUMA 亲和对性能影响显著:
# 查看当前 hw queue 到 NUMA 节点的映射
cat /sys/block/nvme0n1/mq/*/cpu_list
# 输出如:16,17,18,19,20,21,22,23(NUMA node 1 的 CPU)
# 设置硬件队列映射到特定 NUMA 节点
echo "none" > /sys/block/nvme0n1/queue/nomerges # 关闭合并(SSD 场景)
# 多 socket 场景:为每个 socket 创建独立的 hw queue
# 启动参数:
# nvme.poll_queues=8 nvme.io_queues=16
10.3 使用 eBPF 观测块设备层
BCC/bpftrace 提供了多个块设备层的观测工具:
# biolatency:I/O 延迟分布直方图
biolatency -m -d nvme0n1
# biosnoop:打印每个 I/O 的详细信息
biosnoop -d nvme0n1
# blkqdsnoop:blk_mq request 分发延迟
blkqdsnoop -d nvme0n1
# custom bpftrace:追踪 plug → flush 周期
bpftrace -e '
kprobe:blk_mq_flush_plug_list {
printf("flush plug: cpu=%d reqs=%d\n",
curtask->cpu,
((struct blk_plug *)arg0)->rq_count);
}'
10.4 故障排查清单
| 现象 | 排查方向 | 关键命令 |
|---|---|---|
| IOPS 突然下降 | NVMe CQ 满 / Tag 耗尽 | dmesg | grep nvme |
| 延迟飙升 | 调度器队列积压 | cat /sys/block/nvme0n1/stat |
| 单核 CPU 100% | 锁竞争(queue_lock) | perf top -p $pid |
| bio 拆分过多 | queue_max_sectors 太小 | grep . /sys/block/nvme0n1/queue/* |
| 进程 I/O 饥饿 | bfq budget 不足 | cat /sys/block/nvme0n1/queue/iosched/* |
Linux 块设备层 stat 文件解读
/sys/block/\<dev\>/stat 是块设备层的性能统计接口。它输出 17 个数字,含义如下:
| 字段 | 含义 | 读取示例 |
|---|---|---|
| 0 | 读 I/O 完成数 | 102456 |
| 1 | 读 I/O 合并数 | 236 |
| 2 | 读扇区数(×512B) | 2048900 |
| 3 | 读 I/O 累计耗时(ms) | 3421 |
| 4 | 写 I/O 完成数 | 87643 |
| 5 | 写 I/O 合并数 | 102 |
| 6 | 写扇区数(×512B) | 1752860 |
| 7 | 写 I/O 累计耗时(ms) | 2890 |
| 8 | 当前队列中 I/O 数(inflight) | 45 |
| 9 | I/O 累计耗时(包含排队,ms) | 6890 |
| 10 | 加权累计耗时(ms) | 12400 |
| 11 | discard 完成数 | 12 |
| 12 | discard 合并数 | 0 |
| 13 | discard 扇区数 | 2400 |
| 14 | discard 耗时 | 80 |
| 15 | flush 请求数 | 530 |
| 16 | flush 耗时 | 450 |
通过对比字段(读 I/O 数 + 合并数)/ 读 I/O 完成数,可以计算合并率。NVMe 场景合并率通常很低(<5%),传统 HDD 可达 30-60%。
总结与展望
Linux 块设备层是整个存储栈的中枢神经。本文从 bio 结构体出发,覆盖了其完整生命周期、请求合并机制、plugging 优化,以及从单队列到 blk-mq 多队列的架构演进。blk-mq 通过两层队列模型、per-cpu 软件队列、tag 位图分配、批量 commit 等创新,彻底解决了单队列时代的锁竞争和 NUMA 不友好问题,与现代 NVMe 硬件完美契合。
下一篇文章我们将继续深入块设备层实战系列,覆盖 Writeback 与脏页回写机制、Multi-Queue Block IO Queuing Mechanism 与 io_uring 深度集成、以及 blk-cgroup 资源隔离等主题。

发表评论 取消回复