标签: NVMe

  • 深入 NVMe 队列阻塞排查:blk-mq 调度器误用引发的 XFS 元数据锁雪崩与 sys CPU 饱和实战

    高并发写入场景下,NVMe 盘配合 XFS 极易触发 sys CPU 满载与 IO 夯死。核心原因是 NVMe 误用了 mq-deadline 调度器,导致 blk-mq 软件队列自旋锁争用,进而引发 XFS 分配元数据时在 xfs_log_commit_cil 处发生锁雪崩。直接结论:NVMe 设备的 IO 调度器必须设为 none,同时对于高并发盘,需在格式化时调大 XFS 的 agcount 以打散锁粒度。

    故障现场:数据库写入 p99 突增与 sys CPU 飙升

    某次排查过程中,一套承载核心业务的 PostgreSQL 集群(内核版本 5.10.134-el8,底层存储为裸金属物理机的 PCIe Gen4 NVMe SSD)在高并发 COPY 导入数据时,QPS 出现周期性断崖式下跌。

    通过 top 观察,CPU sys 态长期飙升至 70% 以上,iowait 反而在 10% 左右波动。这极不寻常——对于一块标称 100万 IOPS 的 NVMe 盘,IO 没有跑满,CPU 却在内核态被榨干。

    抓取当时的 iostat -x 1 核心指标:

    Device:         r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
    nvme0n1        12.0 42351.0     192.0  680512.0     0.0     0.0    0.0    0.0    0.15   18.42   12.5   16.00    16.06   0.02  85.40
    

    注意 w_await 达到了惊人的 18.42ms,对于 NVMe 来说,这个延迟意味着底层已经严重阻塞。但 %util 只有 85%,设备并未完全饱和。

    使用 perf top -U 直接看内核态热点,现场如下:

      18.45%  [kernel]  [k] queued_spin_lock_slowpath
      12.31%  [kernel]  [k] dd_insert_requests
       8.52%  [kernel]  [k] xfs_log_commit_cil
       6.14%  [kernel]  [k] blk_mq_submit_bio
       5.33%  [kernel]  [k] _raw_spin_lock_irqsave
    

    热点非常集中:dd_insert_requestsxfs_log_commit_cil。这表明系统同时在块设备调度层和文件系统日志提交层发生了严重的锁争用。

    为什么 NVMe 设备使用 mq-deadline 会导致 IO 栈雪崩?

    问题出在 Linux blk-mq(Block Multi-Queue)架构的调度器选择上。

    在传统的单队列(Single Queue)时代,所有 IO 请求进入一个全局队列,需要 CFQ 或 Deadline 这种电梯算法(Elevator)进行合并和排序,以减少机械硬盘的磁头寻道。

    到了 NVMe 时代,硬件支持多达 64K 个提交/完成队列。Linux 为此重构了 blk-mq 架构,分为软件队列(Software Staging Queues,通常每个 CPU 核心一个)和硬件分发队列(Hardware Dispatch Queues)。

    排查发现,该服务器的 NVMe 被默认配置了 mq-deadline 调度器:

    $ cat /sys/block/nvme0n1/queue/scheduler
    [mq-deadline] kyber bfq none
    

    底层阻塞原理: 当调度器设置为 mq-deadline(甚至 bfq)时,IO 请求在进入硬件队列前,必须先挂载到电梯算法的软件队列中。dd_insert_requests 就是 mq-deadline 插入请求的内核函数。由于高并发下成千上万个线程试图向这个软件队列提交 BIO(Block I/O),这就不可避免地触发了自旋锁(queued_spin_lock_slowpath)。 NVMe 的纳秒级响应速度完全被软件队列的自旋锁开销抹平,导致 CPU 在 sys 态空转,IO 提交路径被硬生生卡住。

    剥茧抽丝:XFS 延迟分配与 AIL/CIL 阻塞

    块设备的延迟飙升,迅速引发了文件系统层的连锁反应,这也是为什么 perf 中出现了大量 xfs_log_commit_cil

    XFS 是一种强依赖 Allocation Group(AG)并发设计的日志文件系统(当前版本 V5)。当数据库执行大量写入时,XFS 会利用延迟分配(Delayed Allocation)机制,在内存中缓存数据,直到刷盘时才真正分配物理 Block 并更新元数据。

    1. CIL(Committed Item List)雪崩:元数据变更首先写入内存中的 CIL。当底层 NVMe 因为 mq-deadline 阻塞时,后台刷脏线程(xfsaild)将 AIL(Active Item List)刷入磁盘的速度骤降。

    2. AG 锁争用:CIL 空间被占满,前端业务线程在调用 xfs_alloc_vextent 申请新的空间块时,必须等待日志空间释放。大量 PostgreSQL 线程被迫在同一个 AG 的元数据锁上排队。

    3. 全局夯死:IO 栈的阻塞放大了 XFS 的锁临界区时间,最终导致原本并行的 IO 瀑布般退化为串行等待,形成死锁态势的雪崩。

    解决方案与防御性配置

    解决该问题不需要修改业务代码,纯属系统级架构调优,分为治标和治本两步。

    1. 立即剥离软件调度器(实时恢复)

    将 NVMe 设备的调度器强行切换为 none,绕过所有电梯算法,让 BIO 请求直接从软件多队列打入硬件队列。

    echo none > /sys/block/nvme0n1/queue/scheduler
    

    执行瞬间,sys CPU 从 70% 骤降至 8%,PostgreSQL QPS 恢复正常,w_await 回落至 0.05ms。

    为了防止重启失效,通过 udev 固化防御策略:

    # vim /etc/udev/rules.d/60-io-scheduler.rules
    ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
    

    2. XFS AG 并发度调优(底层重构)

    默认情况下,mkfs.xfs 根据磁盘大小自动计算 agcount(通常是 4 或 8)。对于大容量、极高吞吐的 NVMe 盘和数据库场景,默认 AG 数量太少,容易发生并发分配碰撞。 在节点下线重装阶段,调整格式化参数,人为扩大 AG 数量打散锁粒度:

    # 格式化 XFS:强制启用 32 个 AG,对齐 512M 日志大小
    mkfs.xfs -f -K -d agcount=32 -l size=512m,version=2,su=256k /dev/nvme0n1
    

    注:agcount 并非越大越好,过大会增加 mount 时间和内存开销,通常 16-32 针对高端 NVMe 是甜点区间。

    常见问题

    Q1: io_uring 在遇到这种 XFS 锁争用时,会退化成同步阻塞吗? 会。这是很多人使用 io_uring 踩坑的地方。虽然 io_uring 是异步 IO,但如果在文件系统层发生 metadata 锁争用(比如 XFS 分配 block),底层的 IORING_OP_WRITE 且带有 RWF_NOWAIT 标志位时,内核会直接返回 -EAGAIN。随后 io_uring 只能将这个 IO 任务推入后台的 io_worker 线程池进行同步阻塞处理,纯异步链路被击穿,高并发下依然会导致线程池耗尽。

    Q2: 调度器设置为 none 后,系统还有 IO 合并能力吗? 有,但发生在不同层级。none 确实禁用了电梯算法层的合并,但 blk-mq 在软件队列层(Software Staging Queue)和块设备硬件驱动层依然会利用 scatter-gather list 进行有限的相邻物理段合并。对于 NVMe 而言,本身 4K 随机 IO 的性能极高,强行进行复杂的 IO 合并排序带来的 CPU 锁开销远大于其收益。

    Q3: 如何在生产环境无损监控 XFS 的 AG 锁争用情况? 极力推荐使用 eBPF/bpftrace 而不是 SystemTap。可以通过挂载 tracepoint 实时监控 CIL 提交延迟:

    bpftrace -e 'tracepoint:xfs:xfs_log_commit_cil { @start[tid] = nsecs; } tracepoint:xfs:xfs_log_commit_cil_wait { if(@start[tid]) { @usecs = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); } }'
    

    如果输出的直方图显示大量调用耗时超过 1000 微秒(1ms),说明文件系统日志提交已出现严重积压,需立即排查底层块设备延迟。

  • 深入 NVMe IO 延迟雪崩排查:默认调度器误用引发的 blk-mq 锁争用与软中断打满实战

    某次核心分布式 KV 存储集群进行底层硬件换代,全面升级至 Gen4 NVMe SSD。本以为硬件红利能让 IO 性能起飞,结果压测一上,QPS 刚到 8 万,99线(p99 latency)就开始周期性从 2ms 剧烈抖动到 300ms 以上。Load Average 狂飙至机器核数的两倍,大量写入线程陷入 D 状态(Uninterruptible Sleep)。 一句话交代结论:这是一起典型的“旧时代运维习惯毒害新硬件”的事故。系统镜像中遗留的 udev 规则将 NVMe 设备的 IO 调度器默认设置成了 mq-deadline。在极高并发下,这个多余的软件调度层在内核 blk-mq(块设备多队列)中引发了极其严重的自旋锁争用(Spinlock Contention),直接打满 ksoftirqd 软中断,导致 IO 请求在 OS 提交队列里排队,根本没送进物理磁盘。 解决方式极其简单:echo none > /sys/block/nvme0n1/queue/scheduler

    案发现场与指标拆解

    排查过程中,第一直觉是新批次的 NVMe 盘存在固件缺陷或发生了 GC(垃圾回收)拥塞。习惯性敲下 iostat -xz 1,看到的数据却极为诡异:

    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00    12.00  340.00 45000.00  5440.00 720000.00    32.00   145.50  180.20    1.50  182.10   0.01  15.20%
    

    注意看这几个核心指标的矛盾点:

    1. %util 只有 15.20%:说明磁盘底层的物理带宽和 IOPS 根本没跑满,处于极度饥饿状态。

    2. svctm(服务时间)仅为 0.01ms:说明盘的物理响应极快,一旦请求交到硬件手里,瞬间就能处理完。

    3. w_await 高达 182.10ms,avgqu-sz(平均队列长度)达到 145.5:请求虽然处理得快,但排队时间长得离谱。

    这说明一个铁打的事实:IO 根本没有卡在硬件设备上,而是卡在了 Linux 内核的 IO 栈里。

    为了抓现行,直接祭出 perf top 观察内核态的 CPU 热点,结果满屏红彤彤的自旋锁:

      18.45%  [kernel]       [k] native_queued_spin_lock_slowpath
      12.30%  [kernel]       [k] blk_mq_sched_insert_request
       9.15%  [kernel]       [k] sbitmap_get
       7.20%  [kernel]       [k] dd_insert_requests
    

    再看 CPU 状态,top 显示多核的 si(软中断)飙升,对应的进程全是 ksoftirqd/x

    愚蠢的配置与底层原理解析

    走到这一步,根因已经水落石出:dd_insert_requests 这个函数的出现,意味着系统正在使用 mq-deadline IO 调度器。

    查看设备的调度器配置,果不其然:

    $ cat /sys/block/nvme0n1/queue/scheduler
    [mq-deadline] kyber bfq none
    

    为什么在 NVMe 上用 mq-deadline 是不可原谅的低级错误?

    在 SATA/SAS 机械硬盘时代,磁盘只有一个硬件队列(Queue Depth 通常只有 32 或 256)。为了防止磁头剧烈寻道,Linux 内核设计了 IO 调度器(如 Deadline、CFQ)在软件层面对 IO 请求进行合并(Merge)和重排(Sort)。 但在 NVMe 时代,协议原生支持多达 64K 个硬件队列(Submission/Completion Queues),每个队列深度可达 64K。Linux 内核为此重构了 blk-mq(Block Multi-Queue)架构,将 CPU Core 与 NVMe 硬件队列直接建立映射关系(Software Queue -> Hardware Dispatch Queue)。

    如果在 NVMe 上强行开启 mq-deadlinebfq,相当于在原本宽阔的双向 64 车道高速公路上,硬生生设了一个收费站。 所有并发请求走到 blk_mq_sched_insert_request 时,都需要去抢调度器内部的自旋锁,把请求塞进软件队列进行徒劳的合并尝试。在高并发的 KV 存储场景中,小块随机写极多,合并命中率极低,这种操作不仅毫无意义,反而引发了致命的锁争用(native_queued_spin_lock_slowpath)。同时,底层块设备完成 IO 后抛出的中断,在唤醒软中断处理(NET_RX / BLOCK)时又被上层阻塞,最终导致 ksoftirqd 把 CPU 吃干抹净。

    极客实战:彻底打通 IO 栈

    1. 拔掉收费站(关闭调度器)

    直接将调度器设置为 none,让 blk-mq 完全旁路软件调度层,直通硬件队列。

    echo none > /sys/block/nvme0n1/queue/scheduler
    

    修改后,p99 延迟瞬间从 300ms 回落到稳定的 1.5ms,Load Average 下降 80%。

    2. 防御性配置(根治固化)

    永远不要相信手动命令,写进 udev 规则才是 SRE 的基本素养:

    # /etc/udev/rules.d/60-io-scheduler.rules
    ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
    ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
    

    (注:对 SATA SSD 依然保留 mq-deadline 是一种保守策略,但对 NVMe 必须一律为 none)。

    3. 压榨最后一滴性能(中断亲和性)

    即使调度器改成了 none,如果你发现某些 CPU 核的 hi/si 依然不均衡,那是因为 NVMe 的 MSI-X 中断没有打散。现代 NVMe 驱动通常会自动分配 IRQ,但如果运行了老旧的 irqbalance 守护进程,可能会发生“劣化漂移”。 建议针对极度依赖 IO 的节点,关掉 irqbalance,并使用内核源码自带的 set_irq_affinity 脚本将 NVMe 的完成队列中断静态绑定到 NUMA 节点的对应 CPU 核心上。

    同类问题速查清单 (Troubleshooting Checklist)

    1. 检查 IO 调度器状态: 快速排查集群中所有块设备的调度器配置: awk -F'[][]' '{print $2}' /sys/block/*/queue/scheduler 如果 NVMe 设备输出非 none,立刻整改。

    2. 区分 OS 队列延迟与硬件延迟: 不要只看 iostatawait。使用 eBPF 工具(如 bcc-toolsbiolatency)深入观测: biolatency -Q 若输出中 OS Queue Time (Q-time) 远大于 Device Time (D-time),说明阻塞点在 Linux Block Layer。

    3. 检查 NVMe 多队列深度配置: 确认内核是否正确识别并启用了 NVMe 硬件队列: ls -d /sys/block/nvme0n1/mq/* | wc -l 该数值应接近或等于机器的 CPU 核心数。

    4. 观测软中断打散情况watch -n 1 'cat /proc/interrupts | grep nvme' 观察各 CPU 列的数值增长率。如果只有极少数 CPU 在狂飙,说明 IRQ 绑定策略失效,正在引发单核软中断瓶颈。