标签: IO阻塞

  • 深入 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 瓶颈不在介质本身,而在于文件系统的日志同步机制。

  • 深入 Apache Pulsar 写入雪崩排查:Journal/Ledger 磁盘混用引发的 IO 饱和与 Bookie 假死实战

    某次接手一个号称“完全按照官方最佳实践”部署的 Pulsar 集群,业务方反馈高并发场景下大量 Producer 频繁抛出 PulsarClientException$TimeoutException,P99 写入延迟从常态的 5ms 瞬间飙升至 8000ms+,集群吞吐呈断崖式下跌。直接抛出排查结论:这是典型的底层存储架构无知导致的惨案。部署人员将 BookKeeper 的 journalDirectories(写前日志)和 ledgerDirectories(数据与索引)挂载到了同一块物理磁盘(甚至是同一块云盘)。当 Ledger 触发后台垃圾回收(Garbage Collection)或 RocksDB 刷盘时,海量随机 IO 直接榨干了磁盘 IOPS,导致 Journal 的顺序 fsync 严重阻塞。Bookie 内部线程池大面积挂起,最终因 ZK 心跳超时被踢出集群,引发 NotEnoughBookiesException 全局写入雪崩。

    Pulsar 最大的卖点就是“计算与存储分离”(Broker 与 Bookie 分离),但很多人只停留在节点级别的隔离,完全无视了 BookKeeper 内部极其苛刻的 IO 路径分离要求。

    BookKeeper 的写入模型极其严谨且保守:一条消息到达 Bookie 后,必须强制 fsync 落盘到 Journal(类似 MySQL 的 Redo Log),才会向 Broker 返回 ACK。同时,消息会被写入内存(MemTable),随后异步批量刷入 Ledger 磁盘,并更新 RocksDB 中的索引。 这套设计的初衷非常明确:用 Journal 的极速顺序写保证低延迟和数据可靠性,用 Ledger 的大容量存储应对历史数据读取和高吞吐。

    把 Journal 和 Ledger 混在一块盘上,无异于在高速公路上摆地摊。

    排查期间,登陆故障 Bookie 节点,一条极其普通的 iostat 命令就让问题原形毕露:

    # iostat -dx 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
    nvme1n1           0.00     0.00  850.00 1200.00 10240.00 45000.00    53.89   145.20   70.83   90.50   56.90   0.49 100.00
    

    磁盘 %util 死死钉在 100%,avgqu-sz(请求队列长度)高达 145,await 飙到 70ms 以上(对于 NVMe 来说,超过 5ms 就已经是不及格了)。

    去翻看 Bookie 的 Prometheus 监控,核心指标 bookkeeper_journal_JOURNAL_SYNC_99_per(Journal 落盘 99 线)与磁盘 IO 延迟高度吻合,出现了巨幅毛刺。此时,Broker 的日志里已经尸横遍野:

    org.apache.bookkeeper.client.BKException$BKNotEnoughBookiesException: Not enough non-faulty bookies available
        at org.apache.bookkeeper.client.LedgerCreateOp.initiate(LedgerCreateOp.java:142)
        ...
    

    为什么会突然爆发?因为 BookKeeper 并非只有简单的追加写。当 Ledger 中的 EntryLog 文件里被删除(或过期)的数据达到一定比例时,Bookie 会触发后台 GC 线程(Minor/Major Compaction)。GC 的动作是读取旧文件、过滤有效数据、重写到新文件。这是一个极其暴力的重度随机读 + 顺序写过程。 如果 Journal 和 Ledger 共享物理 IO 设备,GC 产生的海量 IO 请求会瞬间塞满 OS 的 Block Layer 队列,Journal 线程哪怕只是想追加写入几 KB 数据并调用一次 fsync,也只能在队列里绝望地排队。

    不仅如此,由于 Journal 同步阻塞,Bookie 的 Netty Worker 线程被耗尽,导致 Bookie 连发往 ZooKeeper 的心跳都无法及时响应。ZK 判定 Bookie 宕机,Broker 发现 Ensemble 可用节点不足(例如配置了 3 副本,只剩下 2 个健康节点),直接拒绝写入。由于集群是均衡负载的,随着 GC 在各个节点轮番上演,整个 Pulsar 集群如同多米诺骨牌般倒塌。

    解决这种问题,不要去迷信什么神奇的 JVM 调优参数,核心就是尊重物理拓扑

    修复手段与防御性配置:

    1. 物理级别的 IO 隔离(最关键) 修改 bookkeeper.conf,强制分离 Journal 和 Ledger 目录到不同的物理磁盘。Journal 给一块极小但极快的高性能 NVMe SSD(几十G即可,写满会自动清理),Ledger 给大容量的普通 SSD 甚至 HDD。

    # 高速 NVMe 挂载点
    journalDirectories=/mnt/nvme_journal/bookkeeper/journal
    # 大容量 SSD/HDD 挂载点
    ledgerDirectories=/mnt/ssd_ledger/bookkeeper/ledgers
    

    2. 对后台 GC 进行冷酷的资源限流 不要让 GC 跑起来像脱缰的野马。在 bookkeeper.conf 中开启 GC 限速,严格控制其对磁盘带宽的占用:

    # 开启按字节限流
    isThrottleByBytes=true
    # 限制 Compaction 最大速率为 50MB/s (根据底层磁盘能力调整)
    compactionRateByBytes=52428800
    # 避免在高峰期触发 Major Compaction
    minorCompactionThreshold=0.2
    majorCompactionThreshold=0.8
    

    3. RocksDB 索引刷盘的平滑处理 Ledger 中的索引默认由 RocksDB 管理,RocksDB 的 MemTable Flush 同样会带来 IO 尖峰。确保配置了合理的 Write Buffer 和并发度:

    dbStorage_rockdb_writeBufferSizeMB=64
    dbStorage_rockdb_numLevels=6
    

    架构设计不是画几个方块就完事了。Pulsar 这种分布式中间件的性能底座,其实都建立在底层 Linux IO 调度和文件系统特性的基础之上。不理解数据的生命周期流转,不看磁盘的 IOPS 和延迟分布,一键部署出来的集群,最终都会在晚高峰教你做人。

    排查清单:BookKeeper IO 阻塞与假死速查

    1. 磁盘物理拓扑核对:执行 df -hlsblk,严格对照 bookkeeper.conf 中的 journalDirectoriesledgerDirectories,确认两者绝未落在同一块物理盘、同一个 LVM 卷或同一个共享云盘组上。

    2. Journal Sync 延迟监控:紧盯 bookkeeper_journal_JOURNAL_SYNC 的 P99 和 P999 指标,一旦常态超过 10ms,立刻排查底层的 IO 争抢或硬件寿命衰减问题。

    3. ZooKeeper 会话抖动排查:排查 Bookie 侧日志是否有 Expired session,以及 ZK 侧是否有 Closed socket connection for client。如果是 IO 夯死导致的 CPU 调度迟滞,考虑适当调大 zkTimeout(默认通常为 10s-30s),但治本仍在 IO 治理。

    4. GC 日志与速率审查:搜索 Bookie 日志中的 GarbageCollectorThread 关键字,观察 Compaction 触发频率和耗时。确认 isThrottleByBytes 是否开启并配置了合理的阈值,防止后台合并打挂前台写入。

    5. Direct Memory 泄漏挤压 OS Cache:检查 dbStorage_directIO_entryLogger 是否未正确分配,导致 Bookie OOM 或严重依赖 PageCache。确保为 Bookie 预留充足的 Direct Memory 给 RocksDB Block Cache 和 ReadAhead Cache。