深入 XFS 日志阻塞排查:高并发小文件写入引发的 NVMe IO Stall 与 blk-mq 调度瓶颈实战

针对 NVMe 盘在高并发小文件(Metadata 密集型)写入场景下出现的 iowait 飙升与进程 D 状态堆积问题,核心原因是 XFS 默认 logbsize 过小导致高频元数据刷盘(xfsaild 锁竞争),叠加底层 blk-mq 错误使用 mq-deadline 调度器引发的自旋锁开销。通过挂载参数调优 logbsize=256k,logbufs=8 并将 NVMe 调度器改为 none,可直接将 IO 99线延迟从 800ms 压降至 2ms 以内。

案发现场:Load 飙升与幽灵般的 D 状态

排查过程中接到告警,某核心图片处理集群(Kernel 5.10, XFS v5)的 Load Average 突然飙升至节点 CPU 核数的 3 倍以上。业务反馈接口响应超时,API Gateway 层面大量 503。

登录机器,第一反应看 iostat。结果非常反直觉:

$ iostat -dxm 1
Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
nvme0n1           0.00     0.00    0.00 8540.00     0.00    35.20     8.44   124.50   14.50    0.00   14.50   0.12 100.00

磁盘 %util 打满 100%,写入 IOPS w/s 仅为 8500 左右,吞吐量 wMB/s 才 35MB/s。对于一块企业级 NVMe SSD 来说,这个负载连热身都算不上(标称 IOPS 在 40万+),但 await 已经涨到了 14.5ms,甚至偶发飙到数百毫秒。

抓取当前处于 D(Disk Sleep)状态的进程,直接看内核调用栈:

$ for i in $(ps -eo pid,state | awk '$2=="D"{print $1}'); do cat /proc/$i/stack; echo "-----"; done
[<0>] xfs_log_force_lsn+0x2d1/0x3a0 [xfs]
[<0>] xfs_bmap_extents_to_btree+0x2a2/0x7c0 [xfs]
[<0>] xfs_bmapi_write+0x3ab/0x650 [xfs]
[<0>] xfs_iomap_write_direct+0x1eb/0x290 [xfs]
[<0>] iomap_apply+0x11b/0x270
...

大量进程阻塞在 xfs_log_force_lsn。这意味着业务虽然在写数据,但实际上是被文件系统的 Journal(日志)同步刷盘机制卡住了。

为什么超高性能的 NVMe 会被 XFS 日志拖死?

XFS 是一种强一致性的日志文件系统。为了保证 Crash Consistency,任何对元数据(Metadata,如修改文件大小、分配新 Block、修改时间戳)的更改,都必须先写入日志(Journal),然后才能落盘到实际的设备位置。

在这个业务场景中,大量并发写入 KB 级别的小文件,引发了海量的 Block Allocation(块分配)操作,导致元数据剧烈变化。

默认情况下,XFS 的内存日志缓冲区大小(logbsize)为 32KB。当并发小文件写入极度密集时,这个 32KB 的 Buffer 瞬间就被填满。一旦填满,XFS 的 CIL(Committed Item List)机制就会被强制触发同步刷盘(Log Force)。 更致命的是,日志写入是串行的。成百上千个并发线程在等待这 32KB 的日志落盘,底层 NVMe 的并发优势被文件系统层的全局自旋锁(Spinlock)和同步等待队列彻底抹平。

可以通过 xfs_info 查看当前挂载的日志参数:

$ xfs_info /data
meta-data=/dev/nvme0n1           isize=512    agcount=32, agsize=30517961 blks
         =                       sectsz=4096  attr=2, projid32bit=1
         =                       crc=1        finobt=1, sparse=1, rmapbt=0
         =                       reflink=1    bigtime=0 inobtcount=0
data     =                       bsize=4096   blocks=976574768, imaxpct=25
         =                       sunit=0      swidth=0 blks
naming   =version 2              bsize=4096   ascii-ci=0, ftype=1
log      =internal log           bsize=4096   blocks=476843, version=2
         =                       sectsz=4096  sunit=1 blks, lazy-count=1
realtime =none                   extsz=4096   blocks=0, rtextents=0

要缓解日志锁竞争,必须放大缓冲,降低刷盘频率。

深入块设备层:blk-mq 调度器的额外损耗

除了文件系统,底层的 IO 调度器也在这里扮演了“反面角色”。 通过检查 NVMe 设备的调度器配置:

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

系统默认使用了 mq-deadline。这是针对传统 SATA/SAS SSD 优化的多队列调度器,它试图在软件层对 IO 请求进行合并(Merge)和排序,以保证请求不会饿死。

但在纯 NVMe 环境下,NVMe 硬件控制器本身已经具备了极深的金字塔形硬件队列(通常有 64K 个队列,每个队列深 64K)。在极高的并发下,mq-deadline 在内核态维护软件队列的自旋锁开销,反而成了多核 CPU 下的严重瓶颈。

我们通过 perf 抓取内核热点:

$ perf top -F 99 -e cpu-clock

可以看到 blk_mq_sched_insert_requestssbitmap_get 占据了大量的 CPU 周期,这全是调度器无谓的锁开销。

解决方案与性能对撞

既然定位到了两个层面的阻塞,解法就非常明确了:降低 XFS 元数据刷盘频率,卸载块设备的软件调度开销。

1. 调整 XFS 挂载参数

将日志缓冲区大小直接拉到最大限制 256KB,并增加缓冲数量到 8 个(默认 8 个,无需显式改但推荐确认)。开启 noatime 避免读取时引发元数据更新。

# 在 /etc/fstab 中修改挂载参数,或在线 remount
$ mount -o remount,noatime,logbsize=256k,logbufs=8 /data

2. 更改 NVMe 调度器为 none

彻底旁路 IO 调度器,将请求直接打入 NVMe 硬件队列:

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

注:为了持久化,建议写入 udev rule,例如 /etc/udev/rules.d/60-io-scheduler.rules ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"

优化效果对比

应用上述两步操作后,业务请求无缝恢复,再次观察 iostat

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
nvme0n1           0.00     0.00    0.00 42500.00    0.00   285.20    13.74     1.50    0.03    0.00    0.03   0.01  38.50
  • IOPS 直接冲上 4.2万(业务并发量完全释放)。

  • 吞吐量达到 285 MB/s。

  • await 从 14.5ms 暴降至 0.03ms

  • %util 回落到 38.5% 的健康水位。 D 状态进程彻底消失。

常见问题

Q1:ext4 是否存在类似的日志瓶颈?如何排查? 存在。ext4 的日志由 jbd2 内核线程负责。在高并发小IO下,若经常看到 jbd2/nvme0n1-8 占用 100% 单核 CPU,或者大量进程阻塞在 wait_transaction_locked,即为典型的 ext4 日志瓶颈。可通过挂载参数 data=writeback 降低日志开销(牺牲部分数据安全性),或将日志放在独立的外部极速设备上(mke2fs -O journal_dev)。

Q2:如果业务已经在使用 io_uring,还会被 XFS 的日志锁阻塞吗? 会。io_uring 解决的是系统调用(Syscall)开销和 Block 层的异步投递问题,但文件系统层的元数据操作(尤其是文件大小扩展、分配新块)如果在内核中必须走同步的 Log Force,io_uring 也会回退到慢速路径(Worker Thread)。为了让 io_uring 彻底发挥性能,建议使用预分配(fallocate)锁定空间,使后续的写操作变为纯粹的覆写(Overwrite),从而彻底避开元数据更新。

Q3:如何动态观测 XFS 日志的写入频率和延迟? 可以使用 BCC (eBPF) 工具集中的 xfsdistxfsslower 工具。 执行 xfsslower 1,如果屏幕上疯狂打印出 xfs_log_force 的调用堆栈且延迟 > 1ms,就足以说明当前系统的 IO 瓶颈不在介质本身,而在于文件系统的日志同步机制。