从一次NVMe延迟抖动说起
某天线上数据库报警,P99延迟从2ms飙到了80ms。排查发现是I/O路径上的块层调度器`mq-deadline`在混合读写负载下将写请求过度合并,读请求被大面积饿死。意识到底层存储栈的知识才是定位这类问题的硬通货之后,我决定把Linux内核块设备层的来龙去脉彻底梳理一遍。本文就是这轮学习的沉淀。
一、块层总体架构:bio与request的分离哲学
为什么Linux块层要把一次I/O拆成bio和request两级描述符?这得从驱动的工作方式说起。早期磁盘是机械盘,寻道和旋转延迟巨大,所以内核有大量逻辑来做排序、合并、预读以降低磁头移动。现代NVMe设备并行度极高,几乎不需要传统排序。但块层又不能直接抛弃机械盘的现状,于是让bio描述"从哪个扇区读多少数据"的语义,让request描述发给硬件的具体命令队列。bio可以被方便地分割和合并,request则体现硬件队列的约束。
// include/linux/blk_types.h — bio核心结构
struct bio {
struct bio *bi_next; // 链表指针,挂载到请求队列
struct block_device *bi_bdev; // 目标块设备
blk_opf_t bi_opf; // 操作类型(READ/WRITE/DISCARD)
unsigned short bi_vcnt; // bio_vec数量
unsigned short bi_max_vecs; // bio_vec容量
atomic_t bi_cnt; // 引用计数
struct bio_vec *bi_io_vec; // 实际数据段数组(page+offset+len)
bio_end_io_t *bi_end_io; // 完成回调
void *bi_private; // 私有数据(文件系统层通常会存inode等)
// ...
};
struct bio_vec {
struct page *bv_page;
unsigned int bv_offset;
unsigned int bv_len;
};
bio本身不直接交给驱动,而是通过blk-mq框架被插入request。一个request可以包含多个bio(合并),一个bio也可以被拆分到多个request(分割)。理解这个"分裂与合并"的动作,就掌握了块层调度的本质。
二、blk-mq:多队列块I/O框架
从4.0内核引入的Multi-Queue Block I/O Queuing(blk-mq)彻底重构了块层。单队列时代所有CPU共享一个锁的硬件队列,严重限制了多核扩展性。blk-mq引入了两级队列结构:
// 简化的两级队列映射关系
//
// Per-CPU / NUMA节点 Hardware Queue
// (提交队列,软件) (硬件分发队列)
// +-------------+ +-------------+
// | ctx 0 (CPU0)| ----------> | hwq 0 (向量0)|
// | ctx 1 (CPU1)| ----------> | hwq 1 (向量1)|
// | ctx 2 (CPU2)| ----+ +--> | hwq 2 (向量2)|
// | ctx 3 (CPU3)| ----+---+ +-->| hwq 3 (向量3)|
// +-------------+ +-------------+
// 标签映射 IRQ亲和性绑核
关键数据结构:
// include/linux/blk-mq.h
struct blk_mq_tag_set {
const struct blk_mq_ops *ops; // 硬件队列操作函数集
unsigned int nr_maps; // 映射表数量(通常=1)
unsigned int nr_hw_queues; // 硬件队列数量(通常=CPU核数)
unsigned int queue_depth; // 单个队列深度
unsigned int cmd_size; // 额外命令空间(驱动私有)
int numa_node;
void *driver_data;
struct blk_mq_tags *tags[]; // 每个hwq的标签集合
};
struct blk_mq_ops {
.queue_rq = driver_queue_rq, // 将request推送到硬件
.complete_rq = driver_complete_rq, // 硬件完成回调
.init_hctx = driver_init_hctx, // 硬件队列初始化
.exit_hctx = driver_exit_hctx,
.init_request = driver_init_request,
.timeout = driver_timeout,
.map_queues = driver_map_queues, // 映射表计算(决定亲和性)
};
对于NVMe驱动nvme_queue_rq的实现,本质就是把request转换成NVMe SQ(Submission Queue)里的命令描述符,再通过Doorbell寄存器通知控制器:
// drivers/nvme/host/pci.c — 简化的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 ns_info *ns_evt;
struct nvme_command cmnd;
u16 qid = hctx->queue_num;
// 1. 将PCIe映射为DMA可访问的区域
nvme_setup_cmd(ns, req, &cmnd);
// 2. 插入SQ(Submission Queue),单生产者无需锁
spin_lock(&dev->q_lock[qid]);
memcpy_sq_tail(dev->sq_cmds[qid], &cmnd);
writel(tail, dev->q_dbdoors[qid]); // Doorbell通知硬件
spin_unlock(&dev->q_lock[qid]);
return BLK_STS_OK;
}
三、I/O调度器/插入器:四大时空策略
blk-mq框架下调度器称为"插入器"(elevator),通过blk-mq的insert_requests和dispatch_request钩子工作。目前内核提供了四种策略:
| 名称 | 策略 | 适用场景 |
|---|---|---|
| none | 无排序,FIFO | NVMe/SSD(硬件自己排序) |
| mq-deadline | 按截止时间排序,读写分离 | SAS/SATA SSD混合负载 |
| bfq | 按进程预算(budget)分配带宽 | 桌面交互/混部场景 |
| kyber | 基于延迟反馈动态调整队列深度 | 高速NVMe延迟敏感应用 |
mq-deadline的详细机制值得关注:它维护多个红黑树排序的队列:
// block/mq-deadline.c
struct deadline_data {
struct rb_root sort_list[2][2]; // [读/写][按LBA排序 / 按过期时间排序]
struct list_head fifo_list[2]; // [读/写] FIFO链表
unsigned int batching; // 连续合并计数
unsigned int starved; // 饿死计数器
unsigned int fifo_batch; // 每次FIFO批量数
unsigned int writes_starved; // 写相对于读的优先级
unsigned int front_merges; // 前端合并统计
// 参数可通过/sys调整:
// /sys/block/sda/queue/iosched/read_expire (读过期ms,默认500)
// /sys/block/sda/queue/iosched/write_expire (写过期ms,默认5000)
// /sys/block/sda/queue/iosched/writes_starved (写优先级,默认2)
// /sys/block/sda/queue/iosched/fifo_batch (批量大小,默认16)
};
dispatch选择逻辑:优先从读过期队列中取请求确保不饿死;读取fifo_batch个FIFO请求依次派发;当读完轮转给写。writes_starved控制每处理多少个读才能服务一次写。
四、bio生命周期:从文件系统到硬件中断
一次write()系统调用最终如何变成磁盘上的一串电信号?我们来追踪bio的完整生命周期:
用户空间 write()
↓
VFS层 (vfs_write() → __vfs_write() → file->f_op->write_iter())
↓
文件系统层 (ext4/xfs 等,最终调用 submit_bio() 或 submit_bio_wait())
↓ [关键入口]
submit_bio(bio)
↓
generic_make_request() / submit_bio_noacct()
├─ 1. bio检查:大小边界、段数限制、保护类型(PI)
├─ 2. 分裂(bio_split):跨区域时切分
├─ 3. 合并尝试(bio_attempt_back/front_merge)
├─ 4. 小于扇区对齐的部分用bounce buffer补齐
└─ 5. blk_mq_submit_bio() 进入blk-mq
↓
blk_mq_submit_bio()
├─ blk_queue_split():按硬件限制分割(如最大段数)
├─ blk_bio_segment_split():跨区分割
├─ blk_mq_bio_to_request():bio转成rq
├─ blk_mq_try_issue_directly():快速路径直接提交
│ (空硬件队列 && 调度器无积压)
└─ blk_mq_queue_insert():放入hctx的dispatch队列
↓
调度器insert_requests()
↓
blk_mq_run_hw_queue() 或 触发软中断
↓
driver_queue_rq() → Doorbell
↓ [异步]
NVMe控制器DMA搬运数据
↓
完成中断 (硬_irq → 软_irq → complete_rq)
↓
bio->bi_end_io() 回调
↓
end_bio_bh_io_sync() → 唤醒等待进程
↓
用户空间的write()返回
值得注意的性能优化点:plug机制(blk_plug)。在持有fsync等批量写操作时,上层会先"塞住"当前进程的plug队列(I/O累积在一定阈值再统一flush)。这能在提交给块层前合并更多请求,提升NVMe深度利用率。
五、I/O合并策略:LBA相邻性与前端合并
为什么块层需要合并?本质是减少发给硬件的请求数量。对于机械盘,合并能减少寻道;对于NVMe,合并则表现为控制器内部的事务数减少。两种主要的合并策略:
// 后端合并(back merge):新bio的结束 == 已有rq的起始
// 前端合并(front merge):新bio的起始 == 已有rq的结束
//
// 示意图:
// 已有rq: [=existing request=]
// 后端合并情况:
// [=bio=] ← 新bio的扇区紧挨在rq前端
// 前端合并情况:
// [=bio=] ← 新bio的扇区紧挨在rq末端
合并判定的核心在attempt_merge()中实现:
// block/blk-merge.c
enum elv_merge {
ELEVATOR_NO_MERGE = 0,
ELEVATOR_FRONT_MERGE = 1,
ELEVATOR_BACK_MERGE = 2,
ELEVATOR_DISCARD_MERGE = 3,
};
static enum elv_merge
attempt_merge(struct request_queue *q, struct request *rq, struct bio *bio)
{
// 1. 检查op是否一致(不能合并READ和WRITE)
if ((bio->bi_opf & REQ_OP_MASK) != (rq->bio->bi_opf & REQ_OP_MASK))
return ELEVATOR_NO_MERGE;
// 2. 段数硬限制检查(blk_queue_max_segments)
if (blk_nr_total_segments(rq->nr_phys_segments, bio) > q->limits.max_segments)
return ELEVATOR_NO_MERGE;
// 3. 检查边界对齐(max_hw_sectors_kb 和 max_sectors_kb)
// 4. 物理完整性保护标签检查
// 5. 是否有-gap边界的zone限制
// ...
// 后端合并
if (blk_rq_pos(rq) == bio_end_sector(bio))
return ELEVATOR_BACK_MERGE;
// 前端合并
if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector)
return ELEVATOR_FRONT_MERGE;
return ELEVATOR_NO_MERGE;
}
六、cgroup v2 I/O控制:io.max 与 io.weight
在内核5.4之后,cgroup v2支持块I/O资源隔离。通过io.max可以硬限制某cgroup的带宽/IOPS:
# 限制容器A读写带宽上限及IOPS上限
# 格式: "majo:mino rbps=max wbps=max riops=max wiops=max"
echo "8:0 rbps=104857600 wbps=52428800 riops=1000 wiops=500" > \
/sys/fs/cgroup/container-a/io.max
# 按比例分配IO权重(默认100,范围1-10000)
echo "100" > /sys/fs/cgroup/container-a/io.weight
实际控制由blk-iocost(基于_cost model_)实现:它采样设备的"I/O代价模型"——结合基准吞吐/延迟建立比例关系,再按权重做加权公平队列调度。相比于cgroup v1的blkio throttle机制,iocost更加自适应,无需手动调参就能实现比较公平的分配。
七、常见性能问题与诊断方法
7.1 诊断工具链
# 块设备层延迟直方图
cat /sys/block/nvme0n1/queue/stat
# 实时监控I/O延迟
iostat -xmt 1
# blktrace收集事件流(提交→插入→派发→完成)
blktrace -d /dev/nvme0n1 -w 10 -o - | blkparse -i -
# 查看当前调度器
cat /sys/block/nvme0n1/queue/scheduler
# [none] mq-deadline bfq kyber
# 查看队列深度
cat /sys/block/nvme0n1/queue/nr_requests
cat /sys/block/nvme0n1/queue/nr_hw_sectors_kb
# 查看具体请求队列统计
cat /sys/block/nvme0n1/mq/0/nr_tags
cat /sys/block/nvme0n1/mq/1/nr_tags
7.2 常见性能瓶颈及对策
| 现象 | 根因分析 | 对策 |
|---|---|---|
| IOPS低但队列不深 | 应用未使用异步IO或libaio,串行提交 | 调大nr_requests,使用libaio或io_uring |
| 延迟抖动大(类似开篇案例) | mq-deadline读写混合,写合并占队列 | 切换kyber/none,或调read_expire增加读优先级 |
| util 100%但带宽低 | 小块随机IO导致控制器并发度饱和 | 应用io合并,或应用层做read-modify-write coalescing |
| NVMe队列饥饿 | blk-mq队列分配不均,某些hwq空闲而其他满载 | 检查IRQ亲和性绑核(/proc/irq/xxx/smp_affinity_list) |
| 脏页比例过高 | writeback不及时,阻塞在page writeback | 调整dirty_background_ratio/dirty_ratio |
八、io_uring:用户态直通的革命
Linux 5.1引入的io_uring可以说是块层I/O路径的一次范式级革新。传统AIO有诸多限制(必须O_DIRECT,不支持套接字,完成事件复用困难),io_uring则做了三点突破:
// io_uring核心数据结构:共享环形缓冲区
// 避免每次系统调用都走blk层+submit_bio
//
// 用户空间 内核空间
// +--------------------+ +--------------------+
// | SQ (Submission Ring)| --写入op--> | SQ 处理线程读取 |
// | SQ Head | | CQ Tail |
// | SQ Tail (消费者) | | CQ Head (生产者) |
// +--------------------+ +--------------------+
// | CQ (Completion Ring)| <--写完成-- | |
// | CQ Head (消费者) | | |
// | CQ Tail | | |
// +--------------------+ +--------------------+
// 安装与示例
struct io_uring ring;
// 初始化(队列深度4096,IORING_SETUP_SQPOLL使内核轮询SQ)
io_uring_queue_init(4096, &ring, IORING_SETUP_SQPOLL);
// 提交读请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
io_uring_sqe_set_data(sqe, my_data);
// 一次提交多个(批量syscall优化)
io_uring_submit(&ring);
// 收割完成事件
struct io_uring_cqe *cqe;
unsigned head, count = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
// 处理完成 ...
count++;
}
io_uring_cq_advance(&ring, count);
在5.19内核之后,io_uring已经支持linked operation、fixed buffer/register file、zero-copy TX等特性。它对于NVMe这类高速存储的延迟有质的优化——省去了系统调用entry/exit开销和共享锁竞争。
九、未来方向
块层的演进步伐并未停止,以下是几个值得关注的方向:
- ZNS (Zoned Namespace):受SMR硬盘启发,NVMe Zoned命令让主机协同管理写入位置,避免随机写放大。内核已有
blk-zoned核心框架与zonefs文件系统。 - HPB (Host Performance Boost):UFS/eMMC设备中的主机侧FTL缓存机制,逐步推广到NVMe。
- SPDM/TCG:块设备硬件级加密与认证融合到NVMe协议栈。
- 持续完善的blk-iocost:从被动限流转向主动预测I/O负载的带宽分配模型。
十、关键参数速查表
# 调度器选择
echo none > /sys/block/nvme0n1/queue/scheduler # NVMe推荐none
echo mq-deadline > /sys/block/sda/queue/scheduler # SATA SSD推荐
# 队列深度与请求合并
echo 1024 > /sys/block/nvme0n1/queue/nr_requests # 队列深度
echo 0 > /sys/block/nvme0n1/queue/nomerges # 禁用合并(debug用)
echo 128 > /sys/block/nvme0n1/queue/max_sectors_kb # 最大单次IO大小
# mq-deadline调优
echo 200 > /sys/block/sda/queue/iosched/read_expire # 读过期(ms)
echo 3000 > /sys/block/sda/queue/iosched/write_expire # 写过期(ms)
echo 4 > /sys/block/sda/queue/iosched/writes_starved # 写优先级
echo 8 > /sys/block/sda/queue/iosched/fifo_batch # 批量大小
# bfq调优
echo 100 > /sys/block/sda/queue/iosched/low_latency # 启用低延迟模式
echo 600 > /sys/block/sda/queue/iosched/timeout_sync # 同步超时(ms)
# NVMe特定
echo 0 > /sys/block/nvme0n1/queue/iosched/budget # 禁用内部占位预算
cat /sys/block/nvme0n1/transport # 查看传输类型(pcie/rdma/fc/tcp)
结语
从bio拆分、blk-mq多队列框架、I/O调度器策略选择,到cgroup I/O隔离和io_uring的高效路径,现代Linux块的每一层都经过了深入优化。当线上出现I/O类问题时,切勿只看iostat的util%就下结论——理解块层如何把应用写转变成磁盘上的电信号,才能找到真正的瓶颈点。

发表评论 取消回复