标签: IO调优

  • 深入 Linux IO 栈陷阱排查:ext4 jbd2 屏障阻塞引发的 NVMe 队列挂起与 MySQL 抖动实战

    NVMe SSD 时代,数据库 IO 瓶颈往往不在硬件,而在内核 IO 栈的锁与同步机制。排查表明,ext4 的 jbd2 日志线程在提交事务时触发的全盘 Flush/FUA 屏障,会导致底层 blk-mq 硬件队列短暂挂起,引发 MySQL fsync 延迟飙升及 D 状态风暴。本文通过关闭易失性缓存、切换 none 调度器并压平脏页水位,将 99 线延迟从 250ms 压回 2ms 以内。

    故障现场:明明 IOPS 富裕,MySQL 却大面积卡顿

    近期在处理一个高并发支付核心库的性能问题时,遇到一个极其典型的 IO 栈陷阱。 环境为 CentOS 8 Stream,内核 5.15.0,文件系统 ext4,底层挂载企业级 NVMe SSD,MySQL 版本 8.0.32

    症状表现: 压测期间,数据库 QPS 偶尔从 25k 骤降到 500,Load Average 瞬间飙升到 150+。 通过 iostat -x 1 观察,发现磁盘 %util 只有 15% 左右,IOPS 甚至不到 2万(这块 NVMe 标称 50万 IOPS),但是 w_await(写等待时间)却经常出现 200ms 以上的尖刺。

    抓取现场 D 状态进程栈(echo w > /proc/sysrq-trigger 或查阅 dmesg),满屏都是 MySQL 的 page cleaner 线程和业务写入线程卡在内核态:

    [<0>] blk_execute_rq+0x5c/0x100
    [<0>] blkdev_issue_flush+0x6d/0xb0
    [<0>] ext4_sync_file+0x187/0x360 [ext4]
    [<0>] vfs_fsync_range+0x49/0x80
    [<0>] do_fsync+0x3d/0x70
    

    同时,内核的 ext4 日志提交线程 jbd2 也处于阻塞状态:

    [<0>] blk_execute_rq+0x5c/0x100
    [<0>] jbd2_journal_commit_transaction+0x18e5/0x1f00 [jbd2]
    [<0>] kjournald2+0xb5/0x260 [jbd2]
    

    为什么极速的 NVMe 也会被 jbd2 拖垮?

    很多开发甚至运维都有一个误区:只要换上 NVMe,IO 问题就迎刃而解。但这忽视了 Linux IO 栈的屏障(Barrier)机制。

    默认情况下,ext4 采用 data=ordered 日志模式。为了保证崩溃一致性(Crash Consistency),jbd2 内核线程每隔几秒或在满足一定条件时,会将内存中的元数据和数据写入磁盘的 Journal 区。 为确保这些写入真正落盘而不是停留在 SSD 的 DRAM 缓存中,jbd2 会向下层 block layer 发送带有 REQ_OP_FLUSHREQ_FUA(Force Unit Access)标志的 BIO。

    当这个 Flush 请求到达 blk-mq(多队列块层)时,灾难开始了。

    1. 块层会暂停当前块设备所有常规 IO 的下发,必须等待 Flush 完成。

    2. NVMe 控制器收到 Flush 指令后,需要将其内部易失性缓存(Volatile Write Cache, VWC)中的脏数据全部刷入 NAND Flash。

    3. 如果此时系统中有大量碎片化的脏页堆积,或者 SSD 的 GC(垃圾回收)任务正好触发,这个 Flush 操作可能耗时几十甚至上百毫秒。

    4. 在这上百毫秒内,整个 /dev/nvme0n1 队列被“冻结”。MySQL 的 Redo log 发起的 fsync() 全部阻塞,直接导致 TPS 暴跌,连接数堆积。

    深度排查:bpftrace 抓取幽灵延迟

    为了拿到实锤数据,我们直接用 bpftrace 追踪 jbd2_journal_commit_transaction 的耗时分布:

    bpftrace -e '
    kprobe:jbd2_journal_commit_transaction {
        @start[tid] = nsecs;
    }
    kretprobe:jbd2_journal_commit_transaction /@start[tid]/ {
        @ns = hist((nsecs - @start[tid]) / 1000000); 
        delete(@start[tid]);
    }
    '
    

    运行 5 分钟后的输出直方图(单位:毫秒):

    @ns: 
    [2, 4)                 12 |@@                                                 |
    [4, 8)                 45 |@@@@@@@@                                           |
    [8, 16)                89 |@@@@@@@@@@@@@@@@                                   |
    [16, 32)              156 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                     |
    [32, 64)              201 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@            |
    [64, 128)              54 |@@@@@@@@@                                          |
    [128, 256)             12 |@@                                                 |
    [256, 512)              3 |                                                   |
    

    可以看到,大量 jbd2 提交耗时超过了 32ms,极端情况甚至达到了 256ms 以上,这在 NVMe 盘上是绝对不可接受的。

    核心调优与防御性配置

    面对这种内核级的锁竞争与队列挂起,一味调整 MySQL 的 innodb_io_capacity 是徒劳的。必须从 Linux IO 栈自底向上进行防御性加固。

    1. 禁用物理盘易失性缓存(绕过 Flush 屏障)

    前提条件: 你的 NVMe 必须是企业级 SSD,自带掉电保护电容(PLP – Power Loss Protection),或者服务器挂接了可靠的 BBU(电池备份单元)。

    既然硬件保证了掉电不丢数据,我们就不需要内核一遍遍地发 Flush 指令。 老版本的内核可以通过 mount 参数 nobarrier 来禁用屏障,但在 4.19+ 及 5.x 内核中,ext4 的 nobarrier 参数实际上已被废弃并忽略。现代内核的做法是直接关闭底层块设备的 write cache 特性,内核探测到设备没有缓存,就不会再下发 REQ_OP_FLUSH

    对于 NVMe,使用 nvme-cli 直接修改控制器特性:

    # 查看当前 VWC 状态
    nvme get-feature /dev/nvme0 -f 0x6
    
    # 禁用 Volatile Write Cache
    nvme set-feature /dev/nvme0 -f 0x6 -v 0x0
    
    # 通知内核重新校验设备
    echo 1 > /sys/block/nvme0n1/device/rescan
    

    执行后,再次用 bpftrace 观察,jbd2 提交延迟会断崖式下降到 1-2ms 内。

    2. 剥离无用的软件调度器(blk-mq 优化)

    很多系统默认给 NVMe 分配了 mq-deadlinebfq 调度器。对于具备极高并发处理能力且没有磁头寻道开销的 NVMe 来说,软件层的电梯算法纯属多此一举,还会引入额外的 spinlock 竞争。

    立即切到 none 调度器:

    # 实时生效
    echo none > /sys/block/nvme0n1/queue/scheduler
    
    # 持久化(通过 udev 规则,确保重启不丢)
    cat << 'EOF' > /etc/udev/rules.d/60-io-scheduler.rules
    ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
    EOF
    udevadm control --reload-rules && udevadm trigger
    

    3. 启用 ext4 快速提交(Fast Commit)

    如果是较新的内核( >= 5.10 )和 e2fsprogs,强烈建议开启 ext4 的 fast_commit 特性。它通过精简元数据日志的格式,大幅减少了 jbd2 提交时的写入量。

    # 需在 unmount 状态下执行
    tune2fs -O fast_commit /dev/nvme0n1p1
    

    挂载后,可通过 dumpe2fs 确认是否生效。配合 MySQL 这样大量执行 fsync 的应用,fast_commit 可以带来 15%~20% 的吞吐提升。

    4. 压平 VM 脏页水位

    不要让系统的脏页堆积到触发全局同步刷盘的地步,这会加剧 IO 队列深度突刺。 修改 /etc/sysctl.conf

    # 后台异步刷盘阈值压低到 5%(默认 10%)
    vm.dirty_background_ratio = 5
    # 进程同步阻塞刷盘阈值压低到 10%(默认 20%)
    vm.dirty_ratio = 10
    # 缩短脏页老化时间到 5秒(默认 30秒)
    vm.dirty_expire_centisecs = 500
    

    执行 sysctl -p 生效。通过让内核高频、小批量地刷脏,避免 jbd2 或后台 flusher 线程一次性将底层 IO 队列打满。

    常见问题

    Q1:如果是 XFS 文件系统,也会有类似 jbd2 的日志阻塞问题吗? XFS 同样有日志提交流程(xfsaild 线程)。但 XFS 采用延迟日志(Delayed Logging)机制,且支持多 Allocation Group (AG) 并发,锁竞争粒度比 ext4 小得多。在极高并发的数据库场景,XFS 的表现通常更平稳,这也是为什么绝大多数现代 DB 推荐使用 XFS 的原因。不过,如果硬件 VWC 未关闭,XFS 发出的 Flush 依然会导致 NVMe 队列阻塞。

    Q2:数据库改用 io_uring 能绕过这个底层 Flush 瓶颈吗? 不能。io_uring 解决的是系统调用开销(User to Kernel 的 SQE/CQE 环形队列)和线程阻塞问题。但当请求进入到块层(Block Layer),如果发生 Flush 屏障,底层硬件队列 hctx 依然会暂停。即使内核 worker 线程不阻塞用户态应用,IO 仍会积压在环形队列中,最终表现为请求超时。

    Q3:云上的块存储(如 AWS EBS / 阿里云 ESSD)需要禁用 Write Cache 吗? 云盘通常由后端的分布式存储集群(如 Ceph/盘古)保障多副本和持久化。对于 Guest OS 而言,很多云平台会在虚拟化层忽略前端发来的 REQ_FLUSH,因为只要写入成功即代表落盘。但这取决于具体云厂商的实现。实践中,建议查阅云厂商文档,若支持,依然建议在 OS 层面直接把块设备的调度器改为 none,避免宿主机和虚拟机两层排队。

  • 深入 Apache Pulsar 延迟雪崩排查:Bookie Journal 刷盘阻塞引发的 P99 飙升与 IO 瓶颈实战

    近期排查了一起 Pulsar 集群(v2.10.4)P99 写入延迟从 5ms 突增至 2s 的雪崩故障。核心根因是 BookKeeper 的 Journal 与 Ledger 未做物理盘隔离,且 RocksDB Compaction 引发大量写放大,导致 fsync 阻塞 IO 线程组。通过硬隔离 NVMe 盘、开启 Direct IO 并优化 Group Commit 参数,最终将 P99 压回 2ms 以内。

    故障现场:P99 延迟雪崩与 Broker 背压

    排查过程中,监控告警显示某核心业务 Topic 的写入耗时剧增。查看 Prometheus 监控,Broker 侧的 pulsar_broker_publish_latency P99 指标平时稳定在 5ms,突发流量下直接飙升到 2000ms 以上,甚至触发了 Client 端的 TimeoutException

    登录其中一台 Bookie 节点(BookKeeper 4.14.5),直接用 dmesgiostat 摸底硬件状态:

    # 每秒打印一次 IO 状态
    iostat -x -d 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     0.00    0.00 4521.00     0.00 74523.00    32.96    12.45   15.20    0.00   15.20   0.22 100.00
    

    可以看到 nvme0n1%util 已经被打到 100%,且 w_await 高达 15ms(对于 NVMe 来说,超过 2ms 就已经说明底层队列严重排队了)。

    翻看 Bookie 的 bookkeeper.log,果不其然,满屏的 Journal Sync 超时告警:

    22:14:05.123 [bookie-journal-DIR-sync] WARN  org.apache.bookkeeper.bookie.Journal - Sync object took 1250 ms
    22:14:05.345 [bookie-journal-DIR-sync] WARN  org.apache.bookkeeper.bookie.Journal - Sync object took 1420 ms
    22:14:06.001 [bookie-journal-DIR-sync] ERROR org.apache.bookkeeper.bookie.Journal - Journal Sync queue is full, dropping requests!
    

    为什么明明是存算分离架构,Bookie 的 Journal 刷盘依然会被阻塞?

    Pulsar 吹嘘最多的就是“存算分离”和“读写分离”。但在 BookKeeper 底层,读写分离是有前提的。

    BookKeeper 的一次写入流程(Add Entry)包含两步核心操作:

    1. Append to Journal:将数据追加到 Journal 盘(WAL),并在内存中将数据加入 MemTable。只要 Journal 刷盘(fsync)成功,Bookie 就向 Broker 返回 ACK。

    2. Flush to Ledger:后台线程定期将 MemTable 刷入 Entry Logger(数据文件)和 RocksDB(索引文件),这两个文件通常存放在 Ledger 盘。

    理论上,Journal 负责顺序写,Ledger 负责随机读和后台批量写,互不干扰。但现实很骨感:如果你的 Journal 和 Ledger 共享同一块物理盘,或者共享同一个 PCIe 通道/阵列卡带宽,灾难就会发生。

    在我们的现场,之前的运维人员为了图省事,将 /bookkeeper/data/journal/bookkeeper/data/ledger 挂载到了同一块 2TB 的 NVMe 盘上。当业务触发海量小消息写入时,Ledger 目录下的 RocksDB 疯狂进行 Compaction(文件合并),产生极其严重的写放大。底层文件系统的 Page Cache 被瞬间打满,导致 Journal 线程调用 fsync() 时,被 OS 的脏页回写机制死死卡住。

    Journal 线程一卡,Broker 发来的新请求全部堆积在 Bookie 的 Netty 队列里,进而引发 OOM 风险,最终触发 Broker 侧的背压(Backpressure),全局写入延迟雪崩。

    解决与加固:防御性 IO 隔离与参数调优

    知道了痛点,解决思路就很清晰:物理隔离与绕过 Page Cache 污染。

    1. 物理盘绝对隔离

    永远不要让 Journal 和 Ledger 抢 IO。Journal 盘需要极低的 fsync 延迟,容量不需要太大(只保存 WAL,写满后会滚动删除);Ledger 盘需要大容量,对 IOPS 也有一定要求(特别是有 Catch-up 追赶读的场景)。

    实战配置

    • Journal:挂载独立的 100G NVMe SSD。

    • Ledger:挂载大容量的 SATA SSD 或阵列。

    bookkeeper.conf 中明确拆分:

    journalDirectories=/data/journal/bk
    ledgerDirectories=/data/ledger/bk
    

    2. 启用 Ledger Direct IO,避免 Page Cache 污染

    Ledger 的后台 Flush 和 Catch-up 读非常容易污染操作系统的 Page Cache,导致真正需要 Page Cache 加速的读请求被驱逐,甚至影响其他进程的 IO 性能。 在 BookKeeper 4.14+ 中,建议开启 DbLedgerStorage 的 Direct IO,直接让读写绕过 OS 缓存。

    # 使用 DbLedgerStorage
    storage_facility=org.apache.bookkeeper.bookie.DbLedgerStorage
    
    # 针对 Entry Logger 开启 Direct IO (绕过 Page Cache)
    dbStorage_directIOEntryLogger=true
    
    # 分配给 RocksDB Block Cache 的内存(建议占节点物理内存的 10%-20%)
    dbStorage_rocksdbBlockCacheSize=4294967296
    # 增加 Write Cache 以减少高并发下的 RocksDB 刷盘频次
    dbStorage_writeCacheMaxSizeMb=1024
    

    3. Journal Group Commit (组提交) 调优

    Journal 是靠 fsync 保证数据不丢的(journalSyncData=true 绝对不能关,关了就是拿命在跑)。但高并发下,每一条消息都 fsync 会让 NVMe 直接瘫痪。 我们需要调整 Group Commit 参数,让 Bookie 在延迟和吞吐之间找到一个平衡。

    # 最大等待时间,默认 1ms。如果你的网络/磁盘稍有抖动,1ms 太激进。建议改为 2-5ms,牺牲微小延迟换取巨大吞吐。
    journalMaxGroupWaitMSec=2
    
    # 队列中积压多少字节触发一次强制刷盘,默认 512KB,可适当调大到 1MB 或 2MB
    journalBufferedWritesThreshold=1048576
    
    # 如果 Journal 盘写入排队依然严重,可以增加 Journal 目录的数量(多盘配置)
    journalDirectories=/data/journal1/bk,/data/journal2/bk
    

    重启 Bookie 节点后,再次观察 iostatnvme0n1(Journal盘)的 w_await 稳定在 0.5ms 以内,Pulsar 生产端的 P99 写入延迟死死压在 2ms,故障彻底解除。

    常见问题 (FAQ)

    Q:为什么开启了存算分离,消费端(Consumer)追赶历史数据(Catch-up Read)时,还是会导致生产端(Producer)延迟抖动? A:如果没有开启 Ledger 的 Direct IO,追赶读会把大量历史冷数据加载到 OS Page Cache,把操作系统的空闲内存耗光,触发 OS 的全局脏页回写(Global Flush)。此时哪怕 Journal 在另一块盘上,CPU 和总线也会受到系统态 IO 阻塞的影响。解决方案就是强制配置 dbStorage_directIOEntryLogger=true

    Q:journalSyncData=false 能不能用来解决 P99 高的问题? A:可以解决监控上的数字问题,但会解决掉你的饭碗。设置为 false 意味着数据写到 OS Page Cache 就直接向 Broker 返回成功,一旦 Bookie 所在机器断电或 Kernel Panic,尚未刷盘的数据将永久丢失。对于金融级或核心交易场景,必须保持为 true,靠优化硬件和 Group Commit 来提速。

    Q:如何预估和监控 RocksDB 引发的写放大? A:重点监控 Bookie 的 bookkeeper_server_DbLedgerStorage_compaction_time 和 IO 设备的写入量。如果发现 Ledger 盘的实际写入速率是业务写入速率的 3 倍以上,说明遇到了 RocksDB 的写放大瓶颈。可以通过调大 dbStorage_writeCacheMaxSizeMb 和限制后台 Compaction 线程的速率来缓解。

    Q:多租户场景下,个别异常租户的高频写入会不会打满 Bookie 导致全局故障? A:会。Bookie 底层是对所有 Topic 的 Entry 进行混合追加写的,一个 Topic 的流量突增会吃光 Journal 的 fsync 能力。必须在 Broker 端开启严格的资源配额(Quota)和按租户/Namespace 的流量限流(Rate Limiting),这叫“防御性运维”。不要指望底层存储能无限制抗住上层的不合理调用。